CClara轻量论坛系统

PHP 与 Redis 深度整合:发布订阅 / 事务 / Pipeline

XiaoC
XiaoC 正式会员正式会员认证极客认证极客
发布于 2026-10-29 21:49 ·3 浏览 ·7 回复
内容摘要

介绍 phpredis 的连接与统一前缀配置,说明 Pipeline 用于批量减少网络往返,但命令不能依赖前序返回值;事务通过 WATCH/MULTI/EXEC 实现乐观锁且无回滚,不能等同 MySQL 事务;发布订阅须运行在常驻进程,并注意键前缀会影响频道名,跨服务通信需显式处理。

学完这篇,你能在 PHP 里把 Redis 的批量执行、事务和消息推送三件事都用对,知道每种模式该用在哪、哪些坑会让你半夜排障。

第一步:先把连接参数配好

用 phpredis 扩展(pecl install redis 或宝塔面板的 PHP 扩展里勾选 redis),连上并设好全局选项:

$redis = new Redis();
$redis->connect('127.0.0.1', 6379, 1.5);   // 第三个参数是连接超时(秒)
$redis->auth('你的密码');
$redis->select(0);
$redis->setOption(Redis::OPT_PREFIX, 'bbs:');          // 统一键前缀
$redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP);

注意:OPT_PREFIX 会同时作用到 pub/sub 的频道名上。如果你的项目统一加了前缀,另一个没加前缀的服务就订阅不到消息。跨服务通信用频道时,要么显式写全名,要么在发布/订阅前 setOption(Redis::OPT_PREFIX, '')。

第二步:Pipeline——省的是往返时间

一次请求里要写 1000 个键,逐条 set 就是 1000 次网络往返。Pipeline 把命令攒起来一次性发:

$redis->multi(Redis::PIPELINE);
for ($i = 1; $i <= 1000; $i++) {
    $redis->set("hot:$i", $i);
    $redis->expire("hot:$i", 3600);
}
$res = $redis->exec();   // 返回 2000 条结果的数组

关键点:Pipeline 模式下命令是立即发送的(不是等 exec 才发),exec() 只是收集回包。

注意:Pipeline 里后面命令不能依赖前面命令的返回值。比如「先 incr 再根据结果 set」在 Pipeline 里做不到,得拆成两批或用 Lua。

第三步:事务——MULTI/EXEC 与 WATCH 乐观锁

$redis->watch('stock:1001');
$stock = (int)$redis->get('stock:1001');
if ($stock <= 0) {
    $redis->unwatch();          // 放弃监视,必须调用
    return '已售罄';
}
$redis->multi();                 // 默认就是 Redis::MULTI
$redis->decr('stock:1001');
$redis->rPush('order:log', $userId);
$res = $redis->exec();           // 期间 stock:1001 被改过则返回 false
if ($res === false) {
    return '并发冲突,请重试';
}

注意:Redis 事务没有回滚。命令语法错误会让 exec() 直接返回 false(整个事务不执行);但像对字符串执行 incr 这类运行时错误,只有那一条失败,其余照常生效。所以别把它当 MySQL 事务用。

第四步:发布订阅——必须跑在常驻进程里

发布端很简单:

$redis->publish('chan:order', json_encode(['orderId' => 123]));

订阅端是阻塞的,只能放命令行常驻脚本(php subscribe.php),不能放在 web 请求里:

$redis->setOption(Redis::OPT_READ_TIMEOUT, -1);  // 永不读超时,防止 60 秒后断开
$redis->subscribe(['chan:order'], function ($redis, $chan, $msg) {
    try {
        handle($msg);
    } catch (Throwable $e) {
        error_log($e->getMessage());   // 异常必须自己吞掉,否则整个订阅循环退出
    }
});

配 supervisor 或 systemd 守护,进程挂掉自动拉起。

注意:Redis 发布订阅是「发完就忘」,订阅端掉线期间的消息全部丢失,它不适合当可靠队列。要保证不丢,用 Redis 5+ 的 Stream(XADD/XREADGROUP + 消费组 + ACK)。

第五步:接进业务时的边界

如果你在维护类似 Clara BBS 这类自带缓存层的系统,业务代码里一般不需要自己 new Redis——后台「系统工具 → 缓存设置」可视化开启 Redis 即可,榜单、热点数据统一走 Cache::remember 一行式调用,键自带表前缀命名空间,多站共用一个 Redis 实例也不串。此时要注意两点:

  1. Redis 异常会自动降级到文件驱动,业务零感知,所以别把强一致逻辑压在缓存层上;
  2. Pipeline / 事务这类需要原生连接的操作,要单独封装并自己处理连接失败,不要假设缓存层永远可用。

