消息顺序性与幂等消费
“先创建订单、再支付、再发货”这三条消息如果乱序到达,消费端可能先处理发货、再处理支付,业务状态直接错乱。消息队列只保证有限范围的顺序,剩下的要靠业务设计。本章讲乱序从哪来、顺序消息怎么做,以及更常用的折衷方案。
乱序是怎么产生的
| 原因 | 说明 |
|---|---|
| 多分区 / 多队列 | 消息被分散到不同分区并行处理,跨分区没有先后关系 |
| 多消费者 | 同一分区的消息被多个消费者并发拉取处理,处理完成顺序不等于发送顺序 |
| 生产者并发发送 | 多线程共用生产者,或批量发送时部分失败重发,导致先后颠倒 |
| 重试与重投 | 失败的消息被延后重投,后发的消息反而先成功 |
| 刷盘与网络 | 不同副本、不同连接上的到达时间差异 |
一句话总结:消息中间件默认只保证“分区/队列内、单消费者串行消费时”的顺序。
顺序消息的实现思路
核心只有两条:同一业务 key 固定到同一分区,以及该分区内串行消费。
// 以下片段需放进使用 Kafka 客户端的工程中运行
// 用订单号做 key,Kafka 会按 key 哈希到固定分区,分区内保证写入顺序
ProducerRecord<String, String> record =
new ProducerRecord<>("order-events", orderId, payload);
producer.send(record);
// 输出:同一 orderId 的消息落在同一分区,分区内有序
发送端还要注意两个细节:
- 需要防重时可开启生产端幂等;开启后可以放宽在途请求数限制,仍能保持分区内有序(具体参数与上限以官方文档为准)。
- 不要为了吞吐把一批不同 key 的消息合并成一条大消息——那样只能整体成功或整体失败。
消费端的关键是串行:
Kafka:一个分区同一时刻只由消费者组内一个消费者消费,只要消费逻辑单线程处理,分区内即有序
RocketMQ:发送时用按业务 key 选队列的选择器,消费用顺序监听器,Broker 与消费端会对队列加锁
自建/数据库队列:单表 + 单消费者轮询,最土但最稳
顺序消息的代价
顺序不是免费的,代价必须提前算清楚:
| 代价 | 表现 |
|---|---|
| 吞吐受限 | 顺序的并行度上限 = 分区数 × 单分区处理能力,单分区即完全串行 |
| 队头阻塞 | 一条消息处理失败会阻塞它后面的所有消息 |
| 重试困难 | 顺序消费失败通常只能原地重试,不能跳过,否则顺序断了 |
| 扩缩容敏感 | 分区数变化需要重新分配,期间可能短暂乱序 |
因此“全局有序”(全系统只有一条队列、一个消费者串行)几乎总是错误选择:吞吐极低,且一处卡住全链路停摆。
业务上的折衷:顺序不敏感 + 幂等
大多数业务其实不需要严格顺序,只需要最终状态正确。这时候用三招替代顺序消息:
- 只要求局部有序:按业务维度(订单 ID、用户 ID)有序,不同业务之间互不影响。这已经能满足 95% 的需求。
- 幂等消费:同一条消息重复处理多次结果一致。
- 版本号合并 / 状态机约束:让“旧消息”无法覆盖“新状态”。
以状态机为例,用条件更新天然拒绝非法流转:
-- 只有从待支付才能流转到已支付;重复或倒序的消息影响行数为 0
UPDATE orders
SET status = 'PAID', paid_at = NOW()
WHERE id = 1001 AND status = 'UNPAID';
-- 影响行数 1 表示处理成功;0 表示已处理过或状态不允许,直接 ack
用版本号合并则适合“整体覆盖”的场景:
-- 消息里带 version,只有当它比库里新时才更新,旧消息自动被丢弃
UPDATE goods SET price = ?, version = ?
WHERE id = 1001 AND version < ?;
版本号方案示意:
消息1:price=100, version=5
消息2:price=90, version=6
乱序到达:先处理消息2(version=6 写入成功),再处理消息1(5 < 6,影响行数 0,丢弃)
结果:库里是 90,与正确顺序一致
需要注意的是,版本号必须由同一来源单调递增生成(如数据库自增、序列号服务),否则无法比较先后。
选型速查
| 场景 | 是否需要顺序 | 推荐方案 |
|---|---|---|
| 订单状态流转 | 需要局部顺序 | 按订单 ID 分区 + 状态机条件更新 |
| 账户余额变更 | 需要且敏感 | 按账户 ID 分区 + 版本号 + 每日对账 |
| 日志、埋点、统计 | 不需要 | 并发消费 + 幂等或容忍重复 |
| 缓存失效通知 | 不需要 | 删除天然幂等 |
| 全量数据同步 | 不需要 | 版本号合并,或干脆定时全量覆盖 |
小结:乱序来自多分区、多消费者、并发发送和重试;顺序消息要靠“同 key 同分区 + 串行消费”实现,代价是吞吐与队头阻塞。工程上更划算的做法是只做局部有序,再用幂等消费、状态机条件更新和版本号合并兜住乱序——这三样做好,多数业务根本不需要严格的顺序消息。