Introduction
Database connections are expensive. Manage them wisely.
Key Concepts
- Persistent Connections: Reusing database connections across requests with
PDO::ATTR_PERSISTENT, avoiding TCP/SSL handshake overhead. - Connection Pooling: A pool of pre-established database connections shared among application processes.
- Connection Limits: MySQL's
max_connectionssetting and PHP-FPM'spm.max_childrenmust be balanced to prevent exhaustion. - Connection Overhead: Each new MySQL connection involves TCP handshake, SSL negotiation, and authentication — 10-50ms per connection.
Real World Context
Database connection overhead becomes significant at scale. If each of your 100 PHP-FPM workers opens a new MySQL connection per request, you're creating and destroying hundreds of connections per second. Persistent connections or a connection pooler like ProxySQL can reduce connection overhead by 90% and prevent Too many connections errors.
Deep Dive
Intro
Database connections are expensive. Manage them wisely.
Connection pooling
php<?php // Singleton pattern for connection reuse class Database { private static ?PDO $instance = null; public static function getConnection(): PDO { if (self::$instance === null) { self::$instance = new PDO( $_ENV['DATABASE_URL'], $_ENV['DATABASE_USER'], $_ENV['DATABASE_PASS'], [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_PERSISTENT => true, // Persistent connection ] ); } return self::$instance; } }
Persistent connections
php<?php // Enable persistent connections $pdo = new PDO($dsn, $user, $pass, [ PDO::ATTR_PERSISTENT => true, ]); // Benefits: // - Avoids connection overhead per request // - Reuses existing connections // Caveats: // - Connections persist across requests // - Need to clean up state (temp tables, locks) // - May need to increase max_connections
Connection timeouts
php<?php $pdo = new PDO($dsn, $user, $pass, [ PDO::ATTR_TIMEOUT => 5, // 5 second timeout ]); // For MySQL specifically $pdo->exec('SET wait_timeout = 28800'); // 8 hours $pdo->exec('SET interactive_timeout = 28800');
Read replicas
php<?php class DatabaseManager { private PDO $writer; private array $readers = []; public function __construct(string $writerDsn, array $readerDsns) { $this->writer = new PDO($writerDsn); foreach ($readerDsns as $dsn) { $this->readers[] = new PDO($dsn); } } public function write(): PDO { return $this->writer; } public function read(): PDO { // Round-robin or random selection return $this->readers[array_rand($this->readers)]; } } // Usage $db = new DatabaseManager( 'mysql:host=primary.db;dbname=app', [ 'mysql:host=replica1.db;dbname=app', 'mysql:host=replica2.db;dbname=app', ] ); // Reads go to replicas (scalable) $users = $db->read()->query('SELECT * FROM users'); // Writes go to primary $db->write()->exec('INSERT INTO users ...');
Common Pitfalls
- Too many persistent connections — Each PHP-FPM worker holds a persistent connection. With 100 workers × 3 database servers, you need 300 MySQL connections. Plan capacity carefully.
- Not closing connections in long-running scripts — Queue workers and daemons should periodically reconnect to prevent stale connections from holding resources.
Best Practices
- Use persistent connections in production — Set
PDO::ATTR_PERSISTENT => trueand configure MySQLmax_connectionsto accommodate your PHP-FPM worker count. - Consider ProxySQL for large deployments — ProxySQL provides connection multiplexing, query caching, and read/write splitting without code changes.
Summary
- Persistent connections eliminate the 10-50ms overhead of establishing new database connections per request.
- Balance
max_connectionswith your PHP-FPM worker count to prevent connection exhaustion. - Use ProxySQL for connection pooling in large-scale deployments.
Code Examples
php
<?php
declare(strict_types=1);
// Query builder with automatic optimization hints
class OptimizedQueryBuilder
{
private PDO $pdo;
private string $table;
private array $select = ['*'];
private array $where = [];
private array $bindings = [];
private ?int $limit = null;
private ?int $offset = null;
private array $orderBy = [];
private bool $forceIndex = false;
private ?string $indexHint = null;
public function __construct(PDO $pdo, string $table)
{
$this->pdo = $pdo;
$this->table = $table;
}
public function select(string ...$columns): self
{
$this->select = $columns ?: ['*'];
return $this;
}
public function where(string $column, mixed $value, string $operator = '='): self
{
$param = ':w' . count($this->bindings);
$this->where[] = "$column $operator $param";
$this->bindings[$param] = $value;
return $this;
}
public function useIndex(string $indexName): self
{
$this->indexHint = "USE INDEX ($indexName)";
return $this;
}
public function forceIndex(string $indexName): self
{
$this->indexHint = "FORCE INDEX ($indexName)";
return $this;
}
public function limit(int $limit, ?int $offset = null): self
{
$this->limit = $limit;
$this->offset = $offset;
return $this;
}
public function orderBy(string $column, string $direction = 'ASC'): self
{
$this->orderBy[] = "$column $direction";
return $this;
}
public function toSql(): string
{
$sql = 'SELECT ' . implode(', ', $this->select);
$sql .= ' FROM ' . $this->table;
if ($this->indexHint) {
$sql .= ' ' . $this->indexHint;
}
if ($this->where) {
$sql .= ' WHERE ' . implode(' AND ', $this->where);
}
if ($this->orderBy) {
$sql .= ' ORDER BY ' . implode(', ', $this->orderBy);
}
if ($this->limit !== null) {
$sql .= ' LIMIT ' . $this->limit;
if ($this->offset !== null) {
$sql .= ' OFFSET ' . $this->offset;
}
}
return $sql;
}
public function explain(): array
{
$stmt = $this->pdo->prepare('EXPLAIN ' . $this->toSql());
$stmt->execute($this->bindings);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function get(): array
{
$stmt = $this->pdo->prepare($this->toSql());
$stmt->execute($this->bindings);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}
// Usage
$query = new OptimizedQueryBuilder($pdo, 'orders');
$orders = $query
->select('id', 'total', 'created_at')
->where('user_id', 123)
->where('status', 'completed')
->forceIndex('idx_user_status') // Force specific index
->orderBy('created_at', 'DESC')
->limit(10)
->get();
// Debug: check execution plan
print_r($query->explain());
?>