《架构设计》目录

第 1 章

什么时候真的需要消息队列——三个问题定夺,一张任务表兜底

本章目标:

  1. 拿到一套选型判断:三个问题定夺要不要消息队列(MQ),每个问题都落到可观察的业务特征上
  2. 掌握任务表(Transactional Outbox)的完整实现:表结构、状态机、扫表协议、超时回收
  3. 看清装上 MQ 之后的三笔持续支出:堆积、重复、顺序——每一笔都对应一条可执行的防线
  4. 会判死信要不要人工介入,以及任务表到 MQ 的迁移时机
  5. 理解同一个选型在私有化交付和云上 SaaS 两种形态下为什么答案可以不同

本章基线:RocketMQ 4.9.x + MySQL 8.0 | 判断与表结构来自真实 B 端项目(日均投递十万级以内)| 最后核对 2026-11-01。日均投递远超百万级的系统不在这个范围——那一档的单表写入和消费模型都不一样

1.1 先看一个真实场景

订单服务创建完订单,要通知三个下游:库存预占、积分发放、短信提醒。云上的 SaaS 版第一版写法是订单事务提交后,串着调三个 HTTP 接口。积分服务一慢,订单创建跟着超时,用户在收银台前干等。

开发组把 RocketMQ 请了进来,问题解决。三个月后这套系统卖私有化,客户的内网只有 MySQL 和两台应用服务器,运维就一个人。RocketMQ 一套 NameServer 加 Broker 架构(NameServer 是注册中心,Broker 是存储转发节点,最少这两类进程)装是装上了,客户问了一句:这个坏了我们谁来修?

这个问题没有技术答案,只有成本答案。它值得在动手之前问,而不是装完之后被问。

1.2 三个问题:丢不起、等不起、通知几方

要不要 MQ,取决于三个问题的答案。每个问题都对应一种真实需求,一个一个过:

  • 丢不起:订单要扣库存、支付要发积分,这类指令丢了就是资损或客诉;埋点上报、日志采集丢了就丢了,不值得为它们上可靠投递设施。很多人把第二类也搬进 MQ,等于花可靠的成本干不可靠的事。
  • 等不起:判定标准落在业务上——异步之后这件事还成立吗。下单后十分钟发货可以异步,收银台等支付结果不能异步。注意「能晚多久」的单位经常是分钟不是毫秒,多数 B 端系统的异步需求按分钟计就够,这一条直接决定后面的选型。
  • 通知几方:一个订单创建,库存要听、积分要听、风控也要听,日后还会冒出第四个第五个。这种「一条事件、多方订阅、消费者持续增加」的形状下,MQ 的解耦价值才真正兑现。如果从头到尾只有一个消费者,解耦是纸面上的:你不过把 HTTP 调用换成了 MQ 调用,多养了一套中间件。

把常见场景对着三个问题过一遍:

场景 丢 晚 几方 判定
支付成功发短信 丢得起(可重发) 分钟级 1 方 任务表
订单创建通知库存预占 丢不起 秒到分钟级 1–2 方 任务表起步,量大迁 MQ
秒杀流量削峰 丢不起 秒级 1 方,但瞬时量大 MQ
一条事件喂报表、风控、推荐多个团队 看情况 分钟级 3 方以上且在增长 MQ
埋点、日志收集 丢得起 小时级 1 方 哪个都别用,写文件或直接丢

削峰单独说一句:MQ 在这里的作用不是可靠投递而是蓄水池,瞬时写入先落队列,消费者按自己的节奏消化。这个场景没有低配替代——任务表扛不住秒级上万写入,单表写入吞吐先到顶。

要不要消息队列的三个判断分支:丢得起别上可靠投递,分钟级够用任务表正合适,只有一个消费者解耦是纸面的——三个答案都不紧迫走任务表,削峰、广播、多方订阅才上 MQ

1.3 三个答案都不紧迫:一张任务表

1.3.1 原理与建表

任务表的原理一行话:把「要发的消息」当成本地事务的一行数据写进数据库,提交事务的同时消息就落库了,另一个定时任务扫表往外发,发成功才改状态。这个模式叫事务发件箱(Transactional Outbox,本地事务和消息发送的原子性靠同一个数据库事务保证),它补的是 MQ 管不到的一段缝隙——业务事务已经提交、消息还没发出去,或者反过来。

