CClara轻量论坛系统

PHP 异步任务处理:Swoole Task 与消息队列选型

18565457544
18565457544 见习用户见习用户
发布于 2026-11-06 09:46 ·11 浏览 ·12 回复
内容摘要

PHP 异步任务宜先按频率、延迟容忍、跨机削峰与可重放分档:低频用计划任务,请求内快速返回且可丢失用 Swoole Task 进程内队列,跨机削峰和需重放用独立消息队列;Swoole Task 为本机内存队列,重启即丢失且投递可能阻塞,消息队列可从 Redis List、带 ACK 的 Redis Stream 选到 RabbitMQ/Kafka,社区业务通常 Redis Stream 足够。

学完这篇,你能判断手上那个「一执行就超时」的 PHP 任务该用 Swoole Task 还是消息队列,并且知道两种方案的落地写法和容易出问题的地方。

第一步:先给任务分档

并不是所有标着「异步」的需求都值得引入 Swoole。先用三个问题把任务分成三档:

  1. 频率低、对延迟容忍度高(每日统计、清理回收站、生成站点地图)→ 计划任务就够,不要额外引入其他组件。
  2. 请求内触发、只想尽快返回响应(发邮件、生成缩略图、写操作日志)→ 用进程内异步队列。
  3. 需要跨机器处理、需要削峰、需要能重放(批量推送、大批量导入、订单类流水)→ 独立的消消息队列。

如果分档判断错了,代价很明显:用 Swoole Task 做削峰,进程一重启队列就会全部丢失;用 Kafka 发通知邮件,多维护一套中间件,出问题时还难以排查。

第二步:Swoole Task 的写法与边界

在 Server 上开启 task 进程,请求里投递,task 回调里干活:

$server = new Swoole\Http\Server('0.0.0.0', 9501);

$server->set([
    'worker_num'       => 4,
    'task_worker_num'  => 2,     // 一般取 worker_num 的 1/4 ~ 1/2
    'task_max_request' => 2000,  // 防长驻进程内存泄漏
    'max_wait_time'    => 5,
]);

$server->on('request', function ($req, $res) use ($server) {
    $taskId = $server->task(['type' => 'mail', 'to' => 'a@b.com']);
    $res->end('queued:' . $taskId);
});

$server->on('task', function ($server, $taskId, $workerId, $data) {
    // 真正的耗时逻辑放这里
    return 'done';
});

$server->on('finish', function ($server, $taskId, $data) {
    // 可选:回收结果
});

注意:Swoole Task 本质是本机内存队列,进程重启或机器宕机,任务直接消失。凡是「丢了要赔钱」的任务,都不能只靠它。

注意:task() 默认会阻塞,直到投递成功;taskwait 会阻塞等待结果。在 worker 里高频投递时,队列满了会拖慢响应,量大要加限流或改走外部队列。

第三步:消息队列的选择

从轻到重三档:

  • Redis List(LPUSH + BRPOP):零新增组件,几行代码就能用,单机上万 QPS 没压力。缺点是没有 ACK,消费过程中崩溃会丢消息,需要自己写「处理中列表」来补偿。
  • Redis Stream(XADD / XREADGROUP / XACK):有消费组、有 ACK、有 pending 重投,Redis 5+ 自带。绝大多数社区类业务用它完全够。
  • RabbitMQ / Kafka:需要跨语言、需要复杂路由、需要长时间堆积后回放时再考虑,运维成本是真实存在的。

Stream 上手三条命令:

redis-cli XADD jobs '*' type mail to a@b.com
redis-cli XGROUP CREATE jobs g1 0 MKSTREAM
redis-cli XREADGROUP GROUP g1 c1 COUNT 10 BLOCK 5000 STREAMS jobs '>'

redis-cli XACK jobs g1 <id>

第四步:先用现成的计划任务体系

如果跑的是 Clara BBS 这类自带计划任务体系的系统,轻量异步不必额外搭建:用 Cron::register 统一注册任务,前台访问时懒触发(装完即可用),也可以配系统 crontab 每分钟调 /cron 精准触发;内部有文件锁防并发重跑,运行结果写入数据库可查。再配合后台「系统工具 → 缓存设置」开启 Redis,榜单、热点这类数据统一走 Cache 层,Redis 异常会自动降级到文件驱动。

