Skip to content

持久数据库连接

概述

持久数据库连接(Persistent Connections)在 PHP 脚本结束后不会关闭,而是被保留在连接池中,供后续请求复用。这可以减少连接建立的开销,但也带来了连接管理复杂性和状态污染的风险。

现代观点

在 PHP-FPM 环境下,持久连接的收益有限(FPM 进程本身会复用连接)。在新项目中,推荐使用连接池替代持久连接,或直接使用短连接(配合现代数据库连接器的高效重连机制)。

基础概念

短连接 vs 持久连接

特性短连接持久连接
生命周期脚本结束即关闭跨请求复用
连接开销每次请求都建立仅首次建立
状态隔离完全隔离可能残留状态
连接数控制容易控制需要池化管理
事务残留无风险可能残留未提交事务
推荐度推荐谨慎使用

支持持久连接的扩展

扩展持久连接方式说明
PDOPDO::ATTR_PERSISTENT属性控制
MySQLihost:p: 前缀在主机名前加 p:
pgsqlpg_pconnect()使用专用函数

语法与代码

PDO 持久连接

php
<?php
declare(strict_types=1);

// PDO 持久连接 — 通过 ATTR_PERSISTENT 属性
$dsn = 'mysql:host=localhost;dbname=app_db;charset=utf8mb4';

