ARTICLE DETAIL

资讯详情

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

Spring Boot事务管理实战:从@Transactional到分布式事务避坑指南

Spring Boot事务管理实战:从@Transactional到分布式事务避坑指南 说起 Spring Boot 的事务管理我的第一反应是这是每个后端开发都绕不开的基础功但也是坑最多的地方。很多人觉得事务不就是Transactional注解加一下就完事了吗可真到了生产环境事务不生效、数据没回滚、锁超时、连接池被打满……这些问题一旦冒出来排查起来往往让人头皮发麻。这篇文章我不打算从教科书定义讲起而是结合这些年实际踩过的坑从入门到实战把 Spring Boot 事务管理的来龙去脉说透尤其是那些文档里不会明说、但真实项目里总会坑你一把的细节。无论你是刚接触 Spring Boot 的初学者还是已经写过一段时间业务代码、准备系统排查事务问题的开发者这篇文章应该都能帮上忙。1. 事务的底层逻辑数据库和 Spring 到底在忙什么1.1 ACID 四个特性说得轻松落地才知深浅事务这个词本质上解决的是“多步操作不能只做一半”的问题。最经典的场景不用我说你也知道——转账A 扣钱、B 加钱这两件事要么都成功要么都失败。如果 A 扣了钱但 B 加钱失败账就对不上了这在金融系统里是要出大事的。数据库事务围绕四个特性去做文章就是教科书上常说的 ACID原子性Atomicity、一致性Consistency、隔离性Isolation、持久性Durability。我平时和团队里新人聊天时发现大家最容易混淆的是“一致性”。很多同学以为一致性就是让数据库满足主外键、非空这类约束这其实只是最表层的意思。一致性真正强调的是业务层面的状态正确比如转账前后、AB 的总金额必须不变这需要应用层配合事务边界来保证。数据库只负责约束层面的完整性业务上的一致性还得你自己设计好。隔离性就更有意思了。多个事务同时跑的时候互相之间应该看到什么、不能看到什么这引出了脏读、不可重复读、幻读这些概念。数据库提供四种标准隔离级别读未提交、读已提交、可重复读、串行化。MySQL 默认是可重复读Oracle 默认是读已提交。你写代码的时候如果没显式指定隔离级别Spring 会用什么答案是用数据库默认的。这个细节很重要因为后面排查隔离级别相关问题时很多人第一反应是去看代码其实根源在数据库配置上。持久性相对好理解事务一旦提交数据就要永久保存哪怕数据库宕机也要能从日志里恢复出来。这是数据库存储引擎做的事和事务管理器没有直接关系但在分布式系统里持久性会极大影响恢复策略的设计这点后文讲到分布式事务时再展开。1.2 Spring 抽象了三方三个“接口翻译官”回到 Java 这一侧。一个很现实的问题是我们不可能只用一个数据库连接项目里往往同时存在 JDBC、MyBatis、JPA甚至多个数据源。每种技术的“开启事务”“提交”“回滚”API 都不一样Spring 想让你写业务代码时不用关心这些底层差异于是做了抽象。核心就是三个家伙PlatformTransactionManager、TransactionDefinition、TransactionStatus。我在画架构图的时候习惯把它们叫做“三个翻译官”。PlatformTransactionManager负责真正去开启、提交、回滚事务每个数据源技术都有对应的实现比如 JDBC 对应DataSourceTransactionManagerJPA 对应JpaTransactionManager。TransactionDefinition定义事务的属性包括隔离级别、传播行为、超时时间、是否只读。TransactionStatus则是事务运行时的状态比如当前事务是不是新建的、有没有被标记为回滚。你可能会问那我是用编程式事务好还是声明式事务好我的建议是大多数业务场景用声明式也就是Transactional注解。编程式事务没有 AOP 代理那些麻烦事逻辑非常直白适合在事务边界特别复杂、或者声明式事务实在没法解决的时候用。举个例子Spring 给我们提供了TransactionTemplateService public class TransferService { Autowired private TransactionTemplate transactionTemplate; public void transfer(String fromAccount, String toAccount, BigDecimal amount) { transactionTemplate.execute(status - { try { accountDao.decrease(fromAccount, amount); accountDao.increase(toAccount, amount); return Boolean.TRUE; } catch (Exception e) { status.setRollbackOnly(); throw e; } }); } }这段代码把事务边界的控制权完全交到你手里什么时候回滚、什么时候提交一目了然。但代价是模板代码多而且事务边界散落在业务代码里后期改起来比较累。所以正常项目里我建议默认使用Transactional只有在它搞不定的时候或者说你在事务里需要非常精细的异常判断时才考虑TransactionTemplate。2. Transactional 使用边界注解里的每个属性都在回答一个问题2.1 注解放哪才有效类、接口还是方法Transactional可以放在类、接口或方法上。放在类上表示类里所有公有方法都默认开启事务放在方法上则只对这个方法生效。如果类和方法的注解同时存在方法上的配置会覆盖类上的配置这个覆盖机制别记反了。有一个很容易踩的坑是把注解放在接口上。Spring 官方的建议是放在实现类或具体方法上而不是接口上。为什么因为Transactional的生效依赖 AOP 代理而 AOP 代理分成 JDK 动态代理和 CGLIB 两种。当你的 Service 实现了接口Spring 默认使用 JDK 动态代理这时候接口上的注解理应是能被加载到的但实现类上的注解反而加载不到实际上 Spring 对接口上的注解支持并不稳定尤其当你用了某些增强方式或类继承关系时极容易出现注解被忽略的情况。所以我的习惯很简单注解一律写在实现类的方法上接口只定义方法签名完全不要碰事务注解。2.2 传播行为嵌套方法之间的事务怎么搭传播行为这个概念我第一次接触时也觉得绕但它确实是事务设计里最重要的一个开关。简单说当一个带事务的方法去调用另一个带事务的方法时这两个事务要怎么合并。Spring 定义了七种传播行为其中日常项目里用得最多的是这三个传播行为说明推荐场景REQUIRED有事务就加入没有就新建默认值绝大多数业务方法比如同一次请求里的多个写操作REQUIRES_NEW无论如何都新开一个事务挂起旧事务日志记录、操作审计就算主体事务回滚了也要留下来NESTED嵌套事务底层是 Savepoint 机制批量处理中希望单个失败不影响整体但整体还能回滚用个实际例子让你直观感受下。下单方法createOrder()是事务 A里面调用了deductStock()扣库存它本身也有Transactional。如果扣库存用的是默认 REQUIRED它会直接加入事务 A整个过程只有一个事务如果扣库存方法里还有其他不需要跟着下单回滚的逻辑比如扣完库存要发一条操作日志即使下单失败库存回滚了这条日志也不想丢那日志方法就应该用 REQUIRES_NEW让它开一个新事务独立提交。很多项目里常见的设计失误是不分场合全都用 REQUIRED结果日志也跟着业务一起回滚排错时连审计记录都找不到。反过来如果到处都是 REQUIRES_NEW又会导致数据库连接占用时间变长、事务过于碎片化。传播行为的选择本质是在回答“这个子操作的生命周期该不该跟主流程绑定”。2.3 隔离级别越高越安全但性能代价你考虑过吗隔离级别前面提到一点Spring 这里用isolation属性指定。可选项就是READ_UNCOMMITTED、READ_COMMITTED、REPEATABLE_READ、SERIALIZABLE。我见过不少团队把隔离级别统一设置为READ_COMMITTED理由是并发性能好、能避免脏读。这在绝大多数互联网业务里是没问题的但要注意一个前提MySQL 默认的 REPEATABLE_READ 下普通 SELECT 使用的是快照读并不会因为级别调低引发额外问题真正的麻烦往往出现在你用了SELECT ... FOR UPDATE这类当前读时不同隔离级别下锁的释放时机完全不同。我在实际排查里发现很多“事务之间互相阻塞”的故障报告最后都指向隔离级别设置太高或锁范围太大。比如订单金额统计这种纯查询场景设置 REPEATABLE_READ 加间隙锁很容易导致大批量插入操作被卡死。如果你只是读多写少的数据统计场景READ_COMMITTED通常就够了。核心原则一句话不要无脑追求最高隔离级别串行化只适合极少数强一致且并发不高的场景日常业务用数据库默认级别并且接受它即可。2.4 rollbackFor为什么 CheckedException 默认不回滚这是最容易被误解的一个点也直接关联到很多“事务明明加了注解却不回滚”的问题。Spring 声明式事务的默认规则是只对 RuntimeException 和 Error 进行回滚对 CheckedException 不回滚。为什么Spring 当初的设计哲学是受检异常通常代表业务上可预期的错误比如“余额不足”“库存不够”这种场景未必需要让整个事务失败也许你可以补偿、降级所以默认不触发回滚。可现实是我们的业务里面把很多检查异常当成了致命错误看待。比如用 Feign 调用第三方接口抛出的可能是IOException包装的异常如果你不在Transactional里显式声明rollbackFor Exception.class那这段代码里前面执行的写操作就全部留下来了而且你不会从日志里发现任何异常因为异常本身被事务管理器“放过”了。我见过最惨的例子一个对账系统A 表批量插入了 5000 条数据后面一条数据因为字段过长爆了Exception结果是整个方法返回了失败但前 5000 条数据已经提交留在库里了最后还是人工核对账目才发现。所以作为一个习惯我给团队定的规矩是凡是标注了Transactional的方法如果在业务语义上只要发生任何异常数据都不能落库那就必须写rollbackFor Exception.class。这行字不写你就等于把决定权留给了“这个异常是不是运行时异常”这种概率游戏。2.5 timeout 和 readOnly看起来简单用起来有讲究timeout是事务超时秒数默认 -1 表示不限制。设置超时的意义不只是防单个 SQL 跑太久更重要的是防止长事务长时间占着数据库连接。比如一个批量导入方法里循环更新了 10 万条数据中间某条 SQL 发生了行锁等待整个事务可能挂在那里几分钟连接池里其他请求全被阻塞。我自己的习惯是凡是可能操作大量数据的写方法至少要给一个合理的超时时间比如 10 秒、30 秒至少不要让一条不知道哪里来的慢 SQL 拖垮整个应用。readOnly标记这个事务只做读操作不能写。它在底层会给数据库传递一个只读提示部分数据库会做优化MySQL 下基本没有强制的只读约束只是 Spring 层面上帮你省了几步检查对性能提升微乎其微。所以不要指望加了readOnly true事务就飞快也不要在只读事务里做写操作然后指望它报错——MySQL 下它并不会报错只是白白多了一层事务开销。3. 避坑实录事务失效的 5 大经典场景我都替你踩过下面这部分我认为是整篇文章最有价值的地方。上面讲了那么多理论实际项目里事务失效的原因十有八九跑不出下面这几类。我把它们整理成这样一张速查表再一个个展开失效场景根本原因解决方案同类内部方法调用this调用没走代理对象注入自身代理、拆分不同 Bean、用TransactionTemplate方法不是 publicAOP 代理机制限制修改访问权限为 public异常被 try-catch 吞掉事务管理器感知不到异常重新抛出异常或手动 setRollbackOnlyrollbackFor 没设置CheckedException 默认不回滚显式指定rollbackFor Exception.class跨线程调用事务上下文只保存在当前线程里把事务边界控制在异步任务内部3.1 同类内部方法调用最隐蔽的“这注解没用”这个坑我至少帮人排查过十几次。场景是这样的OrderService里有个create()方法它内部调用了同类里另一个标注Transactional的updateStock()方法。结果是updateStock()里发生的异常根本不会触发回滚。原因得回到 AOP 上。Spring 的声明式事务是靠代理对象实现的。容器注入给你的OrderService并不是你写的那个原始对象而是一个经过代理包装的对象。你在类内部写this.updateStock()的时候this指向的是原始对象而不是代理对象所以那层事务增强压根没被激发。这就是为什么大家常说“Spring 的Transactional只有通过外部调用才生效”。解决办法有三种。第一种往自己类里注入一个代理引用比如加一个Lazy的自身依赖然后用self.xxx()去调用第二种把updateStock()拆到另一个 Service 里通过跨 Bean 调用自然能触发代理第三种也是最直观的用前面说的TransactionTemplate把内部事务逻辑包进模板里不依赖 AOP 代理。我个人建议优先用第二种拆类往往还能同时优化代码结构比借助self注入这种技巧更干净。3.2 非 public 方法注解写了也白写Transactional注解作用于非 public 方法时即使不报错事务也不生效。原因是 Spring 对私有方法不做代理包装CGLIB 生成的子类也没办法把父类的 private 方法增强掉。如果你的方法修饰符是 private、默认包访问权限或者 final 方法那事务管理就跟没关系了。我们团队在这上面还真踩过一次一个同事把内部定时任务调用的事务方法写成了 package-private 类型结果事务全程没生效数据写了一半定时任务还报成功最后靠对账才发现。所以检查事务失效问题的时候第一眼就要看方法修饰符是不是 public。这不是什么高深原理就是 Spring 代理机制的硬性约束。3.3 异常被吞掉事务怎么知道你错了还有一种非常常见的误用事务方法里套了 try-catch异常被抓住之后既不重新抛出也不标记回滚然后这个方法就“正常返回”了。事务管理器看到的是方法没抛异常那好提交吧。于是前面所有操作全部落库。有些时候这个坑是新手无意造成的但有时也来自对业务补偿逻辑的错误理解。比如你在方法里处理一条记录其中某一步失败了你把它写进了一个 error 消息队列然后方法继续执行你希望“这一步失败不影响整体”。这时你的诉求不是让整个事务回滚而是“部分操作要保留”。但如果没有仔细设计边界事务就会把不该提交的数据也一起提交了。解决方案看场景如果你确实希望整体失败那就不要 catch或者 catch 之后重新抛出RuntimeException如果你希望“主流程成功子步骤失败影响可控”那就不要把子步骤放进同一个事务里拆成独立事务或者用REQUIRES_NEW。最怕的是你什么都想要最后得到一个说不清语义的代码块。3.4 跨线程执行异步任务事务上下文跟不过去Spring 的事务实现里事务信息是通过ThreadLocal保存的。这意味着它天然跟当前线程绑定。当你用Async把某个方法丢到另一个线程池去执行或者在事务方法里手动new Thread(...)去操作数据库新线程里根本没有事务上下文。最典型的是外面一个大事务方法里调了一个异步发送消息的方法结果异步线程里报错外面的事务却一点感知都没有。这一点在排查“消息发送失败但数据库还是提交了”之类问题时特别容易忽视。解决建议是事务边界和异步边界别交叉。要么把异步操作放到事务提交之后比如事务同步器TransactionSynchronizationManager.registerSynchronization要么让整个异步任务内部自己开一个事务总之别指望事务上下文能自动跨线程传递。3.5 存储引擎不支持事务数据库层面的硬伤这一点不常发生但一旦发生就是全场事故。Spring 的事务管理委托给数据库如果表用的存储引擎不支持事务比如 MySQL 的 MyISAM那即使你在应用层把Transactional写得再完美底层也不会回滚。MySQL 8 默认的 InnoDB 是支持事务的但如果你用了某些老版本的默认配置或者手动建表时指定了 MyISAM那就白搭。我一般建议项目在启动时加一个数据库校验把核心业务表的存储引擎统一检查一遍至少不要让生产环境出现 MyISAM 表。这不算复杂但可以省下一整晚的排查时间。4. 数据一致性升级多数据源、分布式事务与订单场景实践4.1 多数据源下的事务谁才是真正的事务管理器现实项目里一个 Spring Boot 应用连多个数据库是很常见的事。比如读写分离比如多商户商城系统里订单库和商品库分开。如果只配了一个DataSourceSpring Boot 会自动装配一个DataSourceTransactionManager你用Transactional也就自动走这个管理器。但一旦配多个数据源你就得手动指定当前事务走哪个TransactionManager。通常的配置是在Transactional上写transactionManager orderTransactionManager同时在配置类里定义多个事务管理器并指定Primary。这里有个坑Spring 在自动注入PlatformTransactionManager的时候如果容器里有多个它会去寻找Primary标注的那个。如果你忘了加Primary启动时会因为依赖注入不明确直接报错或者你碰巧侥幸启动了但事务却走错了数据源。用一句话总结多数据源情况下事务管理器和数据源要一一对应别让 Spring 猜。不过要注意这种多数据源事务只是“多个独立的本地事务”而不是跨库的全局事务。比如订单库写成功、商品库写失败两边是不会自动一起回滚的。如果业务要求“跨库必须一起成功或一起失败”那就进入分布式事务范畴了。4.2 分布式事务从两阶段提交到最终一致谈到分布式事务先泼一盆冷水Spring Boot 本地事务管理再成熟也解决不了“跨服务、跨数据库的全局一致性”。分布式事务要面对的是 CAP 理论里的取舍现实世界中很少存在完美的全局事务方案。业内常见的方案有这么几类两阶段提交2PC是最古典的强一致方案借助 XA 协议协调多个资源能保证全提交或全回滚但性能开销大、对资源锁定时间长、协调者本身容易成为单点在互联网高并发场景下用得越来越少。TCCTry-Confirm-Cancel是业务层面的补偿方案把事务拆成预留、确认、取消三个阶段对业务侵入性强但性能好、适合跨服务场景。Saga 则把长事务拆成一系列局部事务每步都有对应的补偿动作更偏最终一致。日常业务里我见得最多的其实是本地消息表加消息队列的最终一致方案。比如你要做“创建订单”和“扣减库存”两个操作这两个操作分属不同服务。可以这样做订单服务在本地事务里同时写订单表和一条“库存扣减消息”事务提交后通过消息队列把这条消息发出去库存服务消费消息再做扣减。如果库存扣减失败消息还能重试直到成功或触发人工补偿。这种方案不需要全局锁对系统性能影响小是最贴合互联网业务实践的思路之一。4.3 多商户商城的实战取别把事务标注当成分区结合多商户跨境商城这种常见业务形态事务设计有一个我特别想强调的教训别把一个大方法里所有数据库操作都圈进一个事务里尤其当这个方法还涉及第三方接口调用、文件上传、消息发送的时候。比如下单流程本身并不复杂但很多人习惯在一个Transactional方法里连着做写订单表、扣库存、调支付接口、发短信通知。结果一次支付接口响应慢了几秒整个事务就占了数据库连接几十秒连接池一满其他请求全部排队甚至超时。我建议的拆分方式是只把“必须同生共死”的本地写操作放一个事务里比如写订单表和扣本地库存事务提交后再异步去调支付、发通知。如果支付失败那属于业务补偿层面的事可以通过订单状态机、定时任务去处理而不是让一个长事务把所有资源锁住。事务设计的目标从来不是“所有操作都要在一个事务里”而是“哪些操作必须同生共死”。5. 生产环境中的监控与优化别让事务成为性能杀手5.1 长事务的危害连接池耗尽不是危言耸听事务本质上握着数据库连接和锁事务一长连接占用时间就长锁持有的时间也长。我处理过一次故障一个每日对账任务代码里一个大Transactional方法循环了几千次查询其中还夹着一次远程 HTTP 调用结果整个任务跑了 20 多分钟。期间这个任务独占的那条数据库连接一直不释放连接池大小一调再调都顶不住最终所有依赖这个数据源的下游服务全部报连接池耗尽。这个故障的教训非常典型连接池大小通常只有几十每个连接同时只能执行一个事务。长事务不仅影响自己还会把整个应用的吞吐拖垮。生产环境里一定要重视“事务持续时间”这个指标一旦发现某个事务执行超过几百毫秒就该怀疑事务边界是不是划得太大了。5.2 用 Spring Boot Actuator 和 Admin 盯住事务指标我们聊监控绕不开 Spring Boot Actuator 和 Spring Boot Admin。尤其 Spring Boot Admin它可以把 Actuator 暴露的指标可视化方便观察数据库连接池使用情况、HTTP 请求耗时、线程池状态。虽然事务管理器本身没有直接的“事务耗时”指标但你可以借助连接池指标来间接判断。比如 HikariCP 的hikaricp_connections_pending这个指标如果一直居高不下说明连接在等待释放大概率背后有长事务。再比如数据库侧的information_schema.innodb_trx表能看到当前所有未提交事务的运行时长和 SQL 语句这是排查长事务最直接的入口。我在没有专业 APM 工具的团队里是这样组合使用的代码里用Timed或者 AOP 切面统计每个事务方法的执行耗时数据库侧定时拉取innodb_trx快照两边一对照谁在拖慢数据库、哪个方法事务时间异常基本上一眼就能找出来。5.3 事务优化的五个抓手事务性能优化不是靠一个神器而是几个习惯叠加出来的效果。我在实际项目里总结出下面五条每条都经过线上验证第一事务范围最小化。只把必要的写操作放进事务查询、外部调用、组装数据全部移出去。想想看你已经有数据库连接和锁了为什么还要让它陪着你去等一个第三方接口第二避免事务内网络调用。这个前面提过但值得再强调一遍——数据库事务和远程 IO 是天敌。如果一定要调用至少把调用放到事务提交之后。第三批量操作尽量合并 SQL。一条 UPDATE 语句更新 1000 条记录比循环 1000 次 UPDATE 少了 999 次网络往返和事务里的事务日志写入性能差距可以到数倍。第四合理设置超时。给关键事务方法设置 timeout避免一条异常 SQL 把整个应用拖死。超时的设置要考虑正常业务耗时余量别拍脑袋定个 1 秒导致误杀也别天真地填 600 秒。第五readOnly 别乱用。只在明确纯查询场景使用不要指望它解决性能问题更不要因为加上它就省略对真正耗时查询的优化。事务优化这件事本质上就是四个字收窄边界。收得越小扛住的并发就越大这也是很多高并发系统不把数据库事务当核心协调手段的根本原因——它们把事务拆小了用状态机、消息队列、补偿机制去保证最终一致这套思路在复杂度上升时比单纯依赖数据库长事务可靠得多。最后再分享一个我个人的小习惯每写完一个事务方法我会习惯性地问自己三个问题——这个方法会被同类里的其他方法调用吗异常真的能被传出去吗rollbackFor 设置对了吗这三问能筛掉 80% 的事务失效场景。另外如果条件允许强烈建议在测试环境把数据库隔离级别、存储引擎调成和生产完全一致因为很多事务问题只会特定配置、特定并发量下才冒头。希望这篇文章能帮你把 Spring Boot 事务管理这条路上的坑提前填平至少别在同一个泥坑里栽第二次。
返回列表