这是《RocketMQ 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 顺序消息保证同一个分区键内的消息按发送顺序消费,不保证所有消息全局有序。最常见的错误是把全局顺序当成默认能力,或在顺序消费中混入并发处理,导致业务以为有序而实际无序。
8.1 局部顺序与全局顺序
局部顺序:
Order O1001 -> Queue 0 -> O1001-create, O1001-paid, O1001-shipped
Order O1002 -> Queue 1 -> O1002-create, O1002-paid, O1002-shipped
全局顺序:
All messages -> Queue 0
| 类型 | 队列数 | 吞吐 | 适用 |
|---|---|---|---|
| 局部顺序 | 多队列 | 较高 | 订单、账户、库存 |
| 全局顺序 | 1 队列 | 低 | 强全局时序场景 |
大多数业务只需要局部顺序。使用一个队列追求全局顺序,会同时牺牲吞吐和可用性。
8.2 发送顺序消息
使用 MessageQueueSelector 把相同业务键路由到同一队列:
String orderNo = "O202608250001";
Message created = new Message("OrderSequentialTopic", "Created",
event(orderNo, "CREATED"));
Message paid = new Message("OrderSequentialTopic", "Paid",
event(orderNo, "PAID"));
producer.send(created, (queues, msg, arg) -> {
int index = Math.floorMod(arg.hashCode(), queues.size());
return queues.get(index);
}, orderNo);
producer.send(paid, (queues, msg, arg) -> {
int index = Math.floorMod(arg.hashCode(), queues.size());
return queues.get(index);
}, orderNo);
注意:队列选择逻辑必须稳定,发送重试时也应尽量保持相同队列。
8.3 顺序消费
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("order-seq-consumer");
consumer.subscribe("OrderSequentialTopic", "*");
consumer.registerMessageListener((MessageListenerOrderly) (msgs, context) -> {
try {
for (MessageExt msg : msgs) {
handle(msg);
}
return ConsumeOrderlyStatus.SUCCESS;
} catch (Exception e) {
context.setSuspendCurrentQueueTimeMillis(1000);
return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
}
});
如果使用 MessageListenerConcurrently,即使发送端按队列写入,消费端仍可能并发乱序。
8.4 顺序边界
顺序范围由业务键决定:
| 场景 | 排序键 |
|---|---|
| 订单状态 | orderNo |
| 账户流水 | accountId |
| 商品库存 | skuId |
| 用户消息 | userId |
| 设备指令 | deviceId |
不要随意使用随机数或请求 ID 作为排序键。排序键必须与业务状态归属一致。
8.5 重试策略
顺序消费不能随意跳过失败消息,否则后续消息会先被处理:
Queue: A1(success) -> A2(fail) -> A3
挂起等待 -> retry A2 -> success -> consume A3
推荐策略:
- 设置较短消费超时;
- 设置有限重试次数;
- 达到阈值后告警;
- 可人工处理失败消息;
- 处理完成后恢复队列;
- 对不可修复消息记录证据后再跳过。
无限挂起会造成队列积压,并影响同一队列内其他业务键的消息。
8.6 扩缩容影响
队列数或消费者数变化会影响队列分配:
扩容前:
8 queues -> 2 consumers,每个消费者 4 queues
扩容后:
8 queues -> 4 consumers,每个消费者 2 queues
对单个业务键而言,写入队列通常仍由选择器决定;但重平衡期间可能出现短暂消费暂停。对顺序性要求极高的场景,扩缩容要安排在低峰期。
8.7 异常场景
| 问题 | 原因 |
|---|---|
| 同一订单乱序 | 选择器不一致、发送重试换队列、使用并发消费 |
| 消费卡住 | 有序监听器持续挂起 |
| 队列积压 | 单 key 热点或消费异常 |
| 重平衡后抖动 | 消费者变化导致队列重新分配 |
| 全局吞吐低 | 只有一个队列 |
排查顺序:
确认业务键
-> 查看消息实际 QueueId
-> 检查 MessageQueueSelector
-> 检查监听器类型
-> 查看消费重试和挂起时间
8.8 项目实践
推荐流程:
- 在需求中明确是否真的需要顺序;
- 定义排序键和状态机;
- 生产端按排序键选队列;
- 消费端使用有序监听器;
- 幂等条件使用“事件 ID + 状态版本”;
- 非核心逻辑异步旁路处理;
- 对失败消息建立治理入口;
- 监控队列积压和消息年龄。
本章小结
顺序消息是“相同排序键、相同队列、顺序消费”三者共同成立的结果。业务上优先采用局部顺序,并通过稳定队列选择器、有序监听器、有限重试和状态机校验保证正确性。顺序会降低吞吐和可用弹性,应该只用在确有业务必要的地方。
思考题
- 局部顺序和全局顺序的区别是什么?
- 为什么请求 ID 通常不适合作为排序键?
- 顺序消费失败为什么不能直接跳过?
- 扩消费者数量对顺序性有什么影响?
- 如何证明一次乱序问题发生在生产端还是消费端?