【架构实战】幂等设计接口重复调用的防重方案一、背景用户支付一次扣了两次款2021年春节前夕用户投诉爆发“我只按了一次支付为什么扣了两次款”排查后发现用户在前端点击支付按钮请求超时了网络抖动前端自动重试了一次。第一笔请求实际已经到达服务端并完成了扣款但响应在网络层丢失了。第二笔重试请求又执行了一次扣款。重复扣款的原因链路用户 → 点击支付 → 请求到达服务端 → 扣款成功 → 响应丢失(网络超时) ↓ 前端重试 → 服务端再次收到 → 又扣了一次这不是孤例。排查发现过去一个月有237笔重复扣款涉及金额12.7万元。重复请求的来源比你想象的更多前端重复点击用户手快/按钮没防重网络超时重试Nginx/网关/Feign/OkHttp都有重试机制消息队列重投消费失败重新投递定时任务重复执行人工运维操作补数据脚本跑了两遍在任何分布式系统中你无法保证一条请求只到达一次。你只能保证收到多次时结果是相同的。二、幂等的理论基础2.1 什么是幂等数学定义f(f(x)) f(x) — 函数执行一次和执行多次结果相同。工程定义同一个操作执行一次和执行多次对系统产生的副作用数据变化是一样的。✅ 幂等的操作 - 查询订单: SELECT * FROM orders WHERE id 123 - 删除订单: DELETE FROM orders WHERE id 123 (第二次执行不影响) - 设置状态: UPDATE orders SET status CANCELLED WHERE id 123 ❌ 非幂等的操作 - 扣减余额: UPDATE account SET balance balance - 100 (每次都扣) - 新增订单: INSERT INTO orders ... (产生重复数据) - 增加积分: UPDATE user SET points points 10 (每次都加)2.2 幂等的关键要素幂等三要素幂等键唯一标识一次操作的ID如订单号、支付流水号状态记录记录该操作是否已执行原子性保障检查状态和执行操作必须在同一个原子操作中三、幂等实现方案3.1 方案一数据库唯一约束最推荐原理利用数据库唯一索引天然保证幂等。-- 支付流水表pay_no是幂等键CREATETABLEpayment_record(idBIGINTPRIMARYKEYAUTO_INCREMENT,pay_noVARCHAR(64)NOTNULLCOMMENT支付流水号幂等键,order_idVARCHAR(64)NOTNULL,user_idBIGINTNOTNULL,amountDECIMAL(12,2)NOTNULL,statusVARCHAR(20)NOTNULLDEFAULTSUCCESS,created_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP,UNIQUEKEYuk_pay_no(pay_no)-- 核心唯一约束保证幂等);-- 插入时依靠唯一约束防重INSERTINTOpayment_record(pay_no,order_id,user_id,amount)VALUES(PAY20240101001,ORD001,1001,99.00);-- 第二次插入相同pay_no会抛DuplicateKeyExceptionServicepublicclassPaymentService{AutowiredprivatePaymentRecordMapperpaymentRecordMapper;Transactionalpublicvoidpay(StringpayNo,StringorderId,BigDecimalamount){try{// 尝试插入支付记录PaymentRecordrecordnewPaymentRecord();record.setPayNo(payNo);record.setOrderId(orderId);record.setAmount(amount);record.setStatus(SUCCESS);paymentRecordMapper.insert(record);// 执行扣款accountService.deduct(orderId,amount);}catch(DuplicateKeyExceptione){// 幂等已处理过查询已有结果返回log.info(重复支付请求: payNo{},payNo);PaymentRecordexistingpaymentRecordMapper.findByPayNo(payNo);if(!SUCCESS.equals(existing.getStatus())){thrownewBizException(支付处理中或失败请查询支付结果);}return;}}}优点实现简单数据库天然支持不会有并发问题。缺点适用于新建记录的幂等场景不适用于更新的幂等。3.2 方案二状态机适合有状态流转的业务原理利用数据库状态字段和条件更新保证状态只能正向流转一次。-- 订单状态CREATED → PAID → SHIPPED → COMPLETED-- 支付幂等只有CREATED状态的订单才能变成PAIDUPDATEordersSETstatusPAID,pay_timeNOW()WHEREorder_idORD001ANDstatusCREATED;-- 条件更新第二次执行影响行数0-- 通过影响行数判断是否重复Transactionalpublicvoidpay(StringorderId){// 状态机方式只有满足前置状态才能执行introwsorderMapper.updateStatus(orderId,CREATED,PAID);if(rows0){// 幂等可能已经支付过或者是其他状态OrderorderorderMapper.findById(orderId);if(PAID.equals(order.getStatus())){log.info(订单已支付幂等返回: orderId{},orderId);return;}thrownewBizException(订单状态异常无法支付);}// 执行扣款accountService.deduct(orderId,order.getAmount());}优点不需要额外的幂等表利用业务状态字段即可。缺点只适用于有明确状态流转的业务。3.3 方案三Token机制适合前端重复提交原理服务端生成唯一Token给前端前端提交时带上Token服务端验证后删除Token。RestControllerpublicclassOrderController{AutowiredprivateStringRedisTemplateredisTemplate;// 获取提交TokenGetMapping(/api/order/submit-token)publicResultStringgetToken(RequestParamStringuserId){StringtokenUUID.randomUUID().toString();// Token 5分钟过期redisTemplate.opsForValue().set(submit_token:token,userId,5,TimeUnit.MINUTES);returnResult.success(token);}// 提交订单PostMapping(/api/order/submit)publicResultOrdersubmit(RequestBodySubmitRequestrequest,RequestHeader(X-Submit-Token)Stringtoken){// 1. 验证Token原子操作BooleandeletedredisTemplate.delete(submit_token:token);if(Boolean.FALSE.equals(deleted)){returnResult.error(429,请勿重复提交);}// 2. 执行下单try{OrderorderorderService.createOrder(request);returnResult.success(order);}catch(Exceptione){// Token已删除如果失败需要重新获取Tokenthrowe;}}}优点简单直观前端可以配合按钮置灰。缺点多服务时Token需要共享存储有Redis单点风险。3.4 方案四分布式锁重量级方案ServicepublicclassInventoryService{AutowiredprivateRedissonClientredissonClient;publicvoiddeduct(StringorderId,StringskuId,intquantity){StringlockKeydeduct_lock:orderId;// 订单号作为锁KeyRLocklockredissonClient.getLock(lockKey);try{// 尝试加锁等待5秒锁30秒自动释放if(!lock.tryLock(5,30,TimeUnit.SECONDS)){thrownewBizException(系统繁忙请稍后重试);}// 幂等检查if(deductRecordMapper.existsByOrderId(orderId)){log.info(已扣减过库存: orderId{},orderId);return;}// 扣减库存inventoryMapper.deduct(skuId,quantity);// 记录扣减防重DeductRecordrecordnewDeductRecord();record.setOrderId(orderId);deductRecordMapper.insert(record);}catch(InterruptedExceptione){Thread.currentThread().interrupt();thrownewBizException(获取锁失败);}finally{if(lock.isHeldByCurrentThread()){lock.unlock();}}}}适用场景并发要求极高、需要保证绝对幂等的场景。四、各方案对比方案实现复杂度性能适用场景局限性数据库唯一约束低高新创建记录只适合插入场景状态机低高有状态流转需要设计状态字段Token机制中高前端重复提交多服务需要共享存储分布式锁高中高并发写性能开销大版本号乐观锁中高更新操作需配合重试五、幂等设计最佳实践5.1 幂等键设计规范幂等键命名规范 ✅ 好的幂等键 - 支付: pay_{payNo} - 订单: order_{orderId}_create - 退款: refund_{refundId} - 消息消费: msg_{msgId}_{consumer} ❌ 不合适的幂等键 - 用时间戳并发时可能重复 - 用用户ID同一用户多个操作会冲突 - 用随机数每次不同无法幂等5.2 幂等校验的两步走通用的幂等处理模式 第一步快速幂等检查读缓存 if (幂等键已存在于Redis) { return 已处理结果; } 第二步原子性执行写DB 1. 获取分布式锁(幂等键) 2. 再次检查幂等双检 3. 执行业务逻辑 4. 写入幂等记录 5. 释放锁5.3 幂等结果的缓存// 幂等结果缓存重复请求直接返回上次的结果TransactionalpublicPayResultpay(PayRequestrequest){StringpayNorequest.getPayNo();// 1. 查缓存快速幂等检查StringcachedResultredisTemplate.opsForValue().get(pay_result:payNo);if(cachedResult!null){returnJSON.parseObject(cachedResult,PayResult.class);}// 2. 执行业务try{PayResultresultdoPay(request);// 3. 缓存结果1小时redisTemplate.opsForValue().set(pay_result:payNo,JSON.toJSONString(result),1,TimeUnit.HOURS);returnresult;}catch(DuplicateKeyExceptione){// 并发冲突时等待另一个线程完成for(inti0;i10;i){Thread.sleep(100);StringresultredisTemplate.opsForValue().get(pay_result:payNo);if(result!null){returnJSON.parseObject(result,PayResult.class);}}thrownewBizException(支付处理中请稍后查询);}}六、常见坑点坑1幂等检查在事务外// ❌ 错误幂等检查在事务外publicvoidpay(StringpayNo){if(recordMapper.existsByPayNo(payNo)){// 事务外检查return;}Transactional// 事务内执行publicvoiddoPay(){recordMapper.insert(record);// 并发时可能重复accountMapper.deduct();}}// ✅ 正确幂等检查和执行在同一个事务中Transactionalpublicvoidpay(StringpayNo){if(recordMapper.existsByPayNo(payNo)){return;}recordMapper.insert(record);// 唯一约束做最后兜底accountMapper.deduct();}坑2幂等键设计过于宽泛用userId 操作类型做幂等键 → 用户短时间内只能操作一次 → 用户投诉。应该用具体操作ID作为幂等键如订单号、支付流水号。坑3幂等后返回结果不一致第一次调用返回success第二次幂等返回重复请求——前端根据不同返回值处理混乱。正确做法幂等后返回和第一次同样的结果code、message、data都要一样。七、总结幂等设计的三条黄金法则每个写操作必须有幂等键从业务上定义什么算同一次操作检查与执行必须原子化不能检查完再执行必须是一个事务唯一约束是最后防线即使业务逻辑的幂等检查失败数据库唯一约束也能保证数据不错优先级推荐新创建记录 → 数据库唯一约束最可靠状态变更 → 状态机 条件更新最简洁前端防重 → Token机制最直观高并发写 → 分布式锁最重量幂等不是性能优化是数据正确性的底线。面对这笔钱是不是多扣了的质疑时你能拍胸脯说绝不可能因为你的每行代码都有幂等保护。个人观点仅供参考