【Springboot】项目幂等性处理

📅 2026/7/8 11:38:06 👁️ 阅读次数 📝 编程学习
【Springboot】项目幂等性处理

【Springboot】项目幂等性处理

  • 【一】介绍
  • 【二】方案选型
  • 【三】方案详情
    • 【1】方案一:Token 令牌幂等(HTTP 接口防重复提交)
    • 【2】方案二:Redis 幂等(高并发通用临时幂等)
    • 【3】方案三:数据库独立幂等表
      • (1)幂等核心服务
      • (2)业务层使用案例:支付回调接口
      • (3)MQ 消费场景改造
      • (4)方案优势
    • 【4】方案四:数据库唯一索引兜底
  • 【四】总结

【一】介绍

(1)幂等定义
同一请求重复执行多次,业务结果和执行一次完全一致,不会产生重复数据、重复扣款、重复下单、重复消息消费等脏数据。

(2)必须做幂等的场景
1-前端重复提交表单(重复点击提交按钮)
2-接口超时重试(Feign、RestTemplate、网关重试)
3-MQ 消息重复投递(RabbitMQ/RocketMQ/Kafka 必现,中间件不保证只投递一次)
4-分布式事务、补偿机制回调重复执行
5-网络抖动,客户端没收到响应,自动补发请求

(3)幂等核心思路
唯一标识 + 执行状态存储
每次业务操作分配全局唯一幂等 ID,执行前判断该 ID 是否已处理:
1-已处理:直接返回成功,不执行业务
2-未处理:执行业务,执行完成标记为已处理

【二】方案选型


组合方案:
(1)HTTP 接口:前端 Token + Redis 临时防重 + 数据库唯一索引三层兜底
(2)MQ 消费 / 分布式回调:独立幂等表(MySQL)+ Redis 预判断 + 唯一索引兜底

【三】方案详情

【1】方案一:Token 令牌幂等(HTTP 接口防重复提交)

(1)适用场景
管理后台表单、前端提交接口,防止用户连续点多次提交按钮。
(2)设计流程
1-进入表单页面,前端调用 /getToken 接口,后端生成唯一幂等 Token 存入 Redis(过期 5 分钟);
2-前端提交表单时 Header / 参数携带 idempotent-token;
3-AOP 拦截所有需要幂等的接口:
先删除 Redis 中该 Token,删除成功 = 第一次请求,放行执行业务;
删除失败 = Token 不存在,判定重复提交,直接返回提示;
4-执行异常:补偿恢复 Token,允许用户重新提交。

【2】方案二:Redis 幂等(高并发通用临时幂等)

(1)设计思路
以全局唯一业务流水号作为幂等 Key(订单号、支付流水号、mq 消息唯一 msgId)
1-请求携带唯一幂等号 idempotentNo;
2-执行前使用 SETNX 原子命令抢占标识;
3-抢占成功:执行业务,执行完成更新 key 标记成功,设置过期时间自动清理;
4-抢占失败:重复请求,直接返回。

redis判重工具类

