ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP8.4怎么实现数据库连接池提升性能

PHP8.4怎么实现数据库连接池提升性能 前言高并发下 MySQL 连接被打满、每个请求都要重新握手建连慢、想上连接池——这是做 PHP 性能优化时最常出现的诉求。于是有人去找PHP 8.4 的连接池 API找了半天没找到以为是自己漏看了手册。必须先把事实说清楚PHP 8.4 没有提供任何数据库连接池connection poolAPIPHP 语言层面从来没有过这样的内置能力。原因是 PHP 的进程模型传统的 PHP-FPM 是共享无服务shared-nothing模型一个请求一个进程生命周期请求结束就把脚本里创建的资源全部释放进程本身不能持有跨请求的业务对象所以没有地方去放一个池。那PHP 用连接池这件事到底怎么做只有三条真正可行的路一是利用 PHP 本身提供的持久连接persistent connection让连接在 FPM worker 进程内跨请求复用二是把 PHP 换成常驻内存的协程运行时如 Swoole / OpenSwoole由应用自己维护一个连接池三是把连接池下沉到外部代理层如数据库中间件PHP 侧不感知。三条路的适用场景和坑完全不同。本文按这三条路展开先讲清为什么 FPM 下没有真正的池再给出两段可以落地的代码一段在 FPM 下用PDO::ATTR_PERSISTENT验证连接复用一段在 Swoole 协程里用Swoole\Coroutine\Channel自己实现一个池。所有 API 都以 PHP 官方手册中确实存在的为准示例会标明运行前提。一、为什么 PHP-FPM 下没有真正的连接池一次 PHP-FPM 请求的生命周期大致是worker 进程accept连接 → 编译执行脚本 → 脚本里new PDO(...)建 TCP 连接并完成 MySQL 握手认证 → 请求结束PHP 释放所有变量PDO 对象析构连接关闭。建连 握手 认证这三步是纯网络往返在跨机房或 TLS 场景下开销可观。连接池要省掉的就是这三步。但在 FPM 里请求结束后进程要清理所有状态没有办法把 PDO 对象留下来给下一个请求用——除非使用持久连接它由 PHP 底层mysqlnd或pdo_mysql的连接缓存维护而不是你的代码维护。方案连接复用粒度是否有真正的池适用前提普通new PDO()不复用每请求新建否任何环境PDO::ATTR_PERSISTENT每个 FPM worker 进程复用自身连接每个进程一个实质是复用而非池FPM / mod_phpmysqli主机名加p:前缀同上同上FPM / mod_phpSwoole / OpenSwoole 协程池进程内多连接按需借还是常驻内存协程运行时外部代理数据库中间件代理侧维护对 PHP 透明任何环境需额外部署结论很直白要在 PHP 里做池前提是进程常驻。FPM 下能做到的最好情况就是持久连接它把每请求建连降级为每进程建连一次。二、FPM 下的持久连接能用但要会清场PDO::ATTR_PERSISTENT是 PDO 构造时传入的驱动选项?php declare(strict_types1); $dsn mysql:host127.0.0.1;port3306;dbnameapp;charsetutf8mb4; $options [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES false, PDO::ATTR_PERSISTENT true, // 关键连接在 worker 进程内复用 ]; $pdo new PDO($dsn, app, secret, $options);它的行为是同一个 FPM worker 进程里后续请求用相同 DSN、相同用户名再构造 PDO 时直接复用上一个请求留下的连接跳过握手认证。连接不会在请求结束时关闭而是等进程退出或连接被服务端断开。代价也很明确而且是症状诡异那一类事务泄漏上一个请求开了事务没提交/回滚就结束了下一个复用该连接的请求会直接落在那个未结束的事务里读到的可能是脏数据写操作可能被一起回滚。会话状态泄漏SET x 1、SET SESSION sql_mode ...、临时表、SELECT ... FOR UPDATE留下的锁都会跨请求残留。连接数 进程数连接不会随请求回收FPM 有多少个 worker 就最多有多少条连接。pm.max_children一调大MySQL 的max_connections立刻告急。所以持久连接必须配合清场请求结束前保证事务已结束、会话变量已复位。下面这段代码可以直接放在 FPM 下运行用 MySQL 的CONNECTION_ID()验证连接是否真的被复用。?php declare(strict_types1); function makePdo(bool $persistent): PDO { return new PDO( mysql:host127.0.0.1;port3306;dbnameapp;charsetutf8mb4, app, secret, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES false, PDO::ATTR_PERSISTENT $persistent, ] ); } function currentConnectionId(PDO $pdo): int { return (int) $pdo-query(SELECT CONNECTION_ID())-fetchColumn(); } // 持久连接同一 worker 进程里多次构造得到同一个连接 for ($i 0; $i 3; $i) { $pdo makePdo(true); echo persistent #, $i, connection , currentConnectionId($pdo), PHP_EOL; unset($pdo); } // 非持久连接每次都换一条新连接 for ($i 0; $i 3; $i) { $pdo makePdo(false); echo normal #, $i, connection , currentConnectionId($pdo), PHP_EOL; unset($pdo); }用浏览器连续刷新几次观察输出即可持久连接那三行打印的connection id会是同一个值同一个 worker 进程里刷新页面后仍然是这个值。非持久连接每次都是新值。注意FPM 有多个 worker刷新时命中不同 worker 时持久连接的 id 会变化这是正常的。这正好也说明了持久连接的粒度是每进程一份。清理会话状态的正确做法是给每个请求加一个统一入口用完把连接恢复干净?php declare(strict_types1); function withPdo(PDO $pdo, callable $fn): mixed { try { return $fn($pdo); } finally { // 1) 若还有未结束的事务回滚掉避免脏事务留给下一个请求 if ($pdo-inTransaction()) { $pdo-rollBack(); } // 2) 复位会话级设置避免 SET / 临时表 / 锁残留 $pdo-exec(SET SESSION sql_mode DEFAULT); $pdo-exec(SET SESSION autocommit 1); } }PDO::inTransaction()在 PDO 层是真实存在的rollBack()只在事务内调用才有意义所以要先判断。三、协程运行时里的真连接池要让池名副其实必须让进程常驻。在 Swoole / OpenSwoole 这类协程运行时里进程不会因为请求结束而销毁可以自己维护一组连接并按需借还。核心是Swoole\Coroutine\Channel它是一个协程安全的通道可以指定容量push()放回连接pop()取走连接pop()在通道空时会挂起当前协程而不是阻塞整个进程。下面是一个最小可用的池实现前提是装有 Swoole 扩展php -m | findstr swoole能查到并且运行在协程环境Swoole\Coroutine\run()内。为了聚焦池本身的逻辑这里用一个假连接替代 PDO把Swoole\Coroutine\Channel的借还流程展示完整。?php declare(strict_types1); if (!extension_loaded(swoole)) { exit(需要 Swoole 扩展\n); } class FakeConnection { private static int $seq 0; public readonly int $id; public function __construct() { $this-id self::$seq; } public function ping(): bool { return true; // 真实实现里应当执行 SELECT 1 } public function query(string $sql): string { return conn#{$this-id} ran: {$sql}; } } class ConnectionPool { /** var \Swoole\Coroutine\Channel */ private \Swoole\Coroutine\Channel $channel; public function __construct( private int $size 8, private float $borrowTimeout 1.0, private float $idleTtl 60.0 ) { $this-channel new \Swoole\Coroutine\Channel($this-size); } /** 预热提前建好连接避免首个请求承担建连开销 */ public function warmUp(): void { for ($i 0; $i $this-size; $i) { $this-channel-push(new FakeConnection()); } } /** 借出连接池空时挂起当前协程直到有人归还或超时 */ public function get(): FakeConnection { $conn $this-channel-pop($this-borrowTimeout); if ($conn false) { throw new RuntimeException(连接池已耗尽借出超时); } // 健康检查拿到可能已被服务端断开的空闲连接时重建 if (!$conn-ping()) { $conn new FakeConnection(); } return $conn; } /** 归还连接务必放在 finally 里否则池会被借空 */ public function put(FakeConnection $conn): void { if ($this-channel-isFull()) { return; // 池已满直接丢弃这条连接 } $this-channel-push($conn); } public function stats(): array { return [ capacity $this-size, length $this-channel-length(), full $this-channel-isFull(), empty $this-channel-isEmpty(), ]; } } \Swoole\Coroutine\run(function () { $pool new ConnectionPool(4); $pool-warmUp(); $wg new \Swoole\Coroutine\WaitGroup(); for ($i 0; $i 12; $i) { $wg-add(); \Swoole\Coroutine::create(function () use ($pool, $wg, $i) { try { $conn $pool-get(); try { // 真实场景在这里执行 PDO 语句 echo $conn-query(SELECT {$i}), PHP_EOL; } finally { $pool-put($conn); // 必须归还 } } catch (Throwable $e) { echo error: , $e-getMessage(), PHP_EOL; } finally { $wg-done(); } }); } $wg-wait(); print_r($pool-stats()); });这段代码把池的四个关键动作都体现出来了容量构造Channel时指定决定并发上限、预热启动时建好连接、超时pop()带超时避免无限等待把协程堆死、归还finally里put()。真实场景下把FakeConnection换成 PDO 时有一个硬性要求在协程里必须使用协程化的客户端普通new PDO()是阻塞的一旦某条 SQL 慢会阻塞整个进程的调度池再完美也没用。Swoole 生态里有对应的协程化数据库客户端如果只能用阻塞 PDO那就必须限制并发数小于pm.max_children否则会互相拖死。四、外部代理层最省事的方案如果不想改架构第三种选择是在应用和数据库之间放一层代理如数据库中间件由它维护到 MySQL 的连接池PHP 侧每个请求照旧建连但连的是代理的短连接握手成本被压到局域网级别。PHP 代码一行不用改代价是多一个需要运维的组件以及多了一跳网络的延迟。这不在本文代码范围内但值得知道它存在因为很多团队纠结要不要上 Swoole时其实第一刀应该切在这里。常见坑点❌ 用了PDO::ATTR_PERSISTENT却不管事务某次请求异常退出时事务没结束下一个复用该连接的请求读到的数据处于未提交状态写操作甚至会被一起回滚症状是数据偶尔莫名丢失。✅统一入口用try/finally包住finally里判断inTransaction()并rollBack()。❌ 把pm.max_children从 20 调到 200同时开着持久连接连接数等于 worker 数MySQLmax_connections直接被打爆报Too many connections。✅用持久连接时pm.max_children必须和数据库可承受的连接数一起算或者改用真正的池来限制并发。❌ 以为持久连接能跨 FPM 池或跨机器共享它只在当前 worker 进程内复用多个 pool、多台机器各有一份。✅连接数预算按机器数 × worker 数估算。❌ 持久连接上执行SET SESSION sql_mode ...或建临时表后不清理下一个请求在同一条连接上执行会话设置和临时表都还在SQL 行为与预期不符。✅归还前复位会话变量临时表用完必须DROP TEMPORARY TABLE。❌ 池里的连接不做健康检查MySQL 的wait_timeout会断开长时间空闲的连接从池里取出的第一条就是死连接报MySQL server has gone away。✅借出时ping()真实实现里是SELECT 1或捕获异常后重建别把陈旧连接交给业务。❌ 借出连接后忘了归还某条异常路径上没有put()池的可用连接数只减不增跑一段时间后所有请求都卡在pop()上超时表现是服务整体雪崩。✅get()之后立刻try { ... } finally { put(); }永不例外。❌ 在协程里用阻塞式 PDO 配连接池池看起来正常但每条慢 SQL 都会阻塞整个进程并发能力反而比 FPM 更差。✅协程环境下使用协程化的数据库客户端或者把协程池的并发上限卡在安全值。❌ 把池的容量设成越大越快容量等于对数据库的并发压力容量过大只是把瓶颈从 PHP 转移到 MySQL还容易掩盖慢查询。✅容量按数据库的并发承载能力和慢查询情况来定先优化 SQL 再谈池的大小。总结方案是否需要常驻进程复用效果主要代价普通new PDO()否无复用每请求建连开销PDO::ATTR_PERSISTENT否每 worker 复用一条事务/会话状态泄漏连接数进程数mysqli的p:前缀否同上同上Swoole\Coroutine\Channel自建池是进程内多连接按需借还需要常驻协程运行时必须用协程化客户端外部代理中间件否代理侧维护多一个组件、多一跳网络PHP 8.4 没有连接池 API这件事没什么可抱怨的语言模型决定了它给不了。FPM 下能做的是持久连接收益是省掉每请求的握手代价是必须自己保证连接干净真要一个池前提是把进程变成常驻的。先把事务清理和pm.max_children这两件事做对再决定要不要上协程运行时不迟。
返回列表