ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

事务里 catch 住异常继续提交:Spring Boot 3 批处理脏数据的复现与取舍

事务里 catch 住异常继续提交:Spring Boot 3 批处理脏数据的复现与取舍 本文摘要批处理单行失败时 catch 异常继续提交常见结果是整批回滚或部分行脏写。三个最小复现拆开吞异常、自调用绕代理与 checked 异常不回滚三类成因。一、问题与结论两万行对账导入的写法是单事务内循环INSERTcatch (RuntimeException ex)记一行日志后继续。跑完控制台十几条跳过方法正常返回调用方拿到成功对账时biz_order里一行数据都没有。另一套代码跑在 MySQL 上结果是好行落库、坏行跳过订单与库存对不上。catch只挡住异常向上传播挡不住事务状态机与数据库错误语义。三类成因互相独立吞异常内层参与式事务抛异常已把事务标成rollback-only外层 catch 后继续跑提交点抛UnexpectedRollbackException整批回滚。自调用this.foo()不经过代理Transactional不生效既不开事务也不打标记结果是部分提交。checked 异常默认回滚规则只覆盖RuntimeException与Errorcatch 后继续写坏行按目标库语义落库。想让单行失败不影响整批在同一事务里 catch 后继续写并不成立要么按行分事务要么整批回滚重跑。二、排查与选择依据先看日志再看数据。在application.properties里打开事务与 JDBC 调试logging.level.org.springframework.transactionDEBUG logging.level.org.springframework.jdbcDEBUG按片段判读精确措辞随版本有差异日志片段含义对应成因Creating new transaction代理生效新事务开启正常Participating in existing transaction内层REQUIRED加入外层事务标记会外溢到外层Should mark transaction as rollback-onlycatch 也救不回来吞异常无事务日志但 SQL 照常执行注解没生效自调用Initiating transaction rollback整批丢弃方法可能已返回成功吞异常 / checkedCommitting JDBC transaction但表内无新增行库侧事务已 aborted提交等价回滚目标库语义定位顺序先确认代理是否生效再确认异常类型是否落在回滚规则内最后确认目标库一条语句报错后事务还能不能继续。替代方案与取舍做法解决什么代价与边界不该用的场合自注入Lazy自身 Bean自调用绕代理注入自身构造期不可用仅在注解不生效时需要AopContext.currentProxy()同上需exposeProxytrue测试与序列化易踩坑想保持类可独立实例化时rollbackFor Exception.classchecked 异常不回滚无关 catch 分支一并丢数据外层还有别的 catch 逻辑每行独立事务 死信表坏行隔离好行可提交失去批原子性行间不得有依赖订单与库存、主表与明细整批回滚 setRollbackOnly()全有或全无一行失败整批失败需幂等重跑批很大、失败率高什么时候不该用catch 继续提交写资金、额度、状态机行间存在一致性约束用 JPA 且一次flush失败会污染持久化上下文没有事后对账兜底。三、关键原理Spring 事务由 AOP 代理织入自调用走this不经过代理对象注解不生效。REQUIRED传播下内层方法加入外层事务抛出触发回滚的异常时事务被标记rollback-only提交路径AbstractPlatformTransactionManager.commit发现标记抛UnexpectedRollbackException并回滚与外层是否 catch 无关。默认回滚规则只覆盖RuntimeException与Errorchecked 异常需用rollbackFor显式声明。库侧语义另成一层MySQL/InnoDB 是语句级回滚、事务继续PostgreSQL 报错后事务进入 aborted 状态后续语句全部拒绝执行直到ROLLBACK。同一个 catch 循环在两个库上分别是部分提交和空提交。若改用 JDBC 批量 API驱动的continueBatchOnError、rewriteBatchedStatements会进一步改变错误返回形式默认值需按驱动版本核对。四、可运行示例环境JDK 17、Spring Boot 3.x、spring-boot-starter-jdbcMySQL/InnoDB 与 PostgreSQL 各跑一次PostgreSQL 把AUTO_INCREMENT换成GENERATED BY DEFAULT AS IDENTITY。CREATETABLEIFNOTEXISTSbiz_order(idBIGINTAUTO_INCREMENTPRIMARYKEY,codeVARCHAR(64)NOTNULL,amountDECIMAL(12,2)NOTNULL,CONSTRAINTuk_order_codeUNIQUE(code));packagedemo.tx;importjava.math.BigDecimal;publicrecordImportRow(Stringcode,BigDecimalamount){}packagedemo.tx;importorg.springframework.jdbc.core.JdbcTemplate;importorg.springframework.stereotype.Service;importorg.springframework.transaction.annotation.Transactional;ServicepublicclassInventoryWorker{privatefinalJdbcTemplatejdbc;publicInventoryWorker(JdbcTemplatejdbc){this.jdbcjdbc;}/** 内层 REQUIRED 事务抛出业务异常即把外层事务标记为 rollback-only */Transactionalpublicvoiddeduct(ImportRowrow){jdbc.update(INSERT INTO biz_order(code, amount) VALUES (?, ?),row.code(),row.amount());if(row.amount().signum()0){thrownewIllegalStateException(金额非法: row.code());}}}packagedemo.tx;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.context.annotation.Lazy;importorg.springframework.jdbc.core.JdbcTemplate;importorg.springframework.stereotype.Service;importorg.springframework.transaction.PlatformTransactionManager;importorg.springframework.transaction.support.TransactionTemplate;importjava.util.List;ServicepublicclassBatchImportService{privatefinalJdbcTemplatejdbc;privatefinalInventoryWorkerworker;privatefinalTransactionTemplatetx;AutowiredLazyprivateBatchImportServiceself;// 自注入修复自调用绕代理publicBatchImportService(JdbcTemplatejdbc,InventoryWorkerworker,PlatformTransactionManagertm){this.jdbcjdbc;this.workerworker;this.txnewTransactionTemplate(tm);}/** 场景 A单事务逐行 catch 吞异常后提交 */publicStringscenarioA(ListImportRowrows){StringBuilderbufnewStringBuilder();tx.executeWithoutResult(s-rows.forEach(r-{try{jdbc.update(INSERT INTO biz_order(code, amount) VALUES (?, ?),r.code(),r.amount());}catch(RuntimeExceptionex){buf.append(skip:).append(r.code()).append( );}}));returnbuf.toString();}/** 场景 B内层 REQUIRED 抛异常 → 外层 catch → 提交点才炸 */publicStringscenarioB(ListImportRowrows){StringBuilderbufnewStringBuilder();try{tx.executeWithoutResult(s-rows.forEach(r-{try{worker.deduct(r);}catch(RuntimeExceptionex){buf.append(skip:).append(r.code()).append( );}}));}catch(RuntimeExceptionex){buf.append(commit:).append(ex.getClass().getSimpleName()).append( );}returnbuf.toString();}/** 场景 C被 this 调用时注解不生效改用 self.deductLocally 才走代理 */TransactionalpublicvoiddeductLocally(ImportRowrow){jdbc.update(INSERT INTO biz_order(code, amount) VALUES (?, ?),row.code(),row.amount());if(row.amount().signum()0){thrownewIllegalStateException(金额非法: row.code());}}}操作步骤预埋codeK000制造唯一约束冲突喂入K001、K000、K002调用scenarioA喂入K011、K012金额-1、K013调用scenarioB同一非法行分别用this.deductLocally与self.deductLocally各执行一次最后执行SELECT code, amount FROM biz_order ORDER BY code。五、验证结果与边界预期输出基于文档化行为推断本次未实跑场景 B 在提交阶段抛UnexpectedRollbackException事务回滚表内只剩预埋行场景 A 在 MySQL 上是K001、K002落库、K000跳过在 PostgreSQL 上后续语句被拒绝、提交后表内无新增行this自调用组无任何事务日志行为与 A 相同self自注入组行为与 B 相同。实际输出在两个库各跑一次把事务日志、控制台输出与SELECT结果贴回来逐行对照任一项不符时先核对globalRollbackOnParticipationFailure在所用小版本的默认值再核对驱动的批量错误属性。常见失败把UnexpectedRollbackException再 catch 一次继续跑。此时事务已回滚后续写要么落到新事务要么直接报错失败被放大成多批脏数据。正确做法是把隔离提前到事务边界每行一次tx.execute失败行写死信表必须全有全无时在 catch 后调用status.setRollbackOnly()整批回滚交由幂等重跑。思考行间存在主从依赖时失败行进死信表补跑中间态是否会暴露给读方内层加rollbackFor Exception.class后外层无关的 catch 分支是否也会跟着丢数据参考资料Spring Framework 文档Declarative transaction managementSpring Framework 文档Programmatic transaction managementSpring Framework 文档Spring AOPPostgreSQL 文档TransactionsMySQL 8.0 文档InnoDB Error HandlingSpring Batch 参考文档
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进