ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

编程式事务与声明式事务:从原理到选型的全面解析

编程式事务与声明式事务:从原理到选型的全面解析 这段时间后台收到好几个读者私信都是同一个问题Spring里的编程式事务和声明式事务到底有什么区别很多人是在面试的时候被问到项目里也在用Transactional但真要系统地讲清楚两者的原理、优缺点和适用场景又会觉得一团乱麻。说实话这个问题非常适合用来检验你对Spring事务机制的真实理解因为搞懂了它你才算真正摸到Spring事务的门槛。我在前几年的一次线上事故里就是用Transactional用得太顺手结果碰到内部自调用导致事务完全失效最后才老老实实回头把PlatformTransactionManager、TransactionTemplate的源码翻了个遍。这篇就把两者的原理、代码写法、坑点以及面试时的回答思路一次讲透。1. 先理解编程式事务到底是什么1.1 编程式事务的原始形态JDBC手动事务想搞清楚Spring的编程式事务得先回到没有Spring的年代。用原生JDBC操作数据库时事务控制的代码长这样Connection conn null; try { conn dataSource.getConnection(); // 关闭自动提交事务从这里开始 conn.setAutoCommit(false); PreparedStatement ps conn.prepareStatement(UPDATE account SET balance balance - 100 WHERE id 1); ps.executeUpdate(); PreparedStatement ps2 conn.prepareStatement(UPDATE account SET balance balance 100 WHERE id 2); ps2.executeUpdate(); // 全部成功统一提交 conn.commit(); } catch (Exception e) { // 有异常整体回滚 conn.rollback(); } finally { conn.setAutoCommit(true); conn.close(); }这套代码的核心逻辑很明显事务的开启、提交、回滚全部由开发者在代码里显式控制。这就是“编程式事务”的原型——事务逻辑和业务逻辑写在同一个方法里你决定事务什么时候开始、什么时候结束、什么时候回滚。这个写法的最大问题是一旦项目里的每个业务方法都要这样写一遍会产生大量重复代码。而且一旦漏掉rollback()数据就可能在异常状态下被部分写入后果很严重。Spring提供的编程式事务本质上就是把这段重复逻辑抽象成接口但依然保留了“开发者自己控制事务边界”的思想。1.2 Spring编程式事务的两种写法Spring里编程式事务通常有两种写法一种是用最底层的PlatformTransactionManager一种是更推荐、更高层的TransactionTemplate。先看底层的写法Service public class AccountService { Autowired private PlatformTransactionManager transactionManager; public void transfer() { // 手动定义事务属性 DefaultTransactionDefinition definition new DefaultTransactionDefinition(); definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); definition.setIsolationLevel(TransactionDefinition.ISOLATION_DEFAULT); definition.setTimeout(30); TransactionStatus status transactionManager.getTransaction(definition); try { // 写你的业务代码这里可以执行多条SQL accountDao.updateBalance(1, -100); accountDao.updateBalance(2, 100); // 一切正常手动提交 transactionManager.commit(status); } catch (Exception e) { // 出现异常手动回滚 transactionManager.rollback(status); throw e; } } }这种写法的优点是极度灵活你可以在同一个方法里连续提交多段事务甚至可以根据不同的业务分支走完全不同的提交/回滚逻辑。缺点是样板代码多如果每个方法都这么写依旧很繁琐。所以Spring官方更推荐用TransactionTemplate它相当于对底层API做了一次封装把“开启事务—执行业务—提交/回滚”的流程固定下来Service public class AccountService { Autowired private TransactionTemplate transactionTemplate; public void transfer() { transactionTemplate.executeWithoutResult(status - { accountDao.updateBalance(1, -100); accountDao.updateBalance(2, 100); }); } }看到这里你可能会觉得TransactionTemplate和Transactional差不多都是“把事务包起来”。但区别在于TransactionTemplate依然是在方法内部显式声明事务边界而Transactional是把事务边界声明在方法或类上由Spring通过代理在方法调用前后自动管理。这完全是两种思维模式。1.3 TransactionTemplate的可编程优势点在哪我用TransactionTemplate最多的一个场景是在循环里做分批提交。比如某个定时任务要处理十万条订单如果全部包在同一个事务里数据库连接会被长时间占用锁的范围也会很大很容易拖垮库。正确做法是每处理一千条提交一次public void batchProcess(ListOrder orders) { int batchSize 1000; for (int i 0; i orders.size(); i batchSize) { ListOrder subList orders.subList(i, Math.min(i batchSize, orders.size())); transactionTemplate.executeWithoutResult(status - { for (Order order : subList) { orderDao.process(order); } }); } }这种“分片提交”的需求用声明式事务就很尴尬因为你没法在同一个方法里让事务自动分段。虽然可以拆方法间接实现但代码结构和可读性都会被破坏。编程式事务的价值恰恰是在这些“事务边界需要动态变化”的场景里体现出来的。2. 声明式事务的精髓把事务变成切面2.1 Transactional的写法与直观效果声明式事务就是说你不需要在业务代码里写任何事务管理的语句只需要在方法或类上打一个Transactional注解Spring就会自动帮你把事务打开、提交、回滚。比如Service public class AccountService { Autowired private AccountDao accountDao; Transactional(rollbackFor Exception.class) public void transfer() { accountDao.updateBalance(1, -100); accountDao.updateBalance(2, 100); } }这段代码看起来和普通方法没有任何区别但Spring会在运行期给这个Service生成一个代理对象。外部调用transfer()时真正执行顺序是代理对象先开启事务然后调用真实的业务方法方法正常结束就提交抛出异常就回滚。业务代码里完全没有事务痕迹这就是“声明式”的含义。2.2 AOP代理让事务自动生效声明式事务的原理底层依赖Spring AOP。Spring在启动时扫描到Transactional注解后会判断这个类是否已经存在AOP代理如果没有就为其创建代理对象。这里的核心有两个动态代理如果你的Service实现了接口Spring默认用JDK动态代理如果没实现接口Spring用CGLIB生成子类代理。TransactionInterceptor这是Spring事务的“切面逻辑”它会读取Transactional上的属性传播行为、隔离级别、回滚规则、超时时间等然后在目标方法执行前调用transactionManager.getTransaction()方法正常执行完调用commit()捕获到异常就调用rollback()。也就是说Transactional并不神秘它只是把第一章里那段手动写的事务逻辑交给了TransactionInterceptor这个AOP切面去完成。这也是为什么Spring事务要求调用方必须走代理对象——如果你绕过代理直接调用目标类的内部方法事务切面根本不会执行。2.3 Transactional的关键属性传播行为和回滚规则这部分是面试的高频区也是大家最容易记混的地方。声明式事务最常用的几个属性属性参数值示例作用propagationREQUIRED、REQUIRES_NEW定义事务传播行为处理事务嵌套问题isolationREAD_COMMITTED、REPEATABLE_READ设置事务隔离级别控制并发可见性rollbackForException.class指定哪些异常触发回滚noRollbackForSomeCheckedException.class指定哪些异常不触发回滚readOnlytrue对只读事务做优化timeout5设置事务超时秒数超时自动回滚这里最容易被坑的是rollbackFor。Transactional注解默认只在遇到RuntimeException和Error时回滚遇到受检异常比如IOException时不会回滚。很多人会在业务方法里抛出一个自定义的受检异常结果发现数据居然提交了怎么排查都找不到原因。解决办法很明确写Transactional(rollbackFor Exception.class)让所有异常都触发回滚这是生产环境最基本的习惯。传播行为中的REQUIRED和REQUIRES_NEW我在项目里踩过坑。REQUIRED是默认值表示如果当前已经存在事务就直接加入当前事务REQUIRES_NEW则是无论如何都挂起当前事务新开一个独立事务。比如在某个总事务里调一个发送消息的方法你希望消息发送失败不影响主流程的数据提交那发送方法就应该标注REQUIRES_NEW让它在独立事务里执行。3. 编程式事务与声明式事务差异对比和选型思路3.1 一张表看清核心区别我在团队内部培训时习惯先用一张表给大家建立整体认知对比维度编程式事务声明式事务事务边界控制代码显式控制注解/配置声明代码侵入性业务代码中混入事务代码业务代码几乎无感知灵活性极高可在循环/分支中动态控制较低事务粒度固定为方法学习成本需要理解TransactionManager等API注解易学但原理理解门槛较高定位问题难度逻辑直观较容易排查需要理解AOP和代理排查难度较大适合场景批量处理、动态事务边界、复杂回滚策略大多数常规业务接口和方法级事务必须补充一点这并不意味着声明式事务“高级”或者“更好”两者定位完全不同。声明式事务解决的是“大多数事务场景下减少样板代码”的问题编程式事务解决的是“事务边界需要动态变化”的问题。3.2 按场景选型什么情况用什么从我实际项目经验来看可以按下面几条去判断常规单体接口比如用户下单、更新订单状态、转账优先使用Transactional。因为这种场景事务边界和方法边界高度重合用注解最简单团队维护成本低。定时任务里批量处理数据建议用TransactionTemplate做分批提交。因为大量数据塞进一个事务会让数据库压力巨大一旦中途失败回滚代价也非常高。需要混用多个事务且相互独立比如主流程更新库存、日志记录必须成功写入且不受主流程回滚影响可以考虑用TransactionTemplate配合REQUIRES_NEW比拆Service再调注解更可控。方法内部有复杂的条件分支某些条件下需要提交、某些条件下需要回滚甚至需要根据执行结果动态决定是否提交这只能靠编程式事务完成。声明式事务只能做到“方法结束就提交方法抛异常就回滚”粒度太粗。3.3 我见过的一种“混搭”写法实际工作中还有一个高频场景外层方法有Transactional但内部某个子任务需要立刻提交。如果直接用Transactional(propagation Propagation.REQUIRES_NEW)也能解决但有时子任务的提交点很灵活代码里不方便固定。这时候我会在子任务内部直接用TransactionTemplate并把它的传播行为设为REQUIRES_NEWpublic void mainBiz() { // 主事务由Transactional管理 orderDao.updateStatus(); // 子任务自己管理一个独立事务不受主事务影响 transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); transactionTemplate.executeWithoutResult(status - { messageDao.insertLog(); }); }这种做法的好处是把事务的控制粒度重新收回到代码层面但又不破坏外部方法整体的声明式事务结构。我第一次这么用的时候团队里有人质疑“为什么不用注解”后来发现这种写法在处理“部分提交、部分回滚”的需求时极其好用。4. 实操演示用TransactionTemplate实现一个可靠的分批事务任务4.1 先看一个完整的可运行案例下面这个例子我改自真实项目里的一个对账任务。业务需求是把订单表里所有未对账的订单同步到对账系统成功一条标记一条最后记录处理结果。注意这里不能把一万条订单都放在一个事务里因为任何一个订单失败会导致全部回滚业务上无法接受。我先创建一个简单的Spring Boot工程引入spring-boot-starter-jdbc已经包含了事务管理依赖然后在Service中注入TransactionTemplateService public class ReconciliationService { private static final int BATCH_SIZE 500; private final JdbcTemplate jdbcTemplate; private final TransactionTemplate transactionTemplate; public ReconciliationService(JdbcTemplate jdbcTemplate, TransactionTemplate transactionTemplate) { this.jdbcTemplate jdbcTemplate; this.transactionTemplate transactionTemplate; } public void reconcile() { ListLong orderIds queryUncheckedOrderIds(); int successCount 0; int failCount 0; for (int i 0; i orderIds.size(); i BATCH_SIZE) { ListLong batchIds orderIds.subList(i, Math.min(i BATCH_SIZE, orderIds.size())); try { transactionTemplate.executeWithoutResult(status - { for (Long id : batchIds) { // 调用对账接口这里只做演示用本地表更新代替 jdbcTemplate.update(UPDATE t_order SET checked 1 WHERE id ?, id); } }); successCount batchIds.size(); } catch (Exception e) { // 一批失败记录日志并跳过不能让整个任务中断 log.error(batch reconcile failed, ids {}, batchIds, e); failCount batchIds.size(); } } log.info(reconcile finished, success {}, fail {}, successCount, failCount); } private ListLong queryUncheckedOrderIds() { return jdbcTemplate.queryForList(SELECT id FROM t_order WHERE checked 0, Long.class); } }这段代码最关键的地方在于我把transactionTemplate.executeWithoutResult放在for循环内部每500条一个事务。如果某一批失败我只把这批标记为失败继续处理后面的数据整个任务不会被某一个批次拖垮。4.2 execute和executeWithoutResult怎么选TransactionTemplate提供了两个核心方法execute和executeWithoutResult。区别很简单execute允许你在回调里返回一个结果对象executeWithoutResult的回调没有返回值。// 需要返回业务结果时 public Long calculate() { return transactionTemplate.execute(status - { Long count jdbcTemplate.queryForObject(SELECT COUNT(*) FROM t_order, Long.class); return count; }); } // 不需要返回结果时 public void update() { transactionTemplate.executeWithoutResult(status - { jdbcTemplate.update(UPDATE t_order SET checked 1); }); }在实际代码里我建议优先用executeWithoutResult因为它从签名上就避免了你忘记返回值的尴尬。如果需要返回结果再用execute。4.3 手动回滚什么时候你会需要它TransactionTemplate回调方法里有个TransactionStatus参数它有一个setRollbackOnly()方法。正常情况下只要回调抛出了异常事务就会自动回滚但如果你在回调里自己捕获了异常又不想让事务提交这时候就必须手动标记回滚public void updateWithCondition() { transactionTemplate.executeWithoutResult(status - { try { jdbcTemplate.update(UPDATE t_order SET checked 1 WHERE id ?, 1001); int result jdbcTemplate.update(DELETE FROM t_order_item WHERE order_id ?, 1001); if (result 0) { // 明细删除失败但我不想抛异常又不想让上面的订单更新提交 status.setRollbackOnly(); } } catch (Exception e) { log.error(update failed, e); // 自己记录了日志但事务必须回滚 status.setRollbackOnly(); } }); }这可以说是编程式事务最体现灵活性的一个场景控制回滚不再依赖异常而是由代码状态直接决定。声明式事务要想实现类似效果就得特意在方法里抛异常逻辑上绕了一个弯。4.4 手动管理事务的完整代码模板如果你不想用TransactionTemplate也可以直接用PlatformTransactionManager手写一个完整的事务模板方法这在一些极端情况下比如需要完全自定义事务生命周期很有用public T T executeInTransaction(SupplierT businessAction) { DefaultTransactionDefinition definition new DefaultTransactionDefinition(); definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); TransactionStatus status transactionManager.getTransaction(definition); try { T result businessAction.get(); transactionManager.commit(status); return result; } catch (Exception e) { transactionManager.rollback(status); throw e; } }这个模板相当于自己实现了一个简化版TransactionTemplate。我在给团队做技术分享时经常用它做例子因为它能让你直观地看到“事务生命周期”到底包含哪几个环节。理解了这个模板你再去看TransactionTemplate源码会觉得豁然开朗。5. 面试高频坑为什么你的事务总是不生效5.1 同类内部方法调用Transactional为什么会失效这个问题几乎是Spring面试必考题。看下面这段代码Service public class OrderService { Transactional(rollbackFor Exception.class) public void createOrder(Order order) { orderDao.insert(order); updateStock(order.getProductId()); } Transactional(propagation Propagation.REQUIRES_NEW) public void updateStock(Long productId) { stockDao.deduct(productId); } }表面上看调用createOrder()会先开启事务然后调用updateStock()时会因为REQUIRES_NEW新开一个独立事务。但实际运行时updateStock()上的事务完全不会生效。原因是Spring容器里注入的OrderService是一个代理对象外部调用createOrder()时调用的是代理对象的方法所以事务切面能生效。但createOrder()内部调用updateStock()时this.updateStock()直接调用了目标类的真实方法并没有经过代理对象事务切面自然就不会执行。解决办法无非三种把updateStock()拆到另一个Service类里通过注入另一个Service的代理对象来调用。在类中注入ApplicationContext通过applicationContext.getBean(OrderService.class).updateStock()获取代理对象再调用。自己注入TransactionTemplate在updateStock()内部实现编程式事务。这种“自调用失效”的问题其实也是编程式事务的一个优势TransactionTemplate不依赖代理所以不存在自调用失效的坑。5.2 异常被try-catch吞掉数据照样提交另一个高频问题是很多人明明加了Transactional却发现异常发生后数据没有回滚。典型代码Transactional(rollbackFor Exception.class) public void pay() { try { accountDao.deduct(); messageDao.sendMessage(); } catch (Exception e) { // 日志记录一下不往外抛 log.error(pay failed, e); } }这里messageDao.sendMessage()抛了异常被catch住了方法正常返回事务切面觉得“方法没抛异常一切正常”于是提交事务。结果就是accountDao.deduct()的扣款被执行了数据还提交了。处理方案有几个层次如果真的希望事务回滚那就不应该在业务逻辑里吞掉异常让异常向上抛给事务切面。如果必须记录异常又必须回滚可以借助TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()显式标记回滚。如果这部分异常本来就不影响主流程那说明它本就不该属于当前事务应该拆出去用REQUIRES_NEW或编程式事务处理。这个坑在生产环境尤其危险因为它不会报错只会悄无声息地产生脏数据。5.3 自调用、循环依赖、三级缓存这几个问题怎么串起来搜索热词里经常出现“Spring三级缓存原理”和“循环依赖”它们和事务确实有关系。Spring解决循环依赖靠的是三级缓存第一级缓存存放完整的单例对象第二级缓存存放早期的半成品对象第三级缓存存放对象工厂。在Bean创建过程中如果A依赖B、B依赖ASpring会提前把A的早期引用暴露出来让B完成注入之后再对A进行属性填充和初始化。问题在于Transactional注解是通过后置处理器生成代理对象的。正常情况下Bean在创建完成后会被代理最终放进一级缓存的是代理对象。但在循环依赖场景下如果B提前注入的是A的早期引用而这个早期引用还没有经过代理增强那么B持有的A就不是最终带事务功能的代理对象。Spring 在循环依赖场景下做了很多细节处理比如earlySingletonReference、singletonFactories的使用但依然可能出现“注入的是原始对象而非代理对象”的情况导致Transactional失效。这也是为什么很多资深开发在遇到循环依赖时第一反应是先消除循环依赖而不是去偏信所谓的“三级缓存能解决所有问题”。它能解决单例的循环依赖但解决不了代理失效带来的隐性风险。5.4 面试回答的思路建议如果面试官问你“编程式事务和声明式事务有什么区别”建议按照这个顺序回答先说本质区别编程式事务在代码里显式控制事务边界声明式事务通过注解AOP自动管理事务。说各自的实现方式编程式事务主要用PlatformTransactionManager和TransactionTemplate声明式事务主要用Transactional。说各自的优缺点声明式事务开发效率高、侵入性低但粒度固定为方法、依赖代理机制编程式事务灵活能动态控制事务边界但代码侵入性强样板代码多。结合经验比如你处理过批量分片提交用过TransactionTemplate也踩过自调用导致Transactional失效的坑最后补充一句“后来我倾向于在定时任务和批量场景用TransactionTemplate在普通接口方法上用Transactional”。最后可以提一下循环依赖和代理失效的关联这一下就能和只会背概念的候选人拉开差距。6. 常见问题与排查技巧实录6.1 高频问题速查表下面这些场景全都来自我平时答疑和排查问题的真实记录问题现象可能原因排查与解决方案Transactional方法内调用另一个Transactional方法后者事务不生效同类内部自调用没有走代理对象拆分Service、注入自身代理或改用TransactionTemplate方法明明抛了异常数据库却提交了异常在业务代码里被catch或抛出的不是让Spring识别回滚的异常类型不要把异常吞掉检查rollbackFor是否配置正确外层事务提交后内层事务想独立提交但失败了错误使用了REQUIRED内层事务被合并进外层内层方法改用REQUIRES_NEW或TransactionTemplate批量处理大量数据时数据库锁等待严重所有数据都在一个事务里执行用TransactionTemplate分批提交控制在500~1000条一批定时任务中多条数据一部分成功一部分失败想部分提交整个任务被一个大事务包裹重构为循环多事务用编程式事务控制每个批次的提交状态同一个事务里先更新再查询查不到刚更新的数据数据库隔离级别太低或事务传播导致你读到的其实是旧快照检查隔离级别设置或根据业务调整读的位置报错Transaction rolled back because it has been marked as rollback-only内层REQUIRED事务已标记回滚外层尝试提交时被拒绝不要把需要独立成败的子任务放在REQUIRED里改成REQUIRES_NEW6.2 我总结的几个实战心得第一点能用声明式就用声明式遇到需要动态边界时再切编程式。不要把“灵活性”当成过度设计的理由事务代码放得越少出问题的概率就越低。第二点如果在一个类里同时出现Transactional和TransactionTemplate一定要时刻清楚当前这段逻辑走的是代理还是直接调用。我见过一个同事在同一个Service里写了一个Transactional方法方法内又用TransactionTemplate开了新事务最后回滚时外层把自己的修改也带进来了排查了很久才发现是代理执行顺序的问题。第三点在项目里配置统一的事务异常回滚策略。我建议在基础类或配置层统一约定所有事务方法使用rollbackFor Exception.class同时配合全局异常处理器兜底保证没有异常被静默吞掉。第四点排查事务问题时先把日志级别开到DEBUG。Spring事务切面在DEBUG级别下会打印非常详细的事务开启、提交、回滚日志。比如org.springframework.transaction.interceptor的日志能直接看到事务拦截器执行了哪些逻辑这比盲猜代码高效得多。第五点多看看TransactionSynchronizationManager类。它是Spring事务同步机制的核心线程本地变量、事务资源绑定都发生在这一步。理解了它你就理解了为什么同一个线程内的方法能共享事务也能理解为什么事务必须从代理对象入口进入。关于编程式事务和声明式事务我最后再分享一个小建议刚入行时别急着写Transactional先手写两遍TransactionTemplate的完整流程把getTransaction、commit、rollback这些动作刻在脑子里。等你真正理解了事务的生命周期再回到注解上你会发现自己对Spring事务的理解完全不一样了。
返回列表