《Java 消息中间件·可靠消息与消费治理》2.2 幂等消费:唯一键、状态机与去重表

唯一约束、去重表、状态机是三种成本不同的幂等实现,选错一种会在并发或重放时失效。本篇先比较三者的写放大、存储与失效边界,再给出一份完整可运行的 IdempotentConsumer:按业务键串行化、PROCESSING/DONE 状态、失败不记账、TTL 惰性清理、容量硬上限,并用 3000 次重复投递和 16 线程同键并发验证它。

幂等不是「去重」一个词

幂等的定义很朴素:同一个操作执行一次和执行多次,结果相同。放到消费端,就是同一个事件被投递 N 次,业务副作用只发生一次。

这句话里有两个容易含糊的地方。第一,「结果相同」指的是业务状态相同,不是「处理器被调用一次」。处理器完全可以被调用 N 次,只要每次都在写之前发现「已经做过了」。第二,「同一个事件」需要一个稳定的标识,不能靠消息内容逐字节比较。

实现幂等有三条路,成本依次上升:

  1. 天然幂等:操作本身就是覆盖式的,例如 SET status = 'RETURNED'、把库存写成绝对值而不是 stock = stock - 1。零额外存储,但只适用于少数场景。
  2. 唯一约束:在业务表或去重表上建唯一索引,让第二次写入失败。由存储引擎保证并发安全,成本是一次索引写入。
  3. 去重表 + 状态机:显式记录「这个事件处理到哪一步」,支持中间状态、失败重试和 TTL。最灵活,也最贵。

三者不是互斥的。生产系统里常见「唯一约束兜底 + 状态机表达生命周期」的组合。下面先把选型讲清楚,再给出一份可以运行的内存实现。

唯一键从哪来

去重的前提是有一个稳定、唯一、可存储的键。三种常见来源:

键例子优点风险
事件 IDeventId=2003每个事件唯一,重试不变必须生产端生成
业务主键loanId直观,可直接查业务表同一实体的一生可能有多个事件
复合键loanId + overdueDate能区分同一实体的不同事件组合规则要写进契约

选择规则只有一条:键要在「重试」和「重放」时保持不变,同时能区分合法的重复业务。

举个反例。library.loan.returned 用 bookId 做键,同一本书被借还两次,第二次归还就会被当成重复丢掉;用 loanId 就对了,因为一次借阅只有一次归还。再比如逾期费,同一个 loanId 可能每天产生一条逾期事件,键必须是 loanId + 逾期日,否则第二天的费用会被吞掉。

事件 ID 的位置也很关键。它必须在生产端生成,并随事件体一起发送。消费端用 UUID.randomUUID() 生成没有意义:每次投递都会得到新 ID,去重表永远命不中。上游如果每次重试都重新生成事件 ID,那也不是传输层能修的问题,要回到事件建模去改。

三种实现的成本与边界

维度唯一约束去重表状态机
额外写入1 次索引写入1 次去重记录 + 1 次业务写入1 次条件更新
存储增长随业务表随事件数线性增长不额外增长
并发安全存储引擎保证需要唯一键或原子操作需要行锁或 CAS
失败重试冲突即重复,简单两写之间崩溃会留下半成品状态未推进,可重试
TTL靠分区/归档显式 TTL + 清理随实体生命周期
适用创建型事件任意事件,尤其是无状态副作用有明确生命周期的事件

三个边界要提前想清楚:

  • 唯一约束的边界是「键不能复用」。如果业务允许同一个键合法地产生第二条记录,唯一约束就会误杀。它适合 loan.created 这种「一个 loanId 只创建一次」的事件。
  • 去重表的边界是「两次写入的原子性」。去重记录和业务写入必须在同一个事务里,否则会出现「记了去重但业务没做」或「业务做了但没记去重」。跨库时这条尤其难。
  • 状态机的边界是「事件必须能映射成状态迁移」。通知发送、库存调整这类没有实体状态的事件,状态机表达不了,只能回去重表或唯一约束。

完整实现:IdempotentConsumer

下面这份实现把「按业务键串行化 + 状态机 + TTL + 容量」放在一个类里。表在内存中,进程重启即清空;生产环境把 ConcurrentHashMap 换成带唯一约束的表或 Redis,process 的契约不变。

import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.LongSupplier;
import java.util.function.Supplier;

public final class IdempotentConsumer<V> {

    public enum Status { APPLIED, DUPLICATE }

    private enum State { PROCESSING, DONE }

    private static final class Slot<V> {
        State state = State.PROCESSING;
        V result; // 首次执行的结果,预留给需要返回值的调用方
        long expireAtMillis;
        boolean removed;
        final Object monitor = new Object();
    }

