消息顺序性
订单的创建、支付、取消三个事件,业务上要求按顺序处理,先创建再支付最后取消。消息队列天然是多消费者并发的,三个事件可能被三个消费者同时取走,处理顺序就乱了。本章只回答一个问题,什么时候需要顺序,以及保证顺序要付出什么代价。
1. 顺序为什么会乱
消息队列为了吞吐,把消息分散到多个分区,多个消费者并发拉取。同一条业务链路上的消息一旦落到不同分区,就失去了先后保证。
以订单为例。创建订单 发到分区 0,支付订单 发到分区 1,取消订单 发到分区 2。三个消费者各自消费一个分区,谁先处理完不确定。可能取消先被处理,支付后到,业务逻辑直接错乱。
即使只有一个分区,并发消费也会乱。一个消费者开了多个线程同时处理,线程调度的先后不可控,消息的顺序同样没有保证。
所以顺序乱的根源是两个,消息分散到多个分区,以及单分区内多线程并发处理。
2. 有序的真实含义,分区内有序
消息队列能保证的有序,从来不是全局有序,而是单分区内有序。同一个分区里的消息,按写入顺序被消费,这个保证是可靠的。跨分区的消息之间,没有任何先后关系。
理解这一点,是讨论顺序的前提。很多人一上来就要「全局有序」,但全局有序意味着只能有一个分区,吞吐直接被压到单分区水平,代价极大。
正确的问法不是「怎么保证全局有序」,而是「哪些消息之间需要有序,把它们放进同一个分区」。
3. 把需要有序的消息路由到同一分区
做法是给消息定一个路由键,需要有序的消息用同一个键,队列按键哈希把同键消息固定到同一个分区。
// 订单 ID 作为路由键,同一订单的所有事件进同一分区
rabbitTemplate.convertAndSend("order.exchange", "order." + orderId, event);
// Kafka 侧同理
producer.send(new ProducerRecord<>("order.topic", orderId, event));同一个订单的创建、支付、取消,路由键都是 orderId,哈希后落到同一个分区。分区内按写入顺序消费,顺序就保住了。
这里的边界要清楚,顺序只对「同一个订单」成立。订单 A 和订单 B 的事件之间不需要顺序,它们可以各自散到不同分区。这才是务实的顺序保证,只约束需要有序的,不牺牲整体吞吐。
4. 单分区内还要串行消费
路由到同一分区还不够。同一个分区被一个消费者拉取后,如果这个消费者内部用多线程并发处理,顺序还是会乱。
要保序,同一个分区的消息必须单线程串行处理,处理完一条再处理下一条。
@KafkaListener(topics = "order.topic", concurrency = "1")
public void onMessage(String orderId, String event) {
// 单线程消费,同一分区内顺序处理
process(orderId, event);
}concurrency = 1 保证每个分区只有一个消费线程。线程数上去了吞吐就下来了,这正是顺序的代价,单线程串行必然比多线程并发慢。
5. 重投会破坏顺序
一个容易被忽略的坑。消费到第 3 条消息时处理失败,触发重投,这条消息会排到队列末尾重新投递。而第 4、5 条已经先处理完了,重投的第 3 条再进来时,顺序已经倒置。
这意味着顺序保证在「有失败重投」的前提下并不完整。要么接受这种极少数情况下的乱序,在业务层用状态机兜底,要么引入更复杂的单分区单线程加顺序确认的机制。
多数场景的选择是前者。真正对顺序零容忍的,往往不是靠消息队列保证,而是靠业务层的版本号、状态机来保证最终结果的正确性,消息队列只负责大概率有序。
6. 结论
顺序性不是一个可以无条件满足的需求,它是一笔交易,用吞吐换有序。判断的顺序是先问自己,哪些消息之间真的需要有序,把它们用同一个路由键圈进同一个分区,分区内单线程消费。全局有序和零容忍重投乱序,几乎不值得做,前者牺牲太大,后者该交给业务状态机兜底。
7. 相关章节
消息重复投递是顺序之外的另一层问题,见 消息去重。业务状态机保证结果正确,见 订单状态流转。Kafka 分区与顺序语义的组件细节,见 消息顺序。