幂等不是「去重」一个词
幂等的定义很朴素:同一个操作执行一次和执行多次,结果相同。放到消费端,就是同一个事件被投递 N 次,业务副作用只发生一次。
这句话里有两个容易含糊的地方。第一,「结果相同」指的是业务状态相同,不是「处理器被调用一次」。处理器完全可以被调用 N 次,只要每次都在写之前发现「已经做过了」。第二,「同一个事件」需要一个稳定的标识,不能靠消息内容逐字节比较。
实现幂等有三条路,成本依次上升:
- 天然幂等:操作本身就是覆盖式的,例如
SET status = 'RETURNED'、把库存写成绝对值而不是stock = stock - 1。零额外存储,但只适用于少数场景。 - 唯一约束:在业务表或去重表上建唯一索引,让第二次写入失败。由存储引擎保证并发安全,成本是一次索引写入。
- 去重表 + 状态机:显式记录「这个事件处理到哪一步」,支持中间状态、失败重试和 TTL。最灵活,也最贵。
三者不是互斥的。生产系统里常见「唯一约束兜底 + 状态机表达生命周期」的组合。下面先把选型讲清楚,再给出一份可以运行的内存实现。
唯一键从哪来
去重的前提是有一个稳定、唯一、可存储的键。三种常见来源:
| 键 | 例子 | 优点 | 风险 |
|---|---|---|---|
| 事件 ID | eventId=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。可行的做法有三种:
- 把去重并进业务表的唯一索引:不额外存一份,让数据库的索引承担去重;TTL 靠业务表的归档策略。
- 用 compacted topic 存事件 ID:Kafka 自身按 key 压缩,保留每个 key 的最新值;消费端从 compacted topic 恢复去重集合。代价是启动时要读一遍。
- 缩短幂等窗口:承认只覆盖 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);
};
}
迁移规则:
| 当前状态 | APPROVED | RETURNED | CANCELLED |
|---|---|---|---|
| CREATED | 允许 | 拒绝 | 允许 |
| APPROVED | 幂等返回 | 允许 | 拒绝 |
| RETURNED | 拒绝 | 幂等返回 | 拒绝 |
| CANCELLED | 拒绝 | 拒绝 | 幂等返回 |
测试输出:
state-machine: CREATED->APPROVED=APPROVED repeat=APPROVED APPROVED->CREATED=IllegalStateException
两个要点:
- 重复事件的目标态等于当前态时,直接返回,不报错。这是状态机天然的幂等性:
APPROVED -> APPROVED是空操作,不需要去重表也能挡住。 - 反向迁移必须报错,不能静默忽略。
APPROVED -> CREATED说明事件乱序或上游有 bug;静默忽略会把问题藏起来。正确做法是拒绝并告警,或者把乱序事件放进延迟队列,等前置事件到达后再应用。
状态机不能替代去重表。它只能处理「有实体状态」的事件;library.loan.overdue 这种按天产生的费用事件,状态还是 APPROVED,重复应用不会改变状态,只会重复加钱。这类事件仍然需要事件 ID 去重。
内存版与生产版的差距
这份实现可以直接跑测试,但不能直接上生产。四个差距:
- 重启丢表。进程重启后所有 DONE 记录消失,重启前处理过的事件会重新执行。生产版必须把表放到外部存储,或者从 compacted topic 恢复。
- 多实例各自为政。同一个 key 可能落到不同实例,各自的内存表都认为「没见过」。要么按 key 做分区亲和(同一 key 永远进同一个实例),要么用共享存储。
- GC 与内存上限。无界 Map 会把堆吃满;
maxEntries只是硬上限,不是内存估算。上线前要用真实键长和值大小压测。 - 时钟。TTL 依赖
System.currentTimeMillis(),多机时钟偏差会让过期时间不一致。生产版最好用存储引擎的 TTL 或单调时钟。
生产版的三种落地方式,代价不同:
| 存储 | 原子性 | 延迟 | 备注 |
|---|---|---|---|
| 业务库唯一索引 | 与业务写入同事务 | 一次索引写入 | 最稳,但键必须不可复用 |
Redis SETNX + TTL | 去重原子,业务写入不原子 | 一次网络往返 | 需要处理「去重成功、业务失败」 |
| compacted topic | 由 Kafka 保证 | 启动恢复较慢 | 适合超大规模、可接受最终一致 |
不管换成哪种,process 的契约不变:先占位,再执行,成功记 DONE,失败删占位。变的只是存储。
回到 libraryd
IdempotentConsumer 接在 AtLeastOnceConsumer 后面,键的选择按事件语义定:
| 事件 | 去重键 | 额外状态机 |
|---|---|---|
library.loan.created | eventId 或 loanId | CREATED |
library.loan.returned | eventId | CREATED -> RETURNED |
library.loan.overdue | loanId + overdueDate | 无 |
library.hold.placed | eventId | PLACED |
library.inventory.adjusted | adjustmentId | 无 |
监控上至少看两个指标:duplicate 的比例和 rejected 的次数。重复率突然升高,通常对应再平衡、位移回退或上游重试风暴;rejected 大于零说明去重表容量不够,再涨下去就是幂等失效。这两个指标比「消费是否成功」更能提前发现问题。
下一节把视角拉高:Kafka 事务能保证什么,为什么它仍然不等于业务恰好一次,以及事务的成本该不该付。
阅读导航:上一节 2.1 重复的四种来源 | 下一节 2.3 恰好一次的两种实现与成本
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。