php-redis pconnect长连接原理与PHP-FPM连接释放避坑指南 简介这份资源围绕php-redis的pconnect长连接机制展开深入探讨适合对PHP-FPM运行原理、Redis连接管理及性能优化有进阶需求的开发者。内容从“pconnect是否真正长连接”的疑问出发结合官方文档说明和tideways扩展的实测数据清晰展示了同一PHP-FPM子进程内连接复用、进程重启后连接重建的完整过程并给出了对高并发场景下配置max_requests等参数的实用思考。资源包为单个PDF文档大小47KB内容精炼便于快速查阅。目前已吸引732人浏览学习对于想厘清PHP持久连接细节、避免Redis连接资源浪费的读者具有直接参考价值。1. 关于 php-redis 的 pconnect 长链接一次被前辈结论带偏的排查大概半年前我在处理 Redis 连接数告警时听到一个说法php-redis 的 pconnect 根本不算长链接请求一结束连接就被释放了。这话从一个经验丰富的前辈嘴里说出来我差点就信了。可转念一想如果真是这样php-redis 何必同时提供 connect 和 pconnect查官方文档一行英文点醒了我The connection will not be closed on end of request until the php process ends。连接不会随请求结束关闭要等 PHP 进程结束才释放。也就是说pconnect 是进程级长连接而决定它释放时机的正是 PHP-FPM 子进程的生命周期。下面我会把原理、实验验证和各环节的坑讲清楚适合被 Redis 连接数问题困扰、或准备在 php-fpm 下改用 pconnect 的开发者。2. 长链接到底长在哪connect 与 pconnect 的差异和 PHP-FPM 进程生命周期2.1 connect 每次请求都新建连接pconnect 复用的是进程级连接要搞清楚 pconnect先得理解 connect。用最直白的话说connect 是「一次请求一条连接」每次请求进入 PHP-FPM 子进程执行到 $redis-connect() 时都会向 Redis 发起一次 TCP 握手经过三次握手建立连接请求处理完毕PHP 请求结束连接就被关闭。下次请求哪怕落到同一个子进程照样重新握手。它安全、无状态残留但也贵每次多一次 RTT在高频访问 Redis 的场景下这部分开销会被放大。pconnect 走的是另一条路。它建立的 socket 不会在请求结束后关闭而是被 PHP 进程持有下次同一个请求进来只要还落在同一个子进程$redis-pconnect() 就会直接复用之前那条连接。这样一来TCP 握手只发生一次后续请求省掉了 connect 的开销。它们之间的本质差异可以用一句话概括connect 的连接生命周期等于单次请求pconnect 的连接生命周期等于 PHP 进程生命周期。在 PHP-FPM 模式下一次 HTTP 请求只是子进程处理的一个 job处理完 job 后子进程并不退出而是继续等待下一个任务所以请求结束不等于连接释放这才是 pconnect 能省下握手开销的根源。看一段最简单的代码体会差异$redis new Redis(); $redis-connect(127.0.0.1, 6379); // 短连接请求结束连接关闭 $redis-set(key_connect, 1); $redis-close(); $redis2 new Redis(); $redis2-pconnect(127.0.0.1, 6379); // 持久连接进程存活期间复用 $redis2-set(key_pconnect, 1);connect 那一段是「用完即走」close 主动收尾pconnect 不需要每次 close因为设计意图就是要跨请求保留。需要提醒的是pconnect 第一次建立连接时同样要走一次完整的 TCP 握手所以它优化的是「从第二次请求开始」的累计开销而不是让第一次飞起来。参数层面pconnect 的函数签名和 connect 基本一致pconnect(host, port, timeout, persistent_id?)。timeout 是连接超时秒数默认 -1 表示不超时persistent_id 是可选参数同一个进程内如果指定了不同的 persistent_id会创建多条独立的持久连接。这一点在后面的避坑章节还会提到——它直接关系到连接会不会串。2.2 PHP-FPM 的 max_requests 是决定连接何时释放的钥匙既然 pconnect 的连接生命周期等于 PHP 进程生命周期那么在 PHP-FPM 下真正决定连接什么时候释放的就是子进程的重启策略。PHP-FPM 的 master 进程会管理一组 worker 子进程每个子进程处理完一定数量的请求后会被杀掉并重新拉起一个这个「一定数量」就是 pm.max_requests。看一段常见的池配置[www] user nobody group nobody listen 127.0.0.1:9000 pm dynamic pm.max_children 50 pm.start_servers 10 pm.min_spare_servers 5 pm.max_spare_servers 20 pm.max_requests 5000pm.max_requests 5000 表示每个子进程处理满 5000 个请求后就会被 master 回收然后创建一个新的子进程顶上。子进程退出时PHP 进程结束它持有的所有资源——包括 pconnect 创建的 Redis 连接——才会真正被释放。所以一条 pconnect 连接的寿命最长可以跨越 5000 个请求如果 max_requests 设置为 0表示子进程永不重启连接也就会一直保留。这个参数和连接数的关系可以用表格概括max_requests 配置子进程重启频率pconnect 复用效果主要风险0无限永不主动重启最好握手开销最低内存泄漏、连接长期占用难回收1000高一般连接频繁重建高并发下 Redis 连接数波动大500020000适中好同时兼顾回收需配合内存观察防止野增长极大值低很接近无限进程异常退出时累积连接不可控实际配置时我一般会先看应用的单请求内存增量再把 max_requests 调成足够的请求次数。比如单个请求内存涨 0.5MB子进程默认内存上限 256MB那么 5000 次请求以内是安全的如果内存涨得快就要相应调小而不是为了长连接硬撑。很多人只关注连接数大小忽略了 max_requests 对连接释放时机的决定性作用。你看到的「连接数不降」可能根本不是泄漏而是因为子进程还没处理好它命里的那 5000 次请求。反之如果你把 max_requests 设成 100那 pconnect 和 connect 几乎没有区别——每个子进程才活了 100 次请求就被换掉了连接刚建立就跟着进程一起消亡。这也是后文避坑部分最重要的一个切入点。提示max_requests 的单位是请求次数不是并发数。它是每个 worker 子进程的重启阈值而不是整个 fpm 池子的总请求数阈值。2.3 官方文档那句英文怎么理解直到 PHP 进程结束才关闭php-redis 官方文档里关于 pconnect 有一句经常被引用的话The connection will not be closed on end of request until the php process ends。字面意思就是该连接不会在请求结束时被关闭它会一直保留直到 PHP 进程结束。这句话里有两个关键词end of request 和 php process ends。end of request 指的是 PHP 脚本执行结束对应 PHP-FPM 子进程完成一次请求处理的节点。php process ends 则是指整个 PHP 进程退出在 FPM 下就是子进程被 master 回收的那一刻。这两个事件在时间上可以差得很远——子进程处理完一次请求后还会继续留在池里等待下一个任务所以「请求结束」和「进程结束」是两件完全不同的事。在命令行下运行 PHP 脚本时这个区别不明显脚本执行完进程也就结束了pconnect 连接立刻被系统释放。但在 PHP-FPM 下进程被复用了连接也因此被复用。这就是为什么很多人在 CLI 脚本里测 pconnect 总是测不出「长」的感觉——因为 CLI 的进程生命周期等于单次执行pconnect 在 CLI 里跟 connect 几乎没差别。还有一个容易误读的点官方文档说的是「不会关闭」并不是「永远不会关闭」。它默认了 PHP 进程最终会结束哪怕 FPM 配置了 max_requests0子进程也会因为系统重启、OOM 或其他原因被杀死。被杀死后内核会回收该进程所有的文件描述符Redis 连接自然也就断开了。所以 pconnect 不是无限期占用的僵尸连接它只是把释放时机从「请求结束」延后到了「进程结束」。理解到这层再看后面的压测结果就会顺很多为什么第一次请求有 pconnect 调用第二次就没有因为第二次请求复用了同一条连接根本不需要再去建立。为什么重启 fpm 之后第一次请求又出现了 pconnect因为旧子进程退出连接被释放新子进程必须重新建立连接。这个实验逻辑非常清晰接下来我用 xhprof 系列扩展把它完整跑一遍。3. 用 xhprof 系列扩展验证 pconnect实验设计、测试代码与结果解读3.1 实验环境准备采样扩展与 analyze-xhprof 命令验证 pconnect 是否真的在复用连接最直接的办法是观测每次请求里有没有调用 pconnect。如果第二次请求开始不再出现 pconnect 的函数调用说明连接被进程复用了。这个观测可以用 xhprof 系列的性能分析扩展来完成它会给每次请求生成一份函数调用统计。实验环境大概是这样一台装了 PHP-FPM 的机器Redis 监听在 127.0.0.1:6379PHP 已加载 phpredis 扩展和 xhprof 扩展。xhprof 的启用方式比较简单在 php.ini 里加一行extensionxhprof.so然后写一个按需开启采样的测试脚本。需要说明的是xhprof 扩展提供 xhprof_enable() 和 xhprof_disable() 两个函数前者接收位掩码参数控制采样粒度后者返回本次采样的数据。下面是我使用的执行流程在测试目录放一个 index.php内容只做一次 pconnect 和一次读写用 curl 连续请求两次这个地址对每次请求生成的日志执行 analyze-xhprof 分析看函数调用表重启 php-fpm再请求第一次继续看日志。analyze-xhprof 是一个命令行分析工具输入 xhprof 生成的日志文件输出类似表格的函数耗时统计。它会列出每个函数的调用次数和耗时我们重点看 Redis::pconnect 这行的 COUNT 值。如果采样配置不当xhprof 默认会带上大量内置函数干扰阅读所以实验里我会用 XHPROF_FLAGS_NO_BUILTINS 过滤。3.2 测试代码pconnect 写入与读取测试代码不要写复杂业务只保底验证连接复用即可。我用的测试脚本是?php // 开启 xhprof 采样不统计内置函数保持输出干净 xhprof_enable(XHPROF_FLAGS_NO_BUILTINS); $redis new Redis(); // 重点使用 pconnect 而不是 connect $redis-pconnect(127.0.0.1, 6379); $redis-set(why, 1); $redis-get(why); // 结束采样并把数据写入日志文件文件名按请求时间区分 $data xhprof_disable(); file_put_contents( /tmp/why- . date(Ymd_His) . .log, serialize($data) );这里有几个参数值得解释。xhprof_enable 的参数 XHPROF_FLAGS_NO_BUILTINS 表示不统计 PHP 内置函数只保留业务函数和扩展函数这样 analyze-xhprof 的输出不会淹没在 str_replace 之类的调用里。$redis-pconnect(127.0.0.1, 6379) 没有传第三个参数 timeout会使用默认配置生产环境中建议显式传入超时值避免 fpm 进程阻塞在连接上。set 和 get 逼真模拟了业务中最常见的 Redis 读写保证实验有实际负载而不是只建连不干活。每次请求都会生成一个独立的日志文件文件名的 Ymd_His 精度到秒如果同一秒多个请求文件可能覆盖。实验里我手动增加了一个随机后缀或用循环计数来避免这里为了演示就不写得太复杂。关键点是必须保证能区分第几次请求才能对比 pconnect 是否被调用。3.3 请求两次观察函数调用第一次有 pconnect第二次没有准备完成后第一步是两个连续请求。发送第一次请求收集日志用 analyze-xhprof 打开看到的结果是----------------------------------------------------------------- | FUNCTION | COUNT | WALL-TIME | EXCL WALL-TIME ( 0.75 MS) | ----------------------------------------------------------------- | Redis::pconnect | 1 | 7.54 ms | 7.54 ms | | Redis::get | 1 | 6.32 ms | 6.32 ms | | Redis::set | 1 | 6.21 ms | 6.21 ms | -----------------------------------------------------------------注意 Redis::pconnect 的 COUNT 1说明这次请求真的建立了持久连接。接着发送第二次请求再分析日志函数调用表变成了------------------------------------------------------------ | FUNCTION | COUNT | WALL-TIME | EXCL WALL-TIME ( 1.02 MS) | ------------------------------------------------------------ | Redis::set | 1 | 10.20 ms | 10.20 ms | | Redis::get | 1 | 6.67 ms | 6.67 ms | ------------------------------------------------------------第二次请求里Redis::pconnect 连影子都没有。这背后的逻辑是第一次请求结束时pconnect 的连接没有被关闭第二次请求进入同一个 fpm 子进程后再次执行 $redis-pconnect()内部发现已经有一条指向 127.0.0.1:6379 的持久连接于是直接复用不再走 TCP 握手。所以「第二次请求没有 pconnect 调用」就是连接被复用的直接证据。这里有一个细节值得提第二次请求里 set 的耗时反而更高10.20ms是因为同一台机器上 Redis 刚好在刷盘或受到其他干扰并不代表复用导致变慢。验证的目的是看「有没有建立连接」而不是比较单次耗时的绝对值。如果压测样本足够多复用后的总耗时通常会显著低于每次新建连接的场景。注意analyze-xhprof 输出的 EXCL WALL-TIME 列会过滤掉短耗时函数只显示超过阈值的部分。阈值显示在表头如 0.75 MS如果 pconnect 函数本身耗时极短也可能直接不出现在表里。所以实验里第一次请求必须保证 pconnect 耗时足够高或者用 COUNT 列来做判断。3.4 重启 PHP-FPM 后第一次请求又出现 pconnect第二次请求看不到 pconnect有人可能会怀疑是不是采样丢了函数。为了排除这个怀疑我主动模拟了 PHP-FPM 子进程重启也就是让进程生命周期走到终点。方法是直接杀掉 php-fpm 的 master 进程然后重新启动ps -ef | grep php-fpm # 找到 master 进程 PID这里假设是 9715 sudo kill 9715 sudo php-fpm重启后再发送一次请求分析日志Redis::pconnect 又回来了----------------------------------------------------------------- | FUNCTION | COUNT | WALL-TIME | EXCL WALL-TIME ( 1.03 MS) | ----------------------------------------------------------------- | Redis::pconnect | 1 | 10.33 ms | 10.33 ms | | Redis::get | 1 | 7.89 ms | 7.89 ms | | Redis::set | 1 | 6.87 ms | 6.87 ms | -----------------------------------------------------------------这个结果恰好把上一章的结论坐实了pconnect 的连接生命周期等于 PHP 进程生命周期。旧 master 被杀所有 worker 子进程随之退出它们持有的 Redis 连接被内核回收新 master 拉起的子进程是干净的没有已存在的持久连接所以第一次请求必须重新 pconnect。整个实验过程可以浓缩成一张表方便回顾步骤操作观察结果结论第一次请求请求 index.phpRedis::pconnect COUNT1连接被建立第二次请求再次请求 index.phpRedis::pconnect 无调用连接被复用重启 fpm杀 master 再启动第一次请求 Redis::pconnect COUNT1进程重启连接重建到这里关于「pconnect 到底是不是长链接」的争论已经有了实验答案在 php-fpm 下它是进程级的长链接不是请求级的长链接连接释放点跟 max_requests 和进程重启强相关。下面我把平时最容易踩的坑整理一下很多都是从这个结论延伸出来的。4. 避坑指南pconnect 长链接的四个常见误判与配置陷阱4.1 现象一请求结束后连接一直 ESTABLISHED以为泄漏现象某个接口流量已经降下来了但在服务器上执行 ss -ant | grep 6379仍然看到大量连接处于 ESTABLISHED 状态。第一反应往往是「Redis 连接泄漏了」甚至有人直接在代码里强制 close。原因pconnect 本来就设计成跨请求保留连接在 ESTABLISHED 状态是正常现象。php-fpm 子进程只要还活着它持有的连接就不会因为请求结束而关闭。你看到的连接数是「所有存活子进程持有的持久连接数」而不是「当前正在处理的请求数」。当流量下降子进程空闲下来连接自然还挂着。解决先确认这些连接的远端进程是不是 php-fpm。用 lsof -p 查看某个子进程打开的 redis 连接如果对得上就不是泄漏。判断是否异常的标准应该是「连接数是否超过 pm.max_children」而不是「请求结束后连接是否为零」。如果你确实需要释放某些连接可以调低 pm.max_requests 让子进程更快重启或手动 kill 掉对应子进程让 master 重新拉起。4.2 现象二max_requests 设置过小长链接退化成短链接现象压测时功能正常但 Redis 连接数波动非常剧烈同时日志里每次请求都能看到 Redis::pconnect 调用连接建立耗时占比明显升高。明明是 pconnect怎么看起来跟 connect 一样原因max_requests 设得太小比如 500 甚至 100。子进程平均处理不到几个请求就被重启pconnect 建立的连接刚用上一两次进程就退出连接被迫关闭。下一次请求分配到新子进程又得重新 pconnect。长连接在这种配置下名存实亡。解决把 pm.max_requests 调大让每个子进程有机会多次复用同一条连接。我一般以 5000 为起点再结合单次请求内存增量调整。更准确的做法是观察子进程常驻内存是否有持续上涨趋势用 ps -o rss -p 连续看几个请求周期如果内存一直不回落才考虑调低 max_requests。用「连接复用率」这个指标来验证也很有用如果统计到的 pconnect 调用次数只占总请求数的很小比例说明复用得很好。注意 max_requests 不是越大越好如果子进程长期不重启一些隐性的请求状态污染和内存碎片会慢慢累积最终反而更难排查。4.3 现象三多个 Redis 库切换导致连接串用现象应用里给不同业务用 Redis 的不同逻辑库比如第一个请求 select(0)第二个请求 select(2)结果第二个请求读到的却是 0 号库的数据或者抛错。换成 connect 之后问题消失一改 pconnect 又复现。原因pconnect 复用同一个连接而连接上的「当前数据库」是状态的一部分。子进程第一次处理请求时 select(0)连接保持在 0 号库下一个请求复用连接如果代码没有 select(2)或由于某些分支提前 return就会继续使用上一轮的状态。connect 由于每次新建天然没有这个问题。解决有两种常见做法。一是每个业务独立指定 persistent_id让不同逻辑库使用不同连接$redis-pconnect(127.0.0.1, 6379, 2.5, db2); 这样进程内会创建多条持久连接互不干扰。二是在每次使用前显式 select 目标库并在请求结束时把连接拉回 0 号库作为兜底。注意 pconnect 的 persistent_id 是 php 进程内的连接标识别把它当成 Redis 的 db 编号两者是不同维度。如果项目逻辑库很多我更推荐用「不同业务不同 Redis 实例」或「不同连接」来隔离避免状态串扰。这里我补充一个容易翻车的细节如果使用 persistent_id同一个 fpm 子进程内的不同 persistent_id 会各自创建一条连接。也就是说100 个子进程、5 个 persistent_idRedis 端最多可能看到 500 条连接。Redis 的 maxclients 默认是 10000但如果你误配了很高的 pm.max_children 和大量 persistent_id连接数会成倍增长很快打满。这个变量在压测时一定要算进去。4.4 现象四持久连接与连接池混为一谈现象有人把 pconnect 当成了类似 Java 连接池的东西以为它能自动限流、主动回收空闲连接、动态调整连接数。结果流量上涨时连接数跟着一路上涨最后 Redis 报 maxclients 超限。原因pconnect 只是一个「复用机制」不是「池化机制」。它不管理连接数量php-fpm 有多少个存活子进程就可能有多少条连接子进程空闲时连接也空闲Redis 端只是保持 ESTABLISHED没有超时释放策略的干预它们会一直占用。连接池会有容量上限、空闲回收、借用归还等行为pconnect 完全没有这些。解决把 pconnect 的连接数上限控制问题交给 php-fpm 的 pm.max_children。连接数的理论峰值就是 max_children长连接场景下每个子进程最多一条连到同一 Redis 实例的连接除非用 persistent_id 加开多条。需要控制 Redis 连接数时优先缩小 max_children或给不同进程池设置不同上限。如果还想自动回收空闲连接可以配合 Redis 端的 timeout 配置在 redis.conf 中设置 timeout 300让空闲超过 300 秒的连接被服务端关闭。但要注意php 侧持有的连接会因此在下次请求时失效代码里要做好重连兜底否则会出现「偶尔读不到 Redis」的诡异故障。以上四条是我在实际项目里遇到过的坑。它们都有一个共同点只要理解了「pconnect 进程级长连接」这句话大部分都可以提前规避。最后再补一个衍生提醒pconnect 连接被 Redis 服务端断开后php 侧不会自动重建如果你不检查返回值下一次 pconnect 可能会返回 false 或抛异常。这个问题在测试环境不容易触发上线后却可能因为一次网络抖动让你半夜爬起来查日志。5. 进阶验证不依赖采样扩展用 ss 和 redis-cli 观察连接复用5.1 用 ss 统计 redis 连接数变化采样扩展不是每个环境都能装。验证 pconnect 是否复用的另一个轻量手段是直接观察 TCP 连接数。假设 Redis 端口是 6379在请求前执行 ss -ant | grep 6379 | wc -l请求后再执行一次。第一次请求后连接数加 1第二次请求后连接数不再增加说明连接被复用了。这个方法的缺点是会把其他来源的连接也统计进去比如 session 存储、监控脚本所以统计前最好先关闭无关访问或者用 lsof -i :6379 单独看 php-fpm 进程的连接。5.2 用 INFO clients 确认连接复用更贴近服务端视角的指标是 Redis 的 connected_clients。执行 redis-cli info clients观察这个字段。让请求固定落在同一个 fpm 子进程时第一次请求后 connected_clients 从 X 变成 X1之后多次请求都保持 X1。为了保证结果可解释压测期间可以把 pm 临时设为 static固定子进程数量再把 pm.max_requests 设成较大的值避免子进程在中途被替换。否则子进程数量本身在变连接数也会跟着变看不出复用效果。5.3 给 pconnect 加超时与重试的兜底连接复用验证完之后生产代码里还要给 pconnect 加一层兜底。连接可能因为 Redis 重启、空闲超时、防火墙老化被服务端断开但 php 侧不一定立刻感知。简单做法是封装一个带超时和重试的建连函数function redis_pconnect_with_retry($host, $port, $timeout 2.5) { $redis new Redis(); if ($redis-pconnect($host, $port, $timeout)) { return $redis; } usleep(200000); if ($redis-pconnect($host, $port, $timeout)) { return $redis; } throw new RuntimeException(Redis pconnect failed after retry); }timeout 参数显式传 2.5 秒防止 fpm 子进程因为 Redis 不可用而长时间阻塞。失败后等待 200ms 再试一次能覆盖大多数「Redis 刚好重启」的瞬断场景。如果你的应用对延迟更敏感可以把重试次数和间隔做成配置项而不是写死。从那以后我每次改连接配置都会强制走一遍「看进程数、看连接数、看 pconnect 调用」三步检查进程数对应 fpm 容量连接数对应是否复用pconnect 调用对应是否真的在新建。三步都对得上才敢把代码合进去。希望帮到你。本文还有配套的精品资源点击获取