持久数据库连接
概述
持久数据库连接(Persistent Connections)在 PHP 脚本结束后不会关闭,而是被保留在连接池中,供后续请求复用。这可以减少连接建立的开销,但也带来了连接管理复杂性和状态污染的风险。
现代观点
在 PHP-FPM 环境下,持久连接的收益有限(FPM 进程本身会复用连接)。在新项目中,推荐使用连接池替代持久连接,或直接使用短连接(配合现代数据库连接器的高效重连机制)。
基础概念
短连接 vs 持久连接
| 特性 | 短连接 | 持久连接 |
|---|---|---|
| 生命周期 | 脚本结束即关闭 | 跨请求复用 |
| 连接开销 | 每次请求都建立 | 仅首次建立 |
| 状态隔离 | 完全隔离 | 可能残留状态 |
| 连接数控制 | 容易控制 | 需要池化管理 |
| 事务残留 | 无风险 | 可能残留未提交事务 |
| 推荐度 | 推荐 | 谨慎使用 |
支持持久连接的扩展
| 扩展 | 持久连接方式 | 说明 |
|---|---|---|
| PDO | PDO::ATTR_PERSISTENT | 属性控制 |
| MySQLi | host:p: 前缀 | 在主机名前加 p: |
| pgsql | pg_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:3306PostgreSQL 持久连接
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 个数据库连接
# 自动管理连接复用、故障转移