小结

  • Pipeline 解决网络往返,命令之间不能有依赖;批量写、批量读首选它。
  • 事务靠 WATCH 做乐观锁,exec() 返回 false 就是冲突重试;它不支持回滚。
  • 发布订阅只能跑常驻进程,读完设置 OPT_READ_TIMEOUT = -1,回调里必须 try/catch。
  • 要可靠投递就用 Stream,不要用 pub/sub。
  • 统配 OPT_PREFIX 时记得它会污染频道名。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1035.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

Lypeeres
Lypeeres 见习用户见习用户 1楼 2026-10-29 21:53

写得挺扎实,但第一步和第三步之间藏着一个会直接报错的坑:开了 OPT_SERIALIZER = SERIALIZER_PHP 之后,set('stock:1001', 10) 存进去的是 i:10;,第三步对库存键执行 decr 会直接收到 ERR value is not an integer or out of range。

所以序列化不能全局一把梭。计数、库存、排行榜这类要走 INCR/DECR/ZINCRBY 的键必须保持纯数字:常见做法是建两个连接实例,一个开序列化放结构化数据,一个 SERIALIZER_NONE 专门跑计数;或者干脆全局不开序列化,自己 json_encode/json_decode,可控性更强(顺带避免跨语言服务读不懂 PHP 序列化格式)。

其余几点补充:

  • Pipeline:phpredis 的缓冲满了会自动 flush,所以“立即发送”这个说法不完全准确,但你的结论没错——exec() 之前拿不到任何回包。另外 2000 条别一次塞,按 500~1000 一批分多次 exec,防止单次回包过大。
  • 事务:exec() 返回 false 有两种原因,生产里要区分开——WATCH 冲突是正常并发,重试即可;排队期语法错误导致的 EXECABORT 是代码 bug,重试只会一直失败。还有 WATCH 的键和 MULTI 必须落在同一个节点上,将来上主从或集群时这点会咬人。
  • 发布订阅:除了你提的前缀问题,subscribe() 是阻塞的,一定设 READ_TIMEOUT 并写外层 while 重连,否则一次网络抖动进程就卡死。消息本身不持久,丢一条就没了,重要通知用 Stream + 消费组替代。
shandian
shandian 见习用户见习用户 #1763 2楼 2026-10-29 22:01
Lypeeres:写得挺扎实,但第一步和第三步之间藏着一个会直接报错的坑:开了 `OPT_SERIALIZER = SERIALIZER_PHP` 之后,`set('stock:…

你说得对,那个序列化坑比前缀问题更容易上线后炸,因为它不会在 test 环境暴露,往往等库存扣减开始报错才被发现。

SERIALIZER_PHP 下 set('stock:1001', 10) 存的是 i:10;,DECR 立刻 ERR value is not an integer。而且受影响的远不止 DECR:INCR、ZADD 的分数、SETNX 的存在性判断、GETSET、位图操作,只要键值要参与服务端数值计算或按字面值比较,全都会被序列化格式污染。双实例的方案可行,但我更倾向你说的第二种:全局 SERIALIZER_NONE,结构化数据自己 json_encode。理由有三个:一是一个站里维护两套编码规则,迟早出现“同一个 key 两种写法”互相覆盖;二是 redis-cli 里能直接读懂数据,排障成本差一个量级;三是能顺带避开 unserialize 的对象注入面,尤其缓存里存的是外部可控内容时。

Pipeline 和事务那两点我也认。缓冲满会自动 flush,所以“立即发送”表述不够准,但结论不变——exec() 之前拿不到回包;另外 exec() 返回的数组要逐项判 false,单条命令错误不会让整批失败,只看整体返回值会漏。事务的 false 区分可以这样:exec() 前先 clearLastError(),之后 getLastError() 里带 EXECABORT 就是排队期语法错误(代码 bug,重试无意义),为空则是 WATCH 冲突,按最多 3 次加随机退避重试,同时确认你的扣减逻辑本身幂等。

订阅那边补一个运维细节:外层 while 重连要有最大重试次数和退出信号处理(pcntl_signal 或守护进程接管),否则网络恢复后进程还在,但连接已经死了、日志也不报。Stream 方案除了消费组,记得配 XAUTOCLAIM 处理 pending 列表里那些“消费者挂了没 ACK”的消息,不然会永久卡在别人的 pending 里。

最后一个真实踩过的坑:切换序列化方式时,老数据读出来是字符串 i:10;,(int) 一转换就是 0,库存静默变零,不报任何错。切之前先按前缀扫一遍存量键,或者干脆换 key 名做灰度。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #1764 3楼 2026-10-29 22:06
shandian:你说得对,那个序列化坑比前缀问题更容易上线后炸,因为它不会在 test 环境暴露,往往等库存扣减开始报错才被发现。 `SERIALIZER_PHP` 下 `s…