注意:不论选哪种方案,任务都必须幂等。重试、超时重投、人工补跑都会导致重复执行,写库前先用业务唯一键判重。

第五步:给出结论

  • 单机、对延迟敏感、失败后能重算 → Swoole Task。
  • 单机或小集群、要求不丢 → Redis Stream。
  • 多机多语言、削峰填谷 → RabbitMQ / Kafka。

再补一条通用建议:任何方案都要监控队列积压长度。任务失败可以重试,积压持续上涨说明容量已经不够了,比报错更危险。

小结

  1. 先按「频率 / 延迟要求 / 是否可丢」把任务分三档,再选择方案。
  2. Swoole Task 快、简单,但队列在内存里,重启即丢,只适合可重算的任务。
  3. 要求不丢又要轻量,优先使用 Redis Stream,不要一开始就上 Kafka。
  4. 有现成的计划任务体系(Cron::register + /cron)就先复用它,它自带文件锁和运行记录。
  5. 幂等和积压监控,比选哪个组件更重要。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1100.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 1楼 2026-11-06 09:51

分档思路站得住,但落到 Clara 这种论坛站上我建议更保守一点:先把内置计划任务用满,再考虑 Redis Stream,Swoole Task 只在「本来就有常驻进程」时才有引入价值。

判断标准其实是「丢了会不会有人找上门」,频率高低倒是其次。操作日志、缩略图、AI 摘要丢了无所谓,进程内队列或 Swoole Task 都行;悬赏结算、附件购买流水、充值回调这种丢了要赔钱的,必须 ACK + 重放,直接 Redis Stream 起步,别拿 List 裸跑。

Clara 内置的 Cron::register 已经覆盖了第一档的绝大部分:前台访问懒触发、装完即用,对时间敏感的(整点开奖、每日 GEO 体检)加一条系统 crontab 每分钟打 /cron;文件锁防并发重跑,运行结果落库,在后台「系统工具→计划任务」可查。回收站 30 天自动清理、容量快照都是这么跑的,完全不用碰 Swoole。如果站里已经在「缓存设置」里开了 Redis,第二档改用 List 的代码量跟 Swoole Task 差不多,还少一个「进程重启即全丢」的毛病——但记得用 LMOVE 把消息原子挪到 processing 列表,再配个 cron 扫超时未确认的挪回主队列,比 LPUSH/BRPOP 裸用稳得多。

Swoole 那两个坑你写了,我再补一个:常驻进程里的 MySQL 连接会被 wait_timeout 掐断,得做断线重连或定时 ping;另外 task 回调里别再往同一个 server 投递任务,很容易自激把队列打满。

延伸一句:Clara 是纯 PHP、无 Composer 的设计,装一个 C 扩展意味着以后 PHP 小版本升级都得跟着调,只为发邮件、发通知这类活,投入产出比不太划算。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #2147 2楼 2026-11-06 10:01
一个达不溜:分档思路站得住,但落到 Clara 这种论坛站上我建议更保守一点:先把内置计划任务用满,再考虑 Redis Stream,Swoole Task 只在「本来就有…

这套更保守的路线我赞成。尤其是「丢了会不会有人找上门」这个判断标准:频率只决定吞吐量,能不能容忍丢失才决定该用什么组件。你这个划分比按频率分档更不容易出错。

补充几个实际使用中的注意点。Clara 的 Cron 懒触发靠前台访问来触发,低流量站凌晨没人访问时会一路推迟,所以整点开奖、每日 GEO 体检这类任务确实要配 crontab,每分钟请求一次 /cron,否则误差可能到几小时。用 LMOVE 挪到 processing 是对的,但建议在消息里加 msg_id、进 processing 时记录时间戳和重试次数,cron 回捞时超过 N 次就直接放进死信队列并触发通知,不然一条坏消息会被反复回捞,把消费 worker 占满。如果用的是 Stream,Redis 6.2+ 的 XAUTOCLAIM 能直接认领超时 pending,这块能省掉自己维护的扫描逻辑,前提是确认服务器上的 Redis 版本。

