简介:这份PDF探讨了PHP中使用php-redis扩展时pconnect持久连接的真实行为,面向对PHP-FPM与Redis连接机制存在困惑的中级开发者。资源以一次“大佬观点”引发的争议为切入点,结合官方文档与PHP-FPM多进程、max_requests配置,梳理了pconnect连接何时复用、何时释放,并通过tideways扩展实际观测函数调用记录,验证了进程重启后连接会重建。内容对理解高并发场景下Redis连接复用的收益与风险,以及如何配置子进程生命周期具有直接参考价值。压缩包内为1个PDF文件,大小仅47KB,内容精炼、聚焦单一问题,适合已在生产环境使用PHP-FPM和Redis的工程师快速查阅。目前已有732人学习,说明这一话题确实击中了不少开发者的疑问点。
1. pconnect 到底是什么:先解决"长链接"的误解
线上 Redis 连接数告警,业务方坚持说"我用了 php-redis 的 pconnect 长链接,为什么connected_clients还是飙到几千?"打开redis-cli info clients一看,连接数几乎等于 php-fpm 的max_children配置值,个别 worker 进程还占了不止一条连接。这个场景我第一次遇到时也懵过,后来才意识到:pconnect 并不是一条连接一直挂在 Redis 服务器上,它只是把连接的生命周期从"请求结束就释放"拉长到了"进程结束才释放"。
在 php-fpm 模式下,pconnect 的连接归每个 worker 进程私有,进程还在,连接就一直占着。你以为的长链接是"全局复用",实际上的长链接是"进程级复用"。它确实省掉了 TCP 握手、AUTH 认证的时间和开销,但并不会减少连接数,甚至可能让连接数稳定地逼近 worker 进程数上限。
这篇文章写给那些已经在用或者准备用 php-redis pconnect 的开发者。新手可以照着一套配置把连接复用跑起来,熟手可以避开连接状态残留、断线不重连、连接数爆炸这几个经典的坑。先把这个反直觉的机制搞懂,后面所有参数才调得明白。
2. 从 connect 到 pconnect:连接复用原理与选型理由
2.1 连接的成本:每次握手有多贵
Redis 是 TCP 服务,每次用connect新建连接都要走一遍三次握手。如果 Redis 配置了requirepass,还得额外做一次AUTH。这些开销在本地环境几乎可以忽略,但在远程 Redis、跨机房部署或者高并发场景下,RTT 每增加 1ms,一次普通请求里 Redis 连接建立的时间占比就可能超过 10%。
假设一个接口有 10 个 Redis 操作,每个操作都独立用connect建连,那就是 10 次握手 + 10 次认证。哪怕 Redis 响应本身只要 0.1ms,连接建立也要吃掉大量时延。更麻烦的是,每次connect回到连接池后,如果服务端已经关闭了空闲连接,第一次读写会失败并触发重连,这种隐藏的握手成本更难排查。
pconnect 的核心价值就是把"每次请求建连"变成"每个进程建一次连"。连接建立后一直留在进程内部,后续同一个进程里的所有 Redis 操作都直接复用这个 socket。付出的代价是连接状态会被跨请求保留,这也是后面各种坑的根源。
2.2 pconnect 的实现机制:进程级连接池
php-redis 扩展内部维护了一张以host:port:persistent_id为 key 的连接映射表。当你调用pconnect时,扩展先检查当前 PHP 进程里这张表有没有可复用的连接,有就直接取用,没有才创建新连接。
关键点在于:这个连接池不是全局共享的,而是进程级的。php-fpm 的每个 worker 都是独立进程,所以每个 worker 各自维护自己的 Redis 连接。你开了 100 个 fpm worker,理论上 Redis 侧最多看到 100 条来自 pconnect 的进程级长连接。如果代码里还特意传了多个persistent_id,同一个 worker 内部可能建立多条连接,连接数就会超过 worker 数。
我一般会在本地用内置服务器验证这个机制,写一个最简单脚本:
<?php // demo.php $redis = new Redis(); // 第四个参数 persistent_id 用于区分连接,省略时以 host:port 作为 key $redis->pconnect('127.0.0.1', 6379, 2.5, 'demo'); // 打印当前 PHP 进程号,连续刷新页面看是否变化 echo 'pid=' . getmypid() . "\n"; // client id 是 Redis 侧给当前连接分配的唯一 id,能直接看出是不是同一条连接 echo 'client_id=' . $redis->client('id') . "\n";逻辑说明:getmypid()返回当前 PHP 进程的 pid,client('id')返回这条连接在 Redis 服务端的内部编号。如果连续多次请求都由同一个 pid 处理,并且client_id不变,说明连接确实被复用了。
用php -S 127.0.0.1:9000 demo.php起一个单进程服务,刷新页面就能看到 pid 和 client id 始终不变。换成多进程 fpm 时,pid 会变,但只要落到同一个 worker 的请求,client id 还是会保持不变。这一步验证比看文档更容易理解 pconnect 的进程级复用本质。
2.3 选型理由:什么时候值得用 pconnect
不是所有场景都该用 pconnect。我的选型判断一般分三类:
第一种,常规 php-fpm web 服务,Redis 在远程且请求频率高。这种场景 pconnect 收益最明显,省下的握手和认证时间能直接反映在接口耗时上。但你得接受连接数约等于 worker 进程数,并且要做好连接状态重置。
第二种,CLI 常驻脚本,比如队列消费者、爬虫、长驻同步任务。这种场景我反而不推荐无脑用 pconnect。CLI 进程生命周期长,自己手动connect一次,在循环里复用同一个对象更可控。pconnect 的持久连接池在 CLI 里反而会让"连接是否存活"变得更难判断。
第三种,Redis 就在本机,走 unix socket,延迟极低。这种场景 pconnect 省下的握手开销微乎其微,却要承担连接状态污染的维护成本,不如普通connect省心。
下面这张表是我内部经常用到的选型对照:
| 场景 | connect | pconnect | 我的建议 |
|---|---|---|---|
| 本地 Redis + 低并发 | 推荐 | 不必要 | connect 简单直接 |
| 远程 Redis + 高并发 web | 每次握手成本高 | 推荐 | 用 pconnect 但控制 fpm worker 数 |
| CLI 常驻脚本 | 手动复用 | 可以用但难控制 | 手动 connect 一次,循环复用 |
| 需要严格隔离多个 Redis 库 | 每次请求独立 | 容易串库 | 用不同 persistent_id 或用短连接 |
选型说到底是在"省开销"和"管状态"之间找平衡。pconnect 省的是握手,多出来的管理成本是连接状态。后面第 3 章的参数配置和第 4 章的避坑,都是在补这个管理成本。
3. 配置与参数:把 pconnect 调到可靠的正确姿势
3.1 pconnect 函数签名与基础用法
php-redis 不同版本对pconnect的参数支持略有差异,但最常见的形式是:
$redis = new Redis(); $redis->pconnect( '127.0.0.1', // host 6379, // port 2.5, // timeout,秒,建立 TCP 连接的超时时间 'cache', // persistent_id,同一个进程内用相同 id 复用同一条连接 100 // retry_interval,重试间隔,单位通常为毫秒 );参数说明:timeout是建立 TCP 连接的超时时间,不是读写超时。Redis 服务端不可达时,设成 2.5 秒意味着请求最多等 2.5 秒就会抛异常,不会无限挂起。persistent_id用来给这条持久连接起一个名字,同一个进程里用同一个 id 的pconnect会拿到同一条连接;不传时扩展会以host:port作为默认 key。retry_interval是连接失败后的重试间隔,别设太短,否则 Redis 还没恢复时你的应用会疯狂重试。
一个容易被忽略的点:pconnect返回的是Redis对象,但它并不是真正意义上的"连接对象"。它只是扩展内部连接池的一个句柄。你在同一个进程里多次new Redis()再调pconnect,只要persistent_id相同,拿到的是同一个底层连接。这一点决定了你可以在多个对象之间共享连接,也决定了连接状态是全局残留的。
实际项目里我通常会把 pconnect 的调用收敛到一个统一方法里,避免各处随意传参导致连接池 key 不一致:
// RedisClient.php class RedisClient { public static function create(string $persistentId = 'default'): Redis { $redis = new Redis(); $redis->pconnect( '127.0.0.1', 6379, 2.5, $persistentId, 100 ); return $redis; } }这样同一个请求里所有用到 Redis 的代码,只要通过这个工厂方法创建对象,就会自动命中同一条 pconnect 连接。如果你希望一个请求里同时操作多个 Redis 库,就传入不同的persistent_id,这样每个库的连接状态互不干扰。
3.2 timeout 和 read_write_timeout 参数怎么设
很多人在 pconnect 上只配了timeout,却忽略了读写超时。timeout只管建连,建连成功后如果 Redis 响应很慢,读超时由另一组参数控制。php-redis 里可以通过setOption设置:
$redis = new Redis(); $redis->pconnect('127.0.0.1', 6379, 2.5, 'cache'); // 读超时设为 3 秒,超过 3 秒没有数据返回就抛异常 $redis->setOption(Redis::OPT_READ_TIMEOUT, 3); // 写超时同样可以设置,有些版本也能通过 OPT_WRITE_TIMEOUT 控制 $redis->setOption(Redis::OPT_WRITE_TIMEOUT, 3);参数说明:Redis::OPT_READ_TIMEOUT控制从连接读取数据时的超时时间。pconnect 连接一旦被上一个请求设置了某种状态,下一个请求复用时的超时行为也会继承,所以最好在每次取出连接后统一 reset 这些选项。如果你在长连接上执行BLPOP这类阻塞命令,预期的阻塞时间比读超时长,会把读超时打满直接异常。这时候可以把读超时设成-1,表示永不超时,但我只在 CLI 脚本里这么做,web 请求里设-1等于把一个 worker 无限期挂死。
推荐参数组合是:TCP 连接超时 0.5 到 3 秒,读超时 3 到 5 秒,写超时 3 秒左右。Redis 正常响应都在毫秒级,设 5 秒已经足够宽松,又能避免异常时请求长时间挂起。下面是我的常用配置表:
| 参数 | 作用范围 | 推荐值 | 说明 |
|---|---|---|---|
| timeout | TCP 握手 | 0.5 ~ 3 秒 | 本地设 0.5,远程设 2.5 |
| retry_interval | 建连失败重试 | 100ms 以上 | 太短会造成重试风暴 |
| OPT_READ_TIMEOUT | 读取响应 | 3 ~ 5 秒 | 阻塞命令除外 |
| OPT_WRITE_TIMEOUT | 发送命令 | 3 秒 | 大数据量写入可调大 |
3.3 框架连接配置:Laravel / ThinkPHP 里的持久连接开关
如果你用 Laravel,config/database.php 里的 Redis 连接默认走 phpredis 驱动时,可以通过persistent字段打开 pconnect:
'redis' => [ 'client' => env('REDIS_CLIENT', 'phpredis'), 'options' => [ 'prefix' => env('REDIS_PREFIX', ''), ], 'default' => [ 'host' => env('REDIS_HOST', '127.0.0.1'), 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', 6379), 'database' => 0, 'persistent' => true, ], ],persistent为true时,底层会走pconnect,false 则走普通connect。这里要注意:Laravel 的连接池是进程级的,并且同一个连接会被多个请求复用。框架在请求结束时不会主动清理 Redis 连接上的状态,所以你必须保证每个请求用到的database配置一致,或者在业务代码里显式select。ThinkPHP 的redis配置里也有类似的persistent选项,原理一致,但实际测试过有些版本只在连接器初始化时读取一次,改配置要重启 php-fpm 才能生效。
我建议在框架入口做一个强制重置:获取 Redis 连接后,无条件把 db 选到配置值,并清理掉可能残留的事务状态。
// 伪代码:每个请求开始时执行 $redis->select(config('redis.database')); $redis->discard(); // 如果上一个请求遗留了未完成的事务,discard 掉虽然discard()在未开启事务时会返回错误,但不会抛异常,安全起见可以先判断一下getLastError()。这个强制重置的习惯能帮你挡掉第 4 章里的好多坑。
4. pconnect 常见问题与避坑:5 个典型翻车现场
4.1 连接数不减反增:too many connections
现象:业务方原来用普通connect时,Redis 连接数在合理范围;改成pconnect后,连接数不仅没降,反而持续上涨,直到 Redis 报max number of clients reached,新请求全部被拒。
原因:pconnect 的连接数取决于 php-fpm worker 数以及每个 worker 内部建立的 pconnect 连接数。如果你配置了 200 个 fpm worker,每个 worker 都建立了 1 条连接,Redis 侧就有 200 条连接。如果代码里每次调用都传了不同的persistent_id,比如把时间戳拼进去当 id,整个连接池就完全失效,每一个请求都会新建连接且不释放,连接数直接失控。
解决:先看php-fpm的max_children,确认 worker 数是否合理。然后在代码里统一persistent_id,保证同一个进程、同一个业务复用同一条连接。排查时可以执行lsof -p <pid> | grep redis看单个 worker 建立了多少条连接,如果超过预期,说明persistent_id混用了。还有一种容易被忽略的情况:某些框架自带的连接池工具会在同一个请求内多次new Redis(),每次都调pconnect且传了随机 id,这种代码要收敛到一个工厂方法里。
4.2 数据串用:读到别的请求的写入
现象:同一个 fpm worker 先后处理两个请求,第一个请求往user:100写入了数据,第二个请求的代码明明没有动过这个 key,却读到了这个值,或者执行llen时拿到了上一个请求留下的未消费队列数据。
原因:pconnect 复用了同一条连接,而 Redis 本身没有请求隔离的概念。如果第一个请求在连接上执行了multi()开启了事务但没提交完,或者执行了select切换了 db,第二个请求拿到的就是带脏状态的连接。最典型的是事务残留:multi()之后的命令还在队列里,下一个请求的exec()会把上一个请求堆积的命令一起执行。
解决:每个请求开始前强制重置连接状态。调用$redis->reset()是 phpredis 里比较彻底的方式,可以清空连接上大部分状态;没有reset()的版本就逐个处理:discard()取消未完成事务,unwatch()取消 watch,select(0)切回默认库。我习惯把这些重置动作放到一个resetConnection()函数里,在每次获取连接后调用。不要在连接上做跨请求依赖,这是 pconnect 最反直觉的地方。
4.3 断线后连接失效:pconnect 不自动重连
现象:Redis 服务重启或网络抖动后,应用日志里频繁出现RedisException: read error on connection,但 Redis 已经恢复正常,请求还是持续报错,必须重启 php-fpm 才能恢复。
原因:pconnect 的连接在进程内是复用的,连接 socket 在 Redis 侧断开后,PHP 进程里的连接池仍然保留着这个失效句柄。php-redis 不会每次请求都去检查连接是否可用,只有当真正读写失败时才会抛异常,但失败后它不会自动把池里的句柄删掉重建,于是同一个失效连接被后续请求反复使用。
解决:捕获RedisException后,先调用close()再把连接句柄从池里移除,然后重新pconnect。在业务代码里我是这样处理的:
try { $redis->ping(); } catch (RedisException $e) { // close 会关闭底层 socket,并从 pconnect 连接池中移除当前句柄 // 下次再调用 pconnect,扩展会创建新连接 $redis->close(); $redis->pconnect('127.0.0.1', 6379, 2.5, 'cache'); }这段代码的关键是close()。有的开发者担心close()之后连接池还在,重新 pconnect 会取到老句柄,实际测试中常见行为是 close 会把当前进程里的持久连接标记为失效并释放,再次 pconnect 会新建连接。为了保险,我会在 close 后直接new Redis()再 pconnect,避免复用已释放对象的内部状态。
4.4 select db 之后串库
现象:代码第一处select(5)操作完某个业务库,第二处select(0)应该写默认库,但读取到的数据还是 db5 里的。排查半天发现,连接复用时第二个请求根本没执行select(0),而是沿用了上一个请求的 db5。
原因:和 4.2 相似,但这是最隐蔽的版本。很多框架只在连接初始化时 select 一次,之后不再处理。pconnect 复用后,库选择状态会一直保持到被下一个select覆盖。
解决:不要依赖"初始化时 select 一次"。每次真正使用连接前,都显式select目标库。如果你大量使用多库,建议直接用persistent_id做隔离:db0 用cache0这个 id 的连接,db5 用cache5这个 id 的连接,这样连接本身就不会互相串库。但要注意每多一个persistent_id,每个 worker 进程就多一条常驻连接,Redis 连接数会等比上涨。我的原则是:能拆 Redis 实例就不要依赖多库,真要用多库就接受连接数成本。
4.5 长连接上的阻塞命令卡住整个 worker
现象:某个接口里使用了BLPOP或subscribe,由于队列一直没数据,请求超时后被 fpm 杀掉,但 Redis 连接还挂在服务端。下一个请求复用到这条连接后,读到的是上一个请求残留的响应数据,或者直接抛RedisException,连普通get/set都开始报错。
原因:阻塞命令会让连接停留在"等待数据返回"的状态。如果命令没被正常消费完,socket 缓冲区里可能残留响应,pconnect 复用这条连接时会把残留数据当成当前请求的响应解析,结果全乱。
解决:在 php-fpm 的 web 请求里禁用阻塞命令,改用非阻塞轮询或 Redis 列表的正确消费姿势。队列消费场景用 CLI 脚本,并且手动connect建立专用连接,而不是复用 pconnect 连接池。如果确实要在长连接上用阻塞命令,用完立刻检查$redis->getLastError(),必要时重置连接。这种问题最难复现,因为它是时序性和连接复用叠加出来的,强烈建议用独立连接跑阻塞操作。
5. 进阶用法:把 pconnect 用成可靠的连接资源
5.1 close 的语义:别以为 pconnect 就永远不能 close
很多开发者把 pconnect 理解成"连接永不关闭",这其实是最大误解。close()对 pconnect 同样有效,只是它关闭的是当前进程内的这条持久连接,而不是影响其他进程的连接。调用close()后,扩展会从当前进程的连接池里移除这个句柄,再次调用pconnect会重新建立连接。
这个语义特别有用:当连接因为 Redis 重启、网络抖动变成一个"半死不活"状态时,不要犹豫,直接 close。不要担心关闭会影响其他业务,因为 pconnect 的连接本来就是进程私有的。我在代码里遇到连接异常的第一反应永远是:close,然后重新 pconnect,而不是傻傻地重试同一个失效句柄。
5.2 封装一个可重连的连接管理器(CLI / 单请求内)
pconnect 的原始 API 太底层,裸用容易忘记重置状态。我通常会在 CLI 常驻脚本或者单请求内封装一个简单的连接管理器,把检查连接、重连、选择 db 的逻辑收敛起来。示例代码如下:
class RedisConn { private static $redis; private static $lastCheck = 0; public static function get(string $persistentId = 'default'): Redis { if (!self::$redis) { self::$redis = self::connect($persistentId); } // 每 10 秒做一次健康检查,降低 ping 的额外 RTT 开销 if (time() - self::$lastCheck > 10) { try { self::$redis->ping(); self::$lastCheck = time(); } catch (RedisException $e) { // close 后重新建连,避免持续使用失效 socket self::$redis->close(); self::$redis = self::connect($persistentId); self::$lastCheck = time(); } } return self::$redis; } private static function connect(string $persistentId): Redis { $redis = new Redis(); $redis->pconnect('127.0.0.1', 6379, 2.5, $persistentId, 100); return $redis; } }这段代码每次调用get()时检测是否已有连接,没有就建立;每隔 10 秒用ping()做一次保活检查,发现异常就 close 重连。10 秒的频率既能及时发现断连,也不会给 Redis 增加太多压力,如果请求量很大可以改成 30 秒甚至 60 秒。注意这个类只适用于单个进程内复用,比如 CLI 脚本的 while 循环。在 php-fpm 里,PHP 变量跨请求不保留,static 属性同样在请求结束后清空,所以 fpm 场景下不用 static 缓存,直接依赖扩展内部的 pconnect 连接池即可。
5.3 通过 persistent_id 隔离业务与脏状态
如果你的项目已经重度使用 pconnect,又不想每次重置状态,一个务实的手段是给不同业务分配不同的persistent_id。缓存、队列、会话级数据各用各的连接,让脏状态只在一类业务内传播,不会跨业务互相污染。
$cacheRedis = new Redis(); $cacheRedis->pconnect('127.0.0.1', 6379, 2.5, 'cache'); // 队列业务独立连接,即使之前 select 过别的库,也不影响缓存连接 $queueRedis = new Redis(); $queueRedis->pconnect('127.0.0.1', 6379, 2.5, 'queue');每条独立连接都是进程级别的,所以连接数会翻倍。某个 fpm worker 同时用 cache 和 queue 两条连接,Redis 侧就能看到两条来自同一个 pid 的连接。这个成本需要你事先评估:如果你只有 50 个 worker,多一类业务也就多 50 条连接,完全可控;如果 worker 已经 500 个,再加一条连接 Redis 可能直接顶不住。我的经验是persistent_id的数量控制在 3 个以内,超出就考虑拆 Redis 实例或引入专门的连接代理层。
6. 验证 pconnect 是否生效:从日志到监控的落地技巧
6.1 在应用里打印连接 pid 和 client id
最直接的验证方式是让 Redis 告诉你当前连接是谁。在业务代码里临时加上getmypid()和client('id'),连续请求几次,观察这两个值的变化。如果 pid 变化,说明请求被不同 worker 处理;如果 pid 相同且 client id 也相同,说明 pconnect 命中同一条连接。
$redis = new Redis(); $redis->pconnect('127.0.0.1', 6379, 2.5, 'verify'); echo getmypid() . ' - ' . $redis->client('id') . "\n";client('id')是 Redis 服务端为连接分配的递增编号,同一会话内不变,不同连接必然不同。看到相同的 client id 就说明连接确实复用了。
6.2 用 ss 和 redis-cli 交叉确认连接数
应用侧打印还不够,我习惯同时看系统侧和 Redis 侧的连接状态。ss -tnp能列出每个 php-fpm 进程持有的 TCP 连接,redis-cli client list能看到 Redis 侧所有连接详情。
# 查看 php-fpm worker 持有的 redis 连接 ss -tnp | grep php-fpm # 在 Redis 侧统计每个客户端的连接数 redis-cli client list | awk '{print $2}' | sort | uniq -c对比两边结果:如果你有 100 个 fpm worker,每个 worker 只建立一条 pconnect 连接,client list里应该看到 100 条左右来自应用服务器的连接。如果发现单个 pid 对应多条连接,说明代码里的persistent_id混用了。
6.3 压测对比 connect 与 pconnect 的收益
最后用压测数据说服自己也说服别人。准备两个接口,一个用connect,一个用pconnect,压测时记录 QPS、p99 延迟以及 Redis 连接数。
ab -n 50000 -c 200 http://127.0.0.1:8080/redis_connect.php ab -n 50000 -c 200 http://127.0.0.1:8080/redis_pconnect.php压测时重点看两个指标:一是Requests per second,二是压测结束后redis-cli info clients的 connected_clients。如果 Redis 在远程,pconnect 的吞吐提升会很明显;如果 Redis 就在本机,差距可能只有 1% 到 2%。连接数方面,pconnect 反而会让连接数稳定在 worker 数,connect 则会在压测结束后逐渐归零。
我之前在某项目里压测时,看到 pconnect 的连接数一直往上走,第一反应是代码写错了,后来查清楚是 worker 进程数本身就很大。那次之后我养成了习惯:任何连接方式上线前,先看 worker 数,再压测看连接数,最后才看延迟指标。这次分享的内容也都是从这类实际排查里一点点攒出来的,希望帮到你。
本文还有配套的精品资源,点击获取