数据库重试机制如何避免重复提交——网络抖动场景下的代码模板、故障注入与事务边界
文章目录每日一句正能量前言1. 背景与问题2. 环境与数据3. 复现过程3.1 复现普通事务失败3.2 复现最危险的“提交结果未知”3.3 一个简单的故障注入思路4. 方案实施4.1 第一步先设计稳定的幂等键4.2 数据库唯一约束做最终裁决4.3 JDBC单次事务模板4.4 重试必须发生在新事务中4.5 提交结果未知时先查再决定4.6 MyBatis 适配4.7 JPA / Hibernate 适配4.8 Spring Retry 的正确边界4.9 重试异常必须白名单化4.10 指数退避 抖动4.11 事务内不要做外部重试4.12 重试日志必须记录 attempt 与 txId5. 结果对比实施前实施后6. 风险与复盘6.1 重试不能替代幂等6.2 不要把连接异常都当“未提交”6.3 自动重试次数必须有限6.4 不同数据库异常码不同6.5 ORM flush 和 commit 要分清6.6 故障注入必须覆盖“提交之后失败”结语每日一句正能量山河远阔你是归途风雨有人共伞晴空有人同览晨昏有人问候征程有人并肩。愿世间所有美好都与你环环相扣愿光阴所有馈赠都为你停留守候。山河辽阔人间烟火。无一是你无一不是你。前言数据库重试最危险的误区是把所有异常都理解成“这次执行失败了可以再来一次”。实际上网络抖动时最棘手的状态不是“明确失败”而是客户端不知道数据库到底有没有成功。例如一次订单写入INSERT 成功 COMMIT 成功 数据库准备返回 ACK 网络突然断开客户端只看到Communications link failure如果应用直接把整个业务再执行一遍就可能重复下单、重复扣款、重复入账。这类问题叫做提交结果不确定commit outcome unknown因此可靠的数据库重试机制必须同时解决三件事哪些异常可以重试 重试时如何保证不会重复提交 重试应该发生在事务内还是事务外1. 背景与问题先看一段“看起来很合理”的重试代码TransactionalpublicvoidcreateOrder(CreateOrderRequestrequest){for(inti0;i3;i){try{orderRepository.insert(request);return;}catch(Exceptione){log.warn(database error, retry{},i,e);}}thrownewIllegalStateException(create order failed);}问题很多。第一重试发生在同一个事务里。某些数据库异常发生后当前事务可能已经rollback-only即使下一次 SQL 执行成功最终提交仍可能失败。第二捕获了所有异常。下面这些异常没有必要重试非空约束失败 金额格式错误 唯一键冲突 SQL 语法错误 字段长度超限第三也是最危险的第一次事务可能已经提交成功。如果异常发生在提交结果返回阶段第二次执行会造成重复业务。因此重试不能只是catch-sleep-retry而必须先设计业务幂等。2. 环境与数据本文示例环境JDK 21 Spring Boot 3.3 MySQL 8.0 HikariCP Spring JDBC MyBatis 3.x Hibernate 6 / JPA订单表CREATETABLEorders(idBIGINTPRIMARYKEYAUTO_INCREMENT,request_idVARCHAR(64)NOTNULL,order_noVARCHAR(64)NOTNULL,user_idBIGINTNOTNULL,amountDECIMAL(18,2)NOTNULL,statusVARCHAR(32)NOTNULL,created_atTIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMP,UNIQUEKEYuk_request_id(request_id),UNIQUEKEYuk_order_no(order_no));支付流水表CREATETABLEpayment_ledger(idBIGINTPRIMARYKEYAUTO_INCREMENT,request_idVARCHAR(64)NOTNULL,order_noVARCHAR(64)NOTNULL,amountDECIMAL(18,2)NOTNULL,biz_typeVARCHAR(32)NOTNULL,created_atTIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMP,UNIQUEKEYuk_request_biz(request_id,biz_type));其中request_id就是本次业务请求的幂等键。同一个业务请求无论重试多少次都必须携带同一个request_id3. 复现过程3.1 复现普通事务失败先模拟死锁或临时锁冲突。两个事务分别按不同顺序更新两条记录事务 A先更新 1再更新 2 事务 B先更新 2再更新 1出现死锁时数据库会主动回滚其中一个事务。这种场景比较适合自动重试因为结果是明确的事务已经失败并回滚3.2 复现最危险的“提交结果未知”更危险的情况应用发起 COMMIT 数据库完成 COMMIT 网络在返回结果前断开 客户端收到连接异常客户端此时不能证明事务没有提交。如果继续retryCreateOrder();就会重复执行。如果数据库没有唯一约束第一笔订单成功 第二笔订单也成功重复提交事故就发生了。3.3 一个简单的故障注入思路开发环境可以在业务层模拟“提交后响应失败”。示例ServiceRequiredArgsConstructorpublicclassFaultInjectionService{privatefinalOrderTxServiceorderTxService;publicvoidcreateWithInjectedFailure(CreateOrderRequestrequest){orderTxService.createInTransaction(request);// 模拟数据库已经提交但响应链路失败thrownewRuntimeException(injected network failure after commit);}}如果上层错误地写try{faultInjectionService.createWithInjectedFailure(req);}catch(Exceptione){orderTxService.createInTransaction(req);}第二次执行会再次写库。如果有唯一约束UNIQUE(request_id)第二次会被数据库识别为重复。这就是为什么“重试设计”和“幂等设计”不能分开。4. 方案实施4.1 第一步先设计稳定的幂等键客户端或网关生成requestIdORD-20260809-8f21a7要求同一个业务动作的所有重试必须使用同一个 requestId不能每次重试重新生成 UUID。错误for(inti0;i3;i){StringrequestIdUUID.randomUUID().toString();createOrder(requestId);}这等于每次重试都告诉数据库这是一个全新的请求。正确StringrequestIdrequest.requestId();for(inti0;i3;i){createOrder(requestId);}4.2 数据库唯一约束做最终裁决订单表UNIQUEKEYuk_request_id(request_id)插入INSERTINTOorders(request_id,order_no,user_id,amount,status)VALUES(?,?,?,?,CREATED);如果第二次重试重复提交DuplicateKey数据库会拒绝第二条。应用不能简单把 DuplicateKey 当失败。应该先确认是不是同一个 request_id 的幂等命中。4.3 JDBC单次事务模板事务内部不要自己重试。publicOrderResultcreateOnce(CreateOrderRequestrequest)throwsSQLException{ConnectionconnectiondataSource.getConnection();try{connection.setAutoCommit(false);insertOrder(connection,request);insertLedger(connection,request);connection.commit();returnOrderResult.created();}catch(SQLExceptione){try{connection.rollback();}catch(SQLExceptionrollbackError){e.addSuppressed(rollbackError);}throwe;}finally{connection.close();}}这里一次调用 一次完整事务重试应该在更外层。4.4 重试必须发生在新事务中推荐结构RetryController - TxService.tryOnce() - 新事务结束 - 根据异常决定是否再次调用Spring 示例ServiceRequiredArgsConstructorpublicclassOrderRetryService{privatefinalOrderTxServicetxService;publicOrderResultcreateWithRetry(CreateOrderRequestrequest){intmaxAttempts3;for(intattempt1;attemptmaxAttempts;attempt){try{returntxService.createOnce(request);}catch(DuplicateKeyExceptione){returntxService.findExisting(request.requestId());}catch(CannotAcquireLockExceptione){if(attemptmaxAttempts){throwe;}backoff(attempt);}catch(TransientDataAccessResourceExceptione){if(attemptmaxAttempts){throwe;}backoff(attempt);}}thrownewIllegalStateException(unreachable);}}事务方法ServiceRequiredArgsConstructorpublicclassOrderTxService{privatefinalJdbcTemplatejdbcTemplate;TransactionalpublicOrderResultcreateOnce(CreateOrderRequestrequest){jdbcTemplate.update( INSERT INTO orders( request_id, order_no, user_id, amount, status ) VALUES (?, ?, ?, ?, CREATED) ,request.requestId(),request.orderNo(),request.userId(),request.amount());jdbcTemplate.update( INSERT INTO payment_ledger( request_id, order_no, amount, biz_type ) VALUES (?, ?, ?, ORDER_CREATE) ,request.requestId(),request.orderNo(),request.amount());returnOrderResult.created();}}注意重试方法不要加 Transactional否则多次调用可能仍然处在同一个外层事务中。4.5 提交结果未知时先查再决定这类异常不能直接重试。例如CommunicationsException Connection reset Socket timeout如果它发生在COMMIT 阶段结果可能不确定。更稳妥的处理流程catch(TransientDataAccessResourceExceptione){OptionalOrderexistingtxService.findByRequestId(request.requestId());if(existing.isPresent()){returnOrderResult.alreadyCommitted(existing.get());}if(attemptmaxAttempts){throwe;}backoff(attempt);}流程是异常 - 按 request_id 查结果 - 已存在视为成功 - 不存在再重试这就是处理提交结果不确定性的关键。4.6 MyBatis 适配MapperinsertidinsertOrderINSERT INTO orders( request_id, order_no, user_id, amount, status ) VALUES ( #{requestId}, #{orderNo}, #{userId}, #{amount}, CREATED )/insert查询selectidfindByRequestIdresultTypeOrderSELECT id, request_id, order_no, user_id, amount, status FROM orders WHERE request_id #{requestId}/select事务TransactionalpublicOrderResultcreateOnce(CreateOrderRequestrequest){orderMapper.insertOrder(request);ledgerMapper.insertLedger(request);returnOrderResult.created();}外层重试仍然和 JDBC 一样DuplicateKey - 查询历史结果 死锁 - 新事务重试 提交未知 - 先查状态4.7 JPA / Hibernate 适配EntityEntityTable(nameorders,uniqueConstraints{UniqueConstraint(nameuk_request_id,columnNamesrequest_id)})publicclassOrderEntity{IdGeneratedValue(strategyGenerationType.IDENTITY)privateLongid;Column(namerequest_id,nullablefalse)privateStringrequestId;// ...}ServiceTransactionalpublicOrderResultcreateOnce(CreateOrderRequestrequest){OrderEntityentityOrderEntity.from(request);repository.save(entity);ledgerRepository.save(LedgerEntity.from(request));repository.flush();returnOrderResult.created();}为什么这里有flush();因为 Hibernate 可能延迟真正执行 SQL。如果希望唯一键冲突尽量在当前事务方法内暴露而不是拖到事务提交代理层显式 flush 更容易界定异常位置。但必须理解flush 成功 ≠ commit 已成功真正的提交结果仍然由事务完成阶段决定。4.8 Spring Retry 的正确边界如果项目使用 Spring Retry也要确保Retryable放在事务方法外层。思路Retryable(retryFor{CannotAcquireLockException.class,DeadlockLoserDataAccessException.class},maxAttempts3)publicOrderResultretryableCreate(...){returntxService.createOnce(...);}不要TransactionalRetryablepublicvoidcreate(...){}然后假设注解顺序一定符合预期。AOP 顺序、事务代理和重试代理叠加时非常容易出现边界误判。更清晰的工程结构是RetryService TxService Repository分别承担重试策略 事务边界 数据库操作4.9 重试异常必须白名单化适合自动重试的典型异常死锁 部分锁等待超时 临时连接失败 短暂数据库不可用通常不应该自动重试唯一键冲突 非空约束 数据格式错误 SQL 语法错误 业务校验失败唯一键冲突属于特殊情况如果确认是同一个幂等键导致的重复请求 - 转成“已经处理成功” 如果是不同业务数据撞到同一唯一键 - 仍然是错误4.10 指数退避 抖动不要固定Thread.sleep(100);所有失败线程会一起100 ms 后再次冲击数据库更合理privatevoidbackoff(intattempt){longbase(long)Math.pow(2,attempt)*20;longjitterThreadLocalRandom.current().nextLong(10,50);LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(basejitter));}例如第 1 次约 50~90 ms 第 2 次约 90~130 ms 第 3 次约 170~210 ms避免惊群。4.11 事务内不要做外部重试危险代码TransactionalpublicvoidcreateOrder(){insertOrder();retryRemotePayment();insertLedger();}远程调用重试会把数据库事务拖长。正确思路通常是数据库事务只处理本地原子状态 远程动作放在事务外 使用 Outbox / MQ / 状态机推进4.12 重试日志必须记录 attempt 与 txId推荐{event:db_retry,traceId:9af23d18,requestId:ORD-20260809-8f21a7,attempt:2,maxAttempts:3,exceptionType:DEADLOCK,txId:tx-b8f2,commitUnknown:false}如果是提交结果未知{event:db_retry_check,requestId:ORD-20260809-8f21a7,attempt:1,commitUnknown:true,existingRecordFound:true}这样排查重复提交时能知道到底执行过几次事务 哪一次真正提交 后续是否只是查询确认5. 结果对比实施前错误重试事务执行 - 网络异常 - 直接再次执行 - 又插一条订单结果重复订单 重复流水 重复扣款实施后流程请求携带稳定 request_id 第一次事务 - INSERT orders - INSERT ledger - COMMIT 如果明确死锁 - 新事务重试 如果提交阶段连接异常 - 按 request_id 查询 查询到记录 - 视为首次事务已经成功 - 不再执行写入 查询不到 - 才允许下一次新事务重试数据库唯一约束作为最后一层保护UNIQUE(request_id)即使两个请求同时重试一个成功 另一个 DuplicateKey也不会产生第二笔业务。6. 风险与复盘6.1 重试不能替代幂等如果业务本身不是幂等的余额 100 发优惠券 发送短信 调用支付直接重试会产生副作用。必须先设计业务唯一键 唯一约束 状态机 操作流水再谈重试。6.2 不要把连接异常都当“未提交”这是最危险的认知错误。连接异常只说明客户端通信失败并不能证明数据库没有提交尤其是在 commit 周边。6.3 自动重试次数必须有限数据库已经过载时无限重试会形成原请求压力 重试压力 上游重试最终放大雪崩。通常应该设置2~3 次 指数退避 熔断 快速失败6.4 不同数据库异常码不同MySQL、PostgreSQL、Oracle 对死锁 序列化冲突 锁超时 连接中断的异常码和 SQLState 不完全相同。推荐统一映射成内部枚举enumRetryReason{DEADLOCK,SERIALIZATION_FAILURE,LOCK_TIMEOUT,TRANSIENT_CONNECTION,COMMIT_UNKNOWN,NOT_RETRYABLE}业务层不要直接依赖驱动异常文本。6.5 ORM flush 和 commit 要分清JPAflush只是把 SQL 发送到数据库并同步持久化上下文。它不等于事务已经 commit所以即使 flush 成功提交阶段仍可能发生网络异常。6.6 故障注入必须覆盖“提交之后失败”很多测试只模拟SQL 执行前断网这种测试太简单。真正应该覆盖写 SQL 前失败 写 SQL 中失败 COMMIT 前失败 COMMIT 后响应丢失 查询确认阶段失败 重试阶段再次失败只有这样才能验证重试逻辑不会重复提交。结语数据库重试设计的核心不是失败了再试几次而是先判断这次失败是否真的意味着“没有提交”。对于网络抖动场景最稳妥的结构是稳定幂等键 数据库唯一约束 单次事务 事务外层重试 异常白名单 提交结果未知时先查再重试可以用一句工程原则总结明确回滚的事务可以重试 提交结果未知的事务必须先确认。真正成熟的数据库重试机制目标不是“提高成功率”这么简单而是在提高成功率的同时确保任何网络抖动、驱动异常和事务重跑都不会把一次业务变成两次提交。转载自https://blog.csdn.net/u014727709/article/details/165241505欢迎 点赞✍评论⭐收藏欢迎指正