Spring Boot 3.x 优雅实现接口幂等:4 种方案一次讲透

引言

上个月运营做了一场大促预售,活动开始 10 分钟后客服反馈开始堆积:同一个用户下了两单。查日志发现不是并发 bug,也不是库存逻辑问题——用户在网络卡顿时连点了两次提交按钮,第一次请求 300ms 才返回,第二次请求几乎是同时到达的。前端虽然有按钮置灰,但置灰逻辑在“请求发出后”才生效,两道请求都穿了过去

这类问题防得住吗?防得住,而且手段成熟——接口幂等。但幂等实现恰恰是“看起来简单、做对很难”的典型:网上随手搜到的 Redis SETNX 三行代码,放进真实系统里至少有四个坑等着你(并发窗口、异常回滚、过期时间、重复提交的用户体验)。

这篇文章用那次事故开题,把四种幂等方案一次讲透:唯一索引、Redis SETNX、Token 机制、乐观锁。每种给完整可跑的 Spring Boot 3.x 代码、适用场景、防坑要点——最后给一张选型决策表,照着抄就行。


一、为什么需要幂等:不只是双击

1.1 重复请求的四个来源

双击只是冰山一角。生产系统里重复请求的来源比想象中多:

来源场景特点
用户行为双击提交、刷新重发、返回键重放单用户、低频、难彻底在前端防住
网络重试HTTP 客户端超时重试、网关重试、TCP 重传无法禁用(网络重试是容错必备)
消息重复MQ at-least-once 投递、消费者重试分布式系统的常态,不是异常
定时任务/对账任务重跑、补偿逻辑重复触发时间间隔长,容易被遗忘

关键认知:重复请求不是要“消灭”的 bug,而是要“容忍”的常态。下游服务必须做到“同一个业务请求来 100 次,效果等于 1 次”——这就是幂等。HTTP 规范其实早已约定 GET/PUT/DELETE 天然幂等,真正需要工程手段的是 POST 这类非幂等操作(下单、支付、提交)。

1.2 幂等的本质:业务唯一键 + 执行前检查

剥掉所有方案的外壳,幂等的骨架就一句话:给业务请求找一个“天然或人工的唯一键”,在执行真正的业务逻辑前检查这个键是否已经处理过

四种方案的本质区别只有一个:这个“唯一键 + 检查”放在哪一层、用什么技术承载

方案唯一键放哪检查载体强一致
唯一索引数据库表字段DB 唯一约束✅ 最高
Redis SETNXRedis key(防重表)SETNX 原子操作中(Redis 可用性内)
Token 机制服务端预发 tokenRedis 删除原子性
乐观锁数据行版本号UPDATE 条件语句✅ 最高

二、方案一:唯一索引——最可靠的兜底

2.1 实现:靠数据库约束兜底

-- 订单表:userId + productId + 活动Id 组成业务唯一键
ALTER TABLE t_order
    ADD UNIQUE KEY uk_user_product_activity (user_id, product_id, activity_id);
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderMapper orderMapper;

    public Order createOrder(OrderReq req) {
        Order order = buildOrder(req);
        try {
            orderMapper.insert(order);          // 依赖 DB 唯一索引
        } catch (DuplicateKeyException e) {     // Spring 翻译好的重复键异常
            throw new BizException("ORDER_DUPLICATE", "请勿重复下单");
        }
        return order;
    }
}

2.2 适用场景与定位

唯一索引是所有方案的“最后防线”,而不是首选方案

  • ✅ 适合:数据天然有唯一键的场景(用户+商品+活动的限购单、流水号驱动的支付单);
  • ❌ 不适合:业务本身没有天然唯一键的请求(“创建一条备注”没法造唯一键);
  • ⚠️ 它不防止“业务动作执行了两遍”,只防止“落了两条数据”——如果下单前置逻辑(扣库存、发 MQ)在 insert 之外,唯一索引拦住重复 insert 时,前置动作可能已经执行过(见 2.3 坑 2)。

2.3 防坑要点

说明对策
坑 1:唯一键设计太宽或太窄太宽(只按 userId)→ 正常需求被误杀;太窄(含时间戳)→ 永远拦不住业务唯一键要经过产品确认,如"同一活动同一用户同一商品”
坑 2:异常只拦住了最后一环insert 前的扣库存/发消息已执行,抛 DuplicateKey 后这些动作不会自动回滚唯一索引方案必须包在同一个 DB 事务里;跨资源动作放 insert 成功之后
坑 3:批量插入一颗老鼠屎batchInsert 里一条重复,整批失败拆单条 + IGNORE/ON DUPLICATE KEY,或逐条处理冲突
坑 4:分库分表后唯一索引失效唯一约束只在单库单表内有效分片键必须包含唯一键字段,或改用全局防重层(方案二)

