消息队列:顺序、幂等与可靠投递的工程保证 — editorial cover photo

消息队列:顺序、幂等与可靠投递的工程保证

消息不丢、不重、有序——这三件事,是消息队列在生产环境的底线要求。

2025年7月25日·返回文章列表
文章大纲

接手蜂控平台重构那年,我碰到过一次至今印象深的故障。批量验真的下游服务挂了大概八分钟,验真任务全靠 RabbitMQ 兜着,服务恢复后消费者把积压的几万条消息追平,看起来一切正常。第二天对账时才发现有一批发票被验了两次。问题不在代码逻辑,在于我们一直默认 MQ 会可靠地投递每条消息一次,而真实的投递语义是 at-least-once,至少一次。这八分钟里发生了重试、发生了消费者重启,一条消息被处理了两遍,幂等没做,于是多验了一次。

消息队列在生产环境要回答三个问题,消息会不会丢、会不会重复、会不会乱序。这三个问题任何一个没解决,业务就会出事。丢消息导致数据不一致,重复执行导致重复扣款或重复发货,乱序导致先发货后支付这种状态错乱。这篇文章不讲 MQ 怎么用,讲这三件事在生产里到底是怎么塌的,又该怎么一层层兜住。

图:可靠投递 = 持久化 + ACK + 幂等消费 + 死信补偿

消息在哪个环节会丢

很多人以为引入 Kafka 或 RocketMQ 之后消息就天然可靠了,其实 MQ 只是把可靠投递这件事拆成了三个环节,每个环节都有丢的可能。

第一个环节在生产者发送。生产者调用 send 之后,最怕的是以为发成功了实际没到。如果用 fire-and-forget 的异步发送,发出去不等确认,网络抖动一次这条消息就没了,而生产者完全无感知。第二种丢发生在 Broker 侧,消息进了 Broker 但还没落盘,节点宕机,内存里的消息随之蒸发。第三种丢在消费者,消费者拿到消息、业务处理到一半进程挂了,如果这时消息已经被标记为消费成功,这条消息就永久丢失。

三个环节,三种丢法,对应三套不同的兜底手段。把这套想清楚,比记住任何 API 都重要。

PlantUML
正在加载图表…
PlantUML diagram

图:生产者/Broker/消费者三处都可能丢消息,各有对应兜底;不丢带来重复,故必须幂等

生产者端,确认加本地消息表

生产者防丢的核心是确认机制加重试。同步发送,等 Broker 返回 ACK 才认为成功,超时或异常就重试。这里有个绕不开的矛盾,重试必然带来重复。第一次发送其实成功了只是 ACK 没回来,生产者以为失败重发,于是同一条消息进了 Broker 两次。这个重复只能靠消费端幂等去消化,生产端无解。

对于不能丢、且要和业务事务绑定的消息,更稳的做法是本地消息表。把业务操作和待发消息记录写进同一个本地事务,业务成功则消息记录必然落库。一个后台任务扫描这张表,把未发出去的消息推到 MQ,发成功后更新状态。这样哪怕应用刚写完业务就宕机,重启后扫表任务还能把消息补发出去,做到业务成功则消息必发。代价是多了一张表和一轮扫描,吞吐上有损耗,但换来的是强保证。

Broker 端,持久化和副本是底线

Broker 收到消息后要保证宕机不丢,靠两件事,持久化和副本。

持久化指的是消息落盘之后才返回 ACK 给生产者。RocketMQ 支持同步刷盘和异步刷盘,同步刷盘每次写都等 fsync,最安全但吞吐低,异步刷盘靠操作系统刷盘,性能好但极端情况丢最近一小段。生产环境对不能丢的消息要开同步刷盘。

副本机制是更进一步的兜底。Kafka 用 ISR,RocketMQ 用主从同步,核心都是把消息复制到多个节点,单节点宕机不丢。Kafka 的 acks=all 配合 min.insync.replicas 能保证至少 N 个副本都收到才算成功。这里的取舍很直接,副本越多越安全,写入延迟和吞吐成本也越高。我倾向在金融或资损相关的链路上宁可慢一点也要 all,统计上报这类尽力而为的业务用 acks=1 甚至异步发送就够了。

消费者端,顺序不能颠倒

消费者是最容易出问题的环节,问题出在"拿到消息后什么时候算消费成功"这个动作的时机。

