# 保哥笔记 — 消息队列与异步 > 本分片含 2 篇文章,按发布日期倒序。全部分片索引见 https://zhangwenbao.com/llms-full.md **站点**:https://zhangwenbao.com/ **分类**:消息队列与异步 **生成**:2026-09-12 16:00:11 CST --- ## 跑到第250条时消费者崩了,对账下来500条里少了一条 - URL:https://zhangwenbao.com/message-queue-delivery-guarantee-retry-idempotent.html - 分类:消息队列与异步 - 发布:2026-07-11 | 更新:2026-07-31 - 摘要:队列上线后的五个坑:丢消息、重复消费、消息卡住、重试风暴、坏消息占位。每个都配可自行复现的对账法,以及幂等标识与退避参数的取值依据。 - 关键词:服务器运维,缓存,消息队列 > **TLDR**:摘要:保哥往队列里推了500条消息,让消费者跑到第250条时强制中断,然后对账。取出即删的写法算下来是:已处理249条、队列里还剩250条、差额1条,那条既没处理完也不在队列里,它消失了。换成取出的同时转存到另一个列表,同样在第250条中断,对账变成249加250加1,差额为0,而且能直接指出卡住的是哪一条。这不是实现质量问题——某云厂商的文档原话是标准队列保证至少一次投递、可能重复、可能乱序,某开源代理的文档也直说朴素队列在消费者崩溃时会丢消息。本文用一台真实服务器上的六组对账数据,把丢消息、重复消费、处理中积压、重试风暴、死信这五件事逐个拆开,并给出每一件的判据和代价:可靠投递让吞吐从9343条每秒掉到4420条每秒,慢2.11倍就是可靠性的标价。 > 摘要:保哥往队列里推了500条消息,让消费者跑到第250条时强制中断,然后对账。取出即删的写法算下来是:已处理249条、队列里还剩250条、差额1条,那条既没处理完也不在队列里,它消失了。换成取出的同时转存到另一个列表,同样在第250条中断,对账变成249加250加1,差额为0,而且能直接指出卡住的是哪一条。这不是实现质量问题——某云厂商的文档原话是标准队列保证至少一次投递、可能重复、可能乱序,某开源代理的文档也直说朴素队列在消费者崩溃时会丢消息。本文用一台真实服务器上的六组对账数据,把丢消息、重复消费、处理中积压、重试风暴、死信这五件事逐个拆开,并给出每一件的判据和代价:可靠投递让吞吐从9343条每秒掉到4420条每秒,慢2.11倍就是可靠性的标价。 上一篇聊的是要不要上队列 (https://zhangwenbao.com/async-task-when-to-use-message-queue.html),用的是时间账:把一个900毫秒的第三方调用挪出请求路径,响应从23.9毫秒变923.9毫秒的问题就没了。那篇的结论是大多数站没到需要队列的程度。 这一篇假设你已经上了。上了之后会遇到的第一个真问题不是性能,是你不确定消息到底送到没有。 这个不确定感很难靠读文档消除,因为文档会告诉你有确认机制、有重试、有死信,但不会告诉你不用它们会丢多少。所以保哥干脆在那台服务器上把每一种失败都跑了一遍,用对账的方式看差额。 为什么坚持用对账而不是看日志?因为日志只记录发生过的事,而丢消息的本质是某件事没发生,且没人记录它没发生。一条消息在消费者内存里随进程消失时,日志上不会留任何痕迹——发送方记了发送成功,队列记了投递成功,消费者的那行处理完成压根没打出来,而没打出来的日志是不会有人去找的。 对账则不同。总数是已知的,已处理、队列剩余、处理中三个数是可查的,四个数一相加,差额藏不住。这套方法在排查服务器问题 (https://zhangwenbao.com/linux-server-performance-troubleshooting-high-load-cpu-memory-disk-io-diagnosis.html)时同样好使:先找一个能闭合的等式,再看哪一项对不上。下面每一节都会给出这样一个等式。 ## 一条消息消失的时候,系统里发生了什么? 先看最朴素的写法:生产者往列表里推,消费者从另一端取,取到就算拿走了。 实验设计很简单:推500条,消费者处理到第250条时强制中断——模拟进程被杀、机器断电、或者代码抛了个没接住的异常。然后数三个数。 项目 | 条数 | 已经处理完的 | 249 | 队列里还剩的 | 250 | 合计 | 499 | 差额 | 1 | 那一条去哪了?它被取出来了,所以不在队列里;但处理还没做完,所以也没生效。它在消费者的内存里,随着进程一起没了。 这不是某个实现的缺陷。官方文档写得比我直白,说这种形式的队列并不可靠,消息可能丢失,举的例子正是消费者刚收到消息、还没来得及处理就崩了 (https://redis.io/docs/latest/commands/lmove/)。也就是说,这个丢法是设计上就存在的,不是你写错了。 500条丢1条,比例是0.2%,听起来不大。但换个说法:每崩一次,丢一条。这跟消息总量无关,跟崩溃次数有关。一个跑了半年的消费者,重启过几十次,就是几十条订单邮件没发出去,而你完全不知道是哪几笔。 ## 消息还没进队列就丢了,算谁的? 上面那500条是已经进了队列的。还有一段更早的路容易被忽略:生产者发出去了,但队列没收到。 网络抖一下、队列所在的进程刚好在重启、或者连接池里那条连接其实已经断了只是还没被发现——这些情况下发送方的代码不会报错,它只是把字节写进了操作系统的发送缓冲区,然后就返回了。你的日志里会记一条发送成功,而队列里什么都没有。 某开源代理的文档把这件事讲得很清楚:按标准协议,保证消息不丢的唯一办法原本是用事务 (https://www.rabbitmq.com/docs/reliability),而事务太重,于是引入了一套确认机制——队列收到并落好之后回一个确认给生产者,生产者拿到确认才算数。同一份文档还补了一句边界:如果节点在消息写入磁盘之前就失效,即使标记为持久化的消息也可能丢。 换句话说,投递保证是一条链子,从生产者到队列、从队列到消费者、消费者处理完再回确认,任何一环没有确认,那一环就是漏的。本文后面测的都是第二段和第三段,但第一段同样要配上,否则前面做得再细也是从中间开始的。 落地上就一句话:生产端要开确认,并且确认失败时不能只写日志了事,得有兜底——最常见的做法是先把消息写进本地的一张表,确认成功再删。这样队列没收到时,那张表里还留着。 ## 为什么规范里写的是至少一次,而不是正好一次? 很多人第一次接触队列时会有个朴素期待:既然是专门做消息传递的组件,那它应该保证每条消息不多不少处理一次。 行业的答案是做不到,或者说代价高到不值得。某云厂商的标准队列文档原话是:"Standard queues ensure at-least-once message delivery, but due to the highly distributed architecture, more than one copy of a message might be delivered, and messages may occasionally arrive out of order." (https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues.html) 翻译过来是三句话:保证至少一次;可能收到多份;可能顺序乱。三句都是承诺,第二句和第三句是承诺你会遇到,不是承诺不会。 某开源消息代理的文档说得更具体,它直接给出了应对方式:"consumers must be prepared to handle redeliveries and otherwise be implemented with idempotence in mind." (https://www.rabbitmq.com/docs/confirms)——消费者必须做好重复投递的准备,实现时要考虑幂等。同一份文档还提到重复投递的消息会带一个布尔属性标记,让你知道这不是第一次送。 > 所以正确的心态不是想办法让它不重复,是假定它一定会重复,然后让重复不产生后果。这两条路的工作量差着一个数量级。 为什么正好一次这么难?根子在于确认这个动作本身也会失败。消费者处理完了要告诉队列这条搞定了,这个告知走网络,网络会丢包。队列没收到确认,只能假定消费者没做完,于是重投。而消费者那边其实已经做完了。队列和消费者之间永远差着一个不可能同时提交的边界。 ## 把取出改成两步,能救回那一条吗? 能。改法是取出消息的同时,原子地把它塞进另一个叫处理中的列表,处理完了再从那个列表里删掉。 同样的实验,同样在第250条中断: 项目 | 取出即删 | 取出转存 | 已经处理完的 | 249 | 249 | 队列里还剩的 | 250 | 250 | 处理中列表里的 | — | 1 | 合计 | 499 | 500 | 差额 | 1 | 0 | 不但数对上了,还能直接看到卡住的是哪一条——查一下处理中列表,里面躺着的就是它。实测那条是编号249的任务,跟中断位置完全吻合。 关键在于原子。取出和转存必须是同一个操作,不能先取出再写入。如果拆成两步,两步之间崩溃,消息还是会消失,只是概率变小了——从必然变成偶然,反而更难查。 ## 可靠性的标价是多少? 天下没有白给的保证。同样3000条消息,两种写法的出队速率: 写法 | 耗时 | 出队速率 | 会不会丢 | 取出即删 | 321.1毫秒 | 9343条/秒 | 崩溃时丢 | 取出转存再删 | 678.7毫秒 | 4420条/秒 | 不丢 | 慢2.11倍。原因很直白:一次操作变成两次,往返次数翻倍。 这个价该不该付,要看你的量。4420条每秒对绝大多数独立站是远远够用的,一天能处理3.8亿条。除非你确实撞到了吞吐天花板,否则这2.11倍是这篇文章里最值得花的钱。为了省一倍吞吐去接受随机丢单,属于捡了芝麻。 ## 处理中列表越积越多,谁来收拾? 上一节留了个尾巴:那条消息保住了,但它现在躺在处理中列表里,没人管它。 保哥把崩溃重复了三次,每次都在第5条上中断: 第几次崩溃 | 队列剩余 | 处理中列表 | 第1次 | 25 | 1 | 第2次 | 20 | 2 | 第3次 | 15 | 3 | 三次崩溃之后,处理中列表里躺着三条,实测查出来是编号14、9、4的三个任务。它们不在队列里,所以永远不会被再次取出;也没处理完,所以业务上就是没发生。比丢了更糟——丢了至少数字对不上能发现,卡在这里数字是对的,只是永远不动。 解法是加一个回收动作:定期把处理中列表里停留超过阈值的条目倒回队列。实测跑一次回收,三条全部捞回,队列从15条恢复到18条,一条不少。 这个回收动作的关键参数是阈值,也就是等多久算超时。太短会把正在正常处理的长任务误判成卡死,导致同一条被两个消费者同时处理;太长则故障恢复慢。实践中的取法是取单条处理耗时的九十九分位,再乘以三。用分位数而不是平均值,理由和上一篇量响应时间时一样,平均值会被长尾骗。 取这个分位数有个前提:你得先知道单条处理到底要多久。很多团队卡在这一步,因为消费者里根本没埋耗时统计。排查服务器变慢那篇 (https://zhangwenbao.com/linux-server-performance-troubleshooting-high-load-cpu-memory-disk-io-diagnosis.html)里的思路可以直接搬过来——先有数,再谈阈值,否则设多少都是拍脑袋。保哥的建议是消费者每处理完一条就记一行耗时,攒一周再回头定阈值,这一周里先用一个明显偏大的值兜着。 顺带一提,成熟的消息代理把这套内建了。某开源代理的文档原话是,使用手动确认时,任何未被确认的投递会在对应通道或连接关闭时自动重新入队。这正是上面手写那套回收逻辑的托管版——你不用自己写超时判断,连接一断它就还回去了。内存库那边的流式结构 (https://redis.io/docs/latest/develop/data-types/streams/)也提供了类似的待确认清单,比手搓两个列表要省心。 ## 重复投递躲不掉,那怎么让它不出事? 把丢消息堵上之后,问题就换了个方向:现在消息不会少,但会多。 回收机制本身就是重复的来源。一条消息因为确认丢失被判成超时,倒回队列重投,而它其实已经处理完了。保哥测了一遍最极端的情形——同一条消息连续三轮都是处理完但没来得及确认: - 不做任何防护:这条消息被处理了3次 - 加一个幂等键:真正执行1次,另外2次被挡掉 幂等键的做法就一行:处理之前先尝试写一个以消息标识命名的键,条件是仅当不存在时才写。写成功说明是第一次,往下走;写失败说明有人做过了,直接跳过。 更贴近真实的一组数字是这样跑的:1000条消息,人为制造1%的确认丢失概率。 指标 | 数值 | 消息条数 | 1000 | 第一轮后卡在处理中列表 | 15 | 回收重投后的总投递次数 | 1015 | 被幂等键挡掉的次数 | 15 | 真正执行的次数 | 1000 | 投递次数比消息条数多了1.5%,而执行次数不多不少正好等于消息条数。这就是至少一次加幂等等于效果上的正好一次——这条等式是整个可靠投递体系的落脚点,值得记牢。 ## 幂等键该用什么当标识? 这一步最容易做错。有人拿队列自动生成的消息编号当标识,那是没用的——重投时会生成新编号,两次投递在幂等键眼里是两条不同的消息。 标识必须来自业务,而且要和你想防的那个动作一一对应: 业务动作 | 该用什么当标识 | 不该用什么 | 发订单确认邮件 | 订单号加邮件类型 | 只用订单号,会挡掉发货通知 | 扣库存 | 订单号加行项目编号 | 商品编号,同一商品多单会漏扣 | 记一笔账 | 业务流水号 | 时间戳,两笔同秒的会互相挡 | 推送收录接口 | 网址加当天日期 | 只用网址,明天就推不了了 | 最后一行那个例子很典型。如果幂等键只用网址,那这个网址一辈子只能推一次,改了内容想重推都推不动。加上日期这个维度,幂等的粒度就变成了每天一次,既防了重复又不挡正常需求。幂等不是越严越好,是要跟业务上真正的重复定义对齐。 ## 多个消费者一起跑,顺序会乱成什么样? 幂等解决的是同一条消息被处理多次。还有一类问题是不同消息之间的先后关系被打乱了。 场景很好想:同一个订单先后产生了创建、支付、发货三条消息。两个消费者并发取消息,一个拿到创建一个拿到支付,如果创建那条恰好慢一点,支付先处理完了——数据库里就出现了一笔付了款但订单还不存在的记录。 前面引的那份云厂商文档已经预告了这件事:消息可能乱序到达,而且它只承诺尽最大努力保持顺序。并发消费和保序天生冲突,一个消费者变两个,吞吐翻倍,顺序就没了。 能用的解法有三种,代价依次上升: 解法 | 怎么做 | 代价 | 让消息自带顺序 | 消息里带状态和版本号,处理时校验 | 业务代码要写状态机 | 按键分区 | 同一订单的消息固定进同一个分区 | 并发度受分区数限制 | 干脆不并发 | 单消费者串行处理 | 吞吐降到单机上限 | 实践中第一种用得最多,因为它不牺牲并发度。做法是消费者在处理支付消息时先检查订单是否存在,不存在就把这条消息延后重投——反正有重试机制,延后几秒它自己会回来。把保序问题转成重试问题,是这三种里最省事的一条路。 第二种适合量大且顺序要求硬的场景,代价是并发上限被分区数卡死。有个常见误区是以为加消费者就能加吞吐,实际上分区数才是天花板——十个分区挂二十个消费者,有十个是闲着的。 ## 幂等键的有效期一过会怎样? 这是个很多人没想过的边界,保哥专门测了一次,用的是2秒有效期: - 第1次:执行 - 第2次,紧接着重来:被挡掉 - 第3次,等键过期之后再来:又执行了 结论一句话:幂等窗口只有有效期那么长。这不是缺陷,是必须做的取舍——键不设过期就会无限堆积,实测1000个幂等键大约占100KB,看着不多,但一年下来就是另一回事了。 所以有效期怎么定?规则是要盖住最长的一条重投链路。把这几个时间加起来:处理中列表的回收阈值、最大重试次数乘以最大退避间隔、再加一段富余。如果你的回收阈值是5分钟、最多重试5次、退避最长到64秒,那有效期至少要10分钟以上。设成60秒的话,一条重试到第4次的消息回来时幂等键已经没了,它会被当成新消息重新执行一遍。 另一个坑更隐蔽:幂等键和业务数据必须放在同一个可靠性等级上。上一篇查过那台机器的内存库配置,追加写关闭、快照策略为空、六天没落盘、内存打满按最近最少使用淘汰。把幂等键放在这种实例上,等于给防重复上了一道随时会消失的锁。内存一紧张,键被淘汰,重复就长驱直入了。要么开持久化,要么把幂等键写进业务库——很多团队的做法是直接在业务表上建一个唯一索引,让数据库替你挡,这招粗暴但从不失手。 ## 失败了要重试几次,间隔怎么排? 前面处理的都是消费者自己出问题。还有一大类是下游出问题——邮件服务挂了、支付网关超时、仓储接口返回500。 这时候重试是对的,但重试的排法很有讲究。保哥拿一个必然失败的地址测了固定间隔的写法: 策略 | 请求数 | 总耗时 | 对下游的压力 | 固定间隔重试5次 | 5 | 930毫秒 | 1秒内砸5次 | 指数退避重试7次 | 7 | 127秒 | 摊到2分钟里 | 请求数差不多,甚至退避还多两次,但压力完全不是一回事。 把消费者数量算进来才是真相。假设你有100个消费者,下游刚好趴下,每个消费者手里都有失败的任务在重试:固定间隔的写法会在1秒内往一个已经趴下的服务上砸500个请求。它本来可能只是短暂过载,喘口气就能起来,结果被你的重试按在地上起不来了。这个场景有个很贴切的说法叫重试风暴,它的特点是故障范围随着你的系统规模成正比放大——消费者越多,砸得越狠。 指数退避的排法是每失败一次把间隔翻倍:1秒、2秒、4秒、8秒、16秒、32秒、64秒,累计127秒。给下游留出了恢复窗口。 还有一个细节叫抖动,必须加。如果100个消费者在同一时刻开始退避,它们的第1秒、第2秒、第4秒会完美对齐,退避的效果就打了对折——压力从连续变成了脉冲,峰值没降。做法是在每次间隔上乘一个随机系数,比如0.5到1.5之间,把这100个消费者的重试时刻打散开。 本站在主动推送那篇 (https://zhangwenbao.com/wordpress-baidu-active-push.html)里也遇到过同类问题:接口有配额限制,一失败就猛重试,配额消耗得比正常推送还快。用定时任务串运维动作 (https://zhangwenbao.com/linux-cron-shell-independent-site-automation-ops-backup-sitemap-ssl.html)时同理,失败重跑一定要带退避,否则一个卡住的备份能把整台机器的负载顶上去。 ## 什么样的消息该被判死刑? 退避解决的是下游临时故障。但有一类失败重试多少次都没用——消息体本身就是坏的,比如里面那个订单号根本不存在。 这类消息如果一直放回队列,就会变成一个永动机:取出、失败、放回、再取出。它不会消失,会持续占用消费能力,队列长度看起来还挺正常,实际上消费者一直在原地打转。 解法是死信:给每条消息记一个失败计数,超过上限就转到一个专门的列表里,不再参与正常流转。保哥跑了一遍完整流程,队列里放一条必然失败的和一条正常的: 轮次 | 消息 | 结果 | 第1轮 | 坏消息 | 第1次失败,放回队列 | 第2轮 | 正常消息 | 处理成功 | 第3轮 | 坏消息 | 第2次失败,放回队列 | 第4轮 | 坏消息 | 第3次失败,转入死信 | 最终队列0条、处理中0条、死信1条。注意第2轮——坏消息在重试的间隙里没有挡住正常消息,这正是要的效果。 成熟的消息代理把这套做成了配置项。官方文档列出的转入死信的条件有四种 (https://www.rabbitmq.com/docs/dlx):被消费者明确拒绝且不要求重新入队、消息自身的存活时间到期、队列长度超限被挤出、以及在特定队列类型上超过投递次数上限。 ## 队列自己躺下了怎么办? 把生产端、消费端都堵严实之后,还剩最后一个漏点:队列这个组件本身。 保哥顺手查了那台机器上内存库的持久化配置,读数很能说明问题:追加写日志关闭,快照策略是空的,距离上一次落盘530938秒也就是6.14天,这期间累计108431次变更全在内存里,而内存打满时的淘汰策略是按最近最少使用删键。 如果有人在这个配置下把它当队列用,那前面所有的确认、回收、幂等都白做了:进程一重启,队列里没消费完的消息全部归零,而且不会有任何报错——重启是成功的,服务是正常的,只是那些消息不存在了。 更微妙的是淘汰策略那一条。它按最近最少使用删键,而队列里排在最后面、最久没被碰过的那些消息,恰恰是最容易被判定为最近最少使用的。内存一紧张,被删掉的正好是等得最久的那批。这个优先级刚好跟业务需求反着来。 所以选组件时至少要确认三件事:持久化开没开、内存策略会不会淘汰队列的键、以及队列的键和缓存的键有没有混在同一个实例里。第三条最容易中招,因为缓存实例通常是最早搭起来的,队列上得晚,顺手就用了同一个。本站写对象缓存那篇 (https://zhangwenbao.com/redis-object-cache-wordpress-persistent-cache-hit-rate-operations.html)时量过这个实例的命中率和内存占用,当时的结论是给缓存用绰绰有余——但给队列用是另一套要求。 同一份文档还提到一个很容易踩的坑:死信可以配置成转发到另一个队列,而如果配置成环,消息会一直绕圈。官方的处理是检测到循环且整个环里没有出现过拒绝时,把消息丢掉。也就是说配错了不会报错,会静默丢消息——这类不报错的失败模式最难查,本站在文件监控那篇 (https://zhangwenbao.com/linux-inotify-file-monitoring-inotifywait-incron-realtime-sync.html)里也碰到过同一类问题。 ## 死信进了以后呢? 死信列表最大的价值不是兜底,是它把不确定变成了确定。原来你不知道有没有消息处理失败,现在失败的都在一个地方摆着,可以数、可以看、可以告警。 配套要做三件事:死信条数一旦不为零就告警,因为正常情况下它应该长期是空的;每条死信要带上最后一次的错误原文,否则捞出来也不知道为什么死的;以及要有一个把死信重新放回队列的手动开关,修完代码之后能重放。 最后这一条很多团队会忘。修复了一个解析错误,结果发现死信里躺着的两千条消息没法重新处理,只能写一次性脚本。重放开关应该和死信机制一起做,不是等出事了再补。 ## 上线换版本的时候,队列里那些旧消息怎么办? 这是所有队列系统迟早会撞上的一件事,而它很少被写进教程。 问题是这样的:你改了消息的字段结构,比如把收件人从一个字符串改成一个数组。新代码部署上去,队列里还躺着几百条老格式的消息。新的消费者拿到老消息,解析报错,进重试,重试三次进死信。一次正常的发版,制造了几百条死信。 根子在于队列是有状态的,而部署这个动作默认大家是无状态的。上一篇讲的那三个阶段里,任务表方案其实没这个问题——表里的行你可以先跑一条更新语句改掉,队列里的消息改不了。 能用的办法有三条: - 消息带版本号。每条消息里放一个版本字段,消费者按版本分支处理,老版本的分支保留一到两个发布周期再删。这是最稳的,代价是代码里会攒下一些兼容分支。 - 只加不改。新增字段可以随便加,老消费者读不到会忽略;但不改已有字段的名字和类型,也不删。这条约束养成习惯之后,八成的兼容问题自动消失。 - 发版前先排空。停止生产、等消费者把队列清空、再部署。最简单也最粗暴,只适合队列本来就空得快的场景。 保哥的建议是第二条当默认纪律,第一条当兜底。第三条别当常规手段——它意味着每次发版都要停一段时间的生产,而队列的初衷恰恰是解耦,为了发版重新把两端绑在一起,属于把优点用没了。 ## 这一整套在独立站上要落到哪几个动作? 把上面五件事收敛成一张可执行的清单,按重要性排: 顺序 | 动作 | 解决什么 | 不做的后果 | 1 | 取出转存,别取出即删 | 崩溃丢消息 | 每崩一次丢一条,且无感 | 2 | 关键动作加幂等键 | 重复投递 | 重复发邮件、重复扣库存 | 3 | 处理中列表定期回收 | 消息永久卡住 | 数字对得上但业务没发生 | 4 | 重试用指数退避加抖动 | 重试风暴 | 把临时故障拖成长时间故障 | 5 | 加死信和条数告警 | 坏消息占死消费能力 | 队列看着正常,实际在空转 | 对独立站来说,前两条是必须的,第三到第五条可以随着量的增长逐步补。判断标准很实际:只要你的队列里跑着跟钱有关的动作,第2条就必须先做。重复发一封营销邮件是骚扰,重复扣一次库存是超卖,重复退一次款是真金白银。 还有一个容易被忽略的落点是可观测性。上一篇讲过队列长度这个数单看没用,这一篇可以补齐另外两个必看的数:处理中列表的长度和死信的条数。前者长期不为零说明有消息卡住,后者不为零说明有消息彻底失败。这两个数和队列长度合起来,才算把队列的状态看全了。把消费者做成系统服务 (https://zhangwenbao.com/linux-systemd-service-management-unit-files-custom-daemon-auto-restart.html)之后,进程存活也要一起监控——消费者死了而队列还在涨,是最常见的事故形态。 本站在邮件群发那篇 (https://zhangwenbao.com/magento-2-newsletter-subscriber-management-email-marketing-queue-operations.html)里写过一个真实的反面案例:队列配好了,但没做幂等,一次重投把同一批订阅者的邮件发了两遍,投诉率直接把发信域名的信誉分打下来了。技术上那次投递是成功的,业务上是事故。 ## 常见问题解答 问:小站有必要搞这么复杂吗? 看你队列里跑什么。如果只是生成缩略图、清缓存这类失败了重做一遍也没关系的事,取出即删就够,丢了下次再生成。如果跑的是发邮件、扣库存、对账这类有副作用的动作,那第1条和第2条必须做,加起来也就几十行代码。判断标准是问自己一句:这个动作重复做一次,会不会有人来投诉。 问:为什么不用事务把处理和确认包起来? 因为它们通常不在同一个系统里。业务数据在数据库,队列在另一个组件,两者之间没有共享事务。理论上有分布式事务这种东西,但代价高、依赖多、故障模式更复杂,绝大多数场景不值得。行业的主流答案就是至少一次加幂等,用一个便宜得多的机制达到等价效果。 问:处理中列表的回收阈值到底设多久? 取单条处理耗时的九十九分位再乘以三,这是个好起点。设太短的后果比设太长严重得多——一条正常处理中的长任务被误判成超时,倒回队列被另一个消费者取走,同一件事就有两个消费者在同时做。如果这时候幂等键还没做好,那就是两次扣款。所以拿不准的时候往长了设。 问:幂等键的有效期设多长合适? 必须盖住最长的重投链路。把回收阈值、最大重试次数乘以最大退避间隔加起来,再留一段富余。实测那组数据说明了后果:有效期2秒的键,过期之后同一条消息又执行了一次。如果你的退避最长排到64秒而幂等键只有60秒,那重试到最后一次的消息回来时,防线已经撤了。 问:可靠投递慢2.11倍,能不能只对重要消息用? 可以,而且是推荐做法。按消息类型分队列,发邮件、扣库存这类走可靠通道,生成缩略图、更新统计这类走快通道。这样既拿到了关键路径上的保证,又没有为不重要的消息付双倍成本。分队列还有个额外好处:一类消息堆积不会拖累另一类,缩略图积压一万条也不影响订单邮件按时发出去。 问:消息代理自带这些机制,还需要自己写吗? 确认、重试、死信这三样成熟的代理都自带,配置一下就行,不用自己实现。但幂等一定要自己做——没有任何组件能替你判断两条消息在业务上是不是同一件事,那个标识只能你自己给。这也是为什么前面那张标识对照表值得单独看一遍。 问:死信里的消息该保留多久? 比幂等键长得多,建议按周算。死信的用途是人工介入,而人工介入的响应速度取决于告警有没有人看、以及发现问题的那个人在不在。周五晚上进死信的消息,可能周一才有人处理,所以至少要能扛过一个周末。同时要给死信列表本身设长度上限并配告警,否则某次批量故障能塞进去几十万条,把存储撑爆——那时候你面对的就不是一个业务问题,是两个。 问:怎么验证这套机制真的生效了? 用本文的做法:造一批消息,故意在中间杀掉消费者,然后对账。已处理加队列剩余加处理中,三个数相加应该等于总数,差额必须是0。这个测试成本很低,但它是唯一能证明你的可靠性配置真的在工作的办法。没做过这个对账,你的可靠性配置就只是一份意图声明。 ## 权威参考资料 ## 下单接口里那行发邮件的代码,把24毫秒的响应拖成了924毫秒 - URL:https://zhangwenbao.com/async-task-when-to-use-message-queue.html - 分类:消息队列与异步 - 发布:2026-07-10 | 更新:2026-07-31 - 摘要:四条判据决定要不要上队列,三种替代方案各能撑到哪一步。含任务表七列字段清单、三阶段演进路线,以及上线后必看的三个观测指标。 - 关键词:服务器运维,缓存,消息队列 > **TLDR**:摘要:下单成功后给用户发一封确认邮件,这行代码放在哪里,决定了用户要等多久。保哥在自己那台服务器上把这件事量了一遍:本站响应的首字节中位数是23.9毫秒,而一次真实的第三方接口调用中位数落在437到900毫秒之间,还有一个境外接口整整15秒才超时返回。把它内联进请求路径,响应时间会变成原来的19倍到629倍。同一轮实测里还撞出一个反直觉的结论:把存储从数据库换成内存库,吞吐只提升了10倍;而把逐条调用换成批量调用,同一个存储上提升了29倍。换句话说,决定快慢的主要不是你用什么存,是你往返几次。本文给出的不是队列教程,是一张判据表——什么时候还不需要队列,以及三个替代方案各自能撑到哪一步。 > 摘要:下单成功后给用户发一封确认邮件,这行代码放在哪里,决定了用户要等多久。保哥在自己那台服务器上把这件事量了一遍:本站响应的首字节中位数是23.9毫秒,而一次真实的第三方接口调用中位数落在437到900毫秒之间,还有一个境外接口整整15秒才超时返回。把它内联进请求路径,响应时间会变成原来的19倍到629倍。同一轮实测里还撞出一个反直觉的结论:把存储从数据库换成内存库,吞吐只提升了10倍;而把逐条调用换成批量调用,同一个存储上提升了29倍。换句话说,决定快慢的主要不是你用什么存,是你往返几次。本文给出的不是队列教程,是一张判据表——什么时候还不需要队列,以及三个替代方案各自能撑到哪一步。 先说清楚这篇要解决的问题。市面上讲消息队列的文章,绝大多数从生产者、代理、消费者这三个名词讲起,然后接一张选型对比表。这些内容不能说没用,但它们默认了一个前提——你已经决定要上队列了。 真实情况往往相反。一个日均几百单的独立站,后台跑着几个定时脚本,突然有人说这里应该上个队列,团队里没人能说清为什么。于是要么硬上,多了一个会挂的组件;要么一直不上,把用户晾在那里等第三方接口。 所以保哥决定先不谈方案,先量数字。下面这些数据全部来自本站那台单机,不是从别处抄来的基准测试,跑的时候站点正常对外服务。 ## 为什么第一步是量时间,而不是选型? 那台机器上跑着这个站,热态下的响应很稳。用命令行连打5次,首字节分别是23.8、23.9、23.9、24.0毫秒——四次几乎完全一致。 第5次是486.8毫秒。 这个数字本身就值得停一下:同一个地址、同一分钟、同一台机器,最慢的一次是最快的20.5倍。所以后面所有的对照,保哥都取中位数而不是平均值,平均值会被这种偶发尖刺抬得面目全非。这个坑在服务器负载排查那篇 (https://zhangwenbao.com/linux-server-performance-troubleshooting-high-load-cpu-memory-disk-io-diagnosis.html)里也踩过,一次异常能把平均值抬高好几倍。 基线定下来了:23.9毫秒。现在往里面塞一个外部依赖。 ## 一个第三方调用会往响应里加多少毫秒? 保哥挑了5个真实端点,每个连打5次,取最快、中位、最慢三个值。全部是从那台服务器上发出去的,不是本地网络: 被调用的对象 | 最快 | 中位 | 最慢 | 返回码 | 某代码托管平台的接口 | 296.5 | 436.8 | 981.4 | 403 | 某CDN厂商官网 | 642.3 | 721.9 | 918.9 | 200 | 某接口调试服务 | 876.6 | 899.7 | 1082.2 | 503 | 某搜索引擎首页 | 15000.5 | 15001.1 | 15001.2 | 0 | 本站自己(对照组) | 24.1 | 24.4 | 681.9 | 200 | 单位都是毫秒。最后一列那个0不是笔误,是压根没拿到响应——15秒的超时上限到了,连接被主动掐断。这台机器出不去那个域名,而它三次读数分别是15000.5、15001.1、15001.2,误差不到千分之一。那不是网络抖动,是超时计时器本身的精度。 还有一处细节值得记下来:返回码403和503那两行,耗时并没有比返回200的那行少。失败不比成功快,这是很多人做容量估算时会漏掉的一条——你不能假设出错的调用会很快返回。 把这三档数字加回基线上: - 内联一个437毫秒的调用:23.9变成460.9毫秒,慢19.3倍 - 内联一个900毫秒的调用:23.9变成923.9毫秒,慢38.7倍 - 内联那个出不去的接口:23.9变成15023.9毫秒,慢628.6倍 第三行才是重点。它平时可能一直是好的,某天对方机房出问题,或者某条国际线路抽风,你的下单接口就从24毫秒变成15秒。用户不会知道是第三方的问题,他只知道这个站点击提交之后转了半分钟圈。 > 判断依据很朴素:凡是调用对象不在你的机房里,它的最坏情况就等于你配的超时时长。你配15秒,最坏就是15秒;你不配超时,最坏是无穷。 ## 串行、并行、挪走,三条路差多少? 假设下单之后要做三件事:发确认邮件、通知仓储、同步给数据看板。按上面实测的耗时取437、722、900毫秒,三种做法的结果是: 做法 | 用户等待 | 说明 | 三件事串着做 | 2059毫秒 | 三段时间直接相加 | 三件事并发做 | 900毫秒 | 等于最慢那件事 | 三件事丢进队列 | 0.4毫秒 | 只付了三次写入的钱 | 最后一行是实测值,往内存库里推三条消息实际花了0.4毫秒。跟串行的2059毫秒比,差5148倍。 这个对照里最容易被忽略的是中间那行。很多团队第一反应是那我并发去调不就行了,确实从2059降到了900。但用户还是在等,而且你现在有三个可能失败的调用挂在同一个请求里,任何一个超时都会把响应时间拉回15秒。进程池被慢请求占满这件事 (https://zhangwenbao.com/apache-performance-tuning-mpm-event-php-fpm-maxrequestworkers-high-concurrency.html)本站写过,并发调用只是把占用时间从串行的总和压到了最大值,没有把它从请求路径里拿走。 还要澄清一个常见误解:队列并没有让这三件事完成得更快。发邮件该花437毫秒还是437毫秒,只是这437毫秒发生在用户已经看到成功页面之后。端到端的总时长甚至可能更长,因为多了入队和出队两跳。队列买的是用户不用等,不是事情做得快。 ## 不上队列,还有哪两条退路? 把任务挪出请求路径,队列不是唯一的办法。按复杂度从低到高,实际能用的是三条。 ## 退路一:定时任务轮询 把要做的事写进一张表或一个文件,让定时任务隔一会儿来扫一遍。这是成本最低的方案,几乎不引入新组件。 代价是延迟。定时任务的最小粒度是一分钟——手册页里那句"cron(8) examines cron entries every minute" (https://man7.org/linux/man-pages/man5/crontab.5.html)说得很直白,守护进程每分钟醒一次去比对表达式,你没法让它每10秒跑一趟。所以: 跑的频率 | 最好情况 | 最坏情况 | 平均等待 | 每1分钟 | 0秒 | 60秒 | 30秒 | 每5分钟 | 0秒 | 300秒 | 150秒 | 每15分钟 | 0秒 | 900秒 | 450秒 | 每60分钟 | 0秒 | 3600秒 | 1800秒 | 顺手看了眼那台机器的任务表,上面跑着一条每5分钟触发一次的站点计划任务。也就是说,那个站上任何依赖这条任务的动作,平均要等两分半钟。对于下单后发确认邮件这种场景,两分半钟已经超过了用户的耐心阈值——很多人这时候已经在翻垃圾邮件箱了。 什么时候够用?备份、生成站点地图、续证书、清缓存这类没有人在等结果的事,定时任务完全够用,本站的运维自动化那套 (https://zhangwenbao.com/linux-cron-shell-independent-site-automation-ops-backup-sitemap-ssl.html)就是这么跑的。表达式怎么写 (https://zhangwenbao.com/cron-generator-crontab-expression-seo-task-automation-guide.html)本站也有专门一篇。 ## 退路二:数据库表当队列 建一张任务表,写入的时候插一行,消费的时候锁一行、改状态、干活。这是电商系统里最常见的做法,因为它不引入任何新组件,而且天然带持久化。 保哥在那台机器上真跑了一遍,5000条任务,用最标准的写法——事务里先加锁读取一行,再更新状态: 做法 | 入队耗时 | 出队耗时 | 入队速率 | 出队速率 | 数据库表,逐条 | 5731.0毫秒 | 10092.3毫秒 | 872条/秒 | 495条/秒 | 内存库列表,逐条 | 560.7毫秒 | 549.2毫秒 | 8917条/秒 | 9104条/秒 | 出队慢是有原因的。加锁读取这种写法,官方文档里的原话 (https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html)是它会设置与搜索型更新语句相同的锁,其他事务想更新这些行、或者在某些隔离级别下想读这些行,都会被挡住。一条任务的完整流程是开事务、加锁读、更新、提交,四次往返,实测下来每条2.02毫秒。 顺带量了一下体积:5000条任务的表连同索引占464KB,折合每条95字节。也就是说十万条积压任务大概占9MB,对磁盘来说不值一提。用数据库表当队列,瓶颈从来不是空间,是锁。 495条每秒是什么概念?一天86400秒,理论上能处理4200万条。对于日均几百单的站,这个数字绰绰有余。所以别急着嫌它慢——它慢的是单位吞吐,不是绝对能力。 ## 退路三:真正的队列组件 到这一步才引入独立的消息中间件。它带来的不只是吞吐,更重要的是投递保证、重试、死信这些机制。那部分内容单独占一篇,本文只负责回答要不要走到这一步。 ## 为什么换存储只快10倍,换调用方式却快29倍? 这是本轮实测里最反直觉的一组数字,也是保哥觉得最值得单独拎出来的一条。 同样是5000条,四种做法的入队速率: 做法 | 耗时 | 速率 | 相对最慢 | 数据库表,一条一条插 | 5731.0毫秒 | 872条/秒 | 1倍 | 内存库,一条一条推 | 560.7毫秒 | 8917条/秒 | 10.2倍 | 数据库表,每500条一句 | 215.9毫秒 | 23159条/秒 | 26.6倍 | 内存库,管道批量推 | 19.1毫秒 | 261780条/秒 | 300倍 | 看第二行和第三行。批量写数据库,比逐条写内存库还快2.6倍。如果你的判断依据是内存比磁盘快,这一行就把它推翻了。 原因在往返次数上。单次连接的开销实测是0.095毫秒——1000次心跳探测总共95.4毫秒。这个数字很小,但乘以5000就是475毫秒,已经接近逐条推送总耗时560.7毫秒的85%。把命令打包一次发出去 (https://redis.io/docs/latest/develop/using-commands/pipelining/)之后,5000条只剩19.1毫秒。 > 可以记成一句话:换存储换来一个量级,换调用方式换来两个量级。先把往返次数降下来,再考虑要不要换组件。 这条原语在别的地方也成立。批量推送搜索引擎收录接口、批量写日志、批量更新库存,只要接口支持一次提交多条,收益都比换一个更快的后端来得直接。本站在对象缓存那篇 (https://zhangwenbao.com/redis-object-cache-wordpress-persistent-cache-hit-rate-operations.html)里量过一次同类的差距,结论方向一致。 ## 什么样的任务才真该进队列? 把前面的数字汇总一下,判据其实只有四条,全部满足才值得上: 判据 | 怎么验证 | 不满足时的替代 | 调用方不关心结果 | 问一句失败了要不要当场告诉用户 | 要告诉,就必须同步做 | 耗时进了请求路径 | 量一下它占响应时间的比例 | 占比不到5%,不值得动 | 触发时机不确定 | 是事件驱动还是按点跑 | 按点跑,定时任务就够 | 生产快过消费 | 比较两端的实测速率 | 消费更快,队列永远是空的 | 第四条最容易被忽略。本轮实测里内存库的出队速率是9104条每秒,如果你的业务每秒只产生几十条任务,那队列里永远堆不起东西——你花力气搭的那套削峰机制,一次都不会被触发。削峰这个词只在峰值真的存在时才有意义。 反过来,如果入队5000条每秒而消费只有495条每秒,每秒净积压4505条,十分钟就是270万条。这时候队列不是在削峰,是在给你争取扩容的时间。 顺着这个数往下算一步会更清楚:积压270万条之后,就算流量瞬间降到零,按495条每秒消化完也要5454秒,一个半小时。这一个半小时里,所有排在后面的用户都收不到确认邮件。所以削峰不是把问题消掉了,是把一个五分钟的尖峰摊成了一个半小时的长尾,你得确认业务能接受这个摊法。 真正的解法是让消费端也能横向扩。同一个队列挂四个消费者,理论上消费速率乘以四,积压消化时间除以四。这也是队列相比定时任务的核心优势——定时任务加机器会重复干活,队列加机器直接分摊。前提是消费端本身无状态,这一点在动手前就得想清楚。 ## 任务本身多快,队列就不划算了? 这条判据市面上很少有人给具体数字,保哥把它量了出来。单条入队实测0.107毫秒,单条出队0.108毫秒,加起来0.215毫秒是队列的固定开销,跟任务干什么无关。 任务本身耗时 | 队列开销占比 | 结论 | 0.5毫秒 | 30.1% | 纯粹的浪费 | 5毫秒 | 4.1% | 勉强可接受 | 50毫秒 | 0.4% | 完全可忽略 | 500毫秒 | 0.0% | 该进队列 | 所以有一条很实用的粗判据:任务本身跑不到5毫秒,别往队列里塞。给一个写日志、加一个计数器这类动作,直接做完比排队快,还省掉一个组件。见过有人把每次访问的计数更新丢进队列,结果队列本身的开销比那个更新还贵——这就好比为了省两步路,先花十分钟叫了辆车。 ## 消息里到底该放什么? 这条也值得量。同样5000条,只改消息体大小: 消息体大小 | 入队耗时 | 入队速率 | 内存放大 | 60字节 | 19.1毫秒 | 261780条/秒 | 1.05倍 | 600字节 | 43.1毫秒 | 116009条/秒 | 1.06倍 | 6000字节 | 281.8毫秒 | 17743条/秒 | 1.03倍 | 消息体涨100倍,吞吐掉14.8倍。而内存那一列很干净:放大系数稳定在1.03到1.06之间,说明存储本身几乎不额外收费,贵的是传输。 结论直接:消息里只放标识,不放数据。传一个订单号,让消费者自己去库里查详情,比把整个订单对象序列化进消息体划算得多。顺便还避开了另一个坑——消息里那份数据在排队期间可能已经被改过了,消费者拿到的是快照,而去库里查拿到的是当前值。 ## 上了队列之后会多出哪些新麻烦? 引进一项技术,光知道优点是不够的。保哥在同一台机器上顺手查了一下那个内存库的持久化配置,结果挺能说明问题: - 追加写日志:关闭 - 快照策略:空的,等于不做快照 - 距离上次落盘:530938秒,也就是6.14天 - 这期间累计的未落盘变更:108431次 - 内存打满时的淘汰策略:allkeys-lru 翻译成人话:这台机器上的那个内存库,六天没往磁盘写过一个字节,十万多次变更全在内存里飘着。官方持久化文档 (https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)把这几种模式的取舍讲得很清楚,而这台机器选的是最松的那一档。 如果有人在这个配置下把它当队列用,进程一重启,队列里所有还没消费的消息就全没了。而最后那条淘汰策略更微妙——内存打满时它会按最近最少使用删键,你排队等着处理的消息,在它眼里跟普通缓存键没有任何区别。这就像把待办清单贴在一块随时会被擦掉的白板上,还是那种别人路过顺手就擦的白板。 除了丢消息,还有三件事是原来不存在的: 新问题 | 具体表现 | 要额外做的事 | 可用性下降 | 多一个组件就多一个故障点 | 监控队列长度和消费者存活 | 顺序不保证 | 多个消费者并发处理,先进的不一定先完成 | 需要顺序就得按键分区 | 重复投递 | 确认丢失会导致同一条被处理多次 | 消费端做幂等 | 第三条不是实现质量问题,是行业默认。某云厂商的标准队列文档里写得很坦白:"Standard queues ensure at-least-once message delivery, but due to the highly distributed architecture, more than one copy of a message might be delivered, and messages may occasionally arrive out of order." (https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues.html)——至少一次投递,可能重复,可能乱序。这是设计取舍,不是缺陷。 ## 队列长度这个数,为什么单看没用? 上了队列之后,绝大多数团队会做的第一件监控是盯队列长度,超过多少条就告警。这个指标不能说错,但它单看几乎没有信息量。 原因是队列长度是个瞬时值,它同时受两端影响。长度是100条,可能是生产端刚推了100条而消费端还没来得及动,一秒后就清空了;也可能是消费端已经死了半小时,这100条是最近攒下的一小部分。同一个数字,一个是正常波动,一个是重大事故。 真正有判断力的是这三个数,都能从任务表或者队列元信息里直接算出来: 指标 | 怎么算 | 它回答什么 | 最老一条的等待时长 | 当前时间减最早那条的创建时间 | 最坏情况下用户等了多久 | 队列长度的变化率 | 两次采样的差除以间隔 | 在积压还是在消化 | 单条处理耗时的分位数 | 取九十分位而不是平均 | 消费端自己有没有变慢 | 第一个数最值得挂在最显眼的位置。队列长度1000条听起来吓人,但如果最老那条才等了2秒,说明消费端跟得上,只是刚好赶上一波;反过来长度只有5条却最老那条已经等了20分钟,那基本可以确定消费者卡死了——它连5条都处理不掉。 第三个数用九十分位而不是平均值,理由和文章开头那组23.9对486.8是同一个:平均值会把一次卡死稀释成看不见。相关的排查思路在负载排查那篇 (https://zhangwenbao.com/linux-server-performance-troubleshooting-high-load-cpu-memory-disk-io-diagnosis.html)里展开过,队列这边完全适用。 ## 消费者怎么等下一条,差别有多大? 这是个很容易写错的地方。消费者取不到消息时怎么办,有两种写法,成本差得离谱。 保哥拿空队列测了3秒:不停地问有没有新消息,3秒里发出了29690次请求,折合每秒9897次,全部空手而归。换成阻塞式等待,同样3秒只发出1次请求,一直挂着直到超时返回。 两者之比是29690比1。前一种写法在业务空闲时反而最费资源——半夜没订单,你的消费者却在以每秒近万次的频率骚扰存储。这个反差很值得记住:轮询的成本和业务量成反比,越闲越贵。 ## 怎么分三步走,而不是一步到位? 按前面的数字,一个正在长大的独立站可以这么排: 第一阶段:把它挪出请求路径就行。写一张任务表,定时任务每分钟扫一次。用户不用等,平均延迟30秒。这一步只花半天,覆盖掉八成的场景。这个阶段最重要的动作其实不是写代码,是把任务表的字段设计对——至少要有状态、重试次数、最后一次尝试时间、错误信息这四列,后面所有的排查都靠它们。 ## 第一阶段那张任务表该有哪几列? 这一步做扎实了,后面两个阶段都轻松。保哥的经验是最少七列,缺一列就会在某次排查时后悔: 列 | 作用 | 缺了会怎样 | 类型 | 区分是发邮件还是同步库存 | 只能一种任务一张表,越建越多 | 载荷 | 放订单号这类标识 | 只能靠表名硬编码,改不动 | 状态 | 待处理、处理中、已完成、已放弃 | 并发消费时同一条被多人抢 | 重试次数 | 失败了第几次 | 坏任务无限重试,占死消费能力 | 下次可执行时间 | 实现退避,也实现延迟任务 | 失败就立刻重试,把下游打得更惨 | 最后错误 | 失败原因原文 | 只知道失败,不知道为什么 | 创建时间 | 算积压时长 | 说不清一条任务等了多久 | 其中最容易被省掉的是下次可执行时间那一列,而它恰恰是最有用的。有了它,取任务的条件就从状态等于待处理,变成状态等于待处理并且下次可执行时间已经到了。同一个字段顺手实现了三件事:失败退避、延迟任务、以及定时发送。想让一封提醒邮件三天后发,写一行时间进去就行,不用额外搭调度器。 状态列一定要建索引,这条前面提过一次,这里再强调是因为它是最常见的翻车点:表里几万行的时候感觉不出来,涨到几百万行,每次取任务都变成全表扫,消费速率会一路掉到个位数。 第二阶段:把轮询换成常驻消费。还是那张表,但改成一个常驻进程一直在跑,处理完立刻取下一条。延迟从30秒降到毫秒级,吞吐拿到实测的495条每秒。进程守护交给系统服务管理器就行,写成开机自启服务 (https://zhangwenbao.com/linux-systemd-service-management-unit-files-custom-daemon-auto-restart.html)本站有现成的做法。这一步要注意的是别让常驻进程空转——用带超时的阻塞读,别写成死循环里不停查表,否则就是上面那个29690比1的翻版。 第三阶段:换成专门的队列组件。触发条件是前面那四条判据全部满足,并且第二阶段的消费速率已经追不上生产速率。这时候再上,你至少知道自己要解决哪个具体问题。像主流框架自带的队列抽象 (https://laravel.com/docs/12.x/queues),允许你在不改业务代码的前提下把驱动从数据库换成内存库或者专门的中间件,这个切换成本值得在第一阶段就预留出来。 本站在邮件群发队列那篇 (https://zhangwenbao.com/magento-2-newsletter-subscriber-management-email-marketing-queue-operations.html)里写过一个反面例子:把队列当成万能药,结果配置错了发送节流,邮件一次性全推出去,进垃圾箱的比例反而更高。技术选对了,参数配错了,效果一样是负的。 ## 这套判断在独立站上怎么落地? 把视角切回搜索这一侧。响应时间不只是用户体验问题,它同时影响抓取。 本站热态首字节23.9毫秒,是长期把动态渲染挡在缓存后面的结果。如果在这个链路里内联一个900毫秒的第三方调用,首字节变成923.9毫秒——对访客是能感觉到的卡顿,对爬虫则意味着同样的时间预算里能抓的页面少了一大截。 更麻烦的是不稳定。那个境外接口的15秒超时如果发生在爬虫抓取的时候,拿到的就是超时或者错误页。偶发几次没关系,成为常态就会影响索引。而且这类问题在监控上很难发现——你的正常访客可能因为地理位置不同并不触发那条慢路径,只有爬虫恰好撞上。 具体到独立站,哪些动作应该被挪出渲染路径,其实有一份很稳的清单: 动作 | 典型耗时 | 该放哪 | 发交易类邮件 | 数百毫秒 | 队列 | 推送收录接口 | 数百毫秒 | 队列,且批量提交 | 同步库存到第三方 | 数百毫秒到数秒 | 队列,带重试 | 生成缩略图 | 数百毫秒 | 队列,或者上传时一次做完 | 写访问统计 | 亚毫秒 | 直接做,进队列反而亏 | 刷新缓存标记 | 亚毫秒 | 直接做 | ## 怎么把藏在请求路径里的调用找出来? 上面那张表看着清楚,但真实项目里最难的是第一步——你根本不知道自己的下单流程里到底有几个外部调用。代码是几年里好几个人陆续加的,谁也说不全。 有个不用读代码的笨办法,实测很好使:把同一个接口在两种条件下各打十次,比较耗时的分布形状。一种是正常状态,一种是把出网流量掐掉或者把第三方域名解析到一个不通的地址。如果两组数字差得很多,说明请求路径里确实挂着外部依赖;如果几乎没变,那说明那些调用已经是异步的,或者根本没被触发。 更精细一点,可以看响应时间的分布形状。纯本地处理的接口,快慢会集中在一个很窄的区间里,就像本文开头那四次23.8到24.0毫秒。一旦分布拉出长尾——大部分很快,偶尔冒出几百毫秒甚至几秒——那条长尾几乎一定来自网络调用。本地计算不会突然慢十倍,网络会。 拿本站那五次实测当例子:四次23.9,一次486.8。如果这是你的下单接口,那第五次就是需要去查的对象,而不是当成噪声抹掉。做性能优化的人常犯的错是盯着平均值优化,可用户投诉从来都来自长尾那一端。 某个做户外装备的独立站在旺季促销时就撞过这个:平时下单接口的平均耗时一直很体面,一开闸就有用户反馈点提交没反应。后来发现是库存同步接口挂在下单流程里,平时对方响应很快,促销时对方自己也被打满,响应从两百毫秒涨到十几秒。问题不在自己的代码,但受影响的是自己的转化率——把那个调用挪进队列之后,下单接口的耗时曲线立刻变平了。 所以对独立站来说,把第三方调用挪出请求路径这件事有双重收益:访客等得少,爬虫也抓得动。判断标准可以简化成一句——凡是能在页面渲染完之后再做的事,就不该出现在渲染路径上。这个思路和用工作流工具把重复动作产线化 (https://zhangwenbao.com/n8n-dtc-seo-pipeline-4-scenarios.html)是同一个方向:先把人和请求从等待里解放出来,再谈提速。 ## 常见问题解答 问:几百单的小站到底要不要上队列? 按第四条判据,大概率不用。日均几百单意味着每秒不到一条任务,而一张普通数据库表实测能出队495条每秒,中间差着四个数量级。先做第一阶段——把任务写进表、定时任务扫——通常就够了。等到某天你发现表里积压的行数在持续增长,再考虑往上走。判断标准不是订单量的绝对值,是积压曲线的斜率。 问:数据库表当队列会不会把主库拖垮? 会,但不是因为吞吐,是因为锁。加锁读取会阻塞其他事务对这些行的更新,如果消费者拿着锁去做一件耗时几百毫秒的事,锁就一直握着。正确的写法是先在事务里快速把状态标成处理中然后提交,再在事务外面干活。本轮实测的10092.3毫秒就是标准写法的成本,纯粹是锁和事务的开销,跟实际业务无关。另外记得给状态列建索引,否则每次取任务都是全表扫。 问:为什么并发调用不算数? 因为它没有把任务从请求路径里拿走,只是把总耗时从三段相加压到了最慢那一段。实测是2059毫秒降到900毫秒,看起来省了一半多,但用户仍然在等,而且现在有三个可能超时的调用挂在同一个请求上。任何一个撞上15秒超时,整个请求还是15秒。并发是优化,异步是解耦,两件事。 问:内存库当队列到底靠不靠谱? 取决于配置。本轮在那台机器上查到的是:追加写关闭、快照策略为空、六天没落盘、内存打满按最近最少使用淘汰。这个配置下当队列,重启就丢,内存紧张也丢。要用它当队列,至少得把追加写打开,并且把队列的键放在一个不做淘汰的实例上,不要和缓存混用同一个实例。混用最危险的地方在于:缓存打满触发淘汰,被删掉的可能正好是你的待办任务,而且不会有任何提示。 问:批量提交是不是万能的? 不是。批量把往返次数降下来,代价是延迟和失败粒度。攒够500条再发,第一条要多等499条的时间;而且一批里有一条失败,你得决定是整批重来还是挑出来单独处理。实时性要求高的场景,通常的折中是按条数和时间双阈值触发——攒够100条发一次,或者攒够50毫秒发一次,先到哪个算哪个。 问:怎么判断现在的任务表已经撑不住了? 看三个数:待处理行数是不是在持续增长而不是波动,最老一条待处理任务的等待时长是不是在变长,以及消费一条的平均耗时是不是在变长。前两个说明生产快过消费,第三个说明单条处理本身在退化。三个里出现两个,就该考虑往上走一级了。这三个数都能用一条查询算出来,建议直接挂到监控面板上,别等用户投诉才去看。 问:消息体大小有没有硬上限? 各家组件的上限不一样,但按本轮实测的趋势,你根本用不到那个上限就已经被吞吐劝退了——6000字节的消息体入队速率已经掉到60字节时的十四分之一。实操上把消息体控制在几百字节以内是安全区,超过一两千字节就该考虑只传标识。真需要传大对象,标准做法是把对象存到别处,消息里只放一个取回地址。 ## 权威参考资料