三、方案二:Redis SETNX + 过期时间——推荐主力(完整代码)

3.1 设计:防重令牌 = 业务前缀 + 唯一标识 + 有效期

/**
 * 通用幂等切面:注解 + AOP + Redis SETNX
 * Spring Boot 3.x / Spring Boot 2.x 通用(依赖 spring-boot-starter-data-redis)
 */

/** ① 注解定义 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /** Redis key 中的 SpEL 表达式,从入参提取业务唯一标识 */
    String key();
    /** 防重有效期(须 ≥ 业务最大处理时长 + 网络往返余量) */
    int expireSeconds() default 10;
    /** 重复请求的提示语 */
    String message() default "请求处理中,请勿重复提交";
}

/** ② 切面实现 */
@Aspect
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE)      // 幂等检查要在事务开启前执行
public class IdempotentAspect {

    private final StringRedisTemplate redis;

    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
        String redisKey = buildKey(pjp, idempotent);

        // 核心:SETNX + 过期时间,一条 Lua 保证原子性
        Boolean acquired = redis.opsForValue().setIfAbsent(
                redisKey, "1", idempotent.expireSeconds(), TimeUnit.SECONDS);

        if (Boolean.FALSE.equals(acquired)) {
            throw new BizException("IDEMPOTENT_REJECT", idempotent.message());
        }

        try {
            return pjp.proceed();
        } catch (Throwable e) {
            // 关键决策:业务异常时删除防重键,允许用户重试(见 3.3 坑 2)
            redis.delete(redisKey);
            throw e;
        }
    }

    private String buildKey(ProceedingJoinPoint pjp, Idempotent idempotent) {
        // SpEL 解析:@Idempotent(key = "#req.userId + ':' + #req.activityId")
        ExpressionParser parser = new SpelExpressionParser();
        MethodSignature sig = (MethodSignature) pjp.getSignature();
        StandardEvaluationContext ctx = new StandardEvaluationContext();
        String[] names = sig.getParameterNames();
        Object[] args = pjp.getArgs();
        for (int i = 0; i < names.length; i++) {
            ctx.setVariable(names[i], args[i]);
        }
        String bizKey = parser.parseExpression(idempotent.key()).getValue(ctx, String.class);
        return "idem:" + sig.getDeclaringTypeName() + ":" + sig.getMethod().getName()
                + ":" + bizKey;
    }
}

/** ③ 业务使用:一行注解 */
@Idempotent(key = "#req.userId + ':' + #req.productId", expireSeconds = 15)
@PostMapping("/order")
public OrderVO createOrder(@RequestBody @Valid OrderReq req) {
    return orderService.createOrder(req);
}

为什么 key 里要带方法签名:不同接口的防重相互隔离,userId=1 在下单接口和充值接口的防重互不影响。

3.2 真正的原子性:SETNX 单条命令就够,别再自己造“查询+写入”

网上流传的错误写法是先 GET 判断再 SET——GET 和 SET 之间就是并发窗口,两道请求同时 GET 到 null、同时 SET 成功,防重失效。修复就是 3.1 的 setIfAbsent(key, val, timeout):Redis 的 SETNX 语义保证判定和写入是同一个原子操作,两道并发请求只有一个能拿到 true

同理,删除防重键的时机也不能随手写——这是 3.3 的坑 2。

3.3 防坑要点(每一条都来自真实事故)

现象对策
坑 1:过期时间 < 业务耗时下单要 12s,防重键 5s 过期 → 第 6s 用户重试穿透;或者更糟:第一单还在处理中、第二单也进来了expireSeconds ≥ 最大处理时长的 2 倍;超长流程(如支付回调)配合业务状态机
坑 2:异常时删不删键是两个语义删 → 业务失败允许立即重试(体验好);不删 → 事务回滚了但键还在,用户在剩余有效期内被误拦删除(如 3.1 代码),把“失败可重试”还给用户;但删除动作要在事务回滚之后发生(切面在事务外层,天然满足)
坑 3:键值设 "1" 无业务含义排查问题时不知道这个键是谁、什么时候放的value 存 traceId 或时间戳,排查利器
坑 4:Redis 挂了怎么办防重层整体失效 → 退化为无幂等降级策略显式化:Redis 异常时放行 + 靠方案一唯一索引兜底(双层防线,见第六章组合拳)
坑 5:集群下的时钟/主从延迟主从切换瞬间 SETNX 结果可能丢失可接受(窗口毫秒级);强一致场景换方案一或方案四

