-->

消息乱序问题与哈希分桶保序方案

场景背景

消费数据库变更消息流(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. 三个易错点

  1. 取模前 & 0x7fffffff 清除负号,防止负索引;
  2. release() 必须在 finally,否则许可证泄漏,最终所有消息被静默丢弃;
  3. 必须用非阻塞的 tryAcquire(),阻塞会卡死 MQ 监听线程导致整条链路瘫痪。

6. 注意事项

  • 保序前提:消费端单实例、哈希函数跨进程稳定(用 JDK hashCode);
  • 扩容只能加桶,不能给桶加线程(加线程即退回错误示例的乱序);桶数变更需重启;
  • 超限丢弃消息的前提是业务允许丢失且有兜底(权威数据表可重查对账);
  • 丢弃时必须打带实体标识的日志,否则无法排查。

7. 一句话总结

哈希把同一实体钉进一个队列,单线程 + FIFO 让队列顺序成为唯一执行顺序,信号量给无界队列加全局闸门——顺序交给分桶,容量交给信号量,过载交给快速失败

posted @ 2026-09-03 11:24  ꧁ʚ星月天空ɞ꧂  阅读(8)  评论(0)    收藏  举报