    public record Stats(long applied, long duplicate, long failed,
                        long rejected, long swept) { }

    private final ConcurrentHashMap<String, Slot<V>> table = new ConcurrentHashMap<>();
    private final LongSupplier clock;
    private final long ttlMillis;
    private final int maxEntries;
    private final LongAdder applied = new LongAdder();
    private final LongAdder duplicate = new LongAdder();
    private final LongAdder failed = new LongAdder();
    private final LongAdder rejected = new LongAdder();
    private final LongAdder swept = new LongAdder();

    public IdempotentConsumer(long ttlMillis, int maxEntries) {
        this(ttlMillis, maxEntries, System::currentTimeMillis);
    }

    /** clock 可注入,测试 TTL 时不用真的 sleep。 */
    public IdempotentConsumer(long ttlMillis, int maxEntries, LongSupplier clock) {
        if (ttlMillis <= 0) throw new IllegalArgumentException("ttlMillis must be > 0");
        if (maxEntries <= 0) throw new IllegalArgumentException("maxEntries must be > 0");
        this.ttlMillis = ttlMillis;
        this.maxEntries = maxEntries;
        this.clock = Objects.requireNonNull(clock);
    }

    public Status process(String key, Supplier<V> business) {
        Objects.requireNonNull(key, "key");
        Objects.requireNonNull(business, "business");
        while (true) {
            long now = clock.getAsLong();
            Slot<V> slot = table.get(key);
            if (slot != null && expired(slot, now)) {
                if (remove(key, slot)) {
                    slot = null;
                }
            }
            if (slot == null) {
                ensureCapacity();
                Slot<V> fresh = new Slot<>();
                Slot<V> previous = table.putIfAbsent(key, fresh);
                slot = previous == null ? fresh : previous;
            }
            synchronized (slot.monitor) {
                if (slot.removed) {
                    continue; // 与失败清理或 TTL 清理竞争,重来一次
                }
                if (slot.state == State.DONE) {
                    if (slot.expireAtMillis > clock.getAsLong()) {
                        duplicate.increment();
                        return Status.DUPLICATE;
                    }
                    removed(key, slot);
                    continue;
                }
                try {
                    V result = business.get();
                    slot.result = result;
                    slot.expireAtMillis = clock.getAsLong() + ttlMillis;
                    slot.state = State.DONE;
                    applied.increment();
                    return Status.APPLIED;
                } catch (RuntimeException | Error e) {
                    removed(key, slot);
                    failed.increment();
                    throw e;
                }
            }
        }
    }

    public int size() {
        return table.size();
    }

    public Stats stats() {
        return new Stats(applied.sum(), duplicate.sum(), failed.sum(),
                rejected.sum(), swept.sum());
    }

    /** 主动清理过期槽位,返回清理条数。 */
    public int sweep() {
        long now = clock.getAsLong();
        int removed = 0;
        for (Map.Entry<String, Slot<V>> entry : table.entrySet()) {
            Slot<V> slot = entry.getValue();
            synchronized (slot.monitor) {
                if (!slot.removed && expired(slot, now)) {
                    slot.removed = true;
                    if (table.remove(entry.getKey(), slot)) {
                        removed++;
                    }
                }
            }
        }
        swept.add(removed);
        return removed;
    }

    private boolean expired(Slot<V> slot, long now) {
        return slot.state == State.DONE && slot.expireAtMillis <= now;
    }

    private boolean remove(String key, Slot<V> slot) {
        synchronized (slot.monitor) {
            if (slot.removed) return false;
            slot.removed = true;
            return table.remove(key, slot);
        }
    }

    private void removed(String key, Slot<V> slot) {
        slot.removed = true;
        table.remove(key, slot);
    }

    private void ensureCapacity() {
        if (table.size() < maxEntries) return;
        sweep();
        if (table.size() >= maxEntries) {
            rejected.increment();
            throw new RejectedExecutionException(
                    "idempotent table full: size=" + table.size() + " max=" + maxEntries);
        }
    }
}

几个设计决定值得展开:

  • 按 key 加锁,而不是全表加锁。synchronized (slot.monitor) 只串行化同一个键;不同键落在不同的 Slot 上,可以并行。如果只用 containsKey 再 put,两个线程会同时看到「不存在」,然后各执行一次业务,这就是典型的 check-then-act 竞态。
  • PROCESSING 是内部状态,不对外暴露。第一个线程插入 Slot 时它就是 PROCESSING;后来的线程在 monitor 上等,等到的要么是 DONE(返回 DUPLICATE),要么是槽位被失败清理(重试)。
  • 失败不记 DONE。业务抛异常时删掉槽位,下一次投递重新执行。这把「幂等」和「至少一次」接上了:失败可以重试,成功只记一次。
  • TTL 是惰性清理。每次访问检查过期,sweep() 做全表清理。没有后台线程,避免生命周期管理;代价是长时间不访问的键会占着内存。
  • 容量满了拒绝,不淘汰未过期项。淘汰一个还没过期的键,等于把幂等窗口缩短,下一次重复投递会被当成新事件。拒绝至少是显式的,调用方可以降级或报警。

