消息队列替下单链路扛住了开票洪峰,也把失败藏进了一条更长的时间轴。你会学会看积压斜率、算追平时间,并为丢失、重复与乱序逐项交账。
创业第 3 年,「秒抢」累计用户约 300 万,公司 160 人,后端 30 人。一个月前,开票瞬间的同步写洪峰打垮过下单链路;团队把订单落库、短信和出票拆到消息队列后面,白天的大盘终于变平。然后,半夜 01:30,手机把你从睡梦里拽起来。
你打开队列、消费者和订单库三张大盘。四个数字已经足够排除「消息系统挂了」这个直觉:
消息没有停产,消费者也没有归零;问题是前者每秒比后者多 5,000 条。按这个斜率,每分钟净增 30 万条,4,820,000 条积压只需要约 16 分钟就能形成。分区数和活跃消费者又恰好都是 12,说明再启动一批同组消费者,部分实例只会空等分区。
阿凯在电话里报出另一条线索:画像补算把单条消费的外部查询从 2 次推到 7 次,消费吞吐从约 11,000/s 掉到 7,000/s。队列只是水库;真正变窄的是出水口。
A是对的,理由只有一条:先让斜率改符号。暂停补算能把单条消费成本拉回基线;限制非核心生产者能立刻缩小流入。确认分区与下游余量后再扩消费者,才有可能让消费速率超过生产速率。目标不是让水位看起来小一点,而是先让它开始下降。
B会把延迟事故变成数据事故。队列里的 482 万条包含订单、出票和通知状态。直接清空会让它们失去处理机会,监控曲线会瞬间变绿,对账单却会在天亮后变红。
C的问题是并行度账单没算。12 个分区已经分给 12 个活跃消费者,同一消费组再加 50 个实例不会凭空多出分区;即便扩分区,订单库和外部查询也未必能接住新增并发。实例数不是消费能力的同义词。
D是对的下一步,不是对的第一步。延长保留期是在购买处理时间,适合防止积压尚未追平就过期;它既不降低生产速率,也不提高消费速率。先止住增长,再给恢复窗口加保险。
消息队列把同步链路拆成两个时钟:生产者按自己的速度写入消息,消费者按自己的能力处理。只要短时洪峰之后消费能力重新高于生产,队列就能把尖峰摊平;如果消费长期低于生产,水库会按固定斜率上涨。
先代入告警现场:12,000/s − 7,000/s = 5,000/s。只要这个差值保持为正,积压每秒净增 5,000 条;扩大保留期只能让水库晚一点漫出来,不能改变水位方向。
再代入恢复剧本。假设经过限流和扩容,当前积压正好 500 万条,生产回落到 8,000/s,消费提升到 10,000/s:
计算结果是 2500 秒 ≈ 42 分钟(精确到小数约 41.7 分钟)。最容易犯的错,是用 500 万 ÷ 10,000 得出 500 秒:这忽略了恢复期间每秒仍有 8,000 条新消息进入。消费者的前 8,000/s 只是追上当下,只有多出来的 2,000/s 在偿还旧债。
吞吐之外,消息系统还要同时处理三种语义账单:
| 难题 | 它怎么发生 | 可执行护栏 | 别被什么骗了 |
|---|---|---|---|
| 丢失 | 生产确认前进程退出、队列未持久化、消费先确认后处理失败 | 生产确认、持久化副本、处理成功后确认、对账补发 | 发送接口返回成功,不等于业务结果已落地 |
| 重复 | 消费者处理成功但确认丢失,队列按 at-least-once 再投一次 | 业务幂等键、数据库 UNIQUE 约束、处理结果表 | 去重不能只放进程内存,重启会忘记历史 |
| 乱序 | 相关消息落到不同分区,或失败重试让后发消息先完成 | 同一业务键进同一分区、版本号、状态机拒绝倒退 | 全局顺序代价很高,多数业务只需要 key 内有序 |
失败消息也不能在主队列里无休止打转。连续重试会占住分区、拖慢后续健康消息,还可能反复轰击已经不健康的下游。常见做法是限定重试次数并逐步退避,超过阈值后送入死信队列:保留原消息、失败原因、重试次数和追踪标识,由告警、人工修复或补偿任务接管。
死信队列不是垃圾桶。它是一张待处理病历:如果没人监控数量、年龄和恢复路径,消息只是从主队列的可见积压,搬进了另一个更安静的积压。
订单创建也常采用异步落库:入口先完成资格校验与排队,再由消费者写订单。这条路能把写洪峰削平,但用户拿到的是「已受理」而不是「已成功」。第 10 章会继续追问:当异步消费者真正扣库存时,怎样避免并发请求把同一张票卖两次。
而 at-least-once 的重复投递并不是异常边角,它是可靠交付用重复换丢失的正常账单。第 11 章会正面解决业务幂等:如果同一消息来两次,第二次必须只返回第一次的结果,不能再制造一笔订单。
消息系统最危险的两类病,一类把水位画在大盘上,另一类把大盘保持得过分安静。前者靠速率诊断,后者靠业务账本诊断。
两张卡要一起看:▁▂▃▄▅▆▇ 告诉你系统欠了多少时间债,▁▁▁▁▁ 提醒你监控安静不等于业务账平。队列指标负责发现流动问题,对账负责发现结果问题。
让生产与总消费只差一点,观察水位如何持续爬升;再制造洪峰,用净消费速率直接算出多久能追平。
这些简化是故意的:模拟器的目的是隔离一个因果关系,不是预测生产数值。
实验清单里的每条现象都由 tools/simN-*-model-test.js 的断言保证,
但保证的是这个模型里的现象。
复盘会上,团队要为订单事件写下一句可以验收的交付承诺。两条路都能成立,但责任边界不同:一条承认重复,把正确性放进业务;另一条购买组件提供的事务语义,把约束留在受支持的边界里。每一个都是对的,每一个都有账单。
A · at-least-once + 业务幂等:跨队列、数据库和补偿任务都能沿用同一个业务键,失败后放心重投。账单:每个有副作用的消费者都要实现幂等,保存处理记录并承担存储与清理成本;漏一个入口就可能重复扣款。适合跨系统、结果可由业务键约束的订单链路。
B · 组件 exactly-once:在同一流处理事务边界内,位点和结果可以一起提交,应用代码更简洁。账单:承诺通常止于组件边界;一旦调用外部支付、数据库或人工补偿,端到端仍需幂等。适合输入输出都留在受支持组件内的流式聚合。
C · 分级策略:按副作用范围选择语义,不让核心订单迁就统计任务,也不让简单聚合背负完整业务账本。账单:平台要维护两套运行手册、监控和故障演练,团队必须说清每条链路的承诺边界。适合消息类型多、治理能力成熟的团队。
「秒抢」的选择:订单、支付、出票采用 A,由请求方业务键贯穿生产、消费、死信与补偿,数据库 UNIQUE 约束做最后护栏;实时看板聚合在边界明确时采用 B。理由是核心链路跨越数据库和外部系统,不能把端到端正确性寄托在队列组件的一段承诺上。第 11 章会把这套幂等账本完整展开。
回放 01:30 的正确剧本——先让水位转向,再扩大净消费能力,最后核对结果而不是只看 backlog 归零:
停止继续变窄。暂停画像补算并限住非核心事件;单条消费外部查询从 7 次恢复到 2 次,消费速率升到 9,600/s,生产回落到 8,100/s,积压曲线第一次转向。
按瓶颈扩容。确认订单库写入余量后,将分区从 12 扩到 20,再把活跃消费者升到 20;消费稳定在约 12,000/s,净还债速度接近 4,000/s。
算出恢复窗口。按剩余积压与实时净消费速率估算约 19 分钟追平(此刻约 446 万条 ÷ 净 4,000/s ≈ 1,115 秒),把客服口径从「系统异常」改为「出票延迟,订单已受理」,并延长消息保留期作为保险。
对账后解除。backlog 已于约 02:00 归零、最老消息年龄回到秒级;团队再核对订单、支付与票权数量,处理 37 条死信后才关闭事件,而不是看到队列清空就宣布恢复。
队列 backlog 呈 ▁▂▃▄▅▆▇,生产稳定在 10,000/s,消费稳定在 9,500/s,错误率接近零。最准确的判断是?
A。形状与 500/s 的正差值对应 No.11 消息积压。B 很诱人,因为两个组件都在工作,但只要消费仍低于生产,时间不会替系统还债;D 只扩大容器,不改变流入和流出;C 需要业务对账证据,不能从积压曲线直接推出。
积压 500 万条,生产 8,000/s,消费 10,000/s。忽略速率波动,约多久追平?
B。净消费速率是 10,000 − 8,000 = 2,000/s,5,000,000 ÷ 2,000 = 2,500 秒,约 41.7 分钟。A 是最诱人的错误项,它直接除以总消费速率,漏掉了恢复期间持续进入的新消息。
一个消费组有 12 个分区和 12 个活跃消费者。下游容量未确认时,再启动 30 个同组消费者,最可能先发生什么?
B。同一消费组的并行度先受分区数约束,多出的实例会空闲。A 把进程数当成服务能力,忽略分区与下游两道上限;扩分区后也必须重新核对顺序键和数据库容量,不能只追求消费者数量。
消费者已写入订单库,但发送确认前进程退出,消息随后被再次投递。最稳妥的业务护栏是什么?
B。这是 at-least-once 的正常重复窗口,稳定幂等键能把两次投递收敛成一个业务结果。C 看似消除了重复,却把处理失败变成消息丢失;A 和 D 都在没有业务证据时放弃可靠交付。具体实现会在第 11 章展开。