ElIauk

接口幂等与重复请求防护

重复请求不会只来自用户双击 前端按钮没置灰、用户手抖点两下,只是重复请求最表层的原因。真实系统里还有更多来源:网关超时后客户端自动重试、Feign 配置了重试、MQ 至少一次投递、定时任务重跑、运维手动补单。这些请求带着相同的业务意图,但到达顺序和次数都不确定。 幂等的目标不是“阻止第二次请求”,而

评论
接口幂等与重复请求防护

重复请求不会只来自用户双击

前端按钮没置灰、用户手抖点两下,只是重复请求最表层的原因。真实系统里还有更多来源:网关超时后客户端自动重试、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 重试如果重新生成请求头,服务端会认为是两笔新业务。重试必须复用原始请求的幂等键。

坑五:只防创建,不防更新。 重复支付、重复退款、重复扣库存都发生在更新路径上。更新接口要么带状态条件,要么带版本号。

排查步骤

  1. 按业务单号查数据库,确认是产生了多条记录,还是同一条记录被更新了多次。
  2. 查 Redis 里幂等 key 是否存在、值是什么、TTL 还剩多少,判断是入口没拦住还是 key 提前过期。
  3. 看网关和 Feign 的重试配置,确认重复请求是客户端发的还是框架重试出来的。
  4. 查唯一索引和状态机的 affectedRows 日志,确认最终防线有没有生效。
  5. 用同一个幂等键并发压测一次,确认只有一个请求真正落库,其余返回相同结果。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者