重复投递测试

测试覆盖六种情况:顺序重复、同键并发、多键混合、失败重试、TTL 过期、容量上限。业务动作故意 sleep 1 到 20 毫秒,让并发窗口真实存在。

// 16 个线程同时投递同一个 key
CountDownLatch start = new CountDownLatch(1);
for (int i = 0; i < 16; i++) {
    pool.submit(() -> {
        start.await();
        Status s = consumer.process("loan-42", () -> {
            calls.incrementAndGet();
            sleep(20);
            return "ok";
        });
        if (s == Status.APPLIED) applied.incrementAndGet();
        else duplicate.incrementAndGet();
        return null;
    });
}
start.countDown();

本机实测(Temurin 25.0.5+8 / 单节点 KRaft / 2026-10-29):

single-key: status=APPLIED,DUPLICATE,DUPLICATE businessCalls=1 stats=Stats[applied=1, duplicate=2, failed=0, rejected=0, swept=0]
concurrent-same-key: threads=16 applied=1 duplicate=15 businessCalls=1 stats=Stats[applied=1, duplicate=15, failed=0, rejected=0, swept=0]
many-keys: keys=1000 deliveries=3000 businessCalls=1000 tableSize=1000 stats=Stats[applied=1000, duplicate=2000, failed=0, rejected=0, swept=0] elapsedMs=165 (单次运行,随调度波动,不作基准)
failure-retry: attempts=2 second=APPLIED stats=Stats[applied=1, duplicate=0, failed=1, rejected=0, swept=0] tableSize=1
ttl: first=APPLIED withinTtl=DUPLICATE afterTtl=APPLIED businessCalls=2 swept=1 tableSize=0 stats=Stats[applied=2, duplicate=1, failed=0, rejected=0, swept=1]
capacity: atLimit=1000 rejected=true swept=1000 reinsert=APPLIED stats=Stats[applied=1001, duplicate=0, failed=0, rejected=1, swept=1000]

逐条读:

  • concurrent-same-key 是核心用例。16 个线程同时进来,业务只执行 1 次,15 个拿到 DUPLICATE。如果实现是「先查再写」,这里会看到多次执行。
  • many-keys 验证不同键之间没有被全局锁串行化。1000 个键、3000 次投递、8 个线程,165 ms 完成;这个数字只说明没有明显停顿,不能当吞吐基准。
  • failure-retry 验证失败语义:第一次抛异常,failed=1,槽位被删;第二次重新执行,applied=1。如果失败也被记成 DONE,第二次会返回 DUPLICATE,业务就永久丢了。
  • ttl 验证幂等窗口是有限的。TTL 内重复返回 DUPLICATE,超过 TTL 后同一个键会重新 APPLIED;这时 businessCalls 从 1 变成 2,说明「幂等」只在窗口内成立。
  • capacity 验证表满时的行为:第 1001 个键被拒绝,rejected=1;时间推进后 sweep 清掉 1000 个过期槽位,新键重新可以写入。

去重表的 TTL 与容量

TTL 不是随便填的。它必须覆盖这个事件可能被再次投递的最大时间窗。窗口由三段时间里的最大值决定:

  • broker 的保留期(retention.ms):记录还在,消费者就可能被重置回来读;
  • 消费者最长停机时间:停机越久,重启后要追的历史越长;
  • 下游重放窗口:重建投影、补数可能翻出更早的事件。

如果保留期 7 天、停机最长 1 天、重放窗口 30 天,TTL 至少要 30 天。TTL 短于这个窗口,就会出现「去重表已经忘了,消息又来了」,幂等失效。测试里的 afterTtl=APPLIED 就是这个边界的直接演示。

容量要按最坏情况估算。假设每秒 1000 个事件、TTL 30 天:

1000 × 86400 × 30 = 2,592,000,000 条

按每条约 100 字节估算,需要约 259 GB。这个量级不可能放在单个 JVM 的 ConcurrentHashMap 里,也不适合单机 Redis。可行的做法有三种:

  1. 把去重并进业务表的唯一索引:不额外存一份,让数据库的索引承担去重;TTL 靠业务表的归档策略。
  2. 用 compacted topic 存事件 ID:Kafka 自身按 key 压缩,保留每个 key 的最新值;消费端从 compacted topic 恢复去重集合。代价是启动时要读一遍。
  3. 缩短幂等窗口:承认只覆盖 7 天,超出窗口的重复靠对账兜底。这是取舍,不是免费方案。

