
【Springboot】项目幂等性处理【一】介绍【二】方案选型【三】方案详情【1】方案一Token 令牌幂等HTTP 接口防重复提交【2】方案二Redis 幂等高并发通用临时幂等【3】方案三数据库独立幂等表1幂等核心服务2业务层使用案例支付回调接口3MQ 消费场景改造4方案优势【4】方案四数据库唯一索引兜底【四】总结【一】介绍1幂等定义同一请求重复执行多次业务结果和执行一次完全一致不会产生重复数据、重复扣款、重复下单、重复消息消费等脏数据。2必须做幂等的场景1-前端重复提交表单重复点击提交按钮2-接口超时重试Feign、RestTemplate、网关重试3-MQ 消息重复投递RabbitMQ/RocketMQ/Kafka 必现中间件不保证只投递一次4-分布式事务、补偿机制回调重复执行5-网络抖动客户端没收到响应自动补发请求3幂等核心思路唯一标识 执行状态存储每次业务操作分配全局唯一幂等 ID执行前判断该 ID 是否已处理1-已处理直接返回成功不执行业务2-未处理执行业务执行完成标记为已处理【二】方案选型组合方案1HTTP 接口前端 Token Redis 临时防重 数据库唯一索引三层兜底2MQ 消费 / 分布式回调独立幂等表MySQL Redis 预判断 唯一索引兜底【三】方案详情【1】方案一Token 令牌幂等HTTP 接口防重复提交1适用场景管理后台表单、前端提交接口防止用户连续点多次提交按钮。2设计流程1-进入表单页面前端调用 /getToken 接口后端生成唯一幂等 Token 存入 Redis过期 5 分钟2-前端提交表单时 Header / 参数携带 idempotent-token3-AOP 拦截所有需要幂等的接口先删除 Redis 中该 Token删除成功 第一次请求放行执行业务删除失败 Token 不存在判定重复提交直接返回提示4-执行异常补偿恢复 Token允许用户重新提交。【2】方案二Redis 幂等高并发通用临时幂等1设计思路以全局唯一业务流水号作为幂等 Key订单号、支付流水号、mq 消息唯一 msgId1-请求携带唯一幂等号 idempotentNo2-执行前使用 SETNX 原子命令抢占标识3-抢占成功执行业务执行完成更新 key 标记成功设置过期时间自动清理4-抢占失败重复请求直接返回。redis判重工具类ComponentpublicclassRedisIdempotentUtil{AutowiredprivateStringRedisTemplateredisTemplate;privatestaticfinalStringPREFIXidemp:biz:;/** * 尝试获取幂等执行权 * param idempNo 唯一业务编号 * param expireSecond 过期时间 * return true首次执行 false重复 */publicbooleantryLock(StringidempNo,longexpireSecond){StringkeyPREFIXidempNo;// SET key value EX expire NX 原子操作returnBoolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key,processing,expireSecond,TimeUnit.SECONDS));}// 标记业务执行成功publicvoidmarkSuccess(StringidempNo){StringkeyPREFIXidempNo;redisTemplate.opsForValue().set(key,success,24,TimeUnit.HOURS);}// 判断是否已执行成功publicbooleanisProcessed(StringidempNo){StringvalredisTemplate.opsForValue().get(PREFIXidempNo);returnsuccess.equals(val);}}MQ 消费使用案例RocketMQComponentRocketMQMessageListener(topicpay_topic,consumerGrouppay_consumer_group)publicclassPayConsumerimplementsRocketMQListenerPayMsg{AutowiredprivateRedisIdempotentUtilredisIdempotentUtil;AutowiredprivatePayServicepayService;OverridepublicvoidonMessage(PayMsgmsg){// msgId为MQ全局唯一标识作为幂等号StringidempNomsg.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表结构CREATETABLEt_idempotent_record(idbigintNOTNULLAUTO_INCREMENTCOMMENT主键,idempotent_novarchar(64)NOTNULLCOMMENT全局唯一幂等号订单号/msgId/支付流水号,biz_typevarchar(32)NOTNULLCOMMENT业务类型ORDER_CREATE/PAY_NOTIFY/MQ_CONSUME,statustinyintNOTNULLCOMMENT状态 0处理中 1处理成功 2处理失败,request_datatextCOMMENT原始请求数据,response_datatextCOMMENT业务返回数据,create_timedatetimeNOTNULLDEFAULTCURRENT_TIMESTAMP,update_timedatetimeNOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,UNIQUEKEYuk_idemp_no_biz(idempotent_no,biz_type)-- 联合唯一索引强防重兜底)ENGINEInnoDBDEFAULTCHARSETutf8mb4COMMENT全局幂等记录表;核心联合唯一索引数据库层面杜绝重复插入兜底所有代码漏洞。3幂等状态流转处理中 (0)开启事务先插入幂等记录处理成功 (1)业务执行完毕更新状态处理失败 (2)业务异常更新失败状态允许后续重试。1幂等核心服务ServicepublicclassIdempotentService{AutowiredprivateIdempotentMapperidempotentMapper;AutowiredprivateObjectMapperobjectMapper;/** * 幂等前置校验占位 * param idempNo 唯一幂等号 * param bizType 业务类型 * param reqBody 请求报文 * return null首次执行有记录已处理直接返回历史结果 */Transactional(rollbackForException.class)publicIdempotentRecordcheckAndLock(StringidempNo,StringbizType,ObjectreqBody){// 1. 查询是否已有记录IdempotentRecordrecordidempotentMapper.selectByNo(idempNo,bizType);if(record!null){// 已成功处理直接返回历史数据if(record.getStatus()1){returnrecord;}// 处理中/失败抛出异常拒绝重复执行thrownewRuntimeException(业务正在处理或执行失败请勿重复请求);}// 2. 插入处理中占位记录唯一索引保证不会重复插入IdempotentRecordnewRecordnewIdempotentRecord();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(rollbackForException.class)publicvoidmarkSuccess(StringidempNo,StringbizType,ObjectrespData){Stringrespnull;try{respobjectMapper.writeValueAsString(respData);}catch(JsonProcessingExceptione){resprespData.toString();}idempotentMapper.updateStatus(idempNo,bizType,1,resp);}// 业务执行失败更新失败状态Transactional(rollbackForException.class)publicvoidmarkFail(StringidempNo,StringbizType){idempotentMapper.updateStatus(idempNo,bizType,2,null);}}2业务层使用案例支付回调接口RestControllerRequestMapping(/pay)publicclassPayNotifyController{AutowiredprivateIdempotentServiceidempotentService;AutowiredprivateOrderServiceorderService;PostMapping(/notify)publicResultpayNotify(RequestBodyPayNotifyDTOdto){// 幂等唯一号支付平台流水号StringidempNodto.getOutTradeNo();StringbizTypePAY_NOTIFY;try{// 1. 幂等校验IdempotentRecordrecordidempotentService.checkAndLock(idempNo,bizType,dto);if(record!null){// 已处理成功直接返回上次结果returnResult.success(record.getResponseData());}// 2. 真正支付业务逻辑更新订单、发积分、记账OrderRespresporderService.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());}}}3MQ 消费场景改造OverridepublicvoidonMessage(PayMsgmsg){StringidempNomsg.getMsgId();StringbizTypeMQ_PAY_CONSUME;IdempotentRecordrecordidempotentService.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区分处理中 / 成功 / 失败支持失败后重试控制4HTTP 接口、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){OrderordernewOrder();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雪花算法、第三方流水号不能用自增 ID3-分布式锁Redisson配合幂等表防止跨分片并发重复。4-只用 Redis 不落地数据库Redis 宕机丢失数据产生重复业务5-幂等号设计重复只按订单号未区分业务类型不同业务互相拦截6-无事务插入幂等记录和业务操作分开中间宕机导致数据不一致7-未捕获唯一索引冲突异常直接抛出 500 错误8-MQ 消费只靠 Redis未持久化消息堆积重试造成重复9-软删除场景唯一索引未包含 is_deleted删除后无法新建相同业务号。