重复下单、重复扣款、MQ 重复消费------幂等几乎是每个后端系统绕不开的问题。但"加个分布式锁"并不等于"接口幂等",很多线上事故恰恰出在那些看起来原子、其实有竞态窗口的实现上。
本文不停留在罗列方案,而是用一个可运行的并发压测工程,先证明"先查再写"为什么一定会在并发下被击穿,再逐层给出唯一索引、SETNX 原子占位、Lua 滑动窗口的正确实现,最后讨论锁超时、唯一约束、消息重放等工程边界。所有结论都附带真实压测数据。
1. 先对齐:幂等、防重、分布式锁不是一回事
- 幂等(Idempotency) :同一操作执行一次或多次,对系统状态的最终影响相同,即
f(f(x)) = f(x)。 - 防重复提交:幂等的一个子集,目标是在一个短时间窗内挡住用户/网络造成的重复请求。
- 分布式锁 :一种互斥手段,解决临界区并发,但锁释放后的重放仍可能造成重复,因此锁不是幂等的充分条件。
真正可靠的幂等通常是分层的:入口前置拦截(挡掉绝大多数重复流量)+ 资源层强约束(兜住所有漏网情况) 。理解这一点,后面的方案选型才不会纠结"到底用锁还是用唯一索引"------答案往往是都要。
2. 用并发压测,先看清错误实现是怎么被击穿的
最常见的写法是"先判断有没有,再决定做不做":
kotlin
// 错误示范:check-then-act,两步之间存在竞态
if (!kv.exists(key)) {
// ... 这里可能有任意耗时:查库、RPC、计算
kv.set(key, ttl);
return doCreateOrder();
}
return "重复请求";
它在单线程压测下"看起来没问题",但在并发下:两个线程可能同时通过 exists == false 的判断,随后都执行创建。对应到 Redis,就是把本应由一条命令完成的"判断+写入"拆成了 EXISTS 和 SET 两条命令,中间的网络/计算间隙就是并发窗口。
为了不靠"我觉得"下结论,我写了一个零依赖的并发压测工程,用 100 个线程经 CountDownLatch 同时抢占同一个 key。非原子实现里我特意留了 5ms 间隙来稳定复现(真实系统里这个间隙是查库和网络往返,只会更长):
vbnet
@Override
public boolean tryAcquire(String key, int limit, long windowMillis) {
if (kv.exists(key)) {
return false;
}
Thread.sleep(5); // 放大竞态窗口,真实系统里是 IO 耗时
kv.set(key, windowMillis);
return true;
}
压测结果(100 线程、同一 key、期望只放行 1 次):
sql
non-atomic(check-then-set) passed=100 ← 完全击穿
set-nx(atomic) passed=1 ← 精确
sliding-window(atomic) passed=1 ← 精确
把允许次数放宽到 5、并发提到 200:
sql
non-atomic(check-then-set) passed=200 ← 击穿
set-nx(atomic) passed=1 ← 占位语义,天然只放 1
sliding-window(atomic) passed=5 ← 精确按 limit 计数
非原子写法在 100 并发下放行 100 次,等于完全没防住。这组数据也是后面所有正确实现的出发点:幂等判断必须是一个不可分割的原子操作。
3. 第一性原理:把"判断 + 写入"变成单命令
3.1 Redis SETNX:一条命令的原子占位
Redis 的 SET key value NX PX ttl 在单条命令里完成"仅当不存在才设置 + 带过期时间",Redis 单线程模型保证这期间不会插入其他命令:
vbnet
Boolean first = redisTemplate.opsForValue()
.setIfAbsent("order:" + orderNo, "1", Duration.ofSeconds(10));
if (Boolean.FALSE.equals(first)) {
throw new RepeatSubmitException("请求处理中,请勿重复提交");
}
return doCreateOrder(orderNo);
NX:仅不存在时写入,保证同一业务号只有一个请求占位成功;PX/EX:必须带过期时间,避免服务宕机后 key 永驻导致"永远无法重试"。
压测工程里用 ConcurrentHashMap.compute 在单机内存复现了同样的原子语义(compute 在桶锁内完成判断与写入),结论与 Redis 完全一致------并发 100 只放行 1:
ini
public boolean setIfAbsent(String key, long ttlMillis) {
long now = System.currentTimeMillis();
boolean[] first = {false};
map.compute(key, (k, old) -> {
if (old == null || old.expireAt() <= now) {
first[0] = true;
return new Value(now + ttlMillis);
}
return old;
});
return first[0];
}
3.2 占位 ≠ 计数:SETNX 的适用边界
注意第二节 limit=5 那组数据:SETNX 只放行了 1 次。这不是 bug,而是语义差异------SETNX 解决的是"这个业务键是否第一次出现",它是占位/防重原语,不是限流器。如果你要"一个窗口内允许 N 次",需要计数型算法(滑动窗口、令牌桶),这正是下一节 Lua 的场景。把工具和场景对应清楚,比记住命令更重要。
4. 滑动窗口:当你需要"窗口内 N 次"时
固定窗口在边界处可能出现 2 倍突刺(窗口末尾和下个窗口开头各打满一次)。更精确的是滑动窗口:维护窗口内的通过时间戳,先剔除过期记录,再判断当前计数。
难点同样在原子性------"清理 + 计数 + 入队"三步必须一起完成。Redis 里要把它们放进一段 Lua(在 Redis 单线程内一次执行完)。内存版用于演示算法本身:
arduino
@Override
public boolean tryAcquire(String key, int limit, long windowMillis) {
Deque<Long> deque = windows.computeIfAbsent(key, k -> new ArrayDeque<>());
synchronized (deque) {
long now = System.currentTimeMillis();
long boundary = now - windowMillis;
while (!deque.isEmpty() && deque.peekFirst() <= boundary) {
deque.pollFirst(); // 清理过期时间戳
}
if (deque.size() >= limit) {
return false; // 窗口内已满
}
deque.addLast(now); // 记录本次通过
return true;
}
}
对应的 Redis Lua(ZSet 时间戳方案)大致是:
lua
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
return 0
end
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('PEXPIRE', key, window)
return 1
压测里滑动窗口在 200 并发、limit=5 时精确放行 5 次,既挡住了重复,又不会像 SETNX 那样把合法的第 2~5 次也拒掉。
5. 资源层兜底:唯一索引为什么不可替代
入口层的 Redis 占位很快,但它不是绝对真理:key 可能因过期被删、Redis 可能故障切主丢数据、代码可能在占位后、落库前崩溃。只要存在"占位成功但最终没写库"的路径,就必须有最后一道防线。
对有天然唯一号(订单号、支付流水号)的写操作,数据库唯一索引是最强兜底:
sql
ALTER TABLE t_order ADD UNIQUE KEY uk_order_no (order_no);
csharp
try {
jdbcTemplate.update("INSERT INTO t_order(order_no, ...) VALUES (?, ...)", orderNo, ...);
} catch (DuplicateKeyException e) {
// 唯一约束兜底:说明这是重复请求
throw new BusinessException("订单已存在:" + orderNo);
}
为什么它"最强":约束由存储引擎在提交时保证,不依赖应用层时序,不存在竞态窗口。代价是重复请求会真正打到 DB 并抛异常,因此它适合兜底而非唯一手段------Redis 在前面挡掉 99%,唯一索引接住极端的 1%。
一个完整的下单因此通常长这样(压测工程中的精简版):
arduino
public boolean createOrder(String orderNo, long ttlMillis) {
boolean first = kv.setIfAbsent("order:" + orderNo, ttlMillis); // 入口原子拦截
if (first) {
// 落库;真实项目这里 INSERT 带唯一索引做最终兜底
created.computeIfAbsent(orderNo, k -> new AtomicInteger()).incrementAndGet();
}
return first;
}
业务实测:同一订单串行调 5 次,仅第 1 次 created=true;50 个并发同时打同一订单,真正落库只有 1 次:
ini
50 并发中 created=true 次数: 1 (期望 1)
6. 工程边界:这些问题比命令本身更值得想
6.1 占位 key 过期了,业务还没执行完,会不会重复?
会有这个窗口。假设 TTL=10s,但下单因下游卡顿用了 12s,第二次请求在第 11s 进来时 key 已过期,可能再次占位。应对要组合:
- TTL 设为显著大于接口正常耗时(如 P99 的数倍);
- 真正强一致靠唯一索引/状态条件更新兜底;
- 需要"同一业务键在整个生命周期内唯一"时,幂等键不应只靠短 TTL,而应落一张幂等记录表(唯一键即业务号),用"插入是否成功"判定首次。
6.2 幂等记录表:没有 Redis 或要求强持久时的选择
sql
CREATE TABLE t_idempotent (
biz_key VARCHAR(64) PRIMARY KEY,
status TINYINT,
created_at DATETIME
);
INSERT IGNORE / INSERT ... ON DUPLICATE 的"影响行数是否为 1"就是判定结果。它牺牲了一部分性能,换来不依赖缓存、可审计、生命周期可控。高并发下可配合 Redis 前置减压。
6.3 MQ 重复消费怎么做
消费端用"业务唯一键 + 唯一约束/幂等表",而不是依赖消息中间件的"恰好一次"(多数 MQ 是 at-least-once)。消费成功才 ACK;重复消息在落库时被唯一约束挡住,捕获后直接当成功处理,避免无限重试。
6.4 分布式锁能替代上面的方案吗
不能。锁保证的是临界区互斥,但:①锁有获取失败、超时释放、主从切换丢锁等问题;②锁一旦释放,迟到的重放请求仍可能成功。锁适合保护"查库存→扣减→建单"这类复合临界区,而最终幂等仍要靠唯一约束或状态机。
6.5 状态机:带生命周期业务的幂等
支付、审批这类有状态流转的场景,用条件更新让"只有合法前置状态才能流转":
ini
UPDATE t_payment SET status = 'PAID' WHERE id = ? AND status = 'INIT';
影响行数为 0 即说明状态已被推进,重复回调直接忽略。第三方支付异步通知会重投,靠的就是这个。
7. 方案选型一览
| 方案 | 一致性强度 | 成本 | 适用场景 | 主要边界 |
|---|---|---|---|---|
| 数据库唯一索引 | 最强(兜底) | 极低 | 有唯一业务号的写 | 只在落库层拦截,异常有代价 |
| Redis SETNX 占位 | 强(受 TTL/切主影响) | 低 | 高频接口前置防重 | 需配 TTL,极端情况要兜底 |
| Lua 滑动窗口 | 强、可计数 | 中 | 窗口内允许 N 次、限流+幂等 | 需写对 Lua、管理窗口键 |
| 幂等记录表 | 强、可持久 | 中 | 无缓存/要求审计、全生命周期 | 多一次 DB 写 |
| 分布式锁 | 互斥 | 中高 | 复合临界区 | 不等于幂等,仍需兜底 |
| 状态条件更新 | 强 | 低 | 有明确状态流转 | 仅限状态机型业务 |
实践组合:注解/AOP + Redis 原子占位做前置拦截,唯一索引或状态机做资源层兜底;需要限流语义时用 Lua 滑动窗口;关键资金链路再叠幂等记录表。
8. 可运行源码与复现方式
工程基于 Spring Boot 3.2.5 / Java 17,零外部中间件(用内存原语复现 Redis 原子语义),已放在仓库的掘金专区:
- Gitee:gitee.com/Jackie51/we...
- 目录:
juejin/idempotent-in-depth
bash
mvn spring-boot:run
# 并发压测
curl "http://localhost:8082/benchmark?threads=100&limit=1&window=2000"
curl "http://localhost:8082/benchmark?threads=200&limit=5&window=2000"
# 业务接口
curl -X POST "http://localhost:8082/orders/ORD001?ttl=60000"
迁到生产时,把 KvStore.setIfAbsent 换成 stringRedisTemplate.opsForValue().setIfAbsent(...)、把滑动窗口换成文中 Lua,再加数据库唯一索引,算法与结论保持一致。
9. 小结
- 幂等的核心是让"判断 + 写入"成为原子操作,任何 check-then-act 在并发下都可能被击穿(本文压测 100 并发放行 100 次)。
- SETNX 解决"是否首次",Lua 滑动窗口解决"窗口内 N 次",二者不要混用。
- Redis 前置拦截很快但不是真理,唯一索引/状态机的资源层兜底不可省。
- 锁是互斥工具,不是幂等的替代品;MQ 消费端用业务键 + 唯一约束做幂等。
如果这篇对你有帮助,欢迎点赞收藏,也可以在评论区聊聊你在线上是怎么设计资金类接口幂等的。