当 @Transactional 不再可靠:批量任务中的编程式事务实践

当 @Transactional 不再可靠:批量任务中的编程式事务实践
前言Transactional是 Spring 里最常用的注解之一方法上一挂事务就有了简单到几乎不用思考。但它的简单是有前提的——一旦脱离了外部调用一个 public 方法、异常自然向上抛这个理想模型它就可能悄悄失效而且不报错、不提示只在数据里留下一堆对不上的脏记录。本文以一个真实场景为背景异步批量任务里声明式事务失效最终改用PlatformTransactionManager编程式事务解决。全文配一个可以直接跑的 Spring Boot MySQL demo并附上真实的运行日志不是手写伪造的。你可以自己跑一遍亲眼看到事务根本没开启和事务正常回滚两种截然不同的结果。代码和场景都已抽离成通用 demo不涉及任何具体业务。一、场景批量注册用户两张表要原子假设有一个批量注册接口一次注册多个用户每个用户要往两张表写数据t_user用户基本信息姓名、手机号t_account账户信息初始余额业务要求有两条看似普通却正好卡在声明式事务的软肋上单个用户的两步写入必须原子——t_user和t_account要么都成功要么都失败。不能出现用户建了、账户没建的脏数据。单个用户失败不能影响其他用户——批量几百上千个用户某一个数据有问题把它记下来继续跑不能因为一个失败就整批回滚。先看一个看起来加了事务的朴素实现。Async// 异步执行避免阻塞请求线程publicvoidbatchRegisterDeclarative(ListUserusers){ListStringerrorListnewArrayList();for(Useruser:users){try{// 内部方法调用this.registerOneDeclarative(user);log.info([声明式] 用户[{}]注册成功,user.getName());}catch(Exceptione){// 业务要求单个失败不影响整体记下来继续errorList.add(user.getName(): e.getMessage());log.warn([声明式] 用户[{}]注册失败{},user.getName(),e.getMessage());}}log.info([声明式] 批量注册结束失败 {} 个{},errorList.size(),errorList);}// 期望t_user 和 t_account 两步原子Transactional(rollbackForException.class)publicvoidregisterOneDeclarative(Useruser){jdbcTemplate.update(INSERT INTO t_user(name, phone) VALUES (?, ?),user.getName(),user.getPhone());// 模拟第二步出错余额为负时抛异常if(user.getBalance()0){thrownewIllegalArgumentException(初始余额不能为负数);}jdbcTemplate.update(INSERT INTO t_account(name, balance) VALUES (?, ?),user.getName(),user.getBalance());}registerOneDeclarative上明明白白挂着Transactional(rollbackFor Exception.class)。直觉上如果第二步抛异常第一步的t_user插入应该被回滚。但真跑起来结果完全不是这样。二、复现事务根本没生效造三个用户让第二个用户 Bob 的余额为负它会在写完t_user、准备写t_account前抛异常ListUserusersArrays.asList(newUser(Alice,13800000001,100),newUser(Bob,13800000002,-50),// 故意让它在第二步失败newUser(Cathy,13800000003,200));如果事务生效预期结果是Alice 和 Cathy 两步都成功Bob 两步都回滚两张表都应该只有 Alice、Cathy 两条。跑一下声明式版本真实日志如下21:24:15.989 INFO [main] DemoRunner - 运行模式declarative 21:24:16.009 INFO [task-1] UserRegisterService - [声明式] 批量注册开始共 3 个用户线程task-1 21:24:16.029 INFO [task-1] UserRegisterService - [声明式] 用户[Alice]注册成功 21:24:16.041 WARN [task-1] UserRegisterService - [声明式] 用户[Bob]注册失败初始余额不能为负数 21:24:16.052 INFO [task-1] UserRegisterService - [声明式] 用户[Cathy]注册成功 21:24:16.052 INFO [task-1] UserRegisterService - [声明式] 批量注册结束失败 1 个[Bob: 初始余额不能为负数] 21:24:18.028 INFO [main] DemoRunner - 最终结果 [declarative] 21:24:18.028 INFO [main] DemoRunner - t_user 共 3 条[Alice, Bob, Cathy] 21:24:18.028 INFO [main] DemoRunner - t_account 共 2 条[Alice, Cathy] 21:24:18.028 ERROR [main] DemoRunner - 数据不一致t_user 有 Bob 但 t_account 没有 —— 第一步写入未回滚脏数据产生看最后三行t_user有 3 条含 Bobt_account只有 2 条。Bob 的t_user插入没有被回滚脏数据实实在在地留在了库里。更能说明问题的是事务管理器的 DEBUG 日志。我把JdbcTransactionManager的日志级别开到 DEBUG然后数了一下这个版本里它输出了多少行 声明式模式下 JdbcTransactionManager 的日志行数预期 0 00 行。也就是说Transactional标注的方法执行时Spring 事务管理器压根没被触发——不是开了事务但没回滚而是事务从头到尾就没开启过。注解形同虚设。为什么会这样这里同时踩中了两个坑。三、声明式事务失效的经典场景Transactional的本质是Spring AOP 代理容器为你的 Bean 生成一个代理对象在调用被Transactional标注的方法前后由代理去开启、提交、回滚事务。这个代理是一切的关键也是一切失效的根源。下面几种是最常见的失效场景本例踩了其中两个。3.1 self-invocation内部方法调用绕过代理这是本例的首要原因。AsyncpublicvoidbatchRegisterDeclarative(ListUserusers){for(Useruser:users){registerOneDeclarative(user);// this.registerOneDeclarative(...)}}registerOneDeclarative(user)等价于this.registerOneDeclarative(user)。这里的this是原始对象不是 Spring 生成的代理对象。事务增强逻辑只织在代理对象上用this直接调用请求根本不经过代理这一层Transactional自然不生效。外部调用走代理事务生效 Controller ──→ [代理对象] ──→ 目标方法 ✅ 事务开启 内部调用this.绕过代理事务失效 目标方法A ──→ this.目标方法B ❌ 直接调用没有代理介入只要是同一个类里一个方法直接调用另一个带Transactional的方法被调用方法上的事务注解一律失效。3.2 Async 新线程事务上下文不跨线程传播这是本例的第二个坑。Spring 事务上下文是通过ThreadLocal绑定在当前线程上的连接、事务状态都存在ThreadLocal里。Async会把方法丢到另一个线程执行新线程的ThreadLocal是空的拿不到任何外层事务上下文。即便你想靠外层方法的事务来兜底只要跨了Async的线程边界事务上下文就断了。3.3 其他常见失效场景顺带一并记住失效场景原因方法不是publicSpring 默认只对 public 方法织入事务增强异常被 catch 吞掉没抛出声明式事务靠异常向上抛触发回滚异常被吃掉事务不知道要回滚抛的是受检异常但没配rollbackFor默认只对RuntimeException和Error回滚受检异常不回滚类没有被 Spring 管理自己 new 的没有代理对象注解无从谈起数据库引擎不支持事务如 MyISAM底层就没有事务能力3.4 本例为什么无解回到我们的场景即使把 self-invocation 和Async都绕过去比如把registerOneDeclarative拆到另一个 Bean、用REQUIRES_NEW还有第三个矛盾绕不开业务要求单个失败不影响整体所以循环体里必须try-catch把异常吃掉、继续下一个用户。而声明式事务恰恰依赖异常向上抛才能回滚——你一 catch回滚的触发条件就没了。这两个需求在声明式事务的模型里是天然冲突的。结论这个场景声明式事务从根上就不合适。我们需要能手动控制事务边界的工具。四、编程式事务PlatformTransactionManager 登场PlatformTransactionManager是 Spring 事务的底层核心接口Transactional内部也是靠它工作的。直接用它就能把事务的开启、提交、回滚完全握在自己手里不依赖代理也不依赖异常传播。三个核心角色PlatformTransactionManager事务管理器负责getTransaction开启、commit提交、rollback回滚。Spring Boot 用了 JDBC 会自动注入它的实现。DefaultTransactionDefinition事务定义描述传播行为、隔离级别等用默认值即可。TransactionStatus一次事务的句柄提交/回滚时把它传回去。注入很简单AutowiredprivatePlatformTransactionManagertransactionManager;改造后的单个注册方法/** * 编程式事务手动开启两步写入成功提交失败回滚后把异常抛回上层。 * 事务边界精确地包住写 t_user 写 t_account这两步。 */publicvoidregisterOneProgrammatic(Useruser){TransactionStatusstatustransactionManager.getTransaction(newDefaultTransactionDefinition());try{jdbcTemplate.update(INSERT INTO t_user(name, phone) VALUES (?, ?),user.getName(),user.getPhone());if(user.getBalance()0){thrownewIllegalArgumentException(初始余额不能为负数);}jdbcTemplate.update(INSERT INTO t_account(name, balance) VALUES (?, ?),user.getName(),user.getBalance());transactionManager.commit(status);// 两步都成功 → 提交}catch(Exceptione){transactionManager.rollback(status);// 任一步失败 → 回滚throwe;// 抛回上层让批量循环记录并继续}}批量循环几乎不变只是调用换成编程式版本AsyncpublicvoidbatchRegisterProgrammatic(ListUserusers){ListStringerrorListnewArrayList();for(Useruser:users){try{registerOneProgrammatic(user);log.info([编程式] 用户[{}]注册成功,user.getName());}catch(Exceptione){errorList.add(user.getName(): e.getMessage());log.warn([编程式] 用户[{}]注册失败{},user.getName(),e.getMessage());}}log.info([编程式] 批量注册结束失败 {} 个{},errorList.size(),errorList);}这里有个关键设计——内层回滚 外层记录的配合内层registerOneProgrammatic保证单个用户两步原子。失败时先 rollback 再把异常抛出去。外层循环catch住异常记进errorList继续下一个用户。两个需求就这样各归其位原子性由内层的编程式事务保证失败继续由外层的 catch 保证不再互相打架。五、验证事务真正生效了同样的三个用户、同样让 Bob 失败跑编程式版本真实日志含事务管理器 DEBUG 日志21:26:50.680 INFO [task-1] UserRegisterService - [编程式] 批量注册开始共 3 个用户线程task-1 --- Alice两步成功提交 --- 21:26:50.683 DEBUG [task-1] JdbcTransactionManager - Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT 21:26:50.683 DEBUG [task-1] JdbcTransactionManager - Acquired Connection [HikariProxyConnection1064755138 ...] for JDBC transaction 21:26:50.686 DEBUG [task-1] JdbcTransactionManager - Switching JDBC Connection [...] to manual commit 21:26:50.696 DEBUG [task-1] JdbcTransactionManager - Initiating transaction commit 21:26:50.696 DEBUG [task-1] JdbcTransactionManager - Committing JDBC transaction on Connection [...] 21:26:50.701 DEBUG [task-1] JdbcTransactionManager - Releasing JDBC Connection [...] after transaction 21:26:50.701 INFO [task-1] UserRegisterService - [编程式] 用户[Alice]注册成功 --- Bob第二步失败回滚 --- 21:26:50.701 DEBUG [task-1] JdbcTransactionManager - Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT 21:26:50.701 DEBUG [task-1] JdbcTransactionManager - Acquired Connection [HikariProxyConnection116848456 ...] for JDBC transaction 21:26:50.701 DEBUG [task-1] JdbcTransactionManager - Switching JDBC Connection [...] to manual commit 21:26:50.702 DEBUG [task-1] JdbcTransactionManager - Initiating transaction rollback 21:26:50.702 DEBUG [task-1] JdbcTransactionManager - Rolling back JDBC transaction on Connection [...] 21:26:50.706 DEBUG [task-1] JdbcTransactionManager - Releasing JDBC Connection [...] after transaction 21:26:50.708 WARN [task-1] UserRegisterService - [编程式] 用户[Bob]注册失败初始余额不能为负数 --- Cathy两步成功提交 --- 21:26:50.708 DEBUG [task-1] JdbcTransactionManager - Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT 21:26:50.709 DEBUG [task-1] JdbcTransactionManager - Initiating transaction commit 21:26:50.721 DEBUG [task-1] JdbcTransactionManager - Committing JDBC transaction on Connection [...] 21:26:50.721 INFO [task-1] UserRegisterService - [编程式] 用户[Cathy]注册成功 21:26:50.721 INFO [task-1] UserRegisterService - [编程式] 批量注册结束失败 1 个[Bob: 初始余额不能为负数] 21:26:52.692 INFO [main] DemoRunner - 最终结果 [programmatic] 21:26:52.692 INFO [main] DemoRunner - t_user 共 2 条[Alice, Cathy] 21:26:52.692 INFO [main] DemoRunner - t_account 共 2 条[Alice, Cathy] 21:26:52.692 INFO [main] DemoRunner - 数据一致两张表用户完全对应失败的用户两步都没写入。日志把事务的一生讲得清清楚楚AliceCreating new transaction→Committing两步落库。BobCreating new transaction→Initiating transaction rollback→Rolling back第一步的t_user插入被回滚一条不留。Cathy又是一个独立事务正常提交。最终t_user和t_account都是 2 条完全一致脏数据消失。三个用户各自开了一个独立事务互不影响——这正是我们要的每条一个独立小事务的语义。对照一下前面数过的日志行数 声明式模式下 JdbcTransactionManager 的日志行数 → 0 编程式模式下 JdbcTransactionManager 的日志行数 → 18从 0 到 18事务从根本没开变成实实在在地开了 3 次、commit 2 次、rollback 1 次。六、声明式 vs 编程式怎么选维度声明式Transactional编程式PlatformTransactionManager使用方式方法上加注解手动 get/commit/rollback代码侵入性低几乎无侵入高要写模板代码事务边界整个方法任意代码块精确可控依赖代理是self-invocation 会失效否依赖异常传播是catch 吞掉就不回滚否自己决定何时回滚适合场景常规单请求业务边界清晰、异常自然上抛批量循环细粒度控制、异步任务、需运行时决定提交/回滚一句话总结选型绝大多数场景用声明式——简单、干净够用。下面这些情况用编程式批量循环里要每条一个独立事务、单条失败继续本文场景Async等跨线程执行的任务需要在同一个方法里根据运行时条件多次开关事务、精确控制回滚时机。七、踩坑清单坑 1rollback 之后一定要把异常处理掉抛出或记录}catch(Exceptione){transactionManager.rollback(status);throwe;// 不要默默吞掉否则上层以为成功了}如果 rollback 后既不抛也不记事务是回滚了但调用方完全不知道这条失败了会当成成功处理比脏数据更难排查。坑 2事务里别塞耗时 IO编程式事务同样占用数据库连接。getTransaction到commit之间会一直持有连接如果中间夹了 OSS 上传、远程接口调用这类耗时操作连接被长时间占用高并发下很容易把连接池耗尽。事务里只放必须原子的 DB 操作IO 挪到事务外。坑 3理解默认的传播行为new DefaultTransactionDefinition()默认传播行为是PROPAGATION_REQUIRED——有外层事务就加入没有就新建。在Async新线程里通常没有外层事务所以每次都是新建一个独立事务正好符合我们每条独立的需求。但如果哪天把它放进一个已有事务的上下文里行为会变需要留意。日志里那行PROPAGATION_REQUIRED,ISOLATION_DEFAULT就是它在说明自己的传播行为和隔离级别。坑 4循环里频繁开关事务的性能每条记录开一个事务意味着频繁地获取连接—提交—释放连接。数据量极大时这个开销不可忽略。如果业务允许可以考虑每 N 条一个事务折中在原子粒度和性能之间取平衡但要清楚这会改变失败回滚的范围一批里失败会回滚整批。八、附完整可运行 DemoDemo 基于 Spring Boot 2.5.3 MySQLJdbcTemplate直连不引入任何 ORM几个文件就能跑。建库MySQL 里执行一次表由程序自动建CREATEDATABASEIFNOTEXISTStx_demoDEFAULTCHARACTERSETutf8mb4;application.yml打开事务 DEBUG 日志是关键否则看不到 commit/rollbackspring:datasource:url:jdbc:mysql://127.0.0.1:3306/tx_demo?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8username:rootpassword:000000driver-class-name:com.mysql.cj.jdbc.Driverlogging:level:org.springframework.jdbc.support.JdbcTransactionManager:DEBUGcom.example.txdemo:DEBUGpattern:console:%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n核心类UserRegisterService前文已给出完整代码包含声明式和编程式两个版本。启动类记得开启异步EnableAsyncSpringBootApplicationpublicclassTxDemoApplication{publicstaticvoidmain(String[]args){SpringApplication.run(TxDemoApplication.class,args);}}运行方式用启动参数切换两个版本亲自对比结果# 复现声明式失效t_user 3 条、t_account 2 条脏数据java-jartx-demo.jar--modedeclarative# 编程式修复两张表都是 2 条数据一致java-jartx-demo.jar--modeprogrammatic强烈建议自己跑一遍——亲眼看到t_user里那条没被回滚的 Bob比读十遍原理都印象深刻。总结Transactional好用但它建立在代理生效 异常上抛这个前提上。一旦遇到 self-invocation、Async跨线程、或者业务上必须 catch 吞异常这个前提就崩了而且崩得悄无声息。一句话记忆Transactional的本质是 AOP 代理 → self-invocation、非 public、自己 new 的对象一律失效声明式事务靠异常上抛触发回滚 → catch 吞掉异常回滚就没了Async换了线程 → 事务上下文基于 ThreadLocal不跨线程传播批量循环要单条独立事务 失败继续 → 声明式天生做不到用编程式编程式事务getTransaction开 → 业务 →commitcatch 里rollback后别忘了把异常抛回去事务里只放必须原子的 DB 操作耗时 IO 一律挪到事务外工具没有好坏只有适不适合。理解Transactional什么时候会失效比记住加了它就有事务重要得多。