设想这样一幕:用户付款成功,订单页面已经显示“已支付”,积分却迟迟没到账。排查后发现,积分服务昨晚重启过,重启前正在处理的消息又被投递了一遍。为了避免重复加积分,开发者加了一个判断;流量一大,两个线程同时通过判断,积分又多发了一次。
消息队列的工程问题,经常就藏在这些看起来已经解决的细节里。
你可能已经听过“异步、解耦、削峰填谷”。这几个词解释了为什么引入 MQ,却没有回答另一些问题:消息发送超时算失败吗?消费成功的标准是什么?数据库已经提交,消息还没发出去怎么办?
我们用一笔电商订单,把这些问题串起来。全文以 OrderPaid 表示“订单支付事实已确认”,积分、通知和分析都订阅这个事件。订单创建与支付成功是不同事实,积分发放在本文设定中只由支付成功触发。
文中的数据均为教学假设,代码用于解释机制。RabbitMQ 主要讨论 AMQP 0-9-1 下的队列;Kafka 主要讨论传统 KafkaConsumer 消费组;RocketMQ 的事务与顺序消息以其 5.0 模型为例。
1. 一次支付,为什么牵动这么多服务
订单服务确认支付后,通常还有不少事情要做:通知用户、发放积分、更新搜索索引、记录分析数据。
如果把这些操作全部塞进同步调用链,用户就要等待它们完成。短信供应商的接口一慢,支付结果页跟着慢;分析服务发生故障,订单接口也可能返回错误。此时钱已经付了,用户却看到失败提示,接着再点一次。
这里需要先确定接口的承诺。本文中,“支付成功”意味着支付事实已经核实,订单状态已经可靠提交。通知和积分可以随后完成,但要有明确的完成时限和失败处理方式。
库存是否已经锁定、支付是否真实成功,属于交易流程自己的约束,不能仅凭“发出了一条消息”就宣告成立。需要异步协调的核心步骤,也应通过清楚的状态机暴露“处理中”“失败”等状态。
确定边界后,订单服务便可以记录支付结果,可靠地产生 OrderPaid,让下游各自处理:
#mermaid-svg-DAW3o6lOTAhpoQuG{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DAW3o6lOTAhpoQuG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DAW3o6lOTAhpoQuG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DAW3o6lOTAhpoQuG .error-icon{fill:#552222;}#mermaid-svg-DAW3o6lOTAhpoQuG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DAW3o6lOTAhpoQuG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DAW3o6lOTAhpoQuG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DAW3o6lOTAhpoQuG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DAW3o6lOTAhpoQuG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DAW3o6lOTAhpoQuG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DAW3o6lOTAhpoQuG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DAW3o6lOTAhpoQuG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DAW3o6lOTAhpoQuG .marker.cross{stroke:#333333;}#mermaid-svg-DAW3o6lOTAhpoQuG svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DAW3o6lOTAhpoQuG p{margin:0;}#mermaid-svg-DAW3o6lOTAhpoQuG .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-DAW3o6lOTAhpoQuG .cluster-label text{fill:#333;}#mermaid-svg-DAW3o6lOTAhpoQuG .cluster-label span{color:#333;}#mermaid-svg-DAW3o6lOTAhpoQuG .cluster-label span p{background-color:transparent;}#mermaid-svg-DAW3o6lOTAhpoQuG .label text,#mermaid-svg-DAW3o6lOTAhpoQuG span{fill:#333;color:#333;}#mermaid-svg-DAW3o6lOTAhpoQuG .node rect,#mermaid-svg-DAW3o6lOTAhpoQuG .node circle,#mermaid-svg-DAW3o6lOTAhpoQuG .node ellipse,#mermaid-svg-DAW3o6lOTAhpoQuG .node polygon,#mermaid-svg-DAW3o6lOTAhpoQuG .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DAW3o6lOTAhpoQuG .rough-node .label text,#mermaid-svg-DAW3o6lOTAhpoQuG .node .label text,#mermaid-svg-DAW3o6lOTAhpoQuG .image-shape .label,#mermaid-svg-DAW3o6lOTAhpoQuG .icon-shape .label{text-anchor:middle;}#mermaid-svg-DAW3o6lOTAhpoQuG .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DAW3o6lOTAhpoQuG .rough-node .label,#mermaid-svg-DAW3o6lOTAhpoQuG .node .label,#mermaid-svg-DAW3o6lOTAhpoQuG .image-shape .label,#mermaid-svg-DAW3o6lOTAhpoQuG .icon-shape .label{text-align:center;}#mermaid-svg-DAW3o6lOTAhpoQuG .node.clickable{cursor:pointer;}#mermaid-svg-DAW3o6lOTAhpoQuG .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DAW3o6lOTAhpoQuG .arrowheadPath{fill:#333333;}#mermaid-svg-DAW3o6lOTAhpoQuG .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DAW3o6lOTAhpoQuG .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DAW3o6lOTAhpoQuG .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DAW3o6lOTAhpoQuG .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DAW3o6lOTAhpoQuG .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DAW3o6lOTAhpoQuG .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DAW3o6lOTAhpoQuG .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DAW3o6lOTAhpoQuG .cluster text{fill:#333;}#mermaid-svg-DAW3o6lOTAhpoQuG .cluster span{color:#333;}#mermaid-svg-DAW3o6lOTAhpoQuG div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DAW3o6lOTAhpoQuG .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DAW3o6lOTAhpoQuG rect.text{fill:none;stroke-width:0;}#mermaid-svg-DAW3o6lOTAhpoQuG .icon-shape,#mermaid-svg-DAW3o6lOTAhpoQuG .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DAW3o6lOTAhpoQuG .icon-shape p,#mermaid-svg-DAW3o6lOTAhpoQuG .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DAW3o6lOTAhpoQuG .icon-shape .label rect,#mermaid-svg-DAW3o6lOTAhpoQuG .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DAW3o6lOTAhpoQuG .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DAW3o6lOTAhpoQuG .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DAW3o6lOTAhpoQuG :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
可靠发布 OrderPaid
支付结果已核实
订单服务
订单数据库
消息系统
通知订阅
积分订阅
分析订阅
发送支付通知
提交积分变更
更新分析数据
图中的三个订阅在逻辑上相互独立。通知服务停机时,积分服务仍能继续处理;分析服务也可以稍后追赶。图里“可靠发布”的实现,正是后面要解决的双写问题。
系统从这里开始拥有一种新的状态:订单已支付,但积分尚未到账。接口、前端和客服流程都得理解这个状态,不能仍然假设一次请求结束后,所有数据就已经同步更新。
2. 先分清消息、队列和消费组
消息是一次通信的数据载体。它可以表达一条指令,例如“发送通知”,也可以记录一个事实,例如“订单已经支付”。本文采用事件:OrderPaid 告诉下游发生了什么,下游决定自己的动作。
下面是一条教学消息:
{
"event_id": "evt-paid-1001",
"event_type": "OrderPaid",
"schema_version": 1,
"order_id": "order-1001",
"aggregate_version": 2,
"user_id": 42,
"points_delta": 199,
"occurred_at": "2026-10-03T10:00:00+08:00"
}
event_id 标识这一次业务事件,发送重试和消息重放时应保持不变。order_id 标识订单,一笔订单可能产生支付、退款等多个事件,两种 ID 不能混用。
points_delta 是本例按支付时规则确定的积分值。把它记录下来,能避免消费者几天后重放事件时,用新规则算出另一个结果。schema_version 用于识别消息结构版本,aggregate_version 表示订单状态的版本。
几个常见名词也需要放回各自的位置:
| Producer,生产者 | 把业务事实编码为消息并发布 |
| Broker | 接收、存储和提供消息,维护相关投递状态 |
| Topic,主题 | 按业务含义组织消息,例如订单事件 |
| Queue,队列 | 承载等待处理的消息;具体语义取决于产品 |
| Partition,分区 | 把日志或消息存储拆成多个单元,支持扩展及局部顺序 |
| Consumer,消费者 | 读取消息,执行自己的业务逻辑 |
| Consumer Group,消费组 | 在支持该模型的系统中,表示一个逻辑订阅及其处理进度 |
这些概念不会在每个产品里一一对应。RabbitMQ 的 AMQP 模型通过 Exchange 和绑定规则把消息路由到队列;其中的 topic exchange 是一种路由方式,不能直接当成 Kafka Topic。
更容易混淆的是“多个消费者”。同一项积分业务部署三个实例,目的是分担工作;通知、积分、分析三个业务,则各自需要看见同一个支付事件。
前者是组内分工,后者是独立订阅。把三个业务放到同一个竞争消费单元里,可能出现这笔订单只发了通知,下一笔订单只加了积分。
传统 Kafka 消费组通过分配分区实现组内分工,不同组独立维护进度。
RabbitMQ 队列的竞争消费者共同处理一个队列;需要独立订阅时,通常给各业务建立独立队列并配置路由。
日志消费又有一个区别:消费进度推进后,消息通常仍按保留策略存储,可以供其他订阅读取或重放。Kafka 采用这种模型。
3. 异步、解耦和削峰,分别解决什么
异步缩短了用户请求必须等待的链路。订单接口不用等短信发送完成,但短信发送这件事仍然需要执行,端到端完成时间甚至可能变长。
解耦降低了订单服务对下游在线状态、实例数量和部署节奏的依赖。新增一个分析订阅时,也不必给订单服务再加一次远程调用。
依赖并没有消失。双方仍然要约定事件含义、字段类型、版本兼容和错误处理方式。例如把 points_delta 从整数改为字符串,消费者仍可能一起失败。新增字段通常比改变已有字段的含义更容易兼容。
削峰则利用队列暂存暂时处理不过来的消息。促销期间订单事件突然增加,消费者可以按数据库承受得住的速度处理。
但缓冲有容量,业务有时限。积分允许延迟几秒和允许延迟一天,是完全不同的系统要求。队列长度、存储容量、保留时间以及消费者恢复后的处理余量,都要围绕这个要求设计。
重试、监控、追踪和补偿也随之进入日常工作。同步调用失败时,错误能直接返回给调用方;异步任务失败时,需要另一个渠道让负责的人知道。接口返回成功,只能说明它承诺的那一步已经完成。
4. 沿链路走一遍,消息可能在哪里丢
先看生产者。调用发送函数后,数据可能还在客户端缓冲区,甚至尚未离开进程。函数返回的含义取决于接口和配置,写入本地缓冲区与获得 Broker 确认是不同阶段。
再看网络。生产者发送消息,Broker 保存成功,回传确认时连接断了。生产者看到超时,却无法知道消息是否已经保存。
于是,一次超时有两种可能:消息没到,或者消息到了、确认没回来。直接放弃可能造成丢失;重新发送可能造成重复。发送方应保留可重试的记录,并让重试沿用同一个事件 ID。
到了 Broker,可靠性还涉及持久化、副本和故障恢复。消息只存在内存里,进程退出便可能丢失;写入磁盘,也要考虑磁盘故障和其他副本是否持有有效数据。需要结合确认条件、复制机制以及允许承受的故障范围判断。
持久化不能覆盖所有故障。消息还可能因保留策略到期、容量限制或运维操作被清理。重要消息的存储与恢复能力,应按明确的故障假设设计。
最后看消费者。消息已经收到,积分事务还没提交,进程就退出了。系统需要能够重新投递或重新读取这条消息。
这就引出了两种确认:
| 发送确认 | Broker 按协议与配置处理了这次发布 |
| 消费确认或进度提交 | 消费者声明相应消息已完成处理,或推进了恢复时的读取位置 |
Broker 的发送确认无法证明积分已经到账。RabbitMQ 的 publisher confirms 与 consumer acknowledgements 是两套独立机制。其发送确认也可能确认一条无法路由到任何队列的消息;需要验证路由时,应结合 mandatory 返回机制处理。
消费端则应在业务提交成功后再确认。Kafka 的 offset 提交代表进度,不能当成任意一条消息的独立删除操作;并行处理时,提交位置不能越过尚未完成的前序记录。
这些确认解决了恢复位置的问题,却还留下另一个窗口:业务已经提交,确认尚未完成。
5. 重复消费为什么正常,幂等如何落到数据库
看这条故障时间线:
每一步都说得通,业务结果却错了。
幂等要求同一事件执行多次,仍然得到约定的一次业务效果。直接执行 balance = balance + 199 不具备这个性质。
先查询“是否处理过”,再执行更新,也不够。两个消费者可能同时查到“没有”,然后都更新。需要让数据库的唯一约束参与竞争,并把去重记录与业务变更放在同一个本地事务里。
下面采用 PostgreSQL 风格 SQL,作为教学示例:
CREATE TABLE processed_events (
subscriber VARCHAR(64) NOT NULL,
event_id VARCHAR(80) NOT NULL,
PRIMARY KEY (subscriber, event_id)
);
假设 point_accounts 中已经存在用户账户,消费者的伪代码如下:
收到 OrderPaid 后:
校验事件类型、版本与必要字段
开始数据库事务
inserted = 执行 SQL:
INSERT INTO processed_events(subscriber, event_id)
VALUES ('points-awarder', :event_id)
ON CONFLICT (subscriber, event_id) DO NOTHING
RETURNING event_id
如果 inserted 返回了一行:
changed = 执行 SQL:
UPDATE point_accounts
SET balance = balance + :points_delta
WHERE user_id = :user_id
如果 changed 不是 1:
回滚事务
按失败策略处理,不确认消息
结束本次处理
提交数据库事务
提交成功后,确认消费或安全推进消费进度
发生数据库错误:
回滚事务
按失败策略重试,不把未知错误当成重复消费
这里 subscriber 是稳定的业务订阅身份。通知和积分需要分别处理同一事件,不能共用一条全局去重记录;普通部署升级也不应随意改变这个身份。
两个事务同时插入同一个键时,数据库会协调这次竞争:一个提交后,另一个不能再成功插入;若前者回滚,后者仍有机会执行。数据库也可能因锁超时等原因返回错误,此时应按失败处理。
去重记录和积分更新一起提交,才有“记录存在即业务变更已经成功”的依据。若先单独保存去重标记,随后更新失败,下一次消费就可能被错误跳过。
如果数据库提交时连接中断,提交结果可能未知,此时也不能确认消费。让后续重试通过同一套幂等机制判定结果。
这个方案只覆盖同一数据库内的业务效果。调用外部接口时,还需要对方支持稳定的幂等键,或另行安排可靠任务、结果查询与对账。
另有两处边界:上游若把同一次支付生成不同事件 ID,消费者的事件去重无法自动识别,需要支付流水等业务键约束;去重记录的保留范围,也应覆盖允许的重试和重放范围。
6. “恰好一次”到底保证了哪一层
讨论投递语义前,要明确范围:是在谈发送重试、消息读取,还是最终业务效果?
| At-most-once,至多一次 | 消息在指定边界内最多被交付或处理一次 | 故障时可能遗漏 |
| At-least-once,至少一次 | 在约定故障与恢复条件下,至少交付或处理一次 | 可能重复 |
| Exactly-once,恰好一次 | 在指定边界内产生一次有效处理结果 | 需要明确原子性与协作条件 |
投递成功与业务成功应分别验证。消息交付给了消费者,不代表积分已经提交。“至少一次”也需要可靠存储、保留时间和恢复机制。永久损坏的消息、永远无法恢复的依赖,不能靠无限重试自动变成成功。
消费者先提交进度、后执行业务,可能在崩溃时跳过尚未完成的任务;先提交业务、后提交进度,可能在恢复时再次执行。后者通常配合消费幂等使用。
Kafka 的事务可以把读取进度和输出到 Kafka Topic 的记录纳入同一事务,结合正确的消费配置及 read_committed 隔离,提供 Kafka 内部读、处理、写链路的 exactly-once 语义。处理代码仍可能因事务中止而重新运行,已提交结果的可见性由事务机制控制。
使用 Kafka Streams 时,应显式配置 processing.guarantee=exactly_once_v2;其默认值为 at_least_once。
如果消费者还修改了独立的业务数据库,Kafka 事务并不会自动包住这次更新。需要数据库侧去重,或者由目标系统协作协调处理进度和输出。
生产者幂等同样有边界:防止协议层发送重试产生重复记录,无法自动识别业务应用重新创建的两条事件,也无法约束消费者对外部系统的重复调用。
看到“支持 exactly-once”时,应接着确认:它覆盖哪些输入输出?哪些故障?哪些配置?外部副作用是否在保证范围内?
7. 订单写成功了,消息没发出去怎么办
订单数据库和消息系统是两个独立资源。普通数据库事务通常只覆盖数据库本身。
先提交订单,再发送消息:订单已经是已支付,进程在发送前退出,下游可能一直不知道这次支付。
先发送消息,再提交订单:下游已经开始加积分,订单事务却回滚,事件描述的本地状态没有成立。
把发送函数放进 BEGIN 与 COMMIT 之间,也不会自动扩展数据库事务的边界。外部系统不会因为数据库回滚,就撤销已经发布的普通消息。
Outbox:先在本地保存待发布事件
Transactional Outbox 把订单变更和待发布事件写入同一个数据库事务,再由转发程序发布消息。
教学 SQL 可以表达为:
BEGIN;
UPDATE orders
SET status = 'PAID', version = version + 1
WHERE order_id = :order_id AND status = 'UNPAID';
— 应用必须检查恰好更新 1 行,否则结束事务并处理状态冲突。
INSERT INTO outbox(event_id, event_type, aggregate_id, payload, status)
VALUES (:event_id, 'OrderPaid', :order_id, :payload, 'PENDING');
COMMIT;
支付事实应已核实。重复回调或状态冲突不能继续插入一条新的支付事件。事务提交后,订单状态和待发布记录一起存在;回滚时,两者都不会生效。
积分数据库积分消费者消息系统Outbox 转发程序订单数据库订单服务积分数据库积分消费者消息系统Outbox 转发程序订单数据库订单服务#mermaid-svg-k7IuJymx48tj8vzN{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-k7IuJymx48tj8vzN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-k7IuJymx48tj8vzN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-k7IuJymx48tj8vzN .error-icon{fill:#552222;}#mermaid-svg-k7IuJymx48tj8vzN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-k7IuJymx48tj8vzN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-k7IuJymx48tj8vzN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-k7IuJymx48tj8vzN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-k7IuJymx48tj8vzN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-k7IuJymx48tj8vzN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-k7IuJymx48tj8vzN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-k7IuJymx48tj8vzN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-k7IuJymx48tj8vzN .marker.cross{stroke:#333333;}#mermaid-svg-k7IuJymx48tj8vzN svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-k7IuJymx48tj8vzN p{margin:0;}#mermaid-svg-k7IuJymx48tj8vzN .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-k7IuJymx48tj8vzN text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-k7IuJymx48tj8vzN .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-k7IuJymx48tj8vzN .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-k7IuJymx48tj8vzN .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-k7IuJymx48tj8vzN .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-k7IuJymx48tj8vzN #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-k7IuJymx48tj8vzN .sequenceNumber{fill:white;}#mermaid-svg-k7IuJymx48tj8vzN #sequencenumber{fill:#333;}#mermaid-svg-k7IuJymx48tj8vzN #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-k7IuJymx48tj8vzN .messageText{fill:#333;stroke:none;}#mermaid-svg-k7IuJymx48tj8vzN .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-k7IuJymx48tj8vzN .labelText,#mermaid-svg-k7IuJymx48tj8vzN .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-k7IuJymx48tj8vzN .loopText,#mermaid-svg-k7IuJymx48tj8vzN .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-k7IuJymx48tj8vzN .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-k7IuJymx48tj8vzN .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-k7IuJymx48tj8vzN .noteText,#mermaid-svg-k7IuJymx48tj8vzN .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-k7IuJymx48tj8vzN .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-k7IuJymx48tj8vzN .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-k7IuJymx48tj8vzN .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-k7IuJymx48tj8vzN .actorPopupMenu{position:absolute;}#mermaid-svg-k7IuJymx48tj8vzN .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-k7IuJymx48tj8vzN .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-k7IuJymx48tj8vzN .actor-man circle,#mermaid-svg-k7IuJymx48tj8vzN line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-k7IuJymx48tj8vzN :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}同一事务更新订单并写入 Outbox本地事务提交成功读取已提交的待发布事件OrderPaid 与稳定 event_id发布 OrderPaid按配置返回发送确认标记事件已发送投递 OrderPaid同一事务写去重记录并更新积分提交成功消费确认或提交进度
图中转发记录更新与下游消费的先后顺序只是示意,二者不必相互等待。
转发程序可以轮询 Outbox 表,也可以通过 CDC 读取已提交的数据库变更。轮询需要管理查询负载和领取任务的竞争;CDC 需要维护连接器、读取位置及数据库日志保留。
Outbox 仍可能重复发布:消息系统已经确认,转发程序在标记“已发送”前崩溃,下次会重新发布。因此消费者仍然需要幂等。反过来,先标记已发送再发布,则会重新打开丢失窗口。
还要持续观察未发送记录的数量和年龄。转发程序停了一天,Outbox 可以保住待发布事实,却无法满足几秒内到账的承诺。
RocketMQ 事务消息:协调本地事务与消息可见性
RocketMQ 事务消息采用另一种协调机制:生产者先发送下游暂不可见的半消息,然后执行本地事务,再提交消息的 Commit 或 Rollback 结果。
如果第二阶段结果没有到达,Broker 可以回查生产者,由生产者依据可靠的本地事务记录回答结果。仍在执行的事务应保持 Unknown,不能因为暂时查不到就贸然判定回滚。
它协调的是本地事务结果与消息发布,并没有把积分消费者的数据库纳入同一个全局原子事务。下游仍需正确消费、重试和保持幂等,期间也可能暂时不一致。
采用哪一种方案,要看已有技术栈和故障恢复要求。两种方案都需要能查询业务状态,也都需要在异步处理长期未完成时安排修复。
8. 顺序、重试和死信,需要一起设计
同一笔订单先支付、后退款。如果积分服务先处理退款,再处理支付,简单的“先扣后加”可能违反自己的业务约束。
消息系统一般只能保证某个范围内的顺序。Kafka 的日志顺序在分区内;RocketMQ 5.0 的 FIFO 消息按 message group 定义顺序,需要满足对应生产和消费条件。
如果业务要求同一订单按顺序处理,可以按 order_id 路由,并控制该键上的处理并发。其他订单通常可以并行。
但路由相同还不够。两个生产者并发发送,可能让后发生的业务事实先到 Broker;消费者收到 A、B 后交给线程池,B 也可能先完成;A 进入延迟重试,B 继续执行,顺序仍会改变。
RabbitMQ 队列的顺序也会受到优先级、重新投递等因素影响。队列的出队顺序与并发任务的业务完成顺序,需要分别考虑。
要求严格顺序时,失败事件可能阻塞后续事件。需要决定阻塞的是订单、分区还是更大的范围,并为超时安排人工处理或状态修复。顺序约束越粗,通常越容易限制并行度。
aggregate_version 可以帮助检测重复、过期和版本缺口。但增量事件不能简单丢弃缺失的前序版本;缺口可能需要等待、重试或补查权威状态。检测缺口还需要考虑订阅范围,被过滤的事件版本不能直接当成丢失。
重试也应按失败原因分类。临时断网可以退避重试;不支持的消息版本反复执行通常没有意义;业务拒绝则可能需要明确记录终态。
退避间隔可以逐步增加,并加入随机抖动,避免所有消费者同时冲击刚恢复的依赖。重试还应有次数或时间预算,预算来自业务时限和恢复预期。
超过预算,或确定无法正常处理的毒消息,可以进入死信处理路径。具体触发条件取决于产品和配置:RabbitMQ 通过 dead-letter exchange 等机制处理特定拒绝、过期等情况;RocketMQ 的消费重试策略可在达到上限后进入死信队列。
死信需要保存事件、错误和处理记录,并有明确的负责人与告警。修复后重放原事件,应保留其事件 ID、限制重放速度,并检查业务是否已经通过其他途径完成。修改业务含义的补偿,则应记录为新的、可审计的操作。
9. 积压到底多久能清完
看一组假设数据:
- 峰值流入速度:每秒 10000 条。
- 消费成功速度:每秒 6000 条。
- 峰值持续时间:300 秒,开始时没有积压。
忽略速率波动、重试开销和消息大小差异,峰值结束时积压为:
积压 = (流入速度 – 消费速度) × 持续时间
= (10000 – 6000) × 300
= 1200000 条
峰值过去后,假设流入降至每秒 2000 条,消费者仍能成功处理每秒 6000 条。每秒有 4000 条处理能力用于追赶,清空时间为:
清空时间 = 积压 / (消费速度 – 新消息流入速度)
= 1200000 / (6000 – 2000)
= 300 秒
所以峰值持续五分钟,之后还需要五分钟追赶。计算默认积压清空前速率保持稳定;如果消费速度等于流入速度,积压不会下降,低于流入速度则继续增长。
这里要统计成功完成业务的速度。消费者每秒拉取一万条,其中九千条都失败重试,并不意味着消费能力充足。
积压治理应先找到瓶颈。数据库锁等待高,可能需要调整更新方式;外部接口限流,增加消费者可能只会增加失败;热点业务键或分区负载不均,也可能让总资源看起来还有余量,局部却堵住。
传统 Kafka 消费组的分区分配会限制可用消费并行度。增加实例、增加分区或改变路由,都需要评估分工、重平衡和顺序要求;其他消费模型应按各自的负载分配机制判断。
需要观察的指标至少包括:每个订阅的积压及变化速度、最老未完成消息的年龄、事件到业务提交的延迟、失败与重试率、死信增长,以及 Broker 和下游资源。
条数与年龄应一起看。同样一万条消息,在每秒处理十万条和每秒处理一百条的系统中,含义完全不同。还要监控 Outbox 的等待时间,否则 Broker 队列很空,真正的堵点却在发布之前。
背压可以通过限制拉取、限制在途任务、控制生产速率等方式传递处理能力的约束。转移到消费者内存里的任务依然占用资源,调大缓冲不会减少待完成的工作。
10. Kafka、RabbitMQ、RocketMQ,怎样比较才有用
先确认模型。下表中,Kafka 采用传统消费组;RabbitMQ 采用常规队列,Streams 单独说明;RocketMQ 按相应 Topic、队列和消费者模型讨论。它们并不是三种完全相同的队列换了名字。
| 存储与消费 | 分区日志,订阅维护读取进度 | Exchange 路由到队列,常规队列确认后可移除消息 | 持久化消息存储,Topic 下组织消息队列并维护消费进度 |
| 完成处理 | 传统消费者提交 offset | 消费者确认具体投递 | 按消费者类型提交成功结果或确认 |
| 重放 | 在可用日志范围内重新读取 | 常规队列已消费消息不能直接回放 | 在消息仍保留且机制允许时重置进度等 |
| 顺序范围 | 分区内,业务处理仍需保持顺序 | 受投递配置、并发与重投影响 | FIFO 模型按 message group 等条件保证局部顺序 |
| 事务能力 | Kafka 输出记录与消费进度可协同提交 | 支持协议层事务与发送确认 | 支持本地事务回查与事务消息 |
| 运维关注点 | 分区、副本、保留策略、消费进度和重平衡 | 路由绑定、队列类型、确认、在途消息与资源 | 消息类型、队列负载、消费进度、重试与事务回查 |
RabbitMQ Streams 使用可重复读取的追加日志,不能套用“确认消费后无法回放”的常规队列结论。
Kafka 也提供 Share Consumer 等其他消费模型,传统组的分区分工结论应限定在相应模型内。
RocketMQ 消息不会因为一次消费成功就立即从物理存储中删除,重放仍受保留范围影响。
如果要选择产品,我会先问业务需要怎样读取消息:是分发完成后就可以移除的任务,还是供多个订阅长期读取的事件流?是否需要复杂路由、事务回查或按业务键维持顺序?团队已经维护哪些组件?
事件流、历史重放和数据处理生态需求明显时,可以优先评估 Kafka;工作分发和路由需求明显时,可以评估 RabbitMQ 的相应队列;业务消息需要事务回查、顺序和延迟等能力时,可以评估 RocketMQ。最终仍需按实际配置和故障要求验证。
性能比较要固定消息大小、副本数、确认条件、批量设置以及下游处理成本,再观察满足可靠性目标时的吞吐和延迟。脱离这些条件的排行榜,对实际选择帮助有限。
11. 在系统里留下哪些可恢复的证据
有多个独立订阅者、明显的突发流量,或者需要下游暂时离线后恢复处理,消息队列通常值得考虑。
业务量小、任务只影响当前进程,且没有可靠恢复要求时,可以先用简单的本地异步机制。少量可靠后台任务也可以评估数据库任务表。要求立即返回最终结果的操作,则需要安排相应的同步确认或明确的异步状态查询。
对于本文的积分链路,一套可检查的设计是:订单状态与 Outbox 原子提交,转发程序依据确认结果推进发送记录,积分消费者在同一数据库事务内保存去重记录并更新积分,最后提交消费结果。
还应能回答具体问题:一笔支付对应哪个事件?事件是否已发布?积分事务是否完成?若失败,错误是什么,下一次重试何时发生,谁负责处理?
用户反馈积分没到账时,可以沿着订单、事件、消费记录定位处理停在哪里,再决定重试、修复或补偿。


