ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot接口幂等性实战:四种方案解决重复请求

SpringBoot接口幂等性实战:四种方案解决重复请求 前阵子半夜爬起来处理线上问题某支付渠道回调接口在高峰期偶尔会重复推送一开始没太在意结果用户订单被连续处理了两次、积分发了两份那一刻我盯着数据库里的异常流水才算真正把“SpringBoot接口幂等性”这几个字刻进脑子里。这篇文章想用真实项目里的踩坑经历把接口幂等性的4种实现方案一次讲透Token预生成、数据库唯一索引、乐观锁与状态机、Redis分布式幂等。每种方案我会给出能直接落到代码里的做法也会告诉你哪些场景千万别用。适合刚接触SpringBoot接口开发的初级工程师也适合正在为线上重复扣款、重复发送消息头疼的小伙伴。1. 为什么会被重复请求打懵幂等性的真实场景和我踩过的坑1.1 那次支付回调重复通知差点扣了两次钱那时候订单服务刚上线用户支付成功后由支付平台异步回调来更新订单状态。回调接口逻辑很简单根据订单号查到订单如果状态是待支付就改成已支付顺手给用户加积分、发一张优惠券。第一次写的实现大概是这样PostMapping(/pay/notify) public ResultString payNotify(RequestBody PayNotifyDto dto) { OrderInfo order orderService.getByOrderNo(dto.getOrderNo()); if (UNPAID.equals(order.getStatus())) { order.setStatus(PAID); orderService.updateById(order); userService.addPoints(order.getUserId(), 100); couponService.sendCoupon(order.getUserId()); } return Result.success(ok); }单看这段代码好像没问题已经支付过的订单不会进入if里。但问题恰恰出在并发和重试上。支付平台为了保证消息必达在回调超时或者网络抖动时会多次推送同样的通知。第一个请求把订单改成已支付并加了积分第二个请求如果晚到一会儿进if时发现状态已经是PAID确实不会重复。但如果两个通知几乎同时到达两个请求都先查询到了UNPAID状态然后都执行了状态更新和发奖重复扣款、重复发券就发生了。这个案例让我对幂等有了最直观的理解一个操作不管执行多少次结果跟执行一次完全一样。换句话说接口要能识别出“这次请求其实我已经处理过了”然后直接返回历史结果而不是再执行一遍业务逻辑。1.2 幂等与防重别把这两个概念混成一条很多同学会把“防重”和“幂等”混为一谈我一开始也是。防重是防止用户在短时间内重复点击比如提交订单按钮被连点了两下。防重的常见手段是前端按钮置灰、loading但它只能挡正常用户挡不住脚本刷接口、HTTP超时重试、消息中间件重复消费。幂等是后端接口层面的能力。同一个请求数据发到服务端多次后端要能保证业务结果不重复、不冲突。前端防重只是减少重复入口真正要兜底的是服务端。我自己的总结是防重是降低重复请求的发生概率幂等是保证即使重复请求来了也不会出事。做接口幂等性脑子里的目标应该是后者而不是指望着前端帮忙。2. 方案一Token预生成——对付连点、双击、页面刷新最有效的“纸票”2.1 从发Token到校验销毁一次完整流程Token方案是我在表单提交、下单这类场景里用得比较多的一种。核心思路可以类比电影院检票用户先取一张票进场时检票员把票撕掉一个人只能凭这张票进一次想再进必须重新买票。转换到接口场景就是客户端在进入页面或者点击提交前先调用后端接口获取一个唯一Token。后端把这个Token存入Redis设置一个合理的过期时间然后返回给前端。前端把Token放到请求头或者参数里携带它提交真正的业务请求。后端接口在处理前校验Token如果Redis里存在说明是第一次请求立刻删除Token并放行如果不存在说明是重复请求直接拒绝。这套流程对“用户多点了几次提交按钮”“页面刷新后重复提交”这种场景特别有效。因为前几次点击用的是同一个Token第一次请求已经把Token删掉了后面的请求自然过不了校验。2.2 SpringBoot里落地拦截器 Redis Lua 脚本我选择用拦截器来做Token校验而不是在每个Controller方法里写重复代码。业务侧只需要先调一个发Token的接口提交业务请求时带上Token拦截器统一处理。先看发Token接口RestController RequestMapping(/api/token) public class TokenController { private final StringRedisTemplate stringRedisTemplate; public TokenController(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } GetMapping public ResultString generate() { String token UUID.randomUUID().toString().replace(-, ); stringRedisTemplate.opsForValue().set( idem:token: token, 1, 60, TimeUnit.SECONDS ); return Result.success(token); } }这里直接用StringRedisTemplate避免RedisTemplate默认JDK序列化把key搞出一堆前缀字符。过期时间我习惯设60秒用户从打开页面到提交正常情况下完全够用如果需要长时间填表单可以设为120秒。拦截器里的核心是“校验并删除”必须原子化。如果先判断Token存在再执行删除两个并发请求可能同时通过判断然后同时进入业务代码Token方案就白做了。不能这样写// 错误示范检查与删除不是原子的 if (stringRedisTemplate.hasKey(key)) { stringRedisTemplate.delete(key); return true; } return false;正确做法是用Lua脚本把“检查并删除”合并成一个原子操作if redis.call(exists, KEYS[1]) 1 then redis.call(del, KEYS[1]) return 1 else return 0 end拦截器完整代码public class IdempotentTokenInterceptor implements HandlerInterceptor { private static final String CHECK_AND_DELETE_SCRIPT if redis.call(exists, KEYS[1]) 1 then redis.call(del, KEYS[1]) return 1 else return 0 end; private final StringRedisTemplate stringRedisTemplate; public IdempotentTokenInterceptor(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(idempotent-token); if (StringUtils.isBlank(token)) { response.setStatus(HttpStatus.BAD_REQUEST.value()); return false; } String key idem:token: token; Long result stringRedisTemplate.execute( new DefaultRedisScript(CHECK_AND_DELETE_SCRIPT, Long.class), Collections.singletonList(key) ); if (result null || result 0L) { response.setStatus(HttpStatus.BAD_REQUEST.value()); return false; } return true; } }然后在WebMvcConfig里注册拦截器只拦截需要防重复提交的路径而不是所有接口Configuration public class WebMvcConfig implements WebMvcConfigurer { private final IdempotentTokenInterceptor interceptor; public WebMvcConfig(IdempotentTokenInterceptor interceptor) { this.interceptor interceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(interceptor) .addPathPatterns(/api/order/submit); } }2.3 这套方案的边界只能挡住“同一客户端”的重复请求Token方案用起来简单但我不建议所有接口都套。它有一个天然边界它是前置防重依赖客户端先取Token再提交。如果两个系统之间直接对接比如支付平台回调我们的接口回调方不会先来找你取TokenToken方案就完全不适用。还有一类坑用户第一次点提交后端已经处理成功但因为网络原因前端没收到响应用户刷新页面后想要重试。此时Token已经被删了重试会被拦截。这种场景不能简单拒绝正确做法是给用户展示“这笔单子已经提交成功”的状态而不是暴露一个“重复提交”的错误。所以我在实际项目里会在拦截器返回失败时额外提供一个查询接口前端收到重复提交的标记后自动调查询接口把最新业务状态拉回来。对用户来说体验是“我点击了两次但只看到一次成功结果”而不是“系统报错”。3. 方案二数据库唯一索引——用数据库约束给请求发“身份证”3.1 为什么唯一索引能挡住重复插入数据库唯一索引是天然的幂等屏障。核心做法是给业务表或者专门的流水表加上唯一键比如订单号、支付流水号、业务类型业务ID。当同样的数据第二次插入时数据库会直接报唯一约束冲突应用把这个冲突当成“重复请求”处理就行。我用一个生活化例子每个人身份证号是唯一的你去办证大厅录指纹系统第一次登记张三身份证号成功第二次再来同一个身份证号机器就会提示“该身份证号已登记”。这不是系统判断逻辑有多高级而是数据库层面用唯一索引卡住了。这里有个关键点唯一索引必须选“业务幂等键”不能选自增ID这类每次都不一样的字段。比如支付回调渠道方会给每笔交易一个唯一的流水号我们就该用这个渠道流水号建唯一索引用户注册可以用手机号或邮箱做唯一键。3.2 落地案例支付回调里的流水号去重当时修复支付回调重复消费我在订单表旁边加了一张幂等表专门记录处理过的请求。表结构大概这样CREATE TABLE idempotent_record ( id BIGINT NOT NULL AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT 业务类型payNotify/refundNotify等, biz_id VARCHAR(128) NOT NULL COMMENT 业务唯一ID渠道流水号, request_body TEXT COMMENT 请求原始内容方便排查, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_biz_type_biz_id (biz_type, biz_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;处理回调时在一个事务里先往幂等表插入记录Transactional(rollbackFor Exception.class) public void handlePayNotify(PayNotifyDto dto) { IdempotentRecord record new IdempotentRecord(); record.setBizType(payNotify); record.setBizId(dto.getTransactionId()); record.setRequestBody(JSON.toJSONString(dto)); try { idempotentRecordMapper.insert(record); } catch (DuplicateKeyException e) { // 唯一索引冲突说明已经处理过直接返回成功 return; } // 继续执行真正的业务逻辑更新订单、发积分、发券 OrderInfo order orderService.getByTransactionId(dto.getTransactionId()); // 业务更新... }这里有几个细节值得说。第一insert和业务更新必须在同一个事务里。如果业务更新抛异常回滚了幂等记录也会回滚那么下一个回调重试时就能重新处理这符合“失败重试”的预期。如果把幂等记录放在事务外业务失败记录却留下了后续重试会被永远挡在外面。第二建议用DuplicateKeyException而不是INSERT IGNORE。INSERT IGNORE会把所有类型的错误都吞掉包括字段超长、非空约束等排查问题时会很难受。显式捕获唯一索引冲突语义更明确。第三幂等表的数据会不断增长需要定期清理。我一般是按时间分表或者保留最近3个月的数据。这张表本身就算没有清理也就几十G的规模索引小的话其实问题不大。3.3 唯一索引方案最容易踩的三个坑第一个坑选错了唯一键。有人把“请求时间戳”当成幂等键结果同一毫秒内的两次请求时间戳可能不一样直接导致去重失效。幂等键一定要选业务上能标识“同一次操作”的字段比如订单号、渠道流水号、用户ID订单ID。第二个坑字符集和排序规则导致误判。MySQL默认utf8mb4_general_ci是大小写不敏感的这会让ABC123和abc123被当成同一个唯一键。如果业务上这两个值其实是不同请求唯一索引就会误伤。这种时候需要把字段的排序规则改成utf8mb4_bin按大小写敏感来约束。第三个坑先查询再插入会漏掉并发。很多人习惯先select判断记录是否存在不存在就insert。在并发环境下两个请求都select时记录不存在然后都执行insert其中一个会撞唯一索引。所以最稳妥的方式是直接insert让数据库来做约束而不是让应用层先查一遍。4. 方案三乐观锁与状态机——更新操作幂等的关键在于“条件更新”4.1 version字段这个老办法为什么现在依然好用对于更新型接口比如修改订单状态、修改库存最怕的是两个请求同时拿到的都是旧数据后一个请求把前一个请求的更新覆盖掉。乐观锁是解决这类问题的经典手段。最简单的实现是给表加一个version字段每次更新时携带上一次读取的版本号UPDATE order_info SET status #{targetStatus}, version version 1 WHERE id #{id} AND version #{expectVersion};更新SQL的影响行数如果等于1说明更新成功如果等于0说明版本号已经被别人改了当前请求要么重试要么直接返回失败。第一次请求把version从0改成1第二次请求带着version0再去执行where条件不满足自然更新不到数据。这其实就是幂等同一个旧版本请求重试多次只有第一次能生效。在SpringBoot里用MyBatis-Plus会更省事。先给实体字段加Version注解Data public class OrderInfo { private Long id; private String orderNo; private String status; Version private Integer version; }再配置乐观锁插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }之后orderService.updateById(order)时MyBatis-Plus会自动在UPDATE语句里带上version条件并且把versioin加一。这个方案的好处是代码侵入小缺点是对并发更新频繁的场景不太友好因为一旦更新失败就要通知调用方重试或者重新读数据。4.2 用状态机约束状态流转天然幂等比version更高级一点的思路是用业务状态本身作为更新条件。订单从创建到支付再到退款状态流转是有方向的待支付可以变成已支付已支付可以变成退款中但已支付不能又变回待支付。把“当前状态必须是指定状态”直接写进UPDATE语句UPDATE order_info SET status PAID WHERE order_no #{orderNo} AND status UNPAID;在SpringBoot接口里如果影响行数为0说明当前状态不是UNPAID要么订单已经支付过了要么状态流转不合法两种情况都不应该重复处理。这就天然实现了幂等同一个通知来了三次只有第一次能成功把UNPAID改成PAID后面两次因为where条件不满足影响行数都是0。我还习惯把这个条件和状态机枚举配合起来避免业务代码里写满魔法值public enum OrderStatus { UNPAID, PAID, REFUNDING, REFUNDED; private static final MapOrderStatus, SetOrderStatus ALLOW_TRANSITIONS new EnumMap(OrderStatus.class); static { ALLOW_TRANSITIONS.put(UNPAID, EnumSet.of(PAID)); ALLOW_TRANSITIONS.put(PAID, EnumSet.of(REFUNDING)); ALLOW_TRANSITIONS.put(REFUNDING, EnumSet.of(REFUNDED)); ALLOW_TRANSITIONS.put(REFUNDED, EnumSet.noneOf(OrderStatus.class)); } public boolean canTransitTo(OrderStatus target) { SetOrderStatus allowed ALLOW_TRANSITIONS.get(this); return allowed ! null allowed.contains(target); } }业务代码里先做状态机校验再做条件更新双重保障。状态机让业务规则更清晰条件更新保证并发下不会有两个请求把同一个状态改两次。4.3 乐观锁不能包打天下先想清楚目标数据行乐观锁适合“更新同一行数据”的场景。它要求业务操作必须有一个明确的目标行所以天然的局限性是对“插入型操作”无能为力。新增订单、新增回调流水这种没有原始记录可锁的操作应该回到数据库唯一索引方案。另一个坑是如果一次操作要更新多张表不能只对其中一张表加version。比如扣库存、加积分、改订单状态跨了三张表只锁订单表的version无法保证另外两张表的更新也幂等。这种跨表操作我一般会建议引入一个业务流水表用流水号做唯一索引整体控制事务幂等性。5. 方案四Redis分布式幂等——瞬时锁与业务幂等键两种姿势5.1 两种姿势一个防并发一个防重复Redis做幂等网上方案很多但归纳起来其实就是两种姿势。第一种是分布式锁利用Redis SETNX命令给某个业务ID加锁同一时刻只允许一个请求处理。这个方案主要解决“并发”问题并不完全等价于幂等。比如两个请求同时进来一个成功一个失败失败的那个如果直接重试第二次进来的时候锁已经释放了它仍然会正常执行。也就是说锁只能防止同时执行不能保证失败重试的结果一致。所以要用锁来做幂等还需要配合一个已处理标记。第二种是业务幂等键把业务唯一ID作为Redis的key存储时带上NX不存在才设置参数只有第一次设置成功后续重复请求设置时返回失败。这跟数据库唯一索引的语义几乎一样只是把存储从数据库换成了Redis。它解决的是“这个请求已经处理过了”的问题天然就是幂等。用生活例子区分这两者分布式锁是“这扇门一次只允许一个人进”业务幂等键是“这个身份证号已经登记过了不允许再登记”。5.2 代码示例RedisTemplate Lua脚本保证原子性业务幂等键的落地代码其实很简洁。我封装了一个方法public class IdempotentRedisUtil { private final StringRedisTemplate stringRedisTemplate; public IdempotentRedisUtil(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; } private static final String SETNX_SCRIPT if redis.call(set, KEYS[1], ARGV[1], NX, EX, ARGV[2]) then return 1 else return 0 end; /** * 尝试获取业务幂等键 * param key 唯一键比如 userId 订单号 * param requestId 请求唯一标识 * param expireSeconds 过期时间 */ public boolean tryAcquire(String key, String requestId, long expireSeconds) { Long result stringRedisTemplate.execute( new DefaultRedisScript(SETNX_SCRIPT, Long.class), Collections.singletonList(key), requestId, String.valueOf(expireSeconds) ); return Long.valueOf(1).equals(result); } }在Controller里使用时我通常会再做一个简易注解和AOP切面把“生成幂等key”和“tryAcquire”逻辑统一起来。切面里固定规则生成key比如idem:order:submit:{userId}:{orderNo}。这样业务代码不会散落一堆Redis操作。Redis还有一个好处可以给幂等键设置过期时间。数据库唯一索引需要定期清理Redis直接自动过期非常适合做“一段时间内不重复”的限流式幂等。比如验证码接口同一个手机号一分钟内只能发一次用Redis幂等键加60秒过期天然就实现了。如果还需要在业务完成后主动释放幂等键不能直接delete要校验value是不是当前请求的requestId防止误删别人的锁。释放脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end5.3 高并发下必须注意的四个细节第一过期时间要大于业务最大执行时间。如果业务要跑5秒你给Redis幂等键设了3秒过期业务还没结束key已经没了第二个重复请求就能进来。这种时候宁可设长一点比如30秒或者60秒。如果操作特别耗时还要考虑主动续期否则后续重试还是可能漏过去。第二key的粒度不能太粗也不能太细。太粗会把不同用户的不同请求都拦下来比如单纯用接口路径做key那么所有用户请求都会被当成同一个太细又挡不住真正的重复动作因为两次请求生成的key不一样。我通常以“用户ID 业务ID 操作类型”来组合key比如payNotify:txn_12345、orderSubmit:userId:orderNo。第三Redis不是强一致性数据库极端情况下可能丢数据。如果业务数据非常重要我会把Redis当第一道拦截器数据库唯一索引当最终兜底。Redis先挡住绝大多数重复请求万一Redis重启或者key丢了还有数据库的唯一索引接住。这也是我处理支付回调时的双保险思路。第四要注意Redis不可用时的降级策略。如果Redis连接异常tryAcquire会抛出异常这时候你不能因为Redis挂了就把所有请求直接放行。我建议捕获到Redis异常后记录一条告警日志然后走数据库唯一索引或者本地限流兜底。这是在“高可用”和“数据一致”之间做取舍具体选哪种要看你业务对重复操作的容忍度。6. 四种方案怎么选我的选择标准和最终推荐6.1 一张表对比四种方案做个表格方便大家直接抄作业方案核心思想最佳适用场景优点缺点落地成本Token预生成先取票再检票检完销毁表单提交、下单、按钮防连点能挡住同一用户的重复点击体验好依赖前端配合不适合服务端接口互调中数据库唯一索引数据库约束保证同一条记录只能插入一次支付回调、消息消费、流水记录可靠性最高持久化存储需要建表、索引会留脏数据要清理中乐观锁/状态机更新时带上期望条件影响行数为0即重复订单状态流转、库存扣减性能高不用引入额外组件只适合更新已有数据不适合插入型操作低Redis业务幂等键SETNX 过期时间快速去重高并发秒杀、验证码、限时活动性能最好有自然过期能力依赖Redis可用性极端情况需要兜底低我的排序逻辑是简单接口用乐观锁涉及回调、消息用数据库唯一索引兜底需要快速拦截的大量重复请求先用Redis顶在前头。没有哪一个是银弹大多数项目都是两三个方案叠加使用。6.2 实际项目中的组合拳混合使用才是常态以一个电商下单接口为例我当前项目里就是混合使用前端调用下单页先拿到Token等到真正提交订单时Token拦截器负责挡住用户连点。后端接收下单请求后先生成订单号同时在订单表里用“用户ID订单号”建唯一索引作为数据库层的幂等兜底。扣减库存用乐观锁update stock set count count - #{num} where sku_id #{skuId} and count #{num}这个SQL本身就防止了超卖和重复扣减。如果这笔订单要异步通知OMS系统发给MQ前会用一个Redis幂等键标记“该订单已经发过通知”过期时间设置24小时防止消息重复投递。这样层层设防之后不管重复请求来自用户连点、网关重试、MQ重复消费还是第三方回调都能被某一层挡住。这也是我后来想明白的道理幂等设计不是写完一个方案就结束了而是要从请求入口到数据落地的每一层都考虑一遍。6.3 最后分享一个排查线上重复请求的调试办法如果你怀疑线上某个接口被重复调用了但不确定是哪一层漏了幂等我建议先做一件事在接口入口加一个切面日志把请求参数 请求时间 traceId全部打出来同时用工具模拟多次相同请求。我在本地测试时经常用这个脚本for i in $(seq 1 5); do curl -X POST http://localhost:8080/api/order/submit \ -H Content-Type: application/json \ -H idempotent-token: test-token-001 \ -d {orderNo:20250601001,userId:1001} done观察日志里这5次请求的traceId和参数再对比数据库里的业务数据就能快速判断是哪一层漏了。线上的话同一个业务ID的日志多搜几遍配合幂等表的请求记录基本能还原整个重复调用的链路。我个人的体会是四种方案里最让我服气的不是某个具体技术而是“想清楚你的幂等键是什么”这个思路。无论是Redis、数据库唯一索引还是Version版本号本质上都是围绕同一个唯一标识做约束。只要把“哪次操作算同一次操作”这个问题想明白很多方案即使没接触过也能顺手推导出来。
返回列表