无论哪种,sweep() 的成本都要算。全表扫描是 O(n),259 GB 的表不可能每次插入都扫。生产实现通常用时间轮、按时间分片(每天一张表)、或者依赖存储引擎的 TTL。本文的内存版只适合做单机窗口和测试。

状态机的幂等迁移

去重表解决「这个事件处理过没有」,状态机解决「这个事件该不该在这个状态上执行」。两者经常一起用:先用事件 ID 去重,再用状态机判断迁移是否合法。

enum LoanState { CREATED, APPROVED, RETURNED, CANCELLED }

static LoanState transition(LoanState current, LoanState target) {
    if (current == target) return current; // 目标态已达成,幂等返回
    return switch (current) {
        case CREATED -> {
            if (target == LoanState.APPROVED || target == LoanState.CANCELLED) yield target;
            throw new IllegalStateException(current + " -> " + target);
        }
        case APPROVED -> {
            if (target == LoanState.RETURNED) yield target;
            throw new IllegalStateException(current + " -> " + target);
        }
        case RETURNED, CANCELLED -> throw new IllegalStateException(current + " -> " + target);
    };
}

迁移规则:

当前状态APPROVEDRETURNEDCANCELLED
CREATED允许拒绝允许
APPROVED幂等返回允许拒绝
RETURNED拒绝幂等返回拒绝
CANCELLED拒绝拒绝幂等返回

测试输出:

state-machine: CREATED->APPROVED=APPROVED repeat=APPROVED APPROVED->CREATED=IllegalStateException

两个要点:

  • 重复事件的目标态等于当前态时,直接返回,不报错。这是状态机天然的幂等性:APPROVED -> APPROVED 是空操作,不需要去重表也能挡住。
  • 反向迁移必须报错,不能静默忽略。APPROVED -> CREATED 说明事件乱序或上游有 bug;静默忽略会把问题藏起来。正确做法是拒绝并告警,或者把乱序事件放进延迟队列,等前置事件到达后再应用。

状态机不能替代去重表。它只能处理「有实体状态」的事件;library.loan.overdue 这种按天产生的费用事件,状态还是 APPROVED,重复应用不会改变状态,只会重复加钱。这类事件仍然需要事件 ID 去重。

内存版与生产版的差距

这份实现可以直接跑测试,但不能直接上生产。四个差距:

  1. 重启丢表。进程重启后所有 DONE 记录消失,重启前处理过的事件会重新执行。生产版必须把表放到外部存储,或者从 compacted topic 恢复。
  2. 多实例各自为政。同一个 key 可能落到不同实例,各自的内存表都认为「没见过」。要么按 key 做分区亲和(同一 key 永远进同一个实例),要么用共享存储。
  3. GC 与内存上限。无界 Map 会把堆吃满;maxEntries 只是硬上限,不是内存估算。上线前要用真实键长和值大小压测。
  4. 时钟。TTL 依赖 System.currentTimeMillis(),多机时钟偏差会让过期时间不一致。生产版最好用存储引擎的 TTL 或单调时钟。

生产版的三种落地方式,代价不同:

存储原子性延迟备注
业务库唯一索引与业务写入同事务一次索引写入最稳,但键必须不可复用
Redis SETNX + TTL去重原子,业务写入不原子一次网络往返需要处理「去重成功、业务失败」
compacted topic由 Kafka 保证启动恢复较慢适合超大规模、可接受最终一致

不管换成哪种,process 的契约不变:先占位,再执行,成功记 DONE,失败删占位。变的只是存储。

回到 libraryd

IdempotentConsumer 接在 AtLeastOnceConsumer 后面,键的选择按事件语义定:

事件去重键额外状态机
library.loan.createdeventId 或 loanIdCREATED
library.loan.returnedeventIdCREATED -> RETURNED
library.loan.overdueloanId + overdueDate无
library.hold.placedeventIdPLACED
library.inventory.adjustedadjustmentId无

监控上至少看两个指标:duplicate 的比例和 rejected 的次数。重复率突然升高,通常对应再平衡、位移回退或上游重试风暴;rejected 大于零说明去重表容量不够,再涨下去就是幂等失效。这两个指标比「消费是否成功」更能提前发现问题。

下一节把视角拉高:Kafka 事务能保证什么,为什么它仍然不等于业务恰好一次,以及事务的成本该不该付。

阅读导航:上一节 2.1 重复的四种来源 | 下一节 2.3 恰好一次的两种实现与成本

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收