CREATE TABLE outbox_task (
  id          BIGINT NOT NULL PRIMARY KEY,
  biz_type    VARCHAR(32) NOT NULL COMMENT 'order_created / stock_deduct',
  biz_id      BIGINT NOT NULL COMMENT '排障时回溯业务单据用',
  payload     TEXT NOT NULL COMMENT '消费者要的完整数据,JSON',
  status      VARCHAR(16) NOT NULL DEFAULT 'PENDING',
  owner       VARCHAR(64) NULL COMMENT '抢到这行的实例标识',
  next_retry  DATETIME NOT NULL COMMENT '不晚于它的行才会被扫到',
  retry_count INT NOT NULL DEFAULT 0,
  created_at  DATETIME NOT NULL,
  KEY idx_scan (status, next_retry)
);

payload 用 TEXT 别用 VARCHAR 定长——多行商品的订单 JSON 轻松超过 2000 字符,而 insert 发生在订单事务里,超长报错等于把下单一起拖垮。

1.3.2 扫表协议:抢占、退避、超时回收

状态机四态:PENDING → SENDING → SENT,重试耗尽进 DEAD。扫表每 5 秒一轮(经验值,按消费者承受力调),单批 200 行:

// 抢占:条件更新把行拨给自己,多实例天然错开
int claimed = jdbc.update(
    "UPDATE outbox_task SET status='SENDING', owner=? " +
    "WHERE status='PENDING' AND next_retry<=NOW() LIMIT 200", instanceId);
// 逐条发送;成功置 SENT
// 失败:retry_count+1,next_retry 按指数退避(第 n 次失败后 2^n 分钟再试),
//       超过 8 次标 DEAD 等人看
// 超时回收:owner 是自己但超过 10 分钟没动静的 SENDING 行,
//           重置回 PENDING——实例发送中途宕机时靠它兜底

SENDING 这个中间态是协议的核心:抢占和置 SENT 之间隔着一个网络调用,实例可能死在中间。没有超时回收,这些行会永远卡在 SENDING。

1.3.3 三个容易忽略的细节

  • 完整数据:payload 存消费者要的完整数据,别存「事件类型让消费者回来查库」——查库又把两个系统在数据库层耦上了。
  • 联合索引:扫描走「status 定前缀、next_retry 定范围」的路径,发完的行变成 SENT 后自然退出扫描范围,这才是 (status, next_retry) 联合索引真正的收益;单列 status 在表大了之后扫起来全是无效行。
  • 死信出口:DEAD 状态的行要有人看的出口,接告警或管理页,否则它的名字叫死信但没人知道(介入判定见 1.5)。

1.3.4 至少一次投递:消费端幂等的功课

必须说实话的部分:任务表和 MQ 一样是至少一次投递(at-least-once,置 SENT 前宕机、超时回收、多实例抢占,任何一个环节都可能让同一条消息发两遍),消费者的防重一道都不少做。选任务表省下的是中间件的运维账,不是消费者幂等的功课。

1.3.5 容量账与三个天花板

任务表的真实容量比想象的大:消费者响应在几十毫秒级、单批 200 行的配置下,每秒几十条、每天百万级的投递量,一个扫表循环加两三个 worker 实例绰绰有余。它真正的天花板有三样——所有业务事务在同一张表上 insert 的写入热点(瓶颈在单表吞吐本身:redo 日志、自增主键页争用、连接数)、多实例抢行太细的扫描竞争、SENT 行不清理的表膨胀(每天百万级一年三亿多行,归档任务别忘了排)。三个天花板都碰到之前,私有化和中小 SaaS 的绝大多数异步需求到不了量。

1.4 决定上 MQ:先算三笔账

1.4.1 堆积:MQ 扛得住,业务扛不住

消费能力跟不上生产速度,消息在队列里排长队。MQ 自己扛得住亿级堆积,业务的时效性扛不住——积分晚发一小时没人发现,库存预占晚一小时就是超卖,因为这一个小时里货没有被占住,系统当它有货,接着卖就超了。堆积深度要进监控,但它量的是业务风险,不是中间件健康度。

1.4.2 重复:at-least-once 与防重键

MQ 的投递语义是至少一次,网络抖动就会把同一条消息投两次。重复不是意外,是常态。消费者的每个接口都得按业务键防重,防重键按业务事件选:支付回调用渠道流水号、退款用退款单号——同一个键重试必须不变,业务上允许重复发生的事件(比如部分发货)每次要有不同的键。防重键的完整设计在订单状态机那一章的 biz_key 一节展开。