$pdo = new PDO($dsn, 'user', 'pass', [
    PDO::ATTR_PERSISTENT => true,       // 启用持久连接
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

// 连接建立后,如果 PHP-FPM 进程处理下一个请求
// 且 DSN + username + password 完全匹配,则复用该连接

// 检查是否使用了持久连接
$isPersistent = $pdo->getAttribute(PDO::ATTR_PERSISTENT);
echo "持久连接: " . ($isPersistent ? '是' : '否') . "\n";

// 注意: PDO 使用相同的参数(DSN, user, pass, options)才能复用连接
// 任何参数变化都会创建新连接

MySQLi 持久连接

php
<?php
declare(strict_types=1);

// MySQLi 持久连接 — 在主机名前加 p:
$mysqli = new mysqli('p:localhost', 'user', 'pass', 'app_db');
$mysqli->set_charset('utf8mb4');

// 或使用过程式 API
$mysqli = mysqli_connect('p:localhost', 'user', 'pass', 'app_db');

// 检查连接状态
if ($mysqli->ping()) {
    echo "连接正常\n";
}

// 注意: p: 只在主机名前有效
// p:127.0.0.1, p:db.example.com:3306

PostgreSQL 持久连接

php
<?php
declare(strict_types=1);

// pg_pconnect — PostgreSQL 持久连接
$conn = pg_pconnect('host=localhost dbname=app_db user=user password=pass');

if (!$conn) {
    die("连接失败: " . pg_last_error());
}

// pg_connection_reset — 重置连接状态(清除持久连接的残留状态)
pg_connection_reset($conn);

// pg_pconnect 连接类型参数
$conn = pg_pconnect('host=localhost dbname=app_db', PGSQL_CONNECT_FORCE_NEW);
// PGSQL_CONNECT_FORCE_NEW — 强制创建新连接(不复用)
// PGSQL_CONNECT_ASYNC — 异步连接(PHP 8.1+)

持久连接状态残留

持久连接会保留上次请求的会话状态,包括:

  • 未提交的事务(可能导致锁残留)
  • 临时表
  • 会话变量(SET @var = value
  • 数据库选择(USE database
  • 字符集设置

实战示例

持久连接工厂

php
<?php
declare(strict_types=1);

class PersistentConnectionFactory
{
    private static array $connections = [];
    private static bool $persistent = false;

    /**
     * 启用或禁用持久连接
     */
    public static function setPersistent(bool $enabled): void
    {
        self::$persistent = $enabled;
    }

    /**
     * 获取 PDO 连接
     */
    public static function getPdo(string $dsn, string $username, string $password, string $key = 'default'): PDO
    {
        $cacheKey = $key . ':' . $dsn . ':' . $username;

        if (isset(self::$connections[$cacheKey])) {
            // 验证连接仍然有效
            try {
                self::$connections[$cacheKey]->query('SELECT 1');
                return self::$connections[$cacheKey];
            } catch (PDOException) {
                unset(self::$connections[$cacheKey]);
            }
        }

        $pdo = new PDO($dsn, $username, $password, [
            PDO::ATTR_PERSISTENT => self::$persistent,
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false,
        ]);

        // 持久连接需要重置状态
        if (self::$persistent) {
            self::resetConnectionState($pdo);
        }

        self::$connections[$cacheKey] = $pdo;
        return $pdo;
    }

    /**
     * 重置连接状态 — 避免状态残留
     */
    private static function resetConnectionState(PDO $pdo): void
    {
        // 回滚未提交的事务
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        // MySQL 特定: 重置会话变量
        $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
        if ($driver === 'mysql') {
            $pdo->exec('SET NAMES utf8mb4');
            $pdo->exec('SET SESSION sql_mode = "STRICT_TRANS_TABLES"');
        }
    }

    /**
     * 关闭所有连接
     */
    public static function closeAll(): void
    {
        self::$connections = [];
    }
}

// 使用
PersistentConnectionFactory::setPersistent(true);
$pdo = PersistentConnectionFactory::getPdo(
    'mysql:host=localhost;dbname=app_db;charset=utf8mb4',
    'user',
    'pass'
);

连接池替代方案

php
<?php
declare(strict_types=1);

// 连接池 — 更好的替代方案
class ConnectionPool
{
    private array $available = [];
    private array $inUse = [];
    private int $maxSize;
    private string $dsn;
    private string $username;
    private string $password;
    private int $created = 0;

    public function __construct(string $dsn, string $username, string $password, int $maxSize = 10)
    {
        $this->dsn = $dsn;
        $this->username = $username;
        $this->password = $password;
        $this->maxSize = $maxSize;
    }

    public function getConnection(): PDO
    {
        // 1. 从可用池中取
        if (!empty($this->available)) {
            $pdo = array_pop($this->available);
            try {
                $pdo->query('SELECT 1');
                $this->inUse[] = $pdo;
                return $pdo;
            } catch (PDOException) {
                // 连接已断开,创建新的
            }
        }

        // 2. 创建新连接
        $pdo = new PDO($this->dsn, $this->username, $this->password, [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        ]);
        $this->inUse[] = $pdo;
        $this->created++;

        return $pdo;
    }

    public function releaseConnection(PDO $pdo): void
    {
        $key = array_search($pdo, $this->inUse, true);
        if ($key !== false) {
            unset($this->inUse[$key]);
        }

        // 重置状态后回收到可用池
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }

        if (count($this->available) < $this->maxSize) {
            $this->available[] = $pdo;
        }
    }

    public function getStats(): array
    {
        return [
            'available' => count($this->available),
            'inUse' => count($this->inUse),
            'created' => $this->created,
            'maxSize' => $this->maxSize,
        ];
    }
}

// 注意: 在 PHP-FPM 中,每个请求结束后会销毁所有变量
// 所以连接池的效果有限。真正的连接池需要外部服务(如 ProxySQL)

数据库连接监控

php
<?php
declare(strict_types=1);

class ConnectionMonitor
{
    private PDO $pdo;

    public function __construct(PDO $pdo)
    {
        $this->pdo = $pdo;
    }

    /**
     * 获取连接信息
     */
    public function getConnectionInfo(): array
    {
        $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);

        return [
            'driver' => $driver,
            'persistent' => $this->pdo->getAttribute(PDO::ATTR_PERSISTENT),
            'serverVersion' => $this->pdo->getAttribute(PDO::ATTR_SERVER_VERSION),
            'clientVersion' => $this->pdo->getAttribute(PDO::ATTR_CLIENT_VERSION),
            'status' => $this->pdo->getAttribute(PDO::ATTR_CONNECTION_STATUS),
        ];
    }

    /**
     * MySQL: 查看服务器连接列表
     */
    public function getServerConnections(): array
    {
        $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
        if ($driver !== 'mysql') {
            return [];
        }

        return $this->pdo->query(
            "SHOW PROCESSLIST"
        )->fetchAll();
    }

    /**
     * MySQL: 获取连接统计
     */
    public function getConnectionStats(): array
    {
        $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
        if ($driver !== 'mysql') {
            return [];
        }

        $status = $this->pdo->query("SHOW GLOBAL STATUS LIKE 'Threads%'")->fetchAll(PDO::FETCH_KEY_PAIR);

        return [
            'connected' => (int) ($status['Threads_connected'] ?? 0),
            'running' => (int) ($status['Threads_running'] ?? 0),
            'cached' => (int) ($status['Threads_cached'] ?? 0),
            'created' => (int) ($status['Threads_created'] ?? 0),
        ];
    }
}

注意事项

持久连接的问题

php
<?php
// 问题1: 事务残留
// 如果上一个请求在持久连接上开启了事务但没有 commit/rollback
// 下一个请求可能继续在同一个事务中执行
// 导致: 锁残留、数据不一致

// 解决: 每次请求开始时检查并回滚未提交事务
if ($pdo->inTransaction()) {
    $pdo->rollBack();
}

// 问题2: 临时表残留
// 上一个请求创建的临时表在下一个请求中仍然存在
// 可能导致: 数据混乱、性能下降

// 解决: 不在持久连接上使用临时表,或请求开始时删除
$pdo->exec("DROP TEMPORARY TABLE IF EXISTS temp_table");

// 问题3: 连接耗尽
// 如果 PHP-FPM 进程数 > 数据库最大连接数
// 持久连接会占用数据库连接不释放
// 导致: 新连接请求被拒绝

// 解决: 确保 max_connections >= PHP-FPM max_children
// 或使用 ProxySQL/pgbouncer 管理连接

PHP-FPM 环境下的建议

在 PHP-FPM 中,每个 worker 进程有自己的持久连接池。如果 FPM 有 50 个 worker,数据库需要支持至少 50 个持久连接。如果数据库 max_connections 设置过小,会导致 "Too many connections" 错误。

最佳实践

1. 连接管理建议

php
<?php
// 建议1: 在大多数场景下使用短连接
// 现代数据库驱动重连速度很快(<5ms)
// 短连接避免状态污染,更安全

// 建议2: 如果使用持久连接,必须在请求开始时重置状态
function initConnection(PDO $pdo): void
{
    // 回滚残留事务
    while ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    // MySQL: 重置字符集
    $pdo->exec('SET NAMES utf8mb4');
}

// 建议3: 考虑使用连接代理(ProxySQL/pgbouncer)
// 代理在应用和数据库之间管理连接池
// 应用使用短连接连接代理,代理维护持久的数据库连接
// 好处: 连接管理、查询路由、读写分离

2. 配置参考

bash
# PHP-FPM 配置
# pm.max_children = 50
# 如果使用持久连接: MySQL max_connections 至少 50 + 预留

# MySQL 配置
# max_connections = 100
# wait_timeout = 600      # 空闲连接超时(秒)
# interactive_timeout = 600

# 推荐: 短连接 + 连接代理
# pm.max_children = 50
# MySQL max_connections = 20 (通过 ProxySQL 管理)

3. 连接代理方案

应用层 → PHP-FPM Worker × 50 → ProxySQL → MySQL

                                连接池 20-30 个
bash
# ProxySQL 配置示例
# mysql_servers:
#   - address: 127.0.0.1:3306
#     max_connections: 30
#
# mysql_variables:
#   - max_connections: 30
#
# 优势: 应用 50 个 worker 共享 30 个数据库连接
# 自动管理连接复用、故障转移

参考链接