@ComponentpublicclassRedisIdempotentUtil{@AutowiredprivateStringRedisTemplateredisTemplate;privatestaticfinalStringPREFIX="idemp:biz:";/** * 尝试获取幂等执行权 * @param idempNo 唯一业务编号 * @param expireSecond 过期时间 * @return true=首次执行 false=重复 */publicbooleantryLock(StringidempNo,longexpireSecond){Stringkey=PREFIX+idempNo;// SET key value EX expire NX 原子操作returnBoolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key,"processing",expireSecond,TimeUnit.SECONDS));}// 标记业务执行成功publicvoidmarkSuccess(StringidempNo){Stringkey=PREFIX+idempNo;redisTemplate.opsForValue().set(key,"success",24,TimeUnit.HOURS);}// 判断是否已执行成功publicbooleanisProcessed(StringidempNo){Stringval=redisTemplate.opsForValue().get(PREFIX+idempNo);return"success".equals(val);}}

MQ 消费使用案例(RocketMQ)

@Component@RocketMQMessageListener(topic="pay_topic",consumerGroup="pay_consumer_group")publicclassPayConsumerimplementsRocketMQListener<PayMsg>{@AutowiredprivateRedisIdempotentUtilredisIdempotentUtil;@AutowiredprivatePayServicepayService;@OverridepublicvoidonMessage(PayMsgmsg){// msgId为MQ全局唯一标识,作为幂等号StringidempNo=msg.getMsgId();// 1. 判断是否已处理完成if(redisIdempotentUtil.isProcessed(idempNo)){log.info("重复消息,直接跳过:{}",idempNo);return;}// 2. 抢占执行权,防止并发重复消费if(!redisIdempotentUtil.tryLock(idempNo,3600)){log.info("消息正在处理中,忽略重复:{}",idempNo);return;}try{// 3. 执行业务:更新订单支付状态payService.handlePaySuccess(msg);// 4. 标记处理完成redisIdempotentUtil.markSuccess(idempNo);}catch(Exceptione){log.error("消费失败:{}",idempNo,e);// 抛出异常,MQ自动重试throwe;}}}

Redis 宕机、数据丢失会导致幂等失效;重要支付、订单业务不能单独依赖 Redis,必须搭配数据库幂等表兜底。

【3】方案三:数据库独立幂等表

(1)适用场景
支付、订单、财务流水、MQ 消费、分布式回调等高一致性要求业务,可完整记录处理状态,支持故障恢复、对账排查。

(2)表结构

CREATETABLE`t_idempotent_record`(`id`bigintNOTNULLAUTO_INCREMENTCOMMENT'主键',`idempotent_no`varchar(64)NOTNULLCOMMENT'全局唯一幂等号(订单号/msgId/支付流水号)',`biz_type`varchar(32)NOTNULLCOMMENT'业务类型:ORDER_CREATE/PAY_NOTIFY/MQ_CONSUME',`status`tinyintNOTNULLCOMMENT'状态 0处理中 1处理成功 2处理失败',`request_data`textCOMMENT'原始请求数据',`response_data`textCOMMENT'业务返回数据',`create_time`datetimeNOTNULLDEFAULTCURRENT_TIMESTAMP,`update_time`datetimeNOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,UNIQUEKEYuk_idemp_no_biz(idempotent_no,biz_type)-- 联合唯一索引,强防重兜底)ENGINE=InnoDBDEFAULTCHARSET=utf8mb4COMMENT'全局幂等记录表';

核心:联合唯一索引,数据库层面杜绝重复插入,兜底所有代码漏洞。

(3)幂等状态流转
处理中 (0):开启事务,先插入幂等记录;
处理成功 (1):业务执行完毕更新状态;
处理失败 (2):业务异常更新失败状态,允许后续重试。

(1)幂等核心服务

@ServicepublicclassIdempotentService{@AutowiredprivateIdempotentMapperidempotentMapper;@AutowiredprivateObjectMapperobjectMapper;/** * 幂等前置校验+占位 * @param idempNo 唯一幂等号 * @param bizType 业务类型 * @param reqBody 请求报文 * @return null=首次执行;有记录=已处理,直接返回历史结果 */@Transactional(rollbackFor=Exception.class)publicIdempotentRecordcheckAndLock(StringidempNo,StringbizType,ObjectreqBody){// 1. 查询是否已有记录IdempotentRecordrecord=idempotentMapper.selectByNo(idempNo,bizType);if(record!=null){// 已成功处理,直接返回历史数据if(record.getStatus()==1){returnrecord;}// 处理中/失败:抛出异常,拒绝重复执行thrownewRuntimeException("业务正在处理或执行失败,请勿重复请求");}// 2. 插入处理中占位记录,唯一索引保证不会重复插入IdempotentRecordnewRecord=newIdempotentRecord();newRecord.setIdempotentNo(idempNo);newRecord.setBizType(bizType);newRecord.setStatus(0);try{newRecord.setRequestData(objectMapper.writeValueAsString(reqBody));}catch(JsonProcessingExceptione){newRecord.setRequestData(reqBody.toString());}try{idempotentMapper.insert(newRecord);}catch(DuplicateKeyExceptione){// 并发场景下另一线程抢先插入,直接抛出重复thrownewRuntimeException("重复请求,请勿频繁操作");}// 无历史记录,允许执行业务returnnull;}// 业务执行成功,更新状态@Transactional(rollbackFor=Exception.class)publicvoidmarkSuccess(StringidempNo,StringbizType,ObjectrespData){Stringresp=null;try{resp=objectMapper.writeValueAsString(respData);}catch(JsonProcessingExceptione){resp=respData.toString();}idempotentMapper.updateStatus(idempNo,bizType,1,resp);}// 业务执行失败,更新失败状态@Transactional(rollbackFor=Exception.class)publicvoidmarkFail(StringidempNo,StringbizType){idempotentMapper.updateStatus(idempNo,bizType,2,null);}}

(2)业务层使用案例:支付回调接口

@RestController@RequestMapping("/pay")publicclassPayNotifyController{@AutowiredprivateIdempotentServiceidempotentService;@AutowiredprivateOrderServiceorderService;@PostMapping("/notify")publicResultpayNotify(@RequestBodyPayNotifyDTOdto){// 幂等唯一号:支付平台流水号StringidempNo=dto.getOutTradeNo();StringbizType="PAY_NOTIFY";try{// 1. 幂等校验IdempotentRecordrecord=idempotentService.checkAndLock(idempNo,bizType,dto);if(record!=null){// 已处理成功,直接返回上次结果returnResult.success(record.getResponseData());}// 2. 真正支付业务逻辑(更新订单、发积分、记账)OrderRespresp=orderService.paySuccessHandle(dto);// 3. 标记幂等记录成功idempotentService.markSuccess(idempNo,bizType,resp);returnResult.success(resp);}catch(Exceptione){// 4. 异常标记失败idempotentService.markFail(idempNo,bizType);log.error("支付回调处理失败",e);returnResult.fail(e.getMessage());}}}

(3)MQ 消费场景改造

@OverridepublicvoidonMessage(PayMsgmsg){StringidempNo=msg.getMsgId();StringbizType="MQ_PAY_CONSUME";IdempotentRecordrecord=idempotentService.checkAndLock(idempNo,bizType,msg);if(record!=null){log.info("重复消息直接跳过");return;}try{// 业务处理payService.handle(msg);idempotentService.markSuccess(idempNo,bizType,"处理完成");}catch(Exceptione){idempotentService.markFail(idempNo,bizType);throwe;}}

(4)方案优势

(1)持久化存储,宕机不丢失幂等状态,支持对账、问题排查;
(2)唯一索引兜底,代码逻辑再乱也不会产生重复数据;
(3)区分处理中 / 成功 / 失败,支持失败后重试控制;
(4)HTTP 接口、MQ、回调、分布式任务全场景通用;
(5)可统一做监控:统计失败消息、重复请求数量。

【4】方案四:数据库唯一索引兜底

无需额外幂等表,业务表直接加唯一索引,作为最后一道防线。

(1)示例:订单表

CREATETABLEt_order(idbigintauto_incrementprimarykey,order_novarchar(32)notnullcomment'订单唯一号',user_idbigint,amountdecimal(10,2),create_timedatetime,UNIQUEKEYuk_order_no(order_no));

插入重复 order_no 会直接抛出DuplicateKeyException,捕获后直接返回成功,实现天然幂等。

(2)业务代码示例

@TransactionalpublicvoidcreateOrder(StringorderNo,LonguserId){Orderorder=newOrder();order.setOrderNo(orderNo);order.setUserId(userId);try{orderMapper.insert(order);}catch(DuplicateKeyExceptione){// 唯一索引冲突,代表已创建,直接返回,幂等生效log.warn("订单{}已存在,重复创建拦截",orderNo);}}

(3)局限
仅能拦截新增操作,无法记录执行状态;更新、复杂多步骤业务不适用,只能作为兜底,不能单独作为幂等方案。

【四】总结

(1)前端页面重复点击,或者网络抖动,导致执行了两遍插入操作,表中出现重复数据;或者mq消息重复发送;
1-可以通过代码验证,例如有些数据要求名称不能重复,则插入前对名称进行查询验证,已存在的不做插入
2-通过redis封装限流的注解,使用滑动窗口算法,例如要求创建接口2秒内同一用户不允许重复调用
3-以全局唯一业务流水号作为幂等 Key(订单号、支付流水号、mq 消息唯一 msgId)
相当于分布式锁
执行前使用 SETNX 原子命令抢占标识
抢占成功:执行业务,执行完成更新 key 标记成功,设置过期时间自动清理;
抢占失败:重复请求,直接返回
4-独立幂等记录表 + 表唯一索引兜底

(2)注意事项
1-唯一索引仅单表生效,跨分片会失效,必须依赖全局幂等表;
2-幂等号使用全局唯一 ID(雪花算法、第三方流水号),不能用自增 ID;
3-分布式锁(Redisson)配合幂等表,防止跨分片并发重复。
4-只用 Redis 不落地数据库:Redis 宕机丢失数据,产生重复业务;
5-幂等号设计重复:只按订单号,未区分业务类型,不同业务互相拦截;
6-无事务:插入幂等记录和业务操作分开,中间宕机导致数据不一致;
7-未捕获唯一索引冲突异常,直接抛出 500 错误;
8-MQ 消费只靠 Redis,未持久化,消息堆积重试造成重复;
9-软删除场景唯一索引未包含 is_deleted,删除后无法新建相同业务号。