3.4 适用场景

默认主力方案:有明确业务唯一键的提交类接口(下单、领券、报名)。它把防重从 DB 挪到 Redis,扛并发能力强(SETNX 单线程原子)、侵入性低(一个注解)、体验可控(失败即返回提示)。


四、方案三:Token 机制——先拿令牌,再提交

4.1 设计动机:没有天然唯一键怎么办

有些接口的业务参数天然不唯一:比如“提交一张表单",两次提交的字段完全一样但语义上就该是两条记录(或用户就是想改了再提交)。这时没有业务键可用,Token 机制的思路是人为造一个一次性凭证

① 页面加载时:客户端请求 /token → 服务端生成随机 token 存入 Redis,返回给前端
② 用户提交时:请求头带上这个 token
③ 服务端处理:原子删除 token(DEL 返回 1 = 首次)→ 执行业务;返回 0 = 重复提交拒绝

4.2 完整代码

@RestController
@RequiredArgsConstructor
public class TokenController {

    private final StringRedisTemplate redis;

    /** ① 发放 token:UUID + 存入 Redis(5 分钟有效) */
    @GetMapping("/form/token")
    public Map<String, String> issueToken() {
        String token = UUID.randomUUID().toString();
        redis.opsForValue().set("form:token:" + token, "1", 5, TimeUnit.MINUTES);
        return Map.of("token", token);
    }

    /** ② 提交时校验:原子 DEL,删除成功 = 第一次提交 */
    @PostMapping("/form/submit")
    public Map<String, Object> submit(@RequestHeader("X-Form-Token") String token,
                                      @RequestBody FormReq req) {
        // 核心原子性:DEL 是单命令,删除成功与否天然防并发
        Boolean first = redis.delete("form:token:" + token);
        if (Boolean.FALSE.equals(first)) {
            throw new BizException("IDEMPOTENT_REJECT", "表单已提交,请勿重复操作");
        }
        // token 已核销,安全执行业务
        formService.submit(req);
        return Map.of("ok", true);
    }
}

为什么用 DEL 而不是 GET+DEL:和方案二同理,GET 和 DEL 之间是并发窗口,两道请求同时 GET 到 token 存在、同时执行业务。DEL 的原子性让“核销”这个动作天然只有一次生效。

4.3 防坑要点

说明对策
坑 1:token 何时核销有讲究先执行业务再核销 → 业务执行期间重复请求穿透;先核销再执行业务(本文做法)→ 业务失败 token 已没了,用户重提交要重新拿 token两种顺序选一种并接受其代价:本文选“先核销”,失败的代价是“重新拉一次 token”(体验可接受,一致性更好);核心资金类接口改用方案一/四兜底
坑 2:前端遗漏传 token老版本页面/小程序没带 token → 全被拒绝token 校验失败时返回明确的错误码,前端灰度切换;网关层兼容“无 token 走方案二”的过渡期
坑 3:token 被抓包重放token 本身无用户绑定token 与 userId 绑定存储(value 存 userId,DEL 时校验),并且全程 HTTPS
坑 4:Redis DEL 前服务重启token 丢失 → 用户提交失败token 有效期给足 + 前端提供“重新获取”自动重试

4.4 适用场景

表单防重复提交(无天然业务键)、文件上传、活动报名这类“页面级一次性操作”。它比方案二多一次交互(先拿 token),换来的是“不依赖业务键设计”——业务键梳理不清楚时,Token 是解耦的答案。


五、方案四:乐观锁版本号——状态更新的幂等

5.1 与前三者的本质区别

前三种防的是“动作别执行两次"(insert 两条、处理两遍),乐观锁防的是“状态别被错误覆盖”——它的主战场是 UPDATE 语句

// 需求:订单状态 CAS 流转(待支付 → 已支付),防重复回调把状态改乱

// ❌ 朴素写法:查出来改回去(查出 1ms 内状态可能已被并发修改)
Order order = orderMapper.selectById(orderId);
if (order.getStatus() != PENDING) { throw new BizException("状态错误"); }
order.setStatus(PAID);                        // 中间可能有另一笔回调已改成 PAID
orderMapper.update(order);                    // 覆盖了别人的修改!

