RocketMQNotes

第 08 章:顺序消息

zjc 于 2026-01-08 发布

这是《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

推荐策略:

  1. 设置较短消费超时;
  2. 设置有限重试次数;
  3. 达到阈值后告警;
  4. 可人工处理失败消息;
  5. 处理完成后恢复队列;
  6. 对不可修复消息记录证据后再跳过。

无限挂起会造成队列积压,并影响同一队列内其他业务键的消息。

8.6 扩缩容影响

队列数或消费者数变化会影响队列分配:

扩容前:
8 queues -> 2 consumers,每个消费者 4 queues

扩容后:
8 queues -> 4 consumers,每个消费者 2 queues

对单个业务键而言,写入队列通常仍由选择器决定;但重平衡期间可能出现短暂消费暂停。对顺序性要求极高的场景,扩缩容要安排在低峰期。

8.7 异常场景

问题 原因
同一订单乱序 选择器不一致、发送重试换队列、使用并发消费
消费卡住 有序监听器持续挂起
队列积压 单 key 热点或消费异常
重平衡后抖动 消费者变化导致队列重新分配
全局吞吐低 只有一个队列

排查顺序:

确认业务键
  -> 查看消息实际 QueueId
  -> 检查 MessageQueueSelector
  -> 检查监听器类型
  -> 查看消费重试和挂起时间

8.8 项目实践

推荐流程:

  1. 在需求中明确是否真的需要顺序;
  2. 定义排序键和状态机;
  3. 生产端按排序键选队列;
  4. 消费端使用有序监听器;
  5. 幂等条件使用“事件 ID + 状态版本”;
  6. 非核心逻辑异步旁路处理;
  7. 对失败消息建立治理入口;
  8. 监控队列积压和消息年龄。

本章小结

顺序消息是“相同排序键、相同队列、顺序消费”三者共同成立的结果。业务上优先采用局部顺序,并通过稳定队列选择器、有序监听器、有限重试和状态机校验保证正确性。顺序会降低吞吐和可用弹性,应该只用在确有业务必要的地方。

思考题

  1. 局部顺序和全局顺序的区别是什么?
  2. 为什么请求 ID 通常不适合作为排序键?
  3. 顺序消费失败为什么不能直接跳过?
  4. 扩消费者数量对顺序性有什么影响?
  5. 如何证明一次乱序问题发生在生产端还是消费端?