ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring事务实战:从ACID原理到失效场景与分布式事务选型

Spring事务实战:从ACID原理到失效场景与分布式事务选型 做后端开发这些年Spring事务是无论如何都绕不开的一道坎。哪怕你日常工作只是写CRUD只要一个接口里同时操作了两张表事务就一定会在某个深夜跳出来教你做人。Spring事务封装了数据库底层的提交、回滚、锁、隔离级别等概念用好了能让你的数据稳如老狗用不好就是线上数据一堆烂账半夜被运维电话叫醒。这篇文章我想把所有关于Spring事务的实战经验一次性说透从最基础的“事务到底在解决什么问题”开始到声明式事务和编程式事务的正确姿势再到事务传播行为、失效场景、隔离级别、日志排查最后是分布式事务的落地选型。内容比较长但每一节都是实际敲过代码、踩过坑之后沉淀下来的东西看完之后再去处理事务相关的需求心里会踏实很多。1. 先搞清楚Spring事务到底在解决什么问题1.1 一次崩溃引出的经典场景拿电商系统里最常见的下单流程来说用户点击“提交订单”后端要做的事包括创建订单记录、扣减库存、生成支付流水、更新用户优惠券状态。这一串操作分布在四五张表里任何一步失败前面的操作如果已经写进数据库数据就乱了。假设没有事务扣库存成功之后创建订单因为参数校验失败抛出异常结果就是库存莫名其妙少了但订单根本不存在。更麻烦的是用户过一会儿又下了一单发现库存已经不够了但后台查订单压根没有这笔消费记录整个系统就变成了一个“薛定谔的库存”。事务就是为了解决这类问题而存在的把多个数据库操作捆绑成一个“原子操作”要么全部成功要么全部回滚到操作之前的状态。数据库事务本身提供四大特性也就是教科书里反复强调的ACID原子性Atomicity一组操作要么全部生效要么全部不生效。一致性Consistency事务执行前后数据始终满足业务规则和约束。隔离性Isolation多个事务并发执行时相互之间应该保持适当的隔离。持久性Durability事务一旦提交数据变更就是永久的即使系统崩溃也不会丢失。这四个特性里原子性、一致性、持久性相对好理解难点集中在隔离性上两个事务同时写同一行数据怎么办一个事务在中间过程中读到了另一个事务还没提交的修改怎么办后面讲隔离级别的时候我会展开细说。1.2 Spring在事务里扮演的角色到底是啥经常有人把Spring事务和数据库事务混为一谈但其实这俩不是一个层面的东西。数据库事务是InnoDB引擎提供的能力Spring事务只是在这层能力之上做了一层调度和封装帮你决定什么时候发起BEGIN什么时候执行COMMIT什么时候执行ROLLBACK以及事务边界内的数据库连接怎么管理。打个比方数据库事务像一辆车Spring事务像司机。车本身能跑但谁踩油门、谁踩刹车、什么时候挂挡得有个统一控制的人来操心。没有Spring的时候你得自己在代码里写Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); // 执行各种SQL conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); }每个业务方法都这么写一遍非常折磨人而且只要忘记在某个分支上写rollback()数据就出问题。Spring通过IOC和AOP把这段重复劳动封装掉了你只需要在一个方法上加Transactional注解Spring会创建一个代理对象在进入方法之前自动开启事务方法正常返回则提交方法抛出异常则回滚。这就是声明式事务的核心逻辑。它的底层依赖Spring AOP的代理机制这句话现在听起来没什么感觉但到后面讲“事务失效场景”时你会发现几乎所有经典的失效问题根源都出在“代理”这两个字上。2. 声明式事务和编程式事务怎么选2.1 Transactional的用法和默认行为日常开发中绝大多数场景用声明式事务就够了也就是直接加注解。用法非常简单Service public class OrderService { Transactional(rollbackFor Exception.class) public void createOrder(OrderCreateRequest request) { orderMapper.insert(request.getOrder()); stockMapper.deduct(request.getSkuId(), request.getCount()); couponMapper.consume(request.getUserId(), request.getCouponId()); } }这里有一个特别值得注意的默认值问题Transactional默认只在抛出RuntimeException和Error的时候回滚对于受检异常比如FileNotFoundException、SQLException是不回滚的。这个设计让很多刚接触Spring的人吃过亏方法里抛了个受检异常代理一看“哎这个异常不在回滚列表里”直接给提交了数据就这么错了。所以我的建议是公司内部项目中统一约定业务方法上加注解时一律显式写上rollbackFor Exception.class。不要依赖默认值不要觉得自己一定记得住这个规则因为代码是合作出来的你不知道别人哪天在方法里抛了一个受检异常。另外Transactional有几个常用的配置项需要留意propagation事务的传播行为默认REQUIRED后面专门讲。isolation事务隔离级别默认跟随数据库。timeout事务超时时间默认-1表示不超时。线上建议设置一个合理的超时时间避免慢SQL把事务拖得很长。readOnly是否为只读事务设置为true时会对数据库做优化但前提是事务内确实没有增删改操作。readOnly这个属性有一些误解它不是强制不允许写操作而是给数据库一个提示让MySQL根据只读去优化一些场景比如不分配回滚段之类的。如果你在readOnly true的事务里执行了INSERT或UPDATE大多数情况下MySQL并不会直接报错但语义上不推荐这么做因为结果不可预期。2.2 编程式事务TransactionTemplate的使用姿势声明式事务在大部分场景够用但有一种情况你会想要编程式事务同一个方法里一部分操作需要事务保护另一部分操作不需要而且你希望手动控制提交的时机还希望在提交失败后做一些额外的善后工作。Spring提供了TransactionTemplate来支持这种细粒度控制。先注入一个模板对象Service public class PaymentService { private final TransactionTemplate transactionTemplate; public PaymentService(TransactionTemplate transactionTemplate) { this.transactionTemplate transactionTemplate; } public void processPayment(PaymentRequest request) { String paymentId transactionTemplate.execute(status - { // 事务内扣款、写流水 accountMapper.deduct(request.getUserId(), request.getAmount()); paymentMapper.insert(PaymentRecord.create(request)); // 如果业务上判定需要回滚可以手动调用 if (request.getAmount() 10000) { status.setRollbackOnly(); return null; } return paymentIdGenerator.nextId(); }); // 事务外发送通知、异步对账等 notifyService.sendSuccessMessage(paymentId); } }TransactionTemplate.execute()接收一个TransactionCallback回调里返回什么事务提交后execute就返回什么。如果想中途放弃事务调用status.setRollbackOnly()Spring会放弃提交并执行回滚。我个人的经验是能用Transactional就尽量用注解只有遇到“部分逻辑不能进入事务”“事务提交后需要基于结果做一些外部动作”等复杂场景再考虑TransactionTemplate。编程式事务给你灵活性的同时也把边界责任交到了你手里代码可读性会差一些而且容易忘记关闭模板需要格外仔细。3. 事务传播行为代码一旦嵌套事情就开始复杂了3.1 七种传播行为到底各是什么意思单个方法的事务很好理解方法一进去开事务一出来就提交或回滚。但实际业务里方法之间是有调用关系的ServiceA.methodA()调用了ServiceB.methodB()两个方法上都有事务注解那执行时到底应该共用一个事务还是各自开各自的事务这就涉及到传播行为。Spring定义了七种传播级别传播级别语义典型使用场景REQUIRED当前有事务就加入没有就新建。默认值绝大多数业务方法SUPPORTS当前有事务就加入没有就以非事务方式执行查询类方法可有可无MANDATORY当前必须有事务否则抛异常强制在事务内执行的操作REQUIRES_NEW挂起当前事务新建一个独立事务记录操作日志、发MQ等不希望被主事务回滚的动作NOT_SUPPORTED挂起当前事务以非事务方式执行内部为了减少锁竞争NEVER不能存在事务否则抛异常测试或特殊校验逻辑NESTED有事务时保存点方式嵌套回滚批量处理希望部分回滚这里的重点有两个REQUIRES_NEW和NESTED。很多人会把它们搞混其实差别非常大。用REQUIRES_NEW内层事务被挂到一个全新的连接上完全独立提交和回滚。外层事务回滚时内层已经提交的成果不会被回滚。用NESTED内层事务不是“新事务”而是基于外层事务的保存点Savepoint来实现嵌套内层回滚时只会回滚到保存点外层后续可以继续提交但如果外层最终回滚了内层的成果也会跟着一起没。3.2 两个最容易踩坑的嵌套场景第一个坑来自REQUIRED嵌套导致的“大事务回滚问题”。A方法有事务调用B方法B方法也有事务且是默认的REQUIRED那么B会加入A的事务两者共享一个物理事务。如果B内部抛出异常整个A事务就被标记为rollback-only哪怕你在A里用try-catch捕获了B的异常最后A提交时依然会抛UnexpectedRollbackException因为事务已经被标记为只能回滚。这种场景最常见的报错是Transaction silently rolled back because it has been marked as rollback-only很多人第一次遇到这个错误都懵了明明捕获了异常方法也正常返回了为什么最后还是回滚原因就是rollback-only标记不是本地变量它属于整个共享事务。理解了这一点你会明白为什么在嵌套调用里捕获异常后再继续跑业务是个危险操作。第二个坑来自REQUIRES_NEW连接被占满的问题。内层用独立事务意味着要额外占用一个数据库连接。如果外层事务里循环100次调用一个REQUIRES_NEW方法而连接池最大只有20第21次就会因为拿不到连接而卡死。所以REQUIRES_NEW要慎用尤其不能放在大循环里。如果确实需要“批量处理一部分失败不影响另一部分”我更倾向于使用NESTED。它不额外占用连接失败时只回滚到保存点对连接池的压力小得多。当然NESTED依赖数据库对Savepoint的支持MySQL的InnoDB是支持的。4. 再聊透事务失效的七个经典现场Spring事务失效是面试高频题也是线上事故高发区。下面这些失效场景全部来自真实项目每一条都值得你在代码里做一次排查。4.1 同一类内部调用导致this调用绕过了代理这是最常见也最隐蔽的失效方式。Spring声明式事务基于AOP代理本质上你调用的orderService.createOrder()实际上是代理对象的方法不是原始Bean的方法。但如果你在OrderService里写了一个普通方法直接通过this调用另一个带Transactional的方法这时候this指向的是原始Bean代理根本没进来事务自然就不生效。Service public class OrderService { public void outerMethod() { // 这里通过this调用Transactional完全不生效 this.innerMethod(); } Transactional(rollbackFor Exception.class) public void innerMethod() { orderMapper.insert(...); } }解决办法有几种把innerMethod拆到另一个独立的Service类里调用或者注入自己的代理Autowired自己来调用或者用ApplicationContext.getBean()获取代理对象再调用。最简单的就是拆类这也是代码结构上最清晰的做法。4.2 方法不是public或者类没有交给Spring管理Spring官方文档明确说了Transactional放在public方法上才会被代理拦截。原因不难理解CGLIB或JDK动态代理生成的子类只能重写public和protected方法private方法根本不能被子类调用。如果你把事务注解写在private方法上IDE可能会给个Warning但多数人不会注意注解就悄悄失效了。另外如果类本身没有加Service、Component之类的注解Bean没有注册进Spring容器那你通过new创建对象调用方法Spring对这个对象一无所知事务框架根本不会介入。4.3 异常被catch住事务压根看不到异常异常被捕获这个问题比诡异报错更麻烦因为代码运行得“很成功”数据却错了。下面这段代码就是典型反面教材Transactional(rollbackFor Exception.class) public void updateStock() { try { stockMapper.deduct(skuId, count); // 这里抛了异常但被吞掉了 throw new RuntimeException(库存不足); } catch (Exception e) { log.error(扣库存失败但事务不会回滚因为异常没抛出去); } }Spring判断是否回滚的依据只有一个方法有没有把异常抛到代理外面。一旦你在方法内部把异常吞了代理那边收到的是“方法正常返回”的信号就执行提交了。所以如果你的业务逻辑确实需要捕获异常做善后处理完一定要throw e或者手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。4.4 数据库存储引擎不支持事务这个坑在今天不算太多因为MySQL默认InnoDB引擎已经支持事务。但如果你接手的老系统里某张表用的是MyISAM引擎那在这张表上的操作无论如何都不会有事务效果。MyISAM连BEGIN都不支持你的Transactional注解对它来说就是一张废纸。排查方法很简单执行SHOW TABLE STATUS LIKE your_table_name;看Engine字段是不是InnoDB。顺便提醒一句不要为了图省事把表全部改成InnoDB要考虑数据量、索引、全文检索等需求但业务核心表基本都应该是InnoDB。4.5 事务传播行为设置成了不会开启事务的值有些同学会把Transactional(propagation Propagation.SUPPORTS)用在写操作上意思是“有事务就加入没事务就算”。如果调用链上游没有事务这个方法就会以非事务方式执行写操作一旦出错根本不会回滚。这是一个容易忽视的配置问题。写操作的方法传播级别最好用默认的REQUIRED或者干脆显式写REQUIRED不要让“是否开启事务”变成一个不可预期的开关。4.6 多线程环境下的独立连接导致事务隔离Spring事务默认和线程绑定事务信息保存在ThreadLocal里一个事务对应持有这个事务的线程所用的那一个数据库连接。如果你在事务方法里起了一个子线程在子线程里执行数据库操作这个子线程拿到的是另一个独立的连接它不参与主线程的事务也不会跟着主线程一起提交或回滚。Transactional(rollbackFor Exception.class) public void createOrder() { orderMapper.insert(...); new Thread(() - stockMapper.deduct(...)).start(); // 子线程里扣减库存失败主线程事务也不会感知到 }多线程共享事务是一个复杂的问题没有银弹。常规做法有两种一是避免在事务里使用子线程处理数据库操作把异步动作放到事务提交之后再执行二是设计独立的异步事务配合消息队列做最终一致性。4.7 代理模式的底层限制和误配置Spring Boot 2.x默认使用CGLIB代理Spring Boot 3.x强制使用CGLIB。如果你还在配置EnableTransactionManagement(proxyTargetClass false)强制使用JDK动态代理而你的类没有实现接口那代理创建就会失败或者不生效。另外如果类本身是final的CGLIB没法生成子类代理也会失败。这类问题的共性排查方式是启动时加入-Dspring.aop.proxy-target-classtrue或者确认代理类是否成功生成再或者直接在Bean里打印一下自己对象的Class看是否是代理类。5. 事务隔离级别一次讲清楚5.1 四种隔离级别与三个并发问题事务隔离级别解决的问题是并发事务之间的干扰。这种干扰总结下来是三个问题脏读、不可重复读、幻读。脏读事务A读到了事务B未提交的修改。如果B回滚了A读到的数据就是错的。不可重复读事务A在同一事务内两次读取同一行数据结果不一样因为中间被事务B修改并提交了。幻读事务A按条件查询一批数据期间事务B插入了符合条件的新记录A再次查询时多出来几行“幻觉”数据。Spring事务里对应四种隔离级别隔离级别能防脏读能防不可重复读能防幻读READ_UNCOMMITTED否否否READ_COMMITTED是否否REPEATABLE_READ是是否MySQL默认可以防SERIALIZABLE是是是可以这样记忆隔离强度从低到高性能开销也是从低到高。SERIALIZABLE最安全代价是并发能力极差基本是串行化了生产环境除非特殊数据校验一般不会用。5.2 MySQL InnoDB的默认行为和MVCC特别说明一个容易造成困惑的点MySQL InnoDB默认的隔离级别是REPEATABLE_READ但在InnoDB里这个级别通过MVCC多版本并发控制机制已经解决了大部分幻读问题尤其是快照读普通的SELECT语句。MVCC的原理一句话概括每一行数据在更新时都会生成一个新的版本读操作根据事务开始时的快照来决定应该看到哪个版本的数据。所以同一个事务里普通的SELECT多次执行结果是稳定的即使其他事务插入或修改了新数据也不受影响。但MVCC只对快照读生效。如果你的SQL使用了SELECT ... FOR UPDATE或者INSERT ... ON DUPLICATE KEY UPDATE这类当前读读到的就是最新版本数据此时REPEATABLE_READ级别下幻读保护需要依赖间隙锁Gap Lock和临键锁Next-Key Lock来实现。这也是为什么同样一个事务用普通SELECT和用FOR UPDATE查出来的数据可能不一样。生产环境里我建议不要轻易把隔离级别调整到SERIALIZABLE更不要随便降到READ_UNCOMMITTED。MySQL默认的REPEATABLE_READ在绝大多数场景是够用的。如果你希望每次读到最新已提交数据可以单独修改当前连接的事务隔离级别为READ_COMMITTED但要注意这是对整个Session生效的影响面比较大。5.3 Transactional的isolation配置注意点Spring允许在注解上指定隔离级别Transactional(isolation Isolation.REPEATABLE_READ) public void doSomething() { }这里有个很重要的经验不要过度依赖在应用层配置隔离级别。事务隔离级别最终要数据库支持才行而且应用层指定级别意味着每个数据库连接都可能会切换级别频繁切换会带来额外开销。更合理的做法是数据库端预设好默认隔离级别应用层只在个别特殊业务里指定并且做好注释说明为什么要这里更高或更低。还有一点READ_COMMITTED和REPEATABLE_READ在MySQL上的行为差异在锁方面体现得很明显。READ_COMMITTED下InnoDB的间隙锁定相对少死锁概率更低并发能力略好但你可能需要承受不可重复读的问题。具体选哪个取决于业务对一致性的要求。比如账户余额类查询通常不能接受不可重复读优先考虑REPEATABLE_READ普通订单状态查询READ_COMMITTED就够用了。6. 项目落地事务日志排查与性能优化6.1 如何在日志里确认事务真正开启线上事务出问题时第一步是确认事务到底有没有开启。Spring的日志默认不会输出事务边界需要打开对应包的日志级别logging: level: org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG org.springframework.transaction: DEBUG打开后能看到类似这样的日志Acquired Connection [...], not (re)using existing Registering transaction synchronization for SqlSession Releasing transactional SqlSession after transaction Committing JDBC transaction on Connection [...]看到Committing JDBC transaction和Rolling back JDBC transaction on Connection说明事务确实在起作用。如果只有SQL执行日志没有事务开启和提交的回执就要怀疑上节说的失效问题了。另一个排查手段是直接用数据库端观察。在MySQL里执行SHOW ENGINE INNODB STATUS;查看TRANSACTIONS段能看到当前活跃的事务和持有的锁。线上操作这个要用只读权限的账号最好在业务低峰期做大事务会在这份状态里留下很明显的痕迹。6.2 大事务的危害和优化方向大事务没有统一的定义通常指的是“执行时间长”或“涉及数据行数多”的事务。大事务的危害主要体现在三方面持有的数据库连接时间更长连接池很快被占满。持有的行锁或间隙锁范围更大其他事务被阻塞系统整体吞吐下降。undo log会累积大量数据可能撑爆回滚段的存储空间也拖慢后续的版本链读取。优化大事务我的常规思路是先找出事务代码里哪些操作不是必须放在事务里的。比如一进入方法就调用远程RPC接口这个接口可能需要几百毫秒但你只是拿到一个结果之后再操作数据库那么这段远程调用完全应该放在事务外面。把不相关的耗时操作挪出事务可以大幅缩短事务持有连接和锁的时间。其次考虑分批提交。一次批量插入十万条数据全部放在一个事务里回滚和提交都极其笨重。如果业务允许可以拆成每1000条一个事务失败时只需重试失败批次。最后是索引。事务里更新的字段如果没有走索引会导致范围锁升级加大并发冲突概率。检查一下事务SQL的执行计划确保关键查询用上了合适的索引。6.3 嵌套事务的性能陷阱嵌套事务除了有我们前面说的回滚语义问题还有显而易见的性能问题事务嵌套层级越深持有连接的时间越长事务管理器需要处理的同步点越多稍不留神连接就被线程池里的几个大事务耗尽。我写代码时的一个硬性约定所有事务方法必须是扁平的。也就是说一个事务方法内部只做数据操作不做业务编排不调用另一个同样需要事务的方法。业务编排放在一个非事务的外壳方法里由它来协调多个顶层事务方法。这样虽然牺牲了一点代码上的模块化但换来的是事务边界极其清晰排查问题时一眼就能看清。如果确实需要在一个事务方法里调用另一个事务方法必须先想清楚它们之间的失败关联关系确认好传播级别再动手。不要所有方法都放着默认的REQUIRED等到线上出了问题才来排查“为什么整个链路回滚了”。7. 单库事务搞定了分布式事务怎么办分布式事务是事务话题里最让人头疼的部分也是面试官最喜欢问“刁难题”的地方。它的核心难点在于本地事务只能管理单个数据库连接但微服务架构下订单、库存、积分、物流可能分散在不同的库、不同的服务跨服务的数据一致性没法靠单库事务解决。7.1 分布式事务为什么这么难一个下单操作通常经历以下链路订单服务写入订单表。库存服务扣减库存表。积分服务增加用户积分。这三个操作分布在三个独立数据库里每个库都有自己的事务不可能用同一份BEGIN和COMMIT去控制全局。这时就出现了两种流派的思想一种是要强一致希望全局事务要么全部成功要么全部失败另一种是追求最终一致允许中间状态但一段时间后数据能自动对齐。强一致方案通常性能和可用性受损最终一致方案更贴合高并发业务。7.2 常见分布式事务方案横向对比我把实际项目中能落地的方案梳理了一遍列在下面方案一致性类型侵入性性能适用场景2PC/XA强一致高需要数据库支持XA协议差对一致性要求极高的内部系统TCCTry-Confirm-Cancel最终一致高每个业务都要实现三个方法中资金类、账务类核心链路本地消息表 MQ最终一致中好大多数互联网业务比如订单和库存Saga最终一致中中长流程、多步骤业务编排Seata AT模式最终一致低中上追求自动化补偿和低侵入2PC的问题在于它的“两阶段提交”过程需要事务协调器协调器一旦出问题参与者就会一直卡在待提交状态可用性极差。现在业务系统里直接使用JTA/XA的已经很少了。TCC需要你为每个服务写Try、Confirm、Cancel三套逻辑代码量成倍增加。但资金类业务里这种手动补偿反而最可靠因为它把业务判定权完全放在业务团队手里。Seata是我个人比较推荐的中间件方案。AT模式的工作原理可以这样理解它会在业务SQL执行前后自动生成undo_log记录数据镜像然后通过全局事务协调器控制分支事务异常时自动反向补偿。侵入性低业务代码改动少适合追求效率的团队。7.3 订单和库存这个经典场景到底怎么落地以“订单与库存”为例我给出一个生产中落地过的方案。核心思路是本地消息表加MQ做最终一致再加一个定时对账兜底。步骤拆开是这样的订单服务在本地事务里完成两件事插入订单记录同时插入一条“扣减库存消息”到本地消息表。这两个操作在同一个本地事务里保证订单一定存在对应消息。后台任务扫描消息表把消息发送到MQ消费者是库存服务。库存服务收到消息后扣减库存扣减成功发一条结果通知。如果消息发送失败定时任务会重复扫描重新投递。如果库存扣减失败订单服务会通过回调或定时任务感知到然后给订单置为失败状态并回滚优惠券等资源。这个方案最核心的锦囊就是“本地消息表和业务操作共用一个本地事务”它把分布式事务问题降级成了消息可靠投递问题。虽然做不到强一致但订单从创建到库存扣减成功间隔通常只有几十毫秒用户体验几乎无感而且还不需要引入TCC那样多的代码量。需要特别注意的是这个方案依赖消费者具备幂等性。同样的消息可能被投递多次消费端必须通过业务唯一键或者去重表来保底。最后说两句落地心得项目里真正频繁出问题的地方其实集中在事务失效和大事务两点上。写代码的时候多想一想当前的调用链是不是能形成代理多想一想当前这个事务方法会不会变成一个持有几千条记录锁的怪物。Spring事务用好了它就是保证数据一致的利器用不好它就是数据脏乱差的温床。我个人在实际项目里最常用的一套组合拳是业务方法一律Transactional(rollbackFor Exception.class)事务方法保持扁平化复杂跨库链路优先本地消息表加MQ绝不把外部RPC调用放在事务里面。这套东西看着简单但把它坚持下来线上关于数据一致性的告警会减少九成以上。
返回列表