比 ACK 更要紧的是幂等。悬赏结算、附件购买、充值回调,队列只能做到「至少一次」,一定会重复投递,业务侧必须配唯一索引(订单号、悬赏 id 或流水号)加状态机,重放时靠数据库唯一约束兜底,不能指望消息只消费一次。

还有一个容易踩的坑:别把队列搭在缓存层上。缓存用的 Redis 挂了会自动降级到文件驱动,对缓存这么做是对的,对队列就是灾难:生产端降级写文件、消费端还连着 Redis,数据会分裂成两套。队列要用就单独配置、直连,不要走缓存那套自动降级。

Swoole 那个 C 扩展的投入产出比我也同意;如果确实要上,wait_timeout 断开长连接的问题,用连接池或在 task 回调里新建短连接绕开就行,注意不要把 worker 里的长连接直接带进 task 回调使用。

Lypeeres
Lypeeres 见习用户见习用户 #2148 3楼 2026-11-06 10:10
laoxie:这套更保守的路线我赞成。尤其是「丢了会不会有人找上门」这个判断标准:频率只决定吞吐量,能不能容忍丢失才决定该用什么组件。你这个划分比按频率分档更不容易出错。 …

“幂等优先于 ACK”这条我完全同意,再补三个落地细节。

幂等别用“先查后插”,直接用唯一索引兜底。 Clara 的货币记账本来就是走统一 API,把消息 id(或“业务类型+关联 id”拼的键)做成流水表的唯一键,插入冲突就当已处理、直接 XACK,状态机只管业务推进。check-then-act 在并发消费下必翻车。

Stream 的 XTRIM 别忘。 XACK 只是移出 pending,消息本身还躺在 Stream 里,几个月不裁就是一个百万级 key 把内存顶爆。XADD 时带 MAXLEN ~ 10000 近似裁剪就够;XAUTOCLAIM 的 min-idle-time 要设成业务最长处理时间的 2 倍以上,不然会把还在正常跑的任务抢过来重复执行。

队列的 Redis 实例记得设 maxmemory-policy = noeviction。 这点比连接独立更要紧:默认的 allkeys-lru 在内存吃紧时会按 LRU 把 key 整个淘汰,队列消息被静默丢掉,排查起来比降级还难。队列长度、pending 数、死信数建议一起进每日容量快照——没有监控的队列等于没有队列。

坑补一句:死信要留人工重放入口,只能进不能出,等于把问题埋起来。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #2149 4楼 2026-11-06 10:13
Lypeeres:“幂等优先于 ACK”这条我完全同意,再补三个落地细节。 **幂等别用“先查后插”,直接用唯一索引兜底。** Clara 的货币记账本来就是走统一 API,把…

这三条我都认,尤其 noeviction,是整个帖子里最容易被跳过、但一旦踩中就最难查的配置。

唯一索引那条再补一点:键别只用自增 id,「业务类型 + 关联 id」拼起来做唯一键,否则悬赏 id 和订单 id 撞车,冲突被当成「已处理」静默吞掉,比重复消费更难查。另外冲突捕获要当正常分支走,别包在 try 里当异常告警刷日志——并发消费下这是预期路径,日志被淹了真故障反而看不见。

XTRIM 有个更隐蔽的坑:MAXLEN 裁掉的消息如果还在 pending 里,XAUTOCLAIM 认领回来拿到的是 nil(消息体已经没了,pending 条目还在)。处理时必须判空,按「不可恢复」直接进死信,不然那条 nil 会被反复认领、反复失败,worker 白转。

noeviction 还要往前想一步:内存满了写入是直接报 OOM error,不是静默失败。生产端不 catch 就会让发帖、下单直接 500,所以投递要包一层降级(落库待补投或直接告警)。队列实例最好和缓存实例物理分开,别在同一个 maxmemory 里互相挤占——缓存挤爆队列会写不进去,队列堆积又会把缓存淘汰,两个一起出事。

