消息乱序问题与哈希分桶保序方案
场景背景
消费数据库变更消息流(binlog/Canal → MQ),同一实体的多条状态变更消息必须按发生顺序处理,否则会得出错误的最终状态。
1. 问题与根因
问题:同一实体的状态变更消息(如 产生→清除)到达消费端时有序,但被多线程共享线程池并发消费后执行乱序,导致最终状态错误。
根因:多个线程竞争同一实体的消息,执行顺序由系统调度决定,与到达顺序无关。
2. 解决方案
同一实体串行保序,不同实体并行保吞吐。
同一实体的消息串行化(保序),不同实体之间并行化(保吞吐)。
| 机制 | 职责 | 手段 |
|---|---|---|
| 哈希分桶 | 同一实体永远进同一个队列 | hash(实体标识) % 桶数 |
| 单线程执行器 | 桶内严格先进先出执行 | 每桶 1 个工作线程 + FIFO 队列 |
| 信号量准入控制 | 限制全局在途任务总量,防 OOM | Semaphore.tryAcquire() 快速失败 |
3.错误示例(共享线程池,会乱序)
import java.util.List; import java.util.concurrent.*;
public class WrongSharedPoolDemo {
record OrderEvent(String orderId, String action) {}
public static void main(String[] args) throws Exception {
List<OrderEvent> events = List.of(
new OrderEvent("ORDER-1", "CREATE"),
new OrderEvent("ORDER-1", "PAY"),
new OrderEvent("ORDER-1", "CANCEL"));
// ✗ 错误:8 个线程竞争同一订单的事件,执行顺序不可控
ExecutorService pool = Executors.newFixedThreadPool(8);
for (OrderEvent e : events) {
pool.execute(() -> System.out.println(
Thread.currentThread().getName() + " -> " + e.orderId() + " " + e.action()));
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
}
}
多运行几次,输出顺序会随机变化,例如:
pool-1-thread-2 -> ORDER-1 PAY
pool-1-thread-1 -> ORDER-1 CREATE ← CREATE 被 PAY 越过了!
pool-1-thread-3 -> ORDER-1 CANCEL
三条事件被 3 个线程并发执行,谁先跑完全凭调度——这就是"先清除后产生"的来源。
4. 正确示例(哈希分桶 + 单线程 + 信号量)
import java.util.List; import java.util.Objects; import java.util.concurrent.*;
public class ShardedOrderDemo {
record OrderEvent(String orderId, String action) {}
public static void main(String[] args) throws Exception {
List<OrderEvent> events = List.of(
new OrderEvent("ORDER-1", "CREATE"),
new OrderEvent("ORDER-1", "PAY"),
new OrderEvent("ORDER-1", "CANCEL"));
int bucketCount = 4;
ExecutorService[] workers = new ExecutorService[bucketCount];
for (int i = 0; i < bucketCount; i++) {
final int idx = i;
workers[i] = Executors.newSingleThreadExecutor(r -> new Thread(r, "Worker-" + idx));
}
Semaphore gate = new Semaphore(1000);
for (OrderEvent e : events) {
// ✓ 同一订单永远路由到同一个桶
int bucket = (Objects.hash(e.orderId()) & 0x7fffffff) % bucketCount;
if (gate.tryAcquire()) {
workers[bucket].execute(() -> {
try {
System.out.println(Thread.currentThread().getName()
+ " -> " + e.orderId() + " " + e.action());
} finally {
gate.release(); // ✓ 异常也必须归还许可证
}
});
} else {
System.err.println("在途任务已满,丢弃: " + e);
}
}
for (ExecutorService w : workers) {
w.shutdown();
w.awaitTermination(5, TimeUnit.SECONDS);
}
}
}
无论运行多少次,输出恒定(线程名相同 = 同桶串行):
Worker-2 -> ORDER-1 CREATE
Worker-2 -> ORDER-1 PAY
Worker-2 -> ORDER-1 CANCEL
对比结论:错误示例中三条事件落在 3 个不同线程;正确示例中三条事件全部落在 Worker-2 这一个线程上,队列先入先出且只有一个消费者,后到的消息物理上不可能越过先到的。
5. 三个易错点
- 取模前
& 0x7fffffff清除负号,防止负索引; release()必须在finally,否则许可证泄漏,最终所有消息被静默丢弃;- 必须用非阻塞的
tryAcquire(),阻塞会卡死 MQ 监听线程导致整条链路瘫痪。
6. 注意事项
- 保序前提:消费端单实例、哈希函数跨进程稳定(用 JDK
hashCode); - 扩容只能加桶,不能给桶加线程(加线程即退回错误示例的乱序);桶数变更需重启;
- 超限丢弃消息的前提是业务允许丢失且有兜底(权威数据表可重查对账);
- 丢弃时必须打带实体标识的日志,否则无法排查。
7. 一句话总结
哈希把同一实体钉进一个队列,单线程 + FIFO 让队列顺序成为唯一执行顺序,信号量给无界队列加全局闸门——顺序交给分桶,容量交给信号量,过载交给快速失败。

浙公网安备 33010602011771号