文章发出来十分钟站内搜索还是搜不到,问题不在搜索,在同步。索引双写的十种断法里有七种不报错,把双写写得更狠救不回来;这篇拆同步双写、异步队列、binlog 订阅、定时对账四种做法的代价,以及中小站点该先用对账兜底的理由。
和 Java、Go 的同学聊性能优化,人家张口就是"连接池打满了吗""池子要不要扩",轮到写 PHP 的人,基本插不上话。不是我们不懂,是这门语言从头到尾就没这个东西。
PHP 没有连接池的根本原因:池需要常驻进程持有连接、让多个请求共享,而 PHP-FPM 的模型是一个进程同一时刻只服务一个请求,进程和连接一对一绑死,池根本没有位置。想真正用上连接池,出路是换运行时——Octane、Swoole、FrankenPHP 的 worker 模式,改配置是改不出来的。
连接池到底池的是什么
先把这个概念拆开。连接池的本质是一个活得比请求久的"中间人":连接归池子所有,请求来了借一条,用完还回去。它成立需要两个前提——进程生命周期长于请求,多个请求可以共享同一批连接。
Java 的应用服务器、Go 的常驻进程天然满足这两条。
PHP-FPM 恰好两条都不满足。
一个进程,一次只伺候一个请求
FPM 的架构是 master 加一堆 worker。master 管进程,worker 干活——而且一个 worker 在同一时刻只接一个请求,从入口文件执行到请求结束,中间所有用户态的东西:全局变量、静态属性、容器实例、打开的连接,全部清零。
这不是缺陷,是 PHP 活到今天的看家本领。进程死得快,泄漏就不是泄漏:你一个控制器每个请求漏 2MB 内存?无所谓,反正进程马上就死,操作系统回收。这套 share-nothing 模型养出了无数"不讲究"但稳定运行的 PHP 项目。(带刺一句:Go 程序员要花一下午分析的 goroutine 泄漏,在 PHP 这里根本不存在这个课题。)
但代价就是连接。在这套模型里,连接只有两种活法。
要么跟着请求走:每个请求 new PDO,请求结束连接销毁。干净、可预期,代价是每次请求都做一遍完整握手——TCP 三次握手加上 MySQL 的认证往返。同机房这个数字大概 1ms 上下,跨机房再叠一层 TLS,能到几毫秒。对一个平均 20ms 的接口,单看占比不算肉疼,架不住 QPS 一乘上去全是浪费。
要么跟着进程走,这就是 pconnect:mysqli 的主机名前面加个 p:,或者 PDO 里把 ATTR_PERSISTENT 设为 true。连接从请求级升级成进程级,worker 处理完请求不关连接,下一个进到这个 worker 的请求直接复用。看起来像池了,其实差得远——它不是"多个请求共享一批连接",而是"每个 worker 各自霸占一条连接,谁也借不着谁的"。
pconnect 的坑也全是这个"进程级"带来的。
第一,状态泄漏。上一个请求忘在半路的事务、没释放的锁、SET SESSION 改掉的时区、建了没删的临时表,全留给下一个请求。FPM 模式下你写的烂代码最多祸害自己这个请求,pconnect 之后它祸害的是下一个陌生人。
第二,连接数变成一道乘法题。开 persistent 之后,连接数上限 = pm.max_children × 机器数。单机 max_children 开 50,10 台机器 autoscaling 一扩,就是 500 条连接怼到数据库的 max_connections 上。MySQL 这个值的默认值才 151,不少云数据库给得也不宽裕,超了新连接直接报错——而且是生产高峰期报错。
"谁持有、谁释放"这类问题,我在自己项目里踩过一次很典型的:本地写了个脚本 boot 框架跑数据,任务被丢进 Redis 队列,而本地根本没起 queue worker 去消费,日志里什么错都没有,数据就是不出。最后把队列驱动切成 sync 让它内联执行才了事。连接和队列是同一类东西——只要它活得比你的执行上下文久,"谁来还"就必须有人回答。框架在 FPM 里替你答的部分,换个环境就得你自己答。
pconnect 算不算半个池
算,但只省了握手那一步。
它确实把最频繁的开销消掉了,但池的另一样东西——用最少的连接伺候最多的并发——它给不了。10 个 worker 就是 10 条连接,哪怕其中 9 个在干等下游 IO。真正的池可以用 10 条连接伺候几百个并发请求,因为借还的粒度是"执行一次查询",不是"处理一个请求"。
所以我的态度很明确:QPS 没到让握手成本变得显眼的量级,就老老实实每请求建连,行为可预期,出问题好查;真到了那个量级,也别指望 pconnect 顶上去,直接看下一节。
这也解释了为什么不少公司的 PHP 编码规范直接禁用 persistent connection——省的那 1ms,和它引入的状态泄漏比,不值。
想要真正的池,是换运行时,不是改配置
2026 年这件事的答案已经很成熟了:Laravel Octane。worker 常驻,框架只启动一次——原来是每个请求都 boot 一遍框架,十几到几十毫秒的开销;常驻之后摊薄到接近零。连接跟着 worker 常驻,握手开销同样摊没。
三个引擎里,Swoole 是唯一有"真共享池"的:协程模式下有现成的 PDOPool,几十个协程共享一小批连接,借还粒度是单条查询。FrankenPHP 和 RoadRunner 的 worker 模式则是"每个 worker 各持连接",本质上是把 pconnect 的思路做对了、做全了。
| 方案 | 连接归属 | 复用粒度 | 主要风险 |
|---|---|---|---|
| 每请求建连 | 请求 | 无 | 握手开销 × QPS |
| pconnect | worker 进程 | 请求 | 状态泄漏、连接数乘法 |
| 常驻 worker(Octane / FrankenPHP) | worker 进程 | 请求 | 内存泄漏、静态状态残留 |
| 协程池(Swoole PDOPool) | 池 | 单条查询 | 生态兼容、调试门槛 |
代价也明摆着:进程不再替你兜底了。静态属性、容器单例里存的东西,在 FPM 下"反正马上就死",在 worker 模式下会一直活着、一直涨。所以 Octane 默认每 500 个请求回收一次 worker,还要你手动声明哪些单例在请求之间要 flush——这些配置不是性能调优,是在补"进程不死"欠下的账。
顺带一句,FrankenPHP 这两年成了新项目的默认选择:单个二进制,自带 HTTPS 和 HTTP/3,前面不用再挂一层 Nginx。部署简单这件事,比基准测试里那零点几毫秒有用多了。
最后把话说死:我赌 PHP 9 正式发布的时候,FPM 依然不会有官方连接池。这不是 PHP 做不到——Swoole 证明了做得到——而是 share-nothing 就是 FPM 的终局形态,需要池的人和项目,本来就应该去常驻运行时那边。一个模型把"不用管资源释放"换给普通项目,把"要管资源释放"留给追求极限吞吐的人,各取所需,挺公平的。