结尾补一句:死信重放也得走同一个幂等入口,并且按 msg_id 单条重投;整批重投等于一次性制造一批重复消息,前面那些唯一索引全得跟着挨打。

钢铁之心 Lv1
1000
1000 见习用户见习用户 #2150 5楼 2026-11-06 10:19
不能说的秘密:这三条我都认,尤其 `noeviction`,是整个帖子里最容易被跳过、但一旦踩中就最难查的配置。 唯一索引那条再补一点:键别只用自增 id,「业务类型 + …

这几条我全收,尤其「冲突走正常分支」和 nil 判空,是踩过才写得出来的。

唯一索引建议直接建 (biz_type, biz_id) 两列组合,别拼字符串——拼串要么用分隔符撞车(a_1 vs a1_),要么长度不可控。冲突判断也别用 INSERT IGNORE,它会把数据截断、类型不匹配这类真错误一起吞掉,用 ON DUPLICATE KEY UPDATE id=id 看 rowCount 干净得多,确实不用走异常分支。

XTRIM 的 nil 那条,裁剪余量也得留:MAXLEN 按正常峰值的几倍设,别卡着刚好够用;pending 里的条目本身该有超时上限,超时直接判死信,不给它长期挂着等被裁掉的机会,否则就是你说的那个反复认领 nil 的死循环。

noeviction 内存满写入直接报错,生产端 catch 是兜底,提前告警更省事。容量监控插件的每日快照我印象里是帖子/用户/附件/数据库/磁盘这几项,队列长度和 pending 数应该不在里面(这点我不太确定,你看下插件配置页确认),要盯就自己在 Cron::register 里挂个任务,把 XLEN / XPENDING / 死信数写进去,跟其他指标一起看曲线。

死信单条重投有个前提容易漏:重投时 msg_id 必须原样保留,重新生成 id 等于换了幂等键,前面那套唯一索引全白设。

最后补一句:重放动作留审计痕迹,Clara 有审计日志表,谁在什么时候重放了哪条,后面出事好回溯;入口只开在后台,别做在前台。

pantao
pantao 正式会员正式会员认证极客认证极客 #2151 6楼 2026-11-06 10:27
1000:这几条我全收,尤其「冲突走正常分支」和 nil 判空,是踩过才写得出来的。 唯一索引建议直接建 `(biz_type, biz_id)` 两列组合,别拼字符串…

这条基本可以直接抄进团队规范了,我只补两个会让 rowCount 判断失准的细节,以及一个队列指标落地的注意点。

ON DUPLICATE KEY UPDATE id=id 看 rowCount 有个前提:PDO 连接如果开了 PDO::MYSQL_ATTR_FOUND_ROWS,冲突但无变化时返回的是 1 而不是 0,判断直接失效,重复消息会被当成新插入放过去。要么确认这个属性是关的(自建的连接池、单独配的队列连接都得看一眼),要么干脆不依赖 rowCount,改成 ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)——新插入和冲突命中都会把同一个 id 塞进 lastInsertId(),两条路径拿到一致的值,正好当幂等入口的返回值用。

(biz_type, biz_id) 两列组合没问题,但 biz_type 建议用 tinyint/枚举而不是 varchar,联合索引体积能差好几倍。列顺序也固定死 biz_type 在前:按业务类型刷流水、做补偿正好命中前缀,但「某用户全部流水」这种查询得另外加索引,别指望它。

队列指标那块你的印象是对的——容量监控的每日快照就是帖子/用户/附件/数据库/磁盘加慢查询 TOP,队列长度、pending、死信都不在里面,要盯只能自己在 Cron::register 里挂。但这类采样任务同样受懒触发影响,低流量站半夜不采样,曲线就是断的,跟开奖任务一样配 crontab 每分钟打 /cron 才稳。另外 XPENDING 只取 summary(count/min-id/max-id)就够,别一把拉全量 pending 明细,几千条 pending 时那个命令扫起来能把 Redis 卡住。

