欢迎光临
我们一直在努力

消息队列,远不止“排个队”:从异步解耦到可靠交付

设想这样一幕:用户付款成功,订单页面已经显示“已支付”,积分却迟迟没到账。排查后发现,积分服务昨晚重启过,重启前正在处理的消息又被投递了一遍。为了避免重复加积分,开发者加了一个判断;流量一大,两个线程同时通过判断,积分又多发了一次。

消息队列的工程问题,经常就藏在这些看起来已经解决的细节里。

你可能已经听过“异步、解耦、削峰填谷”。这几个词解释了为什么引入 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. 重复消费为什么正常,幂等如何落到数据库

看这条故障时间线:

  • 积分消费者收到 evt-paid-1001。
  • 数据库提交成功,用户增加 199 积分。
  • 消费者在确认消息或提交进度之前崩溃。
  • 恢复后,系统再次投递或读取该事件。
  • 消费者再次加 199 积分。
  • 每一步都说得通,业务结果却错了。

    幂等要求同一事件执行多次,仍然得到约定的一次业务效果。直接执行 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、队列和消费者模型讨论。它们并不是三种完全相同的队列换了名字。

    维度KafkaRabbitMQRocketMQ
    存储与消费 分区日志,订阅维护读取进度 Exchange 路由到队列,常规队列确认后可移除消息 持久化消息存储,Topic 下组织消息队列并维护消费进度
    完成处理 传统消费者提交 offset 消费者确认具体投递 按消费者类型提交成功结果或确认
    重放 在可用日志范围内重新读取 常规队列已消费消息不能直接回放 在消息仍保留且机制允许时重置进度等
    顺序范围 分区内,业务处理仍需保持顺序 受投递配置、并发与重投影响 FIFO 模型按 message group 等条件保证局部顺序
    事务能力 Kafka 输出记录与消费进度可协同提交 支持协议层事务与发送确认 支持本地事务回查与事务消息
    运维关注点 分区、副本、保留策略、消费进度和重平衡 路由绑定、队列类型、确认、在途消息与资源 消息类型、队列负载、消费进度、重试与事务回查

    RabbitMQ Streams 使用可重复读取的追加日志,不能套用“确认消费后无法回放”的常规队列结论。

    Kafka 也提供 Share Consumer 等其他消费模型,传统组的分区分工结论应限定在相应模型内。

    RocketMQ 消息不会因为一次消费成功就立即从物理存储中删除,重放仍受保留范围影响。

    如果要选择产品,我会先问业务需要怎样读取消息:是分发完成后就可以移除的任务,还是供多个订阅长期读取的事件流?是否需要复杂路由、事务回查或按业务键维持顺序?团队已经维护哪些组件?

    事件流、历史重放和数据处理生态需求明显时,可以优先评估 Kafka;工作分发和路由需求明显时,可以评估 RabbitMQ 的相应队列;业务消息需要事务回查、顺序和延迟等能力时,可以评估 RocketMQ。最终仍需按实际配置和故障要求验证。

    性能比较要固定消息大小、副本数、确认条件、批量设置以及下游处理成本,再观察满足可靠性目标时的吞吐和延迟。脱离这些条件的排行榜,对实际选择帮助有限。

    11. 在系统里留下哪些可恢复的证据

    有多个独立订阅者、明显的突发流量,或者需要下游暂时离线后恢复处理,消息队列通常值得考虑。

    业务量小、任务只影响当前进程,且没有可靠恢复要求时,可以先用简单的本地异步机制。少量可靠后台任务也可以评估数据库任务表。要求立即返回最终结果的操作,则需要安排相应的同步确认或明确的异步状态查询。

    对于本文的积分链路,一套可检查的设计是:订单状态与 Outbox 原子提交,转发程序依据确认结果推进发送记录,积分消费者在同一数据库事务内保存去重记录并更新积分,最后提交消费结果。

    还应能回答具体问题:一笔支付对应哪个事件?事件是否已发布?积分事务是否完成?若失败,错误是什么,下一次重试何时发生,谁负责处理?

    用户反馈积分没到账时,可以沿着订单、事件、消费记录定位处理停在哪里,再决定重试、修复或补偿。

    赞(0)
    未经允许不得转载:171主机测评 » 消息队列,远不止“排个队”:从异步解耦到可靠交付
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址