ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从单机事务到分布式事务:原理、踩坑与实战排查

从单机事务到分布式事务:原理、踩坑与实战排查 1. 事务的本质从一条转账SQL说起先说个真事。上个月帮一个老同事排查线上问题他负责的支付系统在某个时段频繁报“事务日志已满”一开始以为是磁盘满了上去一看磁盘还有20多GB空闲再一看日志文件好家伙自动增长被关了日志文件撑到上限就不动了结果所有写操作全部堵死。这个事情的根子其实不在于存储空间而在于很多人对事务的理解只停留在“要么全成功、要么全失败”这八个字上。事务这个词只要你写过稍微复杂一点的后端代码就一定听过。你没有亲手写过SQL至少也被产品经理追问过“为什么下单成功了库存没扣减”。所以事务不是面试题里的八股文它是真实业务系统每天都在依赖的底层机制。尤其是Java技术栈的朋友几乎天天跟Spring的Transactional打交道但真问到事务的隔离级别、传播行为、日志机制、分布式场景下的取舍能说透的人其实不多。这篇内容我打算把事务从单机数据库到分布式场景完整捋一遍。不会只讲理论更多是我在实际项目中踩过的坑、排查过的故障、面试别人时问过的问题以及我自己在方案选型时的一些心得。适合正在写业务代码的Java后端工程师、准备面试的同学、以及被分布式事务折磨过的架构师们。内容会比较长建议收藏了慢慢看。2. 单机事务的实战细节从注解到锁机制2.1 Transactional注解的正确打开方式绝大多数Java项目里事务的入口就是Spring的Transactional注解。这个注解用起来确实简单一个注解放在方法上方法里的数据库操作就自动进入事务。但很多人不知道这个注解背后依赖的是Spring AOP的动态代理机制而正是因为依赖代理它才有一大堆“看似正常、实际事务根本没生效”的坑。先说说正确用法。Transactional要生效前提是调用方拿到的对象必须是Spring容器里的代理对象。也就是说你这个方法得通过注入进来的Bean去调用而不能是同类内部通过this直接调用。我见过最典型的问题就是这样public class OrderService { public void createOrder(OrderDTO dto) { saveOrder(dto); deductStock(dto.getSkuId(), dto.getCount()); } Transactional public void saveOrder(OrderDTO dto) { orderMapper.insert(dto); } Transactional public void deductStock(Long skuId, Integer count) { stockMapper.deduct(skuId, count); } }createOrder方法调用同类里的saveOrder和deductStock表面看两个方法都加了Transactional但实际事务根本不会开启。因为this.saveOrder调用的是原始对象的方法而不是经过AOP增强的代理方法。你兴高采烈地以为“两个操作要么都成功、要么都回滚”实际上saveOrder提交了deductStock抛异常了saveOrder的数据照样留在数据库里。解决办法也很简单要么自己注入自己要么把两个操作合并到一个事务方法里要么通过ApplicationContext拿到代理对象去调用。但说实话最干净的做法就是调整方法粒度让事务方法不要出现内部自调用。另外Transactional默认只回滚RuntimeException和Error如果你在方法里抛出了一个自定义的受检异常Checked Exception默认是不会触发回滚的。这一点绝对是最容易踩的坑没有之一。我当年第一次接手电商项目就遇到过一个“保存订单失败但是订单数据还是插进去了”的bug原因就是业务代码里抛了自定义Exception而事务没回滚。解决方法是在注解上显式声明Transactional(rollbackFor Exception.class)这个习惯我后来一直保持只要是写事务方法rollbackFor Exception.class一定是标配哪怕我确认方法里不会抛受检异常我也会写上。原因很简单谁知道三个月后接手这段代码的同事会在中间加什么逻辑。防御性编程在事务这里不是空话。2.2 事务失效的经典场景汇总除了自调用和受检异常事务失效的场景还有不少这里我把高频的列成一个清单都是我实际遇到过或者在代码Review里揪出来过的方法不是public的。Spring默认只对public方法做事务增强private、protected方法加了注解也白搭。数据库引擎不支持事务。比如MySQL的MyISAM引擎就不支持事务你注解加得再漂亮也没用该部分成功还是部分成功。现在新项目基本都用InnoDB了但老项目里偶尔还能翻出几张MyISAM表排查问题时一定要先确认。多线程调用。Spring事务默认绑定在当前线程的连接上如果你在事务方法里new了一个线程去执行数据库操作那个线程拿到的连接不是事务连接自然不会纳入事务。异常被吞掉了。代码里catch住了异常没有往外抛事务自然没法感知错误也就不会回滚。我印象最深的一次是排查一个“库存扣了但订单丢了”的案例。代码长这样Transactional public void processOrder(OrderDTO dto) { try { orderMapper.insert(dto); stockMapper.deduct(dto.getSkuId(), dto.getCount()); } catch (Exception e) { log.error(处理订单失败, e); } }你看异常被捕获并记录日志了事务却认为一切正常直接提交。扣库存和插入订单两个操作如果第二个失败了第一个照样提交。这种bug最坑的地方在于它不是必现只在特定数据或特定并发下才出现等到你通过日志去反查的时候大量的脏数据已经产生了。所以我会坚持一个原则事务方法内部尽量不要自己去catch异常实在要catchcatch完必须重新抛出RuntimeException或者手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()来标记回滚。这不是什么高深技巧就是每个写事务代码的人都应该刻烟吸肺的底线。2.3 隔离级别与传播行为怎么选隔离级别解决的是并发事务之间的可见性问题。MySQL默认是REPEATABLE_READ可重复读Oracle和PostgreSQL默认是READ_COMMITTED读已提交。很多Java程序员对隔离级别的认知停留在“脏读、不可重复读、幻读”的定义上但实际业务里怎么选反而很少有人讲清楚。我的建议很简单没有特殊需求就用数据库默认级别。不要动不动就往上调成SERIALIZABLE那玩意儿是拿并发性能换一致性生产环境稍微有点流量就直接打爆。如果你发现业务里确实有幻读问题比如“统计订单数量”这种场景优先考虑用锁SELECT ... FOR UPDATE或者把查询放到事务的最后而不是全局改隔离级别。再说传播行为。PROPAGATION_REQUIRED是最常用的意思是如果有事务就加入没有就新建。这个基本覆盖了90%的场景。REQUIRES_NEW则是挂起当前事务、开启一个新事务常用于“日志记录不能因为主业务失败而回滚”的场景。比如你在创建订单的同一个事务里要写一条操作日志日志表可不能跟着订单一起回滚因为就算订单创建失败、日志也得留下来。这时候就需要REQUIRES_NEW。但REQUIRES_NEW有一个隐藏问题它用的是新的数据库连接。如果当前事务已经持有了数据库锁比如对某一行执行了FOR UPDATEREQUIRES_NEW的新连接去操作同一行数据时就会发生锁等待搞不好就死锁了。所以我在设计写日志这类操作时通常会让日志表独立出来不走数据库事务而是用消息队列或者线程池异步写从根上避开锁的纠缠。3. 事务日志满一次真实故障的完整排查实录3.1 那条让人崩溃的错误消息先看一条经典的数据库报错消息消息 9002级别 17状态 2第 1 行 数据库 ais20221123194008 的事务日志已满。这个报错我第一眼看到就非常有亲切感因为刚工作那会儿经常被它折腾。很多用SQL Server的同学看到这个错误会以为“是不是日志文件太大把磁盘占满了”但仔细看就会发现日志满和磁盘满不是一回事。9002这个错误代表的是日志逻辑上的空间不足日志文件已经达到了它允许增长的上限或者说自动增长被禁用了即使物理磁盘还有空间数据库也拒绝继续写入任何需要记录日志的操作。当时我帮同事排查的那个支付系统就是这样。数据库名叫“ais20221123194008”一看就是自动生成的测试库估计是哪个脚本跑出来的。报错原因很简单事务日志文件设置成了固定大小不自动增长或者说增长的上限被设死了而日志里的空间又没有被循环利用于是一到业务高峰就爆掉。要理解这个错误得先明白事务日志的机制。SQL Server的日志文件.ldf内部是循环使用的它的逻辑结构是“虚拟日志文件”串成一个环事务提交后、日志备份做完那些已经用完的日志段就可以被重用。如果日志没有做备份或者备份频率太低日志文件就会一直增长。如果增长又被限制那就只能全线卡死。3.2 定位问题的完整排查步骤处理这类问题我的排查顺序是这样的也算给大家一个可以操作的模板第一步看日志空间使用情况。SQL Server里可以查sys.databases的log_reuse_wait_desc字段它会告诉你日志为什么不能复用。常见的值有LOG_BACKUP日志备份没做日志被占着、ACTIVE_TRANSACTION有长事务没提交、REPLICATION复制相关等。看到这个字段问题原因基本就过半了。第二步查是否有大事务或者长时间不提交的事务。一个跑了几个小时都没提交的事务会让它后面的所有日志都没法截断整个日志文件只能一直扩张。查长事务可以通过sys.dm_tran_active_transactions、sys.dm_exec_requests和sys.dm_exec_sessions几个视图联查找出会话ID和事务开始时间。第三步检查日志文件大小与增长配置。通过sys.database_files可以看到log文件的size和max_size。如果max_size是-1说明无限增长如果设置了一个固定值那恭喜你早晚会碰到9002。还有增长方式按百分比还是按大小也影响日志扩张的效率。第四步也是最容易忽略的查一下恢复模式。SQL Server有三种恢复模式SIMPLE、BULK_LOGGED、FULL。FULL模式下事务日志必须定期备份否则日志文件只增不减。SIMPLE模式则会自动截断非活动日志根本不需要你操心备份但代价是你无法做时间点恢复。碰到线上紧急故障最优先的目标是让数据库先恢复可用。常用的应急手段大体分两步先想办法腾出日志空间备份日志或切换成SIMPLE模式截断日志再加大日志文件大小包括设置合理自动增长步长。注意收缩日志文件本身解决不了根本问题——日志收缩只是把文件物理缩小如果备份和截断机制没搞好空间很快又会涨回来。3.3 预防措施与恢复方案故障排查完了重点是怎么避免下次再炸。我复盘了那次事故总结出这么几条把恢复模式和工作负载匹配起来。如果业务能接受一定时间点的数据丢失直接用SIMPLE模式省心得多如果不能接受就必须建立日志定期备份的机制。日志文件设置的自动增长不能关。这个看似简单但很多人建库时图省事直接把自动增长关闭了或者设成“不自动增长”。数据库日志跟业务文件不一样它的写入量波动非常大固定大小就是给自己埋雷。给日志文件设置合理的初始大小和增长步长。初始大小我建议按日增量的三倍来估增长步长不要用百分比免得文件变得七零八碎用固定大小增长比如每次2GB或5GB更可控。监控不能少。日志使用率达到80%就要预警不要等它100%才去看。那次事故最后我帮同事写了诊断脚本把这些检查项做进一个存储过程每天定时跑一遍有问题直接推消息到群里。从那以后这个支付系统再没出过9002的报错。说句实在话很多数据库故障不是技术多复杂而是缺少“事前监控事后复盘”的习惯。4. 分布式事务订单与库存的经典难题4.1 为什么单库事务管不住微服务微服务架构下订单服务和库存服务各自有独立数据库这时候最经典的问题来了下单扣库存订单库commit了库存库回滚了怎么办单机事务之所以能保证原子性是因为它依赖数据库自己的UNDO日志和锁机制所有操作都在同一个数据库连接、同一个事务上下文中。一旦拆成两个库、两个服务就再也没有一个数据库引擎能同时掌控两边的提交和回滚了。分布式事务的本质就是在多个独立的资源管理器之间协调出一个“要么全成功、要么全失败”的全局结果。这个问题的难度在于三个字不一致。网络会超时服务会宕机消息可能丢失两个数据源之间没有全局的锁。你让订单服务先扣库存、再写订单如果在扣完库存那一步就宕机了库存就白白少了如果先写订单、再扣库存订单有了而库存没了用户下单之后根本没法发货。还有一个更隐蔽的难点是“幂等性”。分布式环境下你发出去的一个请求可能因为超时被重试同样的扣库存操作可能会被执行两次。如果没有幂等控制数据就乱了。所以分布式事务方案里“幂等设计”永远是不可省略的配套工程。4.2 主流分布式事务方案横向对比我从易到难把主流方案过一遍这些也是面试中最常被问到的方案一XA协议两阶段提交2PCXA是数据库原生支持的分布式事务协议。第一阶段事务协调者向所有参与方发送“准备提交”各方锁定资源、写入UNDO日志第二阶段协调者根据各方响应发送“提交”或“回滚”。这个方案的优点是强一致性缺点也很明显同步阻塞、性能差、协调者单点风险。在高并发互联网场景下真正的强一致XAR恢复记录在两阶段提交的prepare阶段就会强制落盘意味着响应时间的大幅上升通常不会直接拿来扛线上业务流量。方案二TCCTry-Confirm-CancelTCC把一次业务操作拆成三步Try阶段尝试执行并锁定资源Confirm阶段真正提交Cancel阶段回滚。TCC要求业务自己实现这三个方法典型例子就是扣减余额和账户余额的幂等校验。Try扣减冻结掉库存Confirm确认扣减Cancel解冻库存。它的优点是性能比XA好得多、灵活度高缺点是开发成本高每个业务都要手写三套逻辑而且Confirm和Cancel都要保证幂等。方案三本地消息表异步确保把“发送消息”和“业务操作”放在同一个本地事务里。比如订单服务在订单表插入一条订单的同时往本地消息表也插入一条“扣库存”消息。这两个操作在同一个数据库事务里要么都成功、要么都失败。然后由一个后台任务轮询消息表把消息发送到MQ库存服务消费后执行扣减再回调确认状态。这个方案实现简单不依赖外部中间件最终一致性也能保证缺陷是侵入性较强消息表会膨胀需要定期维护。方案四事务消息半消息机制事务消息可以看成是本地消息表的中间件化升级版。RocketMQ的事务消息是最典型的实现。发送方先发送一条“半消息”到MQ消息在“待确认”状态消费方暂时不可见发送方执行本地事务根据事务结果向MQ提交commit或rollback。MQ会定期回查发送方的事务状态确保消息不会被黑洞。这个方案把消息可靠性和本地事务紧密结合是目前电商订单场景最常见的最终一致性方案。4.3 从订单与库存场景看方案落地选择我们拿“下单扣库存”这个真实业务来对比。订单服务创建订单库存服务扣减库存我们希望最终达成“有订单必有库存扣减”的一致结果。如果采用事务消息流程是订单服务在本地事务里创建订单同时向MQ发送一条“扣库存”事务消息本地事务提交后消息才被确认。库存服务消费消息扣减库存。问题来了如果库存扣减失败比如库存不够消息消费方可以返回消费失败消息进入重试队列。一个非常重要的问题是订单已经创建成功了库存却扣减失败用户体验上就是“下单成功但迟迟不发货”或者超卖。所以我喜欢在上述方案基础上再加一步前置校验在下单前先调用库存服务做一次“库存预占”接口预占成功才创建订单随后的事务消息负责实际扣减这样就能把“库存不足”的失败提前拦截掉。如果采用TCC订单服务作为主控方先调用库存服务的Try接口冻结库存再在本地创建订单最后发起Confirm。整个链路里Try阶段是并发加锁阶段Confirm阶段是异步收尾阶段。缺点是你得为每笔订单单独维护一张冻结单压力集中在Try阶段而且一旦某个参与方不稳定Cancel的补偿逻辑必须写得非常健壮。从我的选型经验看支付、订单这类“最终允许短暂不一致”的业务我倾向用异步确保思路因为性能好、架构简单而账户余额变动这种“绝对不允许两边对不上”的资金类核心链路我用TCC哪怕开发成本高一点也值。不要用一个方案包打天下分布式事务选型本质上是业务一致性容忍度和系统复杂度之间的博弈。5. 分布式事务面试高频点与选型心得5.1 面试官到底想考察什么Java事务面试题网上背答案的很多但面试官真正想听的其实是你的“判断力”。比如问“你知道哪些分布式事务方案”大多数人都会背出2PC、TCC、本地消息表、事务消息这只能算及格。往后追问差距就出来了“2PC的阻塞问题具体发生在哪个阶段”能答出来的人少一半。“如果你的消息表方案里消息发送失败了怎么办”这就考你是否有通盘考虑本地消息表状态机。“TCC的Try和Confirm之间如果宕机了如何保证补偿一定执行”能答到这个深度的人通常是有实战经验的人。“你能画一下事务消息的时序图吗”不少面试者卡在这里。“最终一致性方案中如何保证消息消费的幂等性”这是最务实的考点。所以准备面试不要只背“三阶段提交比两阶段好在哪”这类框架性结论要把每种方案的时序流程、失败分支、幂等设计都吃透。面试官要的是能够处理线上事故的人不是人肉百科。我在面试里还特别喜欢问一个问题“假如你的付款服务调用外部支付网关返回超时了你如何处理本地订单状态”这个问题没有标准答案但能引出重试机制、对账机制、状态机设计、幂等键设计等一系列关键点。能把这条链路讲得头头是道的人业务设计能力基本没有大问题。5.2 方案选型的几条铁律我做了多年订单系统的分布式事务总结下来就几条写在这就当送大家的见面礼。第一能不强一致就选最终一致。强一致是拿性能和复杂度换来的绝大多数“一致性问题”都被用户接受范围内的短暂延迟消化了。你不要为了证明自己技术牛就去上最强方案业务不需要就别给自己找不痛快。第二消息至少投递一次消费必须幂等。“至少一次投递”是分布式系统的默认现实因为网络可能超时、重试必然发生。所以消费端一定要有去重能力。最简单的做法就是业务表里建一个唯一键把消息的taskId作为唯一标识重复插入直接忽略或者报唯一键冲突然后捕获处理。第三监控补偿是灵魂。任何分布式事务方案都做不到100%可靠所以必须有“对账系统”和“兜底补偿”。我做过的最靠谱的一套是每天凌晨跑对账脚本将订单表与库存流水表join找出超过五分钟还没达到最终状态的数据自动触发补偿流程。不要指望一次请求就能把事情做完要接受“异步补偿”是做分布式业务的基础思维。第四把一个大的分布式事务降级成多个小事务的组合。能先扣库存预占再发消息异步扣减就不要把两个服务的资源锁在同一个事务里。每个小事务只管一个简单的业务动作状态依赖用消息驱动流转这种设计天然更具韧性。5.3 关于事务我自己踩过的坑写到这里我脑子里翻出很多当年踩过的坑。第一个坑是事务方法里调用RPC接口。早期我在一个服务里直接在事务内调用外部系统结果外部系统响应慢数据库连接池被占满整个服务的所有接口集体变慢。后来我给自己定了一条规矩事务范围内只能做数据库操作绝不做远程调用。如果需要调用外部服务要么放到事务提交之后通过Spring的TransactionSynchronizationManager.registerSynchronization来注册afterCommit回调要么直接使用消息异步化。第二个坑是大事务。一个事务里操作了几万行数据导致锁持有时间过长。具体表现是白天业务高峰期只要跑一次批量任务线上立刻有大量死锁。后来把批量任务拆成小批次每个批次单独一个事务一次只处理几百条死锁问题直接消失。事务不是越大越安全事务应该“短、平、快”长事务是并发性能的隐形杀手。第三个坑是分布式事务补偿逻辑里的“补偿事务本身失败”。补偿逻辑本身也要考虑失败所以我在设计TCC的Cancel时候会把Cancel操作设计成幂等的、可重试的并且把Cancel失败的记录落表定时任务扫描重放。没有重试机制的补偿等于没有补偿。这些都是真金白银砸出来的经验。事务这个东西你越是深入了解越会发现它的本质就是“对不确定性的管理”。单机事务对抗的是数据库进程内的异常分布式事务对抗的是整个网络环境里的异常。最后再分享一个小技巧无论单机还是分布式事务在设计阶段就要把“重试、幂等、对账”这三个词刻在脑子里。任何事务方案如果配套的重试、幂等、对账机制没设计好它迟早会在某个深夜爆发把你从被窝里叫起来去盯着一条“事务日志已满”或者“分布式事务不一致”的报错发愁。愿你多加留意未雨绸缪。
返回列表