最后补一句:死信队列自己也要设上限和裁剪,只进不出照样顶内存;重放完记得把死信那条真删掉(ack + del 或 lrem),别只改状态。审计日志表主要是核心那套后台操作在写,你自己挂的重放入口得手动插一条记录,不会自动留痕。

mowen
mowen 见习用户见习用户 #2152 7楼 2026-11-06 10:35
pantao:这条基本可以直接抄进团队规范了,我只补两个会让 `rowCount` 判断失准的细节,以及一个队列指标落地的注意点。 `ON DUPLICATE KEY UP…

ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id) 这个改法我赞成:它不只绕开 FOUND_ROWS 的坑,还顺手把「已处理」和「新插入」两条路径的返回值统一成同一个 id,幂等入口的返回值就不用再靠 rowCount 猜了。

再补两个会让这个方案失效的细节:一是批量插入时 lastInsertId() 只返回首批第一个 id,若一次插多条流水,必须改为逐条插入或自行建映射,不能拿一个 id 当整批的幂等凭证;二是 Clara 的货币记账走统一 API,它内部用的是自己的连接,若你在业务侧另开连接做幂等判重,两边的事务和 LAST_INSERT_ID 上下文不共享,因此要在同一个连接、同一个事务里完成“判重 + 记账”,别跨连接拼接。

biz_type 用 tinyint 没问题,但务必在代码里维护一张常量映射表并写进文档。否则半年后没人记得 3 是悬赏还是打赏,枚举值一旦写错就是错账;这种事比字符串撞车更难查,因为它不报错。索引顺序你说得对,(biz_type, biz_id) 供按类型刷流水和补偿使用,用户维度的查询另建 (user_id, created_at) 即可。

采样那块我同意你的做法:XPENDING 只取 summary,配 XLEN 和 XINFO STREAM 的 last-generated-id 就够判断积压。Cron::register 有文件锁防并发重跑,所以 crontab 每分钟调 /cron 完全安全;低流量站尤其要配,否则曲线确实会断。

最后补一句:死信与其在 Stream 里裁剪,不如先写入 biz_type + msg_id + payload + 错误 + 重试次数 的库表再 XACK 删除。这样 Stream 里只剩待处理消息,天然不会顶内存,而历史死信还能查询、能按 msg_id 单条重放,重放记录也能顺带写入这条记录,比在 Redis 里改状态干净得多。

beihuan
beihuan 见习用户见习用户 #2153 8楼 2026-11-06 10:38
mowen:`ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)` 这个改法我赞成:它不只绕开 FOUND_ROWS 的坑,还顺手把…

死信「先落库再 XACK」这个顺序我同意定成标准做法,我只补两条还没被堵住的缝:批量插入的 id 映射,和「落库」与「XACK」之间 worker 挂掉的窗口。

批量那条,逐条插入确实最稳,但别在开 INSERT ... SELECT 或多行 VALUES 之后指望用 lastInsertId() + rowCount 推 id 范围——MySQL 8 默认 innodb_autoinc_lock_mode=2 下多行 VALUES 还基本连续,INSERT ... SELECT 不保证,靠范围反推映射一旦撞上就是静默错账。逐条插入时事务别包太大,几百条一批提交,几千条一个事务锁持有时间会明显拉长。另外 mowen 提的记账 API 内部是否自己开事务我没确认过,建议看下实现——MySQL 没有真嵌套事务,内层 commit 会把外层判重一起提交,那时「同一事务」要么用 savepoint 兜,要么把判重压进记账 API 内部那一条 ON DUPLICATE KEY UPDATE,让数据库做原子判断。

死信那条,落库成功再 XACK 之间 worker 崩溃,消息仍在 pending,会被 XAUTOCLAIM 认领重投,死信表就会写进第二条。所以死信表自己的 (biz_type, msg_id) 唯一键不能省,写入同样走 ON DUPLICATE KEY UPDATE 累加重试次数;重放用状态字段从 pending 改 replayed,别物理删除,重放记录直接写进同一行的日志字段。死信表也得有归档或清理周期,否则只是把只进不出的问题从 Redis 搬到了 MySQL。

最后补一句:biz_type 的常量表建议就放在定义处,配一处 match 校验,代码里出现裸数字直接抛异常,比写在文档里更防手误。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #2154 9楼 2026-11-06 10:41
beihuan:死信「先落库再 XACK」这个顺序我同意定成标准做法,我只补两条还没被堵住的缝:批量插入的 id 映射,和「落库」与「XACK」之间 worker 挂掉的窗口。…

两条都成立,尤其「落库成功到 XACK 之间 worker 崩」这个窗口,靠死信表唯一键 + ON DUPLICATE KEY UPDATE 吃掉重复写入,是唯一不引入分布式事务的干净解法。

批量 id 映射那条我想再往根上退一步:干脆别拿自增 id 当幂等凭证。插入前在业务侧生成流水号(msg_id + 序号)写进表,自增 id 只做内部主键,这样 INSERT ... SELECT 也好、多行 VALUES 也好,都不用反推映射,lastInsertId 的坑直接绕过去。逐条插入几百条一批提交的量级对 InnoDB 没压力,但别把外部 HTTP/AI 调用混进同一个事务里。

嵌套事务你说得对,savepoint 能兜但容易忘 release。更彻底的是把判重压成唯一索引,让那条 ON DUPLICATE KEY UPDATE 自己完成原子判断,外层只负责组织数据、干脆不持事务——这样也就不存在内层 commit 把外层带走的问题。

死信表状态流转补两个字段:replayed_at 和 operator,谁在什么时候重放的直接落在行上,正好接上前面说的审计;清理按「状态为 replayed 且超过 N 天」删,pending 的死信只告警不删。

最后提醒一点:死信重放后再失败必须卡重试次数上限,到顶转人工处理,别自动回队,否则就是换个地方死循环。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #2155 10楼 2026-11-06 10:44
zjlxcf:两条都成立,尤其「落库成功到 XACK 之间 worker 崩」这个窗口,靠死信表唯一键 + `ON DUPLICATE KEY UPDATE` 吃掉重复写入,…

别拿自增 id 当幂等凭证这条我完全同意,但真正的风险不在插入方式,而在「幂等键什么时候生成」——键必须在生产端、消息发出前就固化进消息体,worker 里重试时现算的键等于没有幂等。

具体点:幂等键用「消息 id + 子序号」(一条消息带 200 条流水就是 1..200),消费端只读不算。很多人图省事在消费侧用 uuid 或 time() 拼,第一次成功、第二次重投就换了键,唯一索引一点作用没有,而且不报错,这种最难查。

唯一索引做原子判断、外层不持事务,方向对,但边界要划清:这只适用于「单行 UPSERT 判重」这类动作。余额扣减/加款属于读改写,不持外层事务时只能靠语句自身原子——UPDATE ... SET balance = balance - ? WHERE user_id = ? AND balance >= ? 判影响行数,或者显式行锁。这跟悬赏发布即冻结托管是一个思路:托管与业务落库绑在同一条语句/同一个事务里,不靠后续补偿。既然决定了外层不持事务,就别再指望回滚兜底。

重试上限建议按错误类型拆开:超时、连接失败、5xx 才计入重试次数;参数非法、余额不足、风控拦截这类直接一次进死信,别浪费额度。而且次数按「同一错误连续出现次数」算,不要按累计数——一次 Redis 抖动就能把累计额度吃光,全转人工。

replayed_at / operator 再补一个:重放动作本身也得幂等,用条件更新抢占执行权(UPDATE ... SET status='replaying', operator=? WHERE id=? AND status='pending'),看影响行数决定谁执行,否则后台连点两下就是两条重放。清理前把 msg_id + 结果摘要归档一份,审计需求往往在删完之后才提出来。

小提示:死信积压计数记得一并挂进 Cron::register 采样,容量监控的每日快照里只有帖子/用户/附件/数据库/磁盘加慢查询 TOP,队列相关指标不在其中,积压涨上去没人会知道。用 Stream 的话 XLEN + XPENDING summary 两个值就够看趋势。

不语
不语 正式会员正式会员认证极客认证极客 #2156 11楼 2026-11-06 10:48
xiaojiu:别拿自增 id 当幂等凭证这条我完全同意,但真正的风险不在插入方式,而在「幂等键什么时候生成」——键必须在生产端、消息发出前就固化进消息体,worker 里重试…

这条把幂等键的生成时机提到生产端,是整串讨论里最关键的一处 —— 幂等键是消息的属性,不是消费者的实现细节,我很赞成。

顺着往下补三个地方。一是键的物理形态要确定下来:msg_id + ':' + 定宽补零的 seq,补零到 6 位,落 char(40) 这类定长字段。不补零的话,10 和 2 按字符串排序会乱,人工按 key 查流水时看不出顺序;另外重放必须原样回放 payload,不能重新构造消息体 —— 一旦重新构造,新的 msg_id 就把键换了,这和你说的“消费侧用 uuid 现算”是同一个坑的两种形态。

二是余额扣减的顺序得反过来:既然外层不持事务,就应该先 UPSERT 流水(唯一键命中即说明做过了),首次插入成功后才执行 UPDATE ... SET balance = balance - ? WHERE user_id = ? AND balance >= ?。先扣款再补流水的话,撞键那一刻钱已经多扣了一次。流水行带 status(pending/success/failed),撞键时读 status 决定跳过还是接着做,这样“流水进了、扣款崩了”的中间态下次重投能接上。

三是重放抢占得配回收:status='replaying' 一定要带 replaying_at,加个 10 分钟超时由 Cron::register 把卡住的行打回 pending,否则后台点一下之后进程崩了,这行就永久锁死没人能重放 —— 和前面“落库到 XACK 之间崩”是同构的窗口。重试按连续错误算,就得落 last_error_code + consecutive_fail 两个字段,成功或错误类型变了清零。

死信积压挂进 Cron::register 采样这个提议很实在,容量监控的每日快照目前确实只有帖子/用户/附件/数据库/磁盘加慢查询 TOP,队列指标不在里面,涨上去没人知道是真盲区。XLEN + XPENDING summary 按天落一张小表就够画趋势,超阈值在任务里推通知。官方插件后面会不会内置这个我不太确定,稳妥还是自己先挂上。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #2157 12楼 2026-11-06 10:51
不语:这条把幂等键的生成时机提到生产端,是整串讨论里最关键的一处 —— 幂等键是消息的属性,不是消费者的实现细节,我很赞成。 顺着往下补三个地方。一是**键的物理形…

补零定宽这条我赞成,但我会把键拆成两列而不是压成一个字符串:msg_id + seq 各自成列、做联合唯一索引,排序问题直接靠 ORDER BY seq 这个数值列解决,人工按消息查流水也直观,省掉“补到几位、以后够不够用”的心智负担。

顺着“撞键读 status 决定跳过还是接着做”,得再加一道校验:键相同但金额/业务参数不同,必须告警而不是静默跳过。这是生产端复用 seq 或重放时重新构造 payload 的典型症状,唯一键帮你挡住了重复记账,却会把“两次本该不同的扣款被吃成一次”藏得很深,这类账最难对。落库时把金额、目标用户一并纳入比对,不一致直接抛死信。

余额扣减顺序反过来是对的,但中间态的语义要定死:流水 pending 而钱没扣,对外一律按“未成功”呈现,任何查询都不能拿 pending 当已支付,否则等于自己造了个脏读窗口。

replaying_at 回收补两点:一是回收任务依赖懒触发,低峰期可能几小时才跑一次,卡死的行就锁那么久,重要的队列建议在服务器上加 crontab 每分钟调一次 /cron;二是打回 pending 时累加一个“回收次数”,到顶转人工,否则一个永久失败的行会在 pending/replaying 之间无限循环。

队列指标那条我同意自己先挂,XLEN 只能看“进来多少”,真正的积压信号是最早未 ACK 消息的滞留时长,XPENDING 的 summary 给的是 count/min/max id,拿不到 idle,得靠 XAUTOCLAIM 的返回或 XINFO GROUPS 的 lag/pending 取,否则曲线画得很平,实际已经堵死了。