// ✅ 乐观锁:UPDATE 带版本条件,状态机 + 版本号双重保护
int updated = orderMapper.updateStatus(
        orderId,
        expectedStatus = PENDING,             // 期望旧状态
        targetStatus = PAID,
        expectedVersion = order.getVersion(), // 期望旧版本号
        nextVersion = order.getVersion() + 1);

if (updated == 0) {
    // 没更新到 = 状态已被别人流转 → 天然幂等:重复回调第二次进来 updated=0,直接返回成功
    log.info("订单状态已流转,视为重复回调,幂等返回");
}
UPDATE t_order
SET status = #{targetStatus},
    version = version + 1
WHERE id = #{orderId}
  AND status = #{expectedStatus}      -- 状态机条件
  AND version = #{expectedVersion};   -- 版本号条件

乐观锁幂等的妙处:重复请求第二次执行 UPDATE,条件不满足影响行数为 0——它不是“拒绝重复”,而是“重复执行也无害”,这才是幂等最优雅的形态。MyBatis-Plus 的 @Version 注解 + OptimisticLockerInnerInterceptor 可以自动完成版本号读写,不用手写 SQL。

5.2 防坑要点

说明对策
坑 1:updated=0 就抛异常重复回调被当成错误返回 → 上游重试风暴区分“条件不满足但终态正确”(幂等成功返回)和“真正的冲突”(可重试/告警)
坑 2:重试没有上限高并发下版本冲突率上升,无限重试放大 DB 压力重试 2~3 次 + 退避,仍失败转人工/MQ 削峰
坑 3:version 忘了自增每次都更新成功,锁形同虚设version 自增放 SQL 里(version = version + 1),别在 Java 里 set

5.3 适用场景

状态流转类接口的唯一正解:支付回调、订单状态机、审批流、库存扣减(配合 stock > 0 条件)。它不能防 insert 重复(新纪录没有版本号),所以和方案一/二是互补关系。


六、选型决策与组合拳

6.1 四方案对比总表

维度唯一索引Redis SETNXToken 机制乐观锁
防护对象重复落库重复执行动作页面重复提交状态错误覆盖
强一致性✅ 最高中(Redis 可用性内)✅ 最高
性能开销DB 约束检查Redis 一次原子操作Redis DELDB 行锁竞争
侵入性建表设计期确定一个注解,低前后端配合改造UPDATE 语句改造
并发承载中(DB 锁)
前置条件业务天然唯一键业务可提取唯一键无要求更新类接口
用户体验重复时异常即时提示需先拿 token无感

6.2 选型口诀

有天然唯一键 + 数据落库 → 唯一索引兜底 + Redis SETNX 提速(组合拳)
无业务键的页面提交      → Token 机制
状态流转/更新类         → 乐观锁(唯一正解)
拿不准                  → Redis SETNX 先上,唯一索引后补

6.3 生产级组合拳:三层防线

真实的下单接口,推荐三层组合(各层各司其职):

@Idempotent(key = "#req.userId + ':' + #req.productId",   // 第一层:Redis 防重(快速拒绝 + 体验好)
            expireSeconds = 15)
@PostMapping("/order")
public OrderVO createOrder(@RequestBody @Valid OrderReq req) {
    return orderService.createOrder(req);                  // 第二层:事务内唯一索引(DB 兜底)
}

@Service
@Transactional(rollbackFor = Exception.class)              // 第三层:状态流转用乐观锁
public OrderVO createOrder(OrderReq req) {
    stockMapper.deductWithCas(req.getProductId());          // UPDATE ... stock>0(乐观锁语义)
    orderMapper.insert(order);                              // 撞 uk_user_product_activity 兜底
    mqSender.send(orderPaidEvent);                          // insert 成功后才发 MQ
    return toVO(order);
}
防线分工:
  Redis SETNX   → 99% 的重复请求在进入业务前被快速拒绝(扛并发 + 用户提示)
  唯一索引      → Redis 失效/窗口期穿透的最后一道数据防线
  乐观锁        → 状态相关的字段不被并发覆盖

这套组合的哲学:任何单一防线都有失效窗口(Redis 主从切换、DB 约束太晚、前端按钮漏配),幂等不追求单点完美,追求纵深防御


七、常见问题

7.1 幂等键和分布式锁是一回事吗?

不是,但容易混。分布式锁保证“同一时刻只有一个执行者”(互斥,执行完就释放);幂等保证“同一业务请求执行多次效果不变”(防重,记录的是“已处理”事实)。SETNX 只是恰好两者都能实现——锁的 key 语义是“我正在做”,幂等的 key 语义是“我已经做过”。把幂等键当锁用(执行完立刻删),防不住“执行完成后的重放”;把锁当幂等用(一直不释放),又会误拦正常请求。

