重复请求不会只来自用户双击
前端按钮没置灰、用户手抖点两下,只是重复请求最表层的原因。真实系统里还有更多来源:网关超时后客户端自动重试、Feign 配置了重试、MQ 至少一次投递、定时任务重跑、运维手动补单。这些请求带着相同的业务意图,但到达顺序和次数都不确定。
幂等的目标不是“阻止第二次请求”,而是“第二次请求不会产生第二次副作用”。前者靠前端和网关,后者只能靠服务端自己保证。
幂等分四个层次
| 层次 | 做法 | 能挡住什么 |
|---|---|---|
| 入口去重 | 客户端令牌、Redis SETNX |
短时间内的重复提交 |
| 业务唯一键 | 订单号、支付流水号 + 唯一索引 | 同一业务对象的重复创建 |
| 状态机 | UPDATE ... WHERE status = ? |
已支付订单再次支付 |
| 分布式锁 | 按业务键加锁 | 并发执行同一段逻辑 |
这四层不是选一个,而是按业务重要性叠加。入口去重成本最低,但会漏掉“隔了很久的重试”;唯一索引最可靠,但只能防插入,防不了重复扣款这类更新;状态机适合有明确状态流转的业务;分布式锁解决并发窗口,但锁过期时间要小于业务超时,否则会出现“锁释放了、业务还在跑”。
客户端令牌 + Redis 去重
标准做法是前端在进入提交页时先取一个一次性令牌,提交时带上 Idempotency-Key 请求头,服务端用 Redis 做原子占位:
public String submit(SubmitOrderRequest request) {
String key = "idem:order:" + request.getIdempotencyKey();
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(key, "PROCESSING", Duration.ofMinutes(10));
if (!Boolean.TRUE.equals(first)) {
String cached = redisTemplate.opsForValue().get(key + ":result");
if (cached != null) {
return cached;
}
throw new BizException("REPEAT_SUBMIT", "请求正在处理中,请勿重复提交");
}
try {
String result = doSubmit(request);
redisTemplate.opsForValue().set(key + ":result", result, Duration.ofMinutes(10));
return result;
} catch (RuntimeException e) {
redisTemplate.delete(key);
throw e;
}
}
几个细节决定了这段代码能不能用:
setIfAbsent(key, value, ttl)在 Redis 6.2 以上是SET NX PX,原子且自带过期,不要拆成“先 set 再 expire”。- 失败时删 key,允许用户重试;但如果是“结果未知”的超时,不能删,否则可能重复执行。
- 成功时把结果缓存一段时间,重复请求直接返回同一个结果,比返回“重复提交”体验更好,也更符合幂等语义。
- TTL 要覆盖业务的最长重试窗口,通常是几小时到一天,太短等于没做。
如果希望把这段逻辑抽成注解,可以用 AOP 拦截 @Idempotent,从 SpEL 或请求头解析 key。但要小心 AOP 和事务的顺序:去重逻辑必须在事务提交之后才删除 key,否则事务还没提交、key 已经没了,第二个请求就能进来。
状态机与唯一索引:最后的防线
入口去重会漏,锁会过期,只有数据库约束和状态机是最终的。创建类接口用唯一索引:
ALTER TABLE orders
ADD UNIQUE KEY uk_user_request (user_id, request_no);
更新类接口用条件更新,把状态判断放进 WHERE:
UPDATE account
SET balance = balance - #{amount}, version = version + 1
WHERE user_id = #{userId}
AND balance >= #{amount}
AND version = #{version};
判断 affectedRows:等于 0 就说明状态已经变了或者余额不足,直接返回“请勿重复操作”,而不是再查一次再更新。SELECT 再 UPDATE 在并发下必然出问题,条件更新才是原子的。
常见坑
坑一:幂等 key 由服务端生成。 每次请求都生成新 key,等于没有幂等。key 必须由调用方在“发起业务”时生成并复用,或者由服务端从业务参数(订单号、请求号)推导。
坑二:先查后插。 两个并发请求同时查到“不存在”,然后同时插入。没有唯一索引兜底,一定会出现两条数据。查只能作为优化,约束必须落在数据库。
坑三:分布式锁当幂等用。 锁只能保证同一时刻只有一个请求在执行,第二个请求等锁释放后照样会执行。锁 + 状态判断才是完整方案。
坑四:重试用了新的幂等键。 网关重试、Feign 重试如果重新生成请求头,服务端会认为是两笔新业务。重试必须复用原始请求的幂等键。
坑五:只防创建,不防更新。 重复支付、重复退款、重复扣库存都发生在更新路径上。更新接口要么带状态条件,要么带版本号。
排查步骤
- 按业务单号查数据库,确认是产生了多条记录,还是同一条记录被更新了多次。
- 查 Redis 里幂等 key 是否存在、值是什么、TTL 还剩多少,判断是入口没拦住还是 key 提前过期。
- 看网关和 Feign 的重试配置,确认重复请求是客户端发的还是框架重试出来的。
- 查唯一索引和状态机的
affectedRows日志,确认最终防线有没有生效。 - 用同一个幂等键并发压测一次,确认只有一个请求真正落库,其余返回相同结果。
评论