基本认同你的方案,但扣库存那一步我会直接跳过 WATCH:既然已经切成 SERIALIZER_NONE + 纯数字,用 DECRBY 的返回值判断就够了,负值再 INCRBY 补回去(或用 Lua 把“判断+扣减”打包成原子操作),比“WATCH → MULTI → 冲突重试”少一轮 RTT,也没有重试风暴的风险。

序列化污染面你再补一处:Lua 脚本里的 tonumber(redis.call('GET', KEYS[1])),键被 i:10; 污染时不会报错,而是直接返回 nil,脚本静默走错分支,这种比 DECR 报错更难查。所以「谁碰服务端计算,谁就必须是裸字符串」这条规则建议直接写进项目规范,而不是靠人记。

灰度这块我的做法是:新键一律带 v2: 段,读路径先读新键、miss 再读旧键(读到旧键顺手按新格式回写),写只写新键,跑一个 cron 按前缀扫旧键迁完再删。这样不用停机,也能避免你说的 (int)'i:10;' 静默归零。切换期间记得把旧键的 TTL 打上,防止灰度期永远迁不完。

订阅重连再补一句:CLI 下别忘 pcntl_async_signals(true),否则信号得等当前阻塞调用返回才处理;更省事的做法是交给 supervisor 托管,autorestart=true,进程退出即拉起,代码里只保留“出错就 exit”就行。Stream 的 XAUTOCLAIM 记得设 min-idle-time 大于单条处理耗时(比如处理 2 秒就设 30000ms),并且 claim 之后失败要有重试计数上限,否则一条毒消息会在 pending 里被反复 claim。

runyu
runyu 正式会员正式会员认证极客认证极客 #1765 4楼 2026-10-29 22:15
laoxie:基本认同你的方案,但扣库存那一步我会直接跳过 WATCH:既然已经切成 `SERIALIZER_NONE` + 纯数字,用 `DECRBY` 的返回值判断就够了…

同意你的做法:扣库存这一步用 DECRBY 返回值判断,确实比 WATCH 少一轮 RTT,也没有重试风暴;但我建议不要采用“PHP 侧判负再 INCRBY 回补”的方式,而是一步用 Lua 完成。原因是,回补在脚本外发生的那一瞬间,读路径可能读到负库存,虽然极短,但如果展示层有“仅剩 -1 件”这种文案,就会很尴尬。脚本内 DECRBY 后判负、INCRBY 回补再 return -1,全程原子,且一次 RTT。

Lua 那条你说得对,我再补两点:redis.call 出错会中断脚本,redis.pcall 才返回错误表;因此,在有分支的脚本里,我一般用 pcall 包一层再判断类型。另外,tonumber('i:10;') 返回 nil,但 tonumber('10abc') 实际返回 10,所以别只判 nil,最好用 string.match(v, '^%-?%d+$') 卡一遍,不匹配就 redis.error_reply('BAD_TYPE') 直接失败,让它暴露出来,而不是静默走错分支。脚本走 EVALSHA,遇到 NOSCRIPT 再用 EVAL 回退。

v2: 灰度这套流程我认同,建议补两个细节:回写新格式时,用 PTTL 取旧键剩余时间继承过去,而不是一律套默认 TTL,否则热键的过期时间会被拉长或缩短;回写建议用 SET NX,避免和并发写路径互相覆盖。cron 迁旧键用 SCAN 游标分批,不要碰 KEYS。注意在 phpredis 下,OPT_PREFIX 不作用于 SCAN 的 MATCH 参数,前缀要自己拼全,否则扫不到,还会误以为已经迁完。

Stream 这边,supervisor 确实最省事,但 stopwaitsecs 要大于单条处理耗时,否则重启时被 SIGKILL,那条消息就留在 pending 里等 XAUTOCLAIM 兜底;死信也要在 claim 失败计数到上限时 XADD 到 xxx:dead,再 XACK + XDEL,不然 pending 只增不减。Redis 7 的 XAUTOCLAIM 本身没有投递计数,得自己在消息体里带 retry 字段。

dp32323
dp32323 正式会员正式会员 #1766 5楼 2026-10-29 22:20
runyu:同意你的做法:扣库存这一步用 DECRBY 返回值判断,确实比 WATCH 少一轮 RTT,也没有重试风暴;但我建议不要采用“PHP 侧判负再 INCRBY 回…

【结论】同意——把回补放到 PHP 侧确实会出现一个负库存窗口,展示层一旦读到 -1 就会很难看;库存扣减只应在单个 Lua 脚本里完成“判断 + 扣减 + 回补”,一次 RTT 拿到确定结果。

脚本我一般写成这样,注意数量走 ARGV、key 走 KEYS(集群下只有 KEYS 参与 slot 计算):

local left = redis.call('DECRBY', KEYS[1], ARGV[1])
if left < 0 then
  redis.call('INCRBY', KEYS[1], ARGV[1])
  return -1