1.4.3 顺序:并发消费与顺序消费的坑形状不同

同一张订单的「创建」「支付」「退款」三条消息被两个消费者并行处理,库存可能先收到退款再收到创建。RocketMQ 两种消费模式的坑完全不同:并发消费失败重试默认 16 次,重试消息转进重试主题按延迟等级重投、不堵同队列后面的消息,16 次耗尽进死信;顺序消费才是堵着后面等的那个,它的原地重试上限要显式配置,不配就一直在原地转。选哪个之前想清楚自己要哪种——要顺序就按业务键(订单号)选队列,跨键的顺序本来就不该互相等。

1.4.4 两行必须核对的配置

MQ 保证的是「发出之后不丢」,前提是 同步刷盘 + 同步复制 都配上:默认的异步刷盘在 Broker 宕机时会丢消息,私有化客户机器少的环境尤其要核对。这两行配置在「丢不起」的判定下是必选项,不是可选项。

1.5 死信要不要人工介入

判断落在钱上:丢掉的是货款、退款这类要紧事,就配人工出口和告警;埋点、提醒这类丢了能重造或能放弃的,自动过期就行。

死信里的东西 处置 理由
支付回调、退款指令 人工介入 + 告警 丢了是资损,重放要带业务核对
库存预占 人工介入 丢了超卖,但可按单核对补偿
积分、短信提醒 自动过期 + 计数 可重造,丢了影响可接受
埋点、日志采集 直接丢弃 本来就是尽力而为的数据

人工介入的出口要有三个件:能看的页面(死信列表带原文 payload)、能重放的动作(重放前允许改 payload)、能告警的规则(DEAD 行数 > 0 就报)。缺任何一个,死信就只是换了个地方丢。

1.6 迁移:任务表到 MQ 的触发信号

任务表起步不等于永远任务表。迁移的触发信号看两个数:outbox 写入占到业务库写 TPS 的一成以上,或者消费者涨到三家以上。碰到信号再迁,迁移分三步:

  1. 扫表任务的「发送」实现从 HTTP 调用换成 MQ 生产者,业务代码零改动——这就是 1.3 里「把投递实现写成通用组件」的回报。
  2. 消费者改从 MQ 收,防重逻辑原样保留(MQ 的 at-least-once 语义和任务表一样)。
  3. 双跑一段时间对账,outbox 发送量和 MQ 投递量对得上再停扫表。

反过来,别在只有一家消费者、每天几千条的时候上 MQ——那是用运维成本买纸面架构。

1.7 成本归属:同一个选型,两种交付形态

三笔账加起来,就是「客户问坏了谁来修」的完整答案:监控要看堆积深度,版本要跟着升,重试和死信要有人值班。这套运维成本,私有化客户全额承担,云上 SaaS 由平台团队摊薄。同一个技术选型,私有化和云上 SaaS 的答案可以不同,这不叫双标,叫成本归属不同——SaaS 里值得上 MQ 的那部分,到了一个运维就一个人的私有化环境,可能一张任务表才是负责任的选择。

1.8 本章小结

答案组合 选型 关键动作
三项全占(丢不起且晚不起且多方) 直接上 MQ 同步刷盘+同步复制;防重键按事件设计
只占一项 任务表起步 outbox 写成通用组件,盯两个迁移信号
削峰或广播 MQ 任务表没有替代方案
三个都无所谓 同步调用+超时 把需求退回去再问一遍

MQ 是工具不是勋章。三个问题的答案比任何选型文章可靠;三个答案都不紧迫的系统,一张数据库任务表就能把事情做对,而且它在客户环境里是零新增组件。

1.9 练习题

  1. 给你现在的系统列一遍「事务提交后要通知的下游」,逐个过三个问题,标出哪些其实连任务表都不需要。
  2. 把 1.3.2 的扫表协议写成一个可跑的定时任务:两个实例同时扫,故意 kill 掉其中一个,观察超时回收把 SENDING 行救回来的过程。
  3. 给 outbox_task 造 10 万行 PENDING,EXPLAIN 看扫描走不走 idx_scan;再把其中 9 万行置 SENT,再看扫描行数的变化——体会「发完自然退出扫描范围」这句话。
  4. 把一个现有 HTTP 通知接口改造成消费端,设计它的防重键:哪次重复会撞键、哪次合法重放不能被挡?

1.10 延伸读