7.2 幂等过期时间到底设多长?

下限 = 业务最大处理时长 × 2(含下游 RPC、MQ 发送);上限 = “同一业务请求合理的重复间隔”。下单类:15~30 秒足够拦住双击和网络重试;支付回调类:用“业务状态机”代替时间窗(永远幂等);表单类:token 5 分钟。没有万能值,但“小于业务耗时”一定是错的

7.3 幂等拒绝时应该返回什么 HTTP 状态码?

分场景:防重窗口内重复提交 → 409 Conflict + 业务错误码(语义最准);token 已核销 → 409 或 400;幂等成功但属于重复执行(如重复回调的乐观锁路径)→ 返回 200 + 原业务结果,不要返回错误——上游的重试逻辑会因此风暴。返回什么取决于调用方视角:“你的请求我处理过了,结果如下”是成功,不是失败。

7.4 MQTT/消息消费侧的幂等怎么做?

消息消费的幂等不推荐 SETNX 切面(消费线程池并发 + 重试时序复杂),推荐业务唯一键 + 唯一索引/状态机:消费逻辑以“落库或改状态”为幂等锚点,重复消息撞唯一索引后确认提交(ack),让 MQ 的重试机制与业务幂等解耦。消息侧的防重表(msgId + 消费记录表)是另一种通用解,适合无天然业务键的广播消息。

7.5 前端按钮置灰能替代后端幂等吗?

不能,一票否决。理由:① 前端代码可绕过(直接调 API);② 页面多端并存(小程序/APP/开放接口)难以同步防重逻辑;③ 网络层重试发生在 HTTP 客户端,前端完全无感。前端置灰是体验优化,后端幂等是正确性保证——这句话值得写进团队规范。

7.6 怎么验证幂等做对了?

并发双发测试是最低标准:用 JMeter/wrk 对同一请求参数并发打 100 次,断言“业务结果只产生一次”(订单只有一条、库存只扣一次、MQ 只有事件)。再加两个用例:① 失败重试场景(第一次人为失败,第二次应成功——验证坑 2 的“删键”语义);② 慢处理场景(把下游 mock 慢到超过防重有效期,验证窗口设计)。幂等测试和普通功能测试的区别就是这组并发断言,值得为资金类接口专门写一套。


八、总结

四方案速查卡

┌────────────┬─────────────────────┬────────────────────┬──────────────┐
│ 方案        │ 一句话定位            │ 防坑核心             │ 适用          │
├────────────┼─────────────────────┼────────────────────┼──────────────┤
│ 唯一索引    │ 最后的数据防线         │ 唯一键设计 + 事务包裹 │ 有天然唯一键   │
│ Redis SETNX│ 推荐主力(注解化)      │ 原子性 + 异常删键     │ 提交类接口     │
│ Token      │ 无业务键的解耦方案      │ 先核销后执行          │ 表单/上传     │
│ 乐观锁      │ 状态流转唯一正解        │ updated=0 的语义      │ UPDATE 类     │
└────────────┴─────────────────────┴────────────────────┴──────────────┘
生产组合拳:SETNX 快速拒绝 → 唯一索引数据兜底 → 乐观锁状态保护

一句话

接口幂等的全部秘密是“找到业务唯一键,在正确的层次用原子操作检查它”:数据库唯一索引是最硬的防线,Redis SETNX 是最快的守门员,Token 是没有业务键时的人造凭证,乐观锁让“重复执行也无害”成为最优雅的形态。单一方案都有失效窗口,生产级的答案是纵深防御——99% 的重复请求被 Redis 拒之门外,漏网之鱼撞死在唯一索引上,状态字段由乐观锁守到最后。幂等不是选一道题,而是布一个局。

给团队的建议

建议
规范所有 POST 提交类接口默认配 @Idempotent 注解(SETNX 方案),Code Review 必查
设计期建表时就想清楚业务唯一键——唯一索引是免费送的幂等,事后加才痛
组合资金/库存类接口三层防线齐配;普通表单 Token 或 SETNX 单层即可
语义团队统一幂等拒绝的错误码与 HTTP 状态(409 + 业务码),前端统一处理
测试资金类接口必须配“并发双发 100 次 + 失败重试 + 慢处理”三件套用例

互动话题:你被重复提交坑过最惨的一次是什么场景?你们的幂等键是怎么设计的?评论区聊聊。


参考资料


标题:Spring Boot 3.x 优雅实现接口幂等:4 种方案一次讲透
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/11/1788597582453.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消