IDE 激活

SpringBoot接口幂等性方案:4种策略+代码实战,告别重复提交

终码一生 · 2026-10-29

点击“终码一生”,关注,置顶公众号 每日技术干货,第一时间送达! 网络抖动、用户手抖、MQ 重试……任何一次“重复请求”都可能让钱扣钱、商品多发。 今天一次讲透 4 种常用幂等性实现,附可 copy 的代码 。 01 Token 令牌 —— 最经典、最稳 思路: 先拿令牌 → 再执行业务 → 用完即焚。 核心关键字: 预生成、一次性、Redis 原子删除。 精简代码(含注解) @RestController @RequestMapping("/order") publicclassOrderController {     @Autowired     private StringRedisTemplate redis;     // ① 预生成 Token,给前端     @GetMapping("/token")     public String getToken(){         Stringtoken= UUID.randomUUID().toString();         // 10 分钟有效期,足够前端完成下单         redis.opsForValue().set("tk:" + token, "1", Duration.ofMinutes(10));         return token;     }     // ② 下单接口,Header 中带令牌     @PostMapping     public Result create(@RequestHeader("Idempotent-Token") String token,                          @RequestBody OrderReq req) {         Stringkey="tk:" + token;         // 原子删除:成功返回 true 表示第一次使用         Booleanfirst= redis.delete(key);         if (Boolean.FALSE.equals(first)) {             return Result.fail("**请勿重复下单**");         }         // 真正创建订单         Orderorder= orderService.create(req);         return Result.ok(order);     } } 注解: UUID 保证全局唯一,Redis TTL 防呆。 delete 是原子操作,天然并发安全。 用 Header 传递令牌,保持接口语义纯净。 02 数据库唯一索引 —— 低成本、强一致 思路: 把“业务唯一键”做成唯一索引,重复写直接抛异常。 核心关键字: 天然幂等、异常即幂等。 代码示例 @Entity @Table(name = "t_payment",        uniqueConstraints = @UniqueConstraint(columnNames = "transaction_id")) publicclassPayment {     @Id     private Long id;     // 支付平台返回的流水号     @Column(name = "transaction_id")     private String txId;     private BigDecimal amount;     private String status; } @Service publicclassPayService {     @Autowired     private PaymentRepo repo;     public Result pay(PayReq req){         try {             Paymentp=newPayment();             p.setTxId(req.getTxId());             p.setAmount(req.getAmount());             p.setStatus("SUCCESS");             repo.save(p);   // 重复就抛 DataIntegrityViolationException             return Result.ok("**支付成功**");         } catch (DataIntegrityViolationException e) {             // 异常即查询结果,避免重复扣款             Paymentexist= repo.findByTxId(req.getTxId());             return Result.ok("**已支付**", exist.getId());         }     } } 注解: 唯一索引兜底,数据库层面 100 % 防重。 try-catch 把异常转成正常响应,用户体验丝滑。 无外部依赖,适配老旧系统也毫无压力。 03 分布式锁 —— 高并发大杀器 思路: 对“订单号 / 用户ID”加分布式锁,抢到锁再干活。 核心关键字: 互斥、超时、可重入。 Redisson 简洁版 @Service publicclassStockService {     @Autowired     private RedissonClient redisson;     public Result deduct(DeductCmd cmd){         StringlockKey="lock:stock:" + cmd.getProductId();         RLocklock= redisson.getLock(lockKey);         try {             // 最多等 3 秒,持锁 5 秒自动释放             if (!lock.tryLock(3, 5, TimeUnit.SECONDS)) {                 return Result.fail("**处理中,请稍后**");             }             // 业务幂等检查:根据请求 ID 查记录             if (repo.existsByRequestId(cmd.getRequestId())) {                 return Result.ok("**已扣减**");             }             // 真正扣库存             repo.deductStock(cmd);             return Result.ok("**扣减成功**");         } catch (InterruptedException e) {             Thread.currentThread().interrupt();             return Result.fail("**系统繁忙**");         } finally {             if (lock.isHeldByCurrentThread()) {                 lock.unlock();             }         }     } } 注解: Redisson 自带 watchdog,自动续期,不怕死锁。 requestId 做幂等表,锁+唯一索引 双保险。 lock.isHeldByCurrent线程 防止误删别人的锁。 04 请求内容摘要 —— 最透明、最通用 思路: 把请求体做 MD5/SHA256,作为幂等键。 核心关键字: 零额外交互、客户端无感。 自定义注解 + AOP @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public@interface Idempotent {     intexpire()default3600;   // 秒 } @Aspect @Component publicclassIdempotentAspect {     @Autowired     private StringRedisTemplate redis;     @Around("@annotation(idem)")     public Object around(ProceedingJoinPoint pjp, Idempotent idem)throws Throwable {         HttpServletRequestreq= ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();         // ① 计算请求体摘要         Stringbody= IOUtils.toString(req.getReader());         Stringdigest= DigestUtils.md5DigestAsHex(body.getBytes(StandardCharsets.UTF_8));         Stringkey="idem:digest:" + digest;         // ② 第一次:setIfAbsent 返回 true         Booleanabsent= redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(idem.expire()));         if (Boolean.FALSE.equals(absent)) {             return Result.fail("**重复请求**");         }         try {             return pjp.proceed();         } catch (Exception e) {             redis.delete(key);  // 异常时释放,允许重试             throw e;         }     } } // 使用 @RestController publicclassTransferApi {     @PostMapping("/transfer")     @Idempotent(expire = 7200)     public Result transfer(@RequestBody TransferCmd cmd){         return Result.ok(transferSvc.doTransfer(cmd));     } } 注解: MD5 把任意长度报文压缩成 32 位,冲突概率极低。 setIfAbsent 保证原子性,异常回删避免误杀。 注解 + AOP 零侵入,老接口 1 行代码即可拥有幂等。 05 小结 方案 延迟 复杂度 外部依赖 适用场景 Token 中 低 Redis 有预生成环节:下单、支付 唯一索引 低 低 无 支付、注册 分布式锁 中 中 Redis/ZK 高并发抢券、秒杀 内容摘要 低 中 Redis 无预生成:转账、回调 06 实施清单 检查你的核心接口有没有唯一业务键。 优先使用数据库唯一索引,成本最低。 并发量高再上分布式锁或Token,不要过度设计。 给关键接口加上监控告警,幂等失败时第一时间知道。 来源:互联网 —END— PS:防止找不到本篇文章,可以收藏点赞,方便翻阅查找哦。 往期推荐 比 VMware 更快,更轻量的虚拟机! LatchUtils:简化 Java 异步任务同步的利器 王者荣耀为什么不使用微服务架构? 我司为什么要用 Google 的Java规范? 开源项目|一个神级Skill:故事丢进去,AI自动生成上美影风短剧 还用WebSocket?这才是实时消息推送的正确姿势!轻量好用

查看完整内容

更多文章