很多 MQ 客户端默认自动 ACK,消息一拉下来就标记为消费成功。如果这时候业务处理失败或者进程崩了,消息已经不在队列里了,永远丢失。正确的顺序是先处理业务,成功之后再手动 ACK。处理失败就不确认,让 MQ 把这条消息重新投递。这个顺序看似简单,却是 MQ 消费最常见的事故来源。

至少一次投递意味着一定会重复

前面三道防线都做好之后,丢消息的问题基本解决了,但带来了一个新的必然结果,消息会重复。

MQ 的投递语义是 at-least-once,至少一次。生产者重试、消费者重启、Broker 主从切换、网络超时重投,任何一个都会让同一条消息被处理多次。这不是偶发,是结构性必然。所以消费者必须做幂等,否则重复消息就会导致重复扣款、重复发货、重复累加。

幂等的实现有三种成熟思路,根据业务形态选用。第一种是消息去重表,每条消息带唯一 ID,处理前查表,已处理就跳过直接 ACK。第二种是业务唯一键,用订单号这类业务标识在 DB 上建唯一索引,重复插入直接被数据库拒绝。第三种是状态机约束,比如订单已经是 已支付 状态,再来一条支付消息直接返回成功。这三种不是互斥的,生产里常常组合使用,唯一索引兜底加状态机约束合法流转。

PlantUML
正在加载图表…
PlantUML diagram

图:消息去重表、业务唯一键、状态机约束三种幂等思路,生产中常组合使用

顺序消息的代价

有些业务对顺序敏感。订单的状态变更是创建、支付、发货,如果这三条消息被乱序消费,先收到发货再收到支付,状态机直接错乱。

保证顺序最朴素的做法是单队列串行消费,整个 Topic 只有一个分区一个消费者,所有消息严格按发送顺序处理。问题在于这等于把 MQ 退化成串行队列,吞吐极差,生产环境几乎没人这么用。

实际需要的是分区顺序。把消息按业务 key 分区,同一个 key 的消息进同一个分区,由同一个消费者串行消费。订单 A 的所有消息有序,订单 A 和订单 B 之间可以并行。这样既保住了单笔业务内的顺序,又不牺牲多业务间的并发。代价是分区数限制了消费并行度,一个分区只能被一个消费者消费,消费者实例数超过分区数,多出来的实例只能闲置。要扩消费能力,必须同步扩分区数,这是 Kafka 和 RocketMQ 水平扩展的基本约束。

// 按订单号做分区 key,保证同一订单的消息进同一分区顺序消费
producer.send(new ProducerRecord("order_events", orderId, message));
PlantUML
正在加载图表…
PlantUML diagram

图:按业务 key 分区实现分区顺序,单 key 串行保序、多 key 并行提吞吐,并行度受分区数限制

积压是消费能力的镜像

消息积压不是 MQ 的问题,是消费能力跟不上生产能力的信号。

发现积压先判断是临时性还是持续性。临时性的,比如大促瞬时洪峰,加大消费者实例数通常能消化掉,前提是不超过分区数。持续性的要看瓶颈在哪,如果消费者 CPU 或 IO 打满,优化消费逻辑,批量处理、异步化、加缓存;如果受分区数限制,扩分区同时扩消费者。最棘手的是积压严重到业务已无法容忍,这时只能临时丢弃或转储非核心消息,保住核心链路的消费。

消费者数不能超过分区数

一个分区只能被一个消费者消费,这是保证分区内顺序的前提。消费者实例数超过分区数,多余的实例全部闲置。要扩消费能力,必须同步扩分区数。不少团队加机器发现吞吐没涨,根因就是卡在这里。

三件事的取舍

消息队列的不丢不重有序是生产底线,但三件事同时做到的代价很高,而真正需要三者全保的业务极少。

不丢靠同步发送加 ACK 加重试,性能损耗最大。不重靠消费幂等,开发量和存储开销上去了。有序靠分区单消费,并发受限最狠。支付履约链路要的是不丢,宁可慢不能少;统计上报可以尽力而为,丢几条不影响大局;订单状态流转要的是有序,乱序就出错。按业务真实需求去配这套机制,而不是对所有 Topic 一刀切上全套可靠性。我在蜂控把批量验真从同步调用改成 MQ 解耦时,验真消息只保了不丢和幂等,没要求严格顺序,因为每条发票的验真彼此独立,乱序无影响。认清每条消息的语义边界,是 MQ 用得稳的前提。

Continue Reading

关联文档推荐

查看全部
延时队列:Redis、RocketMQ 与时间轮的选型 — editorial cover photo接口幂等治理:重复请求的统一防护 — editorial cover photo短链系统 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。