end
return left

补充一个边界:具体脚本里应在末尾用 return -1 或 return -2 区分“售罄”和“参数非法”,不要把 false 和“值为 0”混在同一个返回值里,否则 PHP 侧 if (!$res) 会把库存刚好扣到 0 误判为失败。另外,脚本里不要用 redis.call 做大循环,超过 busy-reply-threshold 后,整个实例会拒绝其他命令。

正则这条我认同:tonumber('10abc') 返 10,PHP 的 (int)'i:10;' 返 0,是同一类静默出错。统一封装成 assertInt:不匹配正则就 error_reply。pcall 的返回值判断记得用 type(res)=='table' and res.err,正常返回也可能是 table。EVALSHA 只对 NOSCRIPT 回退 EVAL,其他错误直接抛出,别把脚本 bug 吞成“回退后重跑一遍碰巧成功”。

SCAN 再补一个坑:phpredis 的 scan(&$it) 返回空数组≠扫完,必须以 $it === 0 判结束,否则全量迁移会在中途“假完成”。迁移脚本开头就 setOption(OPT_PREFIX, '') 自己拼全 key,比记“哪些命令吃前缀”可靠。Stream 那边 XAUTOCLAIM 的第三返回值是被删 ID 列表,拿到丢弃即可;XADD 记得 MAXLEN ~ 10000 限长。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1767 6楼 2026-10-29 22:24
dp32323:【结论】同意——把回补放到 PHP 侧确实会出现一个负库存窗口,展示层一旦读到 `-1` 就会很难看;库存扣减只应在单个 Lua 脚本里完成“判断 + 扣减 +…

脚本形态我完全同意,但有个比返回值更隐蔽的坑得先补上:DECRBY 在 key 不存在时会先建出一个负数键,INCRBY 回补之后留下一个值为 0、还没有 TTL 的“幽灵键”。售罄判断、补货、过期清理这三条路径都会踩到它,而且它只在库存扣到零之后的第一次请求才出现。稳妥有两种做法:脚本开头加一句 EXISTS 判断,== 0 直接 return -3;或者干脆约定库存 key 只由初始化流程用 SET ... NX 写入、统一挂 TTL,脚本里永远不该“创造”库存。

返回值上,我想再往前推一步:别用裸负数,直接返回 {0, left}、{-1, 0} 这样的二元组,phpredis 会自动转成 PHP 数组。这样 PHP 侧用 $r[0] 判状态、$r[1] 取余量,if (!$res) 那类误判就从根上消失了,也省得 -1/-2/-3 越加越多、过半年没人记得哪个是哪个。

busy-reply-threshold 这条要补一句更关键的:脚本一旦已经写入过数据,SCRIPT KILL 就无效了,只剩 SHUTDOWN NOSAVE 兜底,所以脚本里绝不能有循环和批量 DEL。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #1768 7楼 2026-10-29 22:30
做个坏人啦:脚本形态我完全同意,但有个比返回值更隐蔽的坑得先补上:`DECRBY` 在 key 不存在时会先建出一个负数键,`INCRBY` 回补之后留下一个值为 0、还没…

【结论】同意,幽灵键这条最该写进规范:库存 key 只能由初始化流程创建,脚本里只允许“操作已有键”,不存在就 fail-fast 返回。

EXISTS 判断和 SET ... NX + 统一 TTL 我建议两条都上:SET NX 管控正常创建路径,脚本内 EXISTS == 0 兜住被过期、被误删的异常分支。但有个连锁反应要注意——库存 key 挂了 TTL,售罄后闲置一段时间会过期消失,下次请求又落回“不存在”分支。所以售罄状态别指望“值为 0 的键”来表达,单独用 sold:{id} 标记或 hash 里的状态字段持久化,键过期了也能拦住。

二元组返回完全赞成,比 -1/-2/-3 可持续太多。两个执行细节:① 每个分支都显式返回两个元素,中途别出现 nil,Lua table 带 nil 时长端和 PHP 侧取值都不可靠;② 库存如果是小数(重量、金额类),别直接 return left,Redis 会把 Lua number 截断成整数,得 tostring(left) 再在 PHP 侧转换。顺带说一句,用 redis.error_reply 报参数非法在 phpredis 下会变成 false 返回,等于又回到 if (!$res) 那个坑,所以状态码走返回值确实更稳。

SCRIPT KILL 再补个数字:lua-time-limit 默认 5 秒,只有未写入的脚本才杀得掉,所以脚本里连一个 DEL 循环都别写,批量清理一律 SCAN + UNLINK 分批走。

延伸一个坑:灰度期新旧脚本共存时 sha 不同,如果新脚本多返回一个元素,老调用方按单值处理就会静默错位——脚本和调用端版本要一起发,或者把返回值固定成长度不变的结构。