ARTICLE DETAIL

资讯详情

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

Spring事务失效与分布式事务实战:从传播行为到最终一致性

Spring事务失效与分布式事务实战:从传播行为到最终一致性 1. 从一次线上事故说起事务失效有多可怕做后端开发的朋友对Spring事务肯定不陌生。但“熟悉”和“真的会”之间隔了两道墙一道是各种各样的失效场景一道是分布式状态下的一致性难题。我见过太多次线上事故不是SQL写错而是事务静悄悄失效了数据出现半个操作、半个结果的尴尬状态。举个例子一个订单系统在扣减库存的同时写入订单记录结果库存扣了订单表却因为一个字段超长报错。如果事务生效库存会自动回滚如果事务失效或者根本没配上那库存就白白少了。更麻烦的是这类问题往往不是立刻爆发而是积累到某个阈值才被人发现这时候补数据已经难上加难。这篇文章不讲那种网上到处抄的“Spring事务入门”而是把事务从原理到实战到分布式场景拆开揉碎把我多年踩坑记录里最值钱的部分都拿出来。不管你是刚写Spring Boot不久的新手还是被分布式事务折磨过的老手应该都能从里面找到自己需要的答案。我会把传播行为、隔离级别、注解失效审查、事务消息这些核心点结合真实场景一条条过一遍。2. 事务的本质与Spring事务的底层运转逻辑2.1 为什么我们需要事务要理解事务先看一个生活里的例子。你用手机银行转账从A账户扣了1000元往B账户加1000元。如果系统在扣完A账户之后突然断电B账户的钱没加上你会不会疯掉银行系统绝对不会允许这种“只扣不加”的情况发生。这就是事务存在的意义一组操作要么全部成功要么全部失败不能只执行其中一部分。数据库事务有四个经典特性也就是ACID原子性Atomicity事务里的操作是不可分割的整体要么全做要么全不做。一致性Consistency事务执行前后数据的完整性约束不被破坏。比如转账前后A和B的账户余额总和必须不变。隔离性Isolation多个事务并发执行时一个事务的内部操作对另一个事务不可见彼此隔离。持久性Durability事务一旦提交对数据的修改就是永久性的即使系统崩溃也不会丢失。数据库层面事务是靠日志机制和锁机制实现的。日志用来做回滚或重做锁用来控制并发访问。但数据库的事务是面向单机、单连接方式的在Java应用里往往一个业务操作要跨多个Mapper操作数据库事务就需要从数据库层上升到业务层。Spring做的就是把数据库事务的能力包装成一个通用的编程模型让你不用关心具体用的是JDBC、MyBatis还是JPA一套注解就能声明事务边界。2.2 Spring事务的本质是AOP代理很多人以为Transactional是Spring自动帮你在方法外面包了一层事务逻辑。这个理解方向是对的但不够底层。Spring事务的核心机制是AOP动态代理——你调用的其实不是一个原始的业务Bean而是一个被代理过的Bean。代理对象会在目标方法执行之前开启事务在方法正常返回之后提交事务在方法抛出异常之后回滚事务。这里有个关键点如果代理不生效事务就一定失效。常见的SpringBootApplication下Spring会扫描带Transactional的Bean为它们生成代理对象默认用的是CGLIB代理。从Spring Boot 2.x开始CGLIB已经是默认代理方式不需要额外配置。代理的绕行逻辑可以简单理解为外部调用进入Spring容器拿到的是代理对象。代理对象开启事务拿到数据库连接把连接的自动提交autoCommit关闭。代理对象调用目标业务方法执行业务逻辑和SQL操作。方法正常返回代理对象提交事务恢复连接状态把连接归还连接池。方法抛出异常且异常类型符合回滚规则代理对象执行回滚。Spring的TransactionInterceptor是整个事务逻辑的核心它负责事务的开启、提交、回滚、挂起和恢复。在事务执行时事务相关的状态会保存在TransactionStatus对象和ThreadLocal变量中保证同一个线程里多个数据库操作能共享同一个事务。2.3 事务管理器和数据源的关系Spring事务需要配合一个事务管理器使用。在Spring Boot中你只要引入了spring-boot-starter-jdbc或spring-boot-starter-data-jpaSpring Boot会自动为你配置好DataSourceTransactionManager。如果你用的是MyBatis通常也是配置DataSourceTransactionManager。事务管理器负责协调数据源连接、事务边界和提交回滚操作。一个常见误区是在多数据源的情况下事务管理器必须明确指定用的是哪个数据源。否则Spring默认使用主数据源第二个数据源的写操作可能不在同一个事务里。注意事务是绑定在数据库连接上的而连接是从数据源连接池里获取的。Spring事务通过DataSourceUtils把连接绑到当前线程的ThreadLocal上因此同一个事务中多次执行SQL拿到的都是同一个连接。这也是为什么事务和线程密切相关——换了线程ThreadLocal里的连接就取不到了事务自然失效。2.4 Spring事务与数据库事务的衔接细节做了一个简单的代码示例就是配置一个事务可以看到日志输出Spring开启事务时会执行setAutoCommit(false)提交时执行commit()回滚时执行rollback()。这个过程本质上是把Java层的事务语义翻译成JDBC接口调用。如果你手动在代码里调用了DataSourceUtils.getConnection()获取连接然后又通过Connection.setAutoCommit(true)修改了自动提交状态就可能在Spring提交事务之前提前提交了SQL破坏整个事务的一致性。种操作要绝对避免别去强行干预JDBC连接状态。3. 事务传播行为九种选择绕不开的REQUIRED与REQUIRES_NEW3.1 传播行为解决的问题传播行为解决的是“多个事务方法相互调用”时的事务边界问题。简单来说就是一个已经存在事务的方法去调用另一个事务方法后者该如何处理——是加入当前事务还是挂起当前事务另外新建一个事务Spring定义了七种传播行为再加上两种事务超时相关的通过Transactional(propagation Propagation.XX)指定。我用一个表整理一下最核心的几个传播行为含义典型场景REQUIRED如果当前存在事务则加入否则新建事务大多数业务方法默认值REQUIRES_NEW挂起当前事务新建一个独立事务日志记录、审计、异步通知等不能随主事务回滚的操作NESTED如果当前存在事务创建嵌套事务Savepoint机制局部回滚场景比如批量导入时跳过错误数据SUPPORTS当前有事务就加入没有就以非事务方式执行查询方法NOT_SUPPORTED如果有事务先挂起以非事务方式执行一些耗时操作、批量复制不占事务MANDATORY当前必须有事务否则抛异常要求必须在事务内执行的内部方法NEVER当前不能有事务否则抛异常一些禁止在事务中执行的操作3.2 REQUIRED和REQUIRES_NEW的经典取舍先聊REQUIRED。这是Spring的默认传播行为最直观的理解是“把多个方法合并到一个事务里”。比如Service public class OrderService { Transactional public void createOrder(Order order) { orderMapper.insert(order); inventoryService.deductStock(order.getProductId(), order.getQuantity()); } } Service public class InventoryService { Transactional(propagation Propagation.REQUIRED) public void deductStock(Long productId, Integer quantity) { inventoryMapper.deduct(productId, quantity); } }当createOrder调用deductStock时因为REQUIRED传播行为检测到当前线程已经有事务所以deductStock直接加入当前事务不创建新事务。也就是说createOrder方法内的两段数据操作要么一起提交要么一起回滚。这个场景符合绝大多数业务需求。但有个隐患如果你想让某个内部操作即使主事务失败也不能回滚REQUIRED就做不到了。比如用户下单成功后要发一条站内通知你希望通知行为是独立的失败不影响订单提交反过来订单失败时通知也不该跟着回滚。这时候该用REQUIRES_NEW。它会把当前事务挂起开启一个全新的事务完全独立于外层事务。外层的回滚和提交都不影响这个新事务。像日志记录、消息推送、审计流水这类“边缘操作”非常适合用REQUIRES_NEW。Transactional(propagation Propagation.REQUIRES_NEW) public void writeAuditLog(AuditLog log) { auditLogMapper.insert(log); }但用REQUIRES_NEW要小心两个坑一是数据库连接池的负担每个REQUIRES_NEW事务都会占用一个额外连接并发高的时候可能把连接池耗尽二是这是一个真正的物理新事务不是把外层事务的一部分拆出来所以在内层事务还没提交时外层事务是看不到它写入的数据的。3.3 NESTED传播行为局部回滚的利器NESTED的行为和REQUIRES_NEW有本质区别。NESTED是以Savepoint机制实现的嵌套事务内层事务的回滚不影响外层事务已经执行的部分外层事务的回滚会影响内层已经提交的部分。它在概念上更像是“把一个大事务分成多个可以局部回滚的小片段”。典型的应用场景是批量导入数据。比如一次上传1000条记录你要循环插入数据库但希望某一条数据格式错误时不至于让全部数据都回滚而是跳过这一条继续处理后面的。这时可以在单条插入方法上使用NESTED传播行为配合捕获异常处理。不过要特别注意NESTED依赖于数据库的Savepoint能力。MySQL的InnoDB引擎支持Savepoint所以NESTED在MySQL下通常可以正常工作。但有些数据库或连接池配置下NESTED可能无法得到预期效果生产环境使用前一定要确认数据库文档和实际测试。3.4 传播行为选择的一个可落地决策思路我在给团队制定规范时一般按这个思路来选传播行为默认一律使用REQUIRED绝大部分业务方法都用它简单、安全、符合直觉。查询类的数据访问方法使用SUPPORTS或SUPPORTS加只读事务配置既不强制事务又能节省无谓事务开销。日志、审计、异步通知这些“主事务失败也不能被回滚”的操作使用REQUIRES_NEW。批量导入这种需要局部回滚跳过错误行的场景使用NESTED不要自己费劲去做手写Savepoint。MANDATORY和NEVER在内部框架开发或维护性代码中使用日常业务很少碰到。选错传播行为的后果往往要到联调阶段才暴雷。最常见的情况是一个内部方法用了REQUIRES_NEW开发者的本意是“这个方法必须新建事务”但他没有考虑到这个内部方法会频繁被调用结果连接池不够用反而引发新的性能问题。选型前先想清楚这个方法的生命周期、并发量、失败容忍度再下结论。4. 事务隔离级别与锁并发场景下的一致性防线4.1 从脏读、不可重复读、幻读三个问题讲起数据库事务的隔离性不是绝对的SQL标准定义了不同程度的隔离性对应不同的问题规避能力。先明确三个经典问题脏读事务A读取了事务B尚未提交的数据之后B回滚A读到的就是错误数据。不可重复读事务A读取某一行数据后事务B修改了该行并提交A再次读取时发现数据变了。重点在于“同一条记录内容变了”。幻读事务A按条件查询一批记录事务B插入了符合条件的新记录并提交A再查时发现多出来几行。重点在于“记录条数变了”。要注意脏读是绝对不能出现的任何隔离级别都不能允许脏读。但不可重复读和幻读则要看隔离级别给不给力。4.2 四种隔离级别对照隔离级别脏读不可重复读幻读说明READ_UNCOMMITTED可能可能可能几乎不用性能最好但数据最不可靠READ_COMMITTED不可能可能可能Oracle默认能避免脏读REPEATABLE_READ不可能不可能可能InnoDB下基本解决MySQL默认快照读机制解决多数幻读SERIALIZABLE不可能不可能不可能完全串行化性能最差在Spring配置隔离级别非常简单在注解上指定即可Transactional(isolation Isolation.REPEATABLE_READ) public void someBusinessMethod() { // ... }不配置的时候Spring事务使用数据库的默认隔离级别。MySQL默认是REPEATABLE_READOracle默认是READ_COMMITTED。这里有个隐藏的坑如果团队里既有用MySQL的环境又有用Oracle的环境同一套代码在两个数据库上的隔离行为可能不一致。4.3 MySQL的REPEATABLE_READ如何解决大部分幻读MySQL的InnoDB引擎在REPEATABLE_READ级别下通过MVCC多版本并发控制和间隙锁机制基本消除了幻读问题。MVCC让快照读普通SELECT看到的是一个一致性快照同一事务内的多次查询结果保持一致间隙锁则锁住查询范围内的间隙阻止其他事务插入新记录。但你得区分“快照读”和“当前读”的差别。快照读是普通的SELECT语句不加锁当前读是指SELECT ... FOR UPDATE、INSERT、UPDATE、DELETE这类语句它们读的是最新版本需要加锁。如果事务里先做了SELECT再执行UPDATE这个UPDATE必须基于最新数据那MVCC的快照就不能帮上忙遇到并发时更加依赖锁机制。很多“明明设置了可重复读还是产生了数据不一致”的问题就是因为代码混用了快照读和当前读破坏了MVCC的一致性保证。在Spring事务中方法里的SQL执行顺序、加锁情况最终决定实际的一致性行为。注解上的isolation只是告诉数据库“按这个级别去跑”数据库怎么保障全靠引擎自己的机制。个人经验隔离级别不要定得太高SERIALIZABLE在绝大多数互联网业务场景下不可接受并发稍微一高就把吞吐量打崩了。MySQL下用默认的REPEATABLE_READ加上合理的索引和必要的行锁绝大多数一致性需求都能满足。真遇到需要串行化资源的场景可以考虑分布式锁或悲观锁方案而不是把整个事务隔离级别拉满。4.4 只读事务到底有没有用Transactional(readOnly true)是我看到很多人在用、但很多人不理解的一个属性。它有两个作用对JDBC层面它会设置connection.setReadOnly(true)有些数据库会针对只读事务做优化。对JPA/Hibernate层面它会关闭脏检查dirty checking避免在事务中意外修改实体但没提交。对MyBatis没有太明显的限制效果因为MyBatis的SQL执行并不可感知这个只读标志。所以我一般建议查询方法加readOnly true有写操作的方法不加。它虽然不能100%防止你的SQL里出现写操作MySQL下非空表插入、临时表操作也不受只读约束但从语义上明确了方法的用途也让团队在代码review时一眼看出这个方法不应该包含写逻辑。5. Transactional注解失效的八种典型场景排查5.1 失效场景一自调用绕过了代理这是最常见、也最隐蔽的坑。同一个类中的一个方法调用同类中的另一个带Transactional的方法事务会失效。Service public class OrderService { public void outerMethod(Order order) { this.innerTransactionMethod(order); // 事务失效 } Transactional public void innerTransactionMethod(Order order) { orderMapper.insert(order); } }原因在于this调用不会经过代理对象。Spring的AOP代理是外部调用进入时生效内部this调用直接绕开了代理事务注解自然不会被解析。解决办法有三种第一种是把事务方法拆到另一个Service类中通过注入的Bean调用第二种是注入AopContext.currentProxy()通过代理对象调用第三种是在启动类上设置EnableAspectJAutoProxy(exposeProxy true)让代理可以暴露出来。((OrderService) AopContext.currentProxy()).innerTransactionMethod(order);我实际项目中更倾向于拆Service因为拆开后职责也更清晰而且不用额外配置暴露代理。注意AopContext.currentProxy()需要额外配置而且返回值类型是Object需要强转代码可读性稍有下降。5.2 失效场景二方法被final或static修饰CGLIB代理是通过生成目标类的子类来实现的如果方法是final的CGLIB无法重写它static方法也属于类级别的也不受CGLIB覆盖。因此带上Transactional也不会生效。Spring Boot默认采用的CGLIB代理对final方法无能为力这类方法上的事务注解形同虚设。如果一个方法需要事务支持就绝对不要把它标成final。这点在团队Code Review规范中一定要写进去。5.3 失效场景三异常被吞掉了Transactional public void businessMethod() { try { orderMapper.insert(order); inventoryMapper.deduct(productId, quantity); } catch (Exception e) { log.error(操作失败, e); // 没有抛出异常 } }上面这段代码里事务会在insert之后、deduct失败时进入catch块吞掉异常然后正常return。Spring只有在方法抛出异常时才会回滚异常都没抛出来它当然认为事务该正常提交。于是出现了“扣库存失败了但订单还写进去了”的不一致问题。处理方式有两种一是catch块中必须重新抛出异常RuntimeException二是把要回滚的异常类型明确指出来。注意Spring默认只会对RuntimeException和Error回滚受检异常Exception的直接子类或自定义异常继承Exception不会触发回滚。如果你希望IOException或SQLException这类受检异常也回滚可以用Transactional(rollbackFor Exception.class)。这可能是线上事故中出现频率最高的一种失效场景。很多老代码里都有一段“try...catch只打日志不往外抛”的坏味道一定要处理干净。5.4 失效场景四多线程环境传递了事务吗这里要明确Spring事务和线程强绑定。事务信息存在ThreadLocal中ThreadLocal只在当前线程可见。如果业务方法里用new Thread()或线程池异步执行某个数据库操作那么子线程里是没有主线程事务信息的。Transactional public void process() { new Thread(() - { // 这里的SQL操作不受主线程事务管理 orderMapper.insert(order); }).start(); }要解决这个问题不能用普通异步线程硬塞要么把异步操作改造成独立事务在异步方法上加上事务注解并让异步执行的Bean通过Spring管理要么使用TransactionTemplate在异步任务内部手动开启事务。更标准的做法是使用Spring的Async配合REQUIRES_NEW让异步方法拥有自己独立的事务边界。还有一种情况事务方法里调用了一个RPC或发送消息操作这个操作如果包含数据库写操作并且是在另一个线程中执行的那这段写操作就不在该事务中。不要想当然认为“我在方法里发了个消息消息消费者里的事务也归我管”这是完全错误的假设。5.5 失效场景五类没有被Spring管理Spring容器只对纳入IoC管理的Bean做代理增强。如果你在Service类上忘了加Service注解或使用了new关键字手动创建对象那么这个类根本不在Spring容器里事务注解自然完全无效。另外很多框架类比如一些定时任务框架调用的类没有通过Spring代理也需要额外注意。定时任务如果用Scheduled注解标注归属于Spring容器管理代理可以生效。但如果你在main方法里直接new一个配置类去调用那Spring管理就是空谈。5.6 失效场景六数据库引擎不支持事务MyISAM引擎的MySQL表是不支持事务的。虽然这个引擎现在已经很少使用但遗留项目里仍然可能存在。如果订单表是MyISAM即使注解再怎么配置SQL执行后也不会真正回滚。排查这种问题的方法很简单执行SHOW TABLE STATUS LIKE table_name查看Engine字段是否为InnoDB。凡是要求事务一致性的核心表必须确保引擎是InnoDB同时字符集、排序规则也要合理配置否则高并发和一致性的基础都是空中楼阁。5.7 失效场景七事务方法内部又调用了不同数据源的写操作多数据源环境下一个Service方法内可能涉及两个DataSource的操作。如果只给主数据源配了事务管理器第二个数据源的写操作完全不受事务控制。这种情况要么给第二个数据源单独配置事务管理器在注解中指定transactionManager要么把跨数据源的写操作拆开处理各自用独立事务并在业务层做补偿。跨数据源的事务在单机层面很难两全这也是分布式事务问题的起源。5.8 失效场景八事务传播行为配置错误导致意外提交前文说过的REQUIRES_NEW如果被随意使用外层事务被挂起、内层事务独立提交也会造成“看起来事务失效”的错觉。比如A方法REQUIRED开启事务中间调用了B方法REQUIRES_NEWB提交了自己的事务。如果A后续报错回滚B已经提交的数据不会跟着回滚。这在日志、审计场景是刻意为之但在业务数据上就是事故。所以排查“事务不回滚”问题时除了看异常有没有被吞、代理有没有生效还要检查调用链上有没有REQUIRES_NEW传播行为。5.9 事务失效排查速查表问题表现可能原因检查手段事务不回滚异常被catch吞掉查日志确认异常有没有继续抛出事务不回滚方法自调用加断点看代理对象是否介入事务不回滚非public方法检查方法修饰符Spring AOP默认只增强public方法数据仍然写入REQUIRES_NEW独立提交检查传播行为配置数据一致多线程异步操作查ThreadLocal确认子线程无事务上下文事务不生效类未交给Spring管理检查Bean是否注入IoC容器事务不生效表引擎不是InnoDB查看建表语句和引擎信息意外提前提交手动改变了Connection自动提交状态搜索代码里的setAutoCommit调用6. 分布式事务与事务消息微服务架构下的最终一致性6.1 单库事务已死微服务下事务怎么做互联网业务发展到今天单库单表早就撑不住高并发场景了。订单服务、库存服务、支付服务各自独立拆分成了多个应用模块甚至部署在不同机器上。此时数据库的本地事务只能保证单个服务内部自己那部分数据的一致性跨服务的多个写操作要实现原子性难度陡增。我用一个贴近实际的例子说明。用户下单购买一件商品订单服务写入订单数据库存服务扣减库存。两个服务各管各的库各自的数据库事务只能保证自己库内的操作原子性却无法保证跨服务的“要么都成功、要么都失败”。如果订单服务写订单成功库存服务扣减失败用户会看到订单已生成但库存没有扣减超卖风险立刻就来了。分布式事务就是来解决这种问题的。但分布式事务和单机事务有本质区别单机事务靠数据库的锁和日志就能搞定分布式事务往往需要中间件、消息队列、本地消息表、补偿方案等多种手段配合。6.2 从两阶段提交到TCC分布式事务方案演进业内最常见的分布式事务方案有几种两阶段提交2PC是最早的方案由协调者统一控制所有参与方的prepare和commit阶段。它保证了强一致性但有一个致命弱点协调者单点故障时整个事务可能一直阻塞参与方在prepare之后如果网络异常协调者无法知道该提交还是回滚。所以2PC在跨服务的高并发场景中用得很少。TCCTry-Confirm-Cancel是补偿型事务的经典方案。Try阶段锁定资源、预留额度Confirm阶段真正执行Cancel阶段释放资源。它把“扣减库存”拆成“预留库存”和“确认扣减”两个动作灵活度高但业务侵入强每个操作都要手动实现Try/Confirm/Cancel三个方法代码量大。本地消息表消息队列是很多团队在实践中的“平民方案”。核心思路是把业务操作和消息写入放在同一个本地事务里然后由消息队列异步通知其他服务。比如订单服务在本地事务中插入订单记录同时向一张local_message表插入一条待发送消息之后由定时任务把消息投递到RocketMQ库存服务消费消息执行扣减。这个方案不追求强一致而是追求最终一致性在稍后的某个时间点各个系统的数据会达到一致状态。对于电商类的订单、库存场景这个模型非常实用因为短暂的不一致比如下单后库存没有立刻扣完是可以接受的只要最终不超卖或者订单状态最终能对账清楚。6.3 事务消息为什么能优雅解决消息和业务的一致性问题上面说到本地消息表这种方式虽然简单但需要额外维护一张表还要考虑重试和去重。更优雅的方案是使用事务消息即将消息发送和本地事务绑定在一起用消息中间件的事务回查机制保证一致性。以RocketMQ为例事务消息的整体流程是生产者发送一条半消息Half Message到Broker此时消费者还看不到这条消息。生产者执行本地事务比如写入订单表。生产者根据本地事务的执行结果向Broker提交或回滚半消息。如果生产者一直没有提交RocketMQ会提供事务回查接口主动向生产者询问该本地事务状态。根据回查结果Broker决定是否让消费者可见这条消息并投递。Spring Cloud Alibaba生态中RocketMQ事务消息已经和Spring集成得很成熟了。使用Transactional和RocketMQTemplate配合可以较方便地实现事务消息模式。这里有一个关键点本地事务必须是数据库事务且消息提交和本地事务提交的顺序必须保证。任何一方失败通过回查机制兜底。Transactional public void createOrderAndSendMessage(Order order) { orderMapper.insert(order); rocketMQTemplate.sendMessageInTransaction( order-topic, MessageBuilder.withPayload(order).build(), order ); }当createOrderAndSendMessage方法内数据库事务提交后消息才会对消费者可见如果数据库事务回滚半消息也会被回滚。这就在不引入分布式事务框架的情况下解决了“发消息”和“写数据库”的一致性问题。6.4 订单与库存分布式事务的落地实践用一个比较实在的落地案例收尾这个章节。假设系统架构是订单服务和库存服务分离都注册在Spring Cloud Alibaba的Nacos上通过OpenFeign进行服务间调用。具体的做法是订单服务在本地事务中创建订单同时发送一条事务消息到订单主题。消息内容是“订单已创建包含商品ID和购买数量”。库存服务监听这个主题收到消息后执行库存扣减。如果扣减失败库存服务记录扣减失败日志并重试如果重试多次仍然失败就把消息转入死信队列由人工或补偿任务介入处理。这套方案有几个好处一是订单服务和库存服务并没有强耦合两边只依赖消息队列二是订单服务的本地事务天然简单不用担心跨服务锁资源消耗三是消息回查机制保证了订单创建和消息发送的一致性不会出现“订单没建但消息发了”的脏数据。当然也有局限性事务消息和本地消息表一样是最终一致性而不是强一致性。如果你想做到读到自己写的强一致比如用户下单后立刻查库存就要看到最新扣减数那么需要额外加一些机制比如查询时走订单服务聚合库存状态或者引入分布式事务框架如Seata。6.5 分布式事务方案怎么选用一句话总结我的选择思路如果业务可以接受最终一致性优先考虑事务消息或本地消息表成本低、理解简单、适配绝大多数互联网业务。如果业务必须强一致比如资金扣减、账户余额调整在相对可控的边界内使用TCC并请有经验的人把关。如果团队规模小、业务链路不复杂不要一上来就引入Seata或者2PC中间件复杂度往往会压垮团队。分布式事务没有银弹每种方案都在一致性、可用性、性能之间做取舍。技术选型前先和业务方确认清楚SLA要求和数据一致性容忍度再动手。7. Spring事务配置中的几个细节与实用技巧7.1 事务超时时间Transactional(timeout 5)表示事务最长允许执行5秒超过就抛异常并回滚。默认值为-1表示使用数据库默认的超时时间。真实业务里长事务往往是性能杀手它会占用连接不释放持有的锁也会阻塞其他事务。建议对耗时敏感的关键操作显式设置超时时间。需要说明的是超时计时是从事务开始到执行的累计时间如果事务方法里有一段长时间的外部调用期间事务一直没提交它也会算在超时时间内。所以超时时间设置要留足余量别卡的太死。7.2 事务方法内部的耗时不代表事务该膨胀所有的事务都会延长数据库连接的占用时间特别是REQUIRES_NEW嵌套、长链条调用、远程接口调用放在事务里这种情况会让事务的边界拉得特别长。我见过一个极端的case事务方法内调用了外部支付接口等了3秒才返回事务就拿着数据库连接干等了3秒。连接池20个连接20个并发请求一来全部占满其他业务全部排队。改造办法是事务方法只负责数据库写操作远程调用放到事务外等数据库事务提交后再发起远程请求。或者用TransactionSynchronizationManager.registerSynchronization()在事务提交后执行回调逻辑。TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { // 事务提交后调用远程接口或发送消息 paymentClient.notify(orderId); } });7.3 事务日志怎么看排查事务问题时把日志级别调高很管用。Spring的事务日志是通过org.springframework.transaction和org.springframework.jdbc.datasource包输出的。在application.yml中配置logging: level: org.springframework.transaction: DEBUG org.springframework.jdbc.datasource: DEBUG开启后你能清楚地看到事务的开启、提交、回滚过程比如Getting transaction for ...、Completing transaction for ...、Initiating transaction commit、Initiating transaction rollback。这些日志是排查事务失效问题的第一手线索比到处猜代码靠谱得多。MyBatis的SQL日志也同样重要观察Preparing、Parameters、Updating等信息能帮助你判断SQL执行是否在你的事务边界内。7.4 连接池耗尽问题的一个小注意事项事务挂起会占用连接。REQUIRES_NEW、长时间的数据库锁、事务内远程调用都会导致连接池连接被占用。如果连接池参数设置偏小比如HikariCP默认10个连接那一旦出现事务延迟很容易把连接池打满后续请求全部等待连接表现为接口大面积超时。在配置HikariCP时重点关注的参数是maximum-pool-size、connection-timeout和max-lifetime。我的经验是核心服务把连接池上限调到合理的值并且每个界面都监控连接活跃数一旦发现连接数长期高水位就要警惕是否存在慢SQL或长事务。7.5 编码时的自查清单我在团队里推行一个事务自查清单提交代码前过一遍能避免很多低级问题事务方法是否为public。方法上是否明确指定了rollbackFor还是默认只回滚RuntimeException。方法内部是否有try-catch吞异常的行为。事务方法是否被同类其他方法自调用。是否有异步子线程参与数据库操作。方法内部是否调用了外部服务且耗时较长。是否把只读查询方法标记为readOnly true。事务方法是否依赖多个数据源如果是事务管理器指定清楚没有。同类代码在不同数据库上跑隔离级别是否符合预期。是否显式使用了TransactionTemplate的编程式事务。8. 编程式事务什么时候放弃Transactional8.1 TransactionTemplate的适用场景注解式事务确实方便但有些场景下它不够灵活。比如说你在一个循环中处理多个独立事务每个事务之间都希望互不影响且某个事务失败后需要继续处理后续的循环这种场景用注解事务会非常别扭。TransactionTemplate则提供了编程式事务的灵活性。先注入PlatformTransactionManager或直接注入TransactionTemplate然后在需要事务的地方手动executeService public class BatchImportService { private final TransactionTemplate transactionTemplate; public BatchImportService(PlatformTransactionManager transactionManager) { this.transactionTemplate new TransactionTemplate(transactionManager); } public void batchImport(ListItem items) { for (Item item : items) { try { transactionTemplate.execute(status - { itemMapper.insert(item); return null; }); } catch (Exception e) { log.error(单条导入失败{}, item, e); // 继续处理下一条 } } } }这种方式可以精确控制每一条数据的独立事务异常可以被捕获而且不至于中断整个循环。8.2 编程式事务的边界控制编程式事务要自己注意事务的开启、提交和回滚。TransactionTemplate的execute方法里如果回调抛出异常事务自动回滚如果正常返回则自动提交。这个语义比手动调用begin/commit/rollback安全得多。实际工作中我更推荐混合使用策略常规业务方法用注解式事务循环批量、复杂分支或者需要在事务前后执行额外逻辑的场景用编程式事务配合TransactionTemplate。不要在一个Service类里过度混用否则代码的可读性会急剧下降后来的维护者很容易搞不清什么操作在哪个事务里。8.3 事务同步机制事务提交后的回调Spring提供了一套事务同步机制可以在事务提交前、提交后、回滚后执行自定义逻辑。除了前面提到的afterCommit还可以注册beforeCommit、afterCompletion等回调。这个机制在处理“事务提交后发事件”“事务提交后写操作日志”时非常好用。TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { eventPublisher.publishEvent(new OrderCreatedEvent(orderId)); } });这样能规避一个常见问题应用先提交事务、再发事件如果事件消费者立刻查库而此时事务尚未提交完成数据可能查不到。用afterCommit回调保证事件在事务提交后才发出。9. Spring事务相关的面试高频问题答案提纲9.1 谈谈Spring事务的传播机制传播机制考察的是你对“多方法嵌套调用”时事务边界划分的理解。回答时可以从REQUIRED默认值入手说明加入现有事务的语义再讲REQUIRES_NEW挂起当前事务另开新事务的场景再补充NESTED的Savepoint原理和局部回滚能力。如果能结合一个线上实际例子日志记录用REQUIRES_NEW、批量导入用NESTED会比较加分。9.2 Spring事务默认回滚规则是什么Spring默认只回滚RuntimeException和Error受检异常默认不回滚。可以通过rollbackFor指定受检异常也触发回滚比如rollbackFor Exception.class。这个题看起来简单但很容易踩坑因为很多人默认“所有异常都应该回滚”实际上Spring在这一点上是有意设计成“编译期异常往往代表业务规则问题不一定需要回滚”。9.3 什么是事务失效哪些场景会导致失效回答时按我前文中的场景列出来就好自调用绕过代理、final/private方法、异常被吞、多线程穿透、类未被Spring管理、数据库引擎不支持事务、传播行为配置错误。每说一个都带一句“底层原因是代理机制没有生效”或“ThreadLocal上下文没传递”这样显得有深度。9.4 MySQL默认隔离级别是什么为什么选REPEATABLE_READMySQL默认是REPEATABLE_READ并且通过MVCC解决大部分幻读问题。这里的核心点是MVCC的快照读和当前读的区别。如果面试官追问“那你有没有遇到在REPEATABLE_READ下还出现幻读的情况”可以从当前读加锁、间隙锁失效、数据和索引不匹配等方面展开说明MySQL在REPEATABLE_READ下并不是100%杜绝幻读。9.5 Spring事务底层怎么实现的回答思路基于AOP动态代理核心是TransactionInterceptor和PlatformTransactionManager。代理对象拦截方法调用通过事务管理器获取连接、关闭自动提交、执行方法、提交或回滚。要把TransactionStatus、ThreadLocal、DataSourceUtils几个关键词说清楚面试官基本就认可了。9.6 分布式事务怎么保证最终一致性结合项目实际讲方案。我一般推荐讲本地消息表或RocketMQ事务消息因为这是真实落地的方案面试官听起来会更有感觉。讲清楚半消息、事务回查机制、消费幂等去重然后补充对比一下TCC和2PC在业务侵入性上的差异就能看出你对整个分布式事务体系有了解。10. 综合案例一个真实项目中事务优化的复盘10.1 背景项目是一个电商平台的核心交易链路技术栈基于Spring Boot Spring Cloud Alibaba MyBatis MySQL。线上突然出现一个问题订单峰值期数据库连接池被打满接口大面积超时部分订单状态错乱用户重复点击下单后生成了重复订单。10.2 排查过程先看监控发现HikariCP连接池活跃连接数长时间接近上限而且有大量连接被占用超过10秒。进一步打印Spring事务日志发现很多请求事务边界过长在事务内执行了远程OpenFeign调用。再查数据库发现部分表的并发写入产生了死锁和间隙锁冲突导致等待时间拉长。最终定位到三个核心原因事务方法内调用了库存服务和支付服务事务边界长达23秒。下单接口没有做幂等控制重复点击产生多条订单数据错乱。部分长事务因锁等待超时连接迟迟不释放。10.3 改造方案第一把事务方法内部的远程调用移到事务外具体方案是使用TransactionSynchronization的afterCommit回调或直接调整代码结构让远程调用在事务提交后执行。第二增加幂等控制在订单创建接口入口处用Redis存储幂等key重复请求直接返回原结果。第三优化SQL索引消除不必要的间隙锁冲突。通过这三步改造连接池水位恢复正常订单错乱问题也没有再复现。10.4 复盘心得事务边界过长是很多性能问题的根源远程调用、IO操作、网络等待一定不能放在事务里。幂等设计要前置不能等出了问题再补救。日志和监控是定位事务问题的第一抓手没有可观测性就无从谈起优化。11. 写在最后我的几点体会做后端这些年Spring事务是我见过使用频率最高、踩坑人数最多的知识点。很多人能说出ACID、能用上Transactional就往方法上一放但真正遇到数据不一致问题时又不知道从哪排查起。我个人在实际操作中最大的体会是事务这件事配置只是入门代理机制、异常路径、线程模型、数据库隔离级别才是深水区。每次改代码前先问自己一句“这个操作放在事务里了吗放在事务里合适吗”远比事后debug来得高效。再分享一个小技巧排查事务问题的时候先把事务日志打开然后写一个最小复现代码用断点去看代理对象长什么样观察它有没有被增强。只要代理在异常在抛绝大多数问题都能在半小时内定位。事务不是银弹但掌握了它背后的原理和边界你至少能在设计系统时提前避开很多坑。希望这篇文章能帮你少走一点弯路把那些年我踩过的坑变成你脚下踩平的路。
返回列表