ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

事务实战:从本地事务到分布式事务,讲透一致性、隔离级别与排查方法

2026/9/30 3:21:45 拓冰建站 浏览量
事务实战:从本地事务到分布式事务,讲透一致性、隔离级别与排查方法 手头正好攒了一批跟“事务Transaction”相关的实战记录包括本地事务、Spring声明式事务、分布式事务一致性、事务日志排查这些内容。从几个真实的故障现场聊起把事务背后的原理、常见坑和排查思路一次讲透希望能给正在跟事务打交道的同学一些参考。1. 事务到底是什么为什么每次聊到它都绕不开一致性先从一个最直观的问题说起为什么系统一复杂凡是牵扯到钱、库存、订单这类核心数据的地方几乎都会被“事务”这个词支配原因很简单事务就是用来保证数据在并发、失败、重试这些乱七八糟的情况下依然能保持一致性的唯一可靠手段。1.1 从一个订单库存的故障现场说起早前有一个订单系统下单时要做三件事写订单表、扣库存、给用户账户加积分。最初实现是在代码里按顺序执行这三段SQL每一段都单独提交。结果某次线上促销流量一上来扣库存成功了写订单表却因为字段溢出报错用户这边收到了“下单失败”但库存却实实在在少了一件。更麻烦的是积分那边还照常加了用户来投诉说积分不对复盘的时候核对半天才发现是这三步没有放在同一个事务里。这类问题在开发初期特别容易忽视因为单机本地开发时三步执行完基本不会出错但线上并发一高任何一步都可能异常。而事务的作用就是把这若干步操作绑成一个不可分割的整体要么全部成功要么全部回滚到执行之前的状态。这就是ACID里原子性Atomicity最直白的解释。1.2 隔离级别为什么是事务里最容易出幺蛾子的地方除了原子性事务还有一致性Consistency、隔离性Isolation、持久性Durability合起来就是常说的ACID。其中隔离性是最容易被误解、也最容易引发线上问题的一个维度。SQL标准定义了四个隔离级别隔离级别脏读不可重复读幻读典型场景读未提交可能可能可能几乎不用风险太大读已提交避免可能可能Oracle默认级别可重复读避免避免可能InnoDB可避免MySQL InnoDB默认级别可串行化避免避免避免并发极低性能牺牲大实际开发中90%的团队用的是数据库默认级别也就是MySQL的“可重复读”。但要注意隔离级别只是定义了“这个事务能看到什么数据”它不决定“这个数据是不是最新”。遇到过不少同事在可重复读级别下A事务先查了一条订单金额B事务随后把金额改了并提交A事务再去查发现金额还是老样子于是代码里直接拿着旧金额去计算最终数据对不上。这不是事务的Bug而是没有理解隔离级别对数据可见性的约束。1.3 事务的生命周期回滚不是简单的undo理解事务还要知道它的生命周期开始、执行、提交、回滚。很多框架把“开始”封装好了开发者基本感知不到反而是“回滚”这个动作在实际线上环境中远比想象中复杂。MySQL InnoDB的回滚依赖undo log它记录的是“变更前的数据”。如果事务更新了一行数据数据库会先把旧值写入undo log再修改实际行数据。一旦事务回滚就用undo log把数据还原。但有一个关键点常被忽略undo log本身也是要持久化的如果事务执行了很久、改了上万行回滚也同样要花很久期间数据可能表现为短暂的不一致状态。所以事务不是越大越好很多人以为把读写操作全包在一个事务里就万无一失结果遇到大批量数据操作时事务过大不仅锁范围大回滚代价也高反而成了性能瓶颈和故障源。2. Spring事务的注解为何经常“失效”以及自调用问题的深层原理Java后端开发十有八九要用Spring管理事务。上手很简单一个Transactional就完事但线上出了事务没回滚的问题排查起来往往比想象中费劲得多。这里面的坑多数出在“事务是如何被Spring生效的”这一层。2.1 Transactional的本质是AOP代理Spring的声明式事务本质上是AOP。它在Bean初始化时生成一个代理对象。当外部调用某个带Transactional的方法时实际调用的是代理对象代理会先开启事务再执行目标方法最后根据是否有异常决定提交还是回滚。这就是为什么“同类内部方法调用”会让事务失效。比如你写了一个UserServiceService public class UserService { Transactional public void createUser() { insertUser(); insertLog(); } public void insertLog() { // 这里也有insert操作 } }表面上createUser方法有事务如果insertLog失败理论上应该回滚。但如果在UserService内部有一个方法调用了createUser比如public void register() { this.createUser(); }此时register方法通过this直接调用createUser绕过了Spring的代理对象事务根本不会开启。这就是网上常说的“自调用事务失效”。解决办法也很简单把内部调用拆到另一个Bean里或者通过ApplicationContext拿到代理对象再调用最省事的方式是自己注入自己Autowired private UserService self; public void register() { self.createUser(); }2.2 异常被吞掉和异常类型是两座大山事务不回滚的头号原因不是自调用而是异常被try-catch吃掉了。下面的写法在代码评审里出现的频率极高Transactional public void doBiz() { try { insertOrder(); deductStock(); } catch (Exception e) { log.error(error, e); } }事务拿到的结果是“方法正常返回”它并不知道内部有异常当然不可能回滚。另一种情况是异常类型不对Spring默认只对RuntimeException和Error回滚检查异常Exception的子类比如IOException默认不回滚。初次接触Spring事务的人最容易在这里翻车以为任何异常都会触发回滚。个人建议凡是核心写操作事务方法内不要自己捕获异常要么让异常抛出去要么在catch里手动标记回滚Transactional public void doBiz() { try { insertOrder(); deductStock(); } catch (Exception e) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); log.error(error, e); } }2.3 事务注解贴在接口还是实现类以及私有方法问题再补充两个细节。第一Transactional可以放在接口上也可以放在实现类上但Spring官方推荐放在实现类的方法上因为JDK动态代理基于接口如果用了CGLIB代理接口上的注解在某些边界情况下不会被解析。第二Transactional标记在private方法上事务不会生效因为代理类无法增强私有方法。这个在编译期不会报错属于“看起来正常实际上无效”的典型案例。另外还有一个常被忽略的点同一个类里的两个Transactional方法互相调用比如methodA调用methodB如果methodB挂了methodA内部又没有异常抛出比如methodB自己catch了整个事务同样不会回滚因为这两个方法处于同一个事务切面中代理只拦截了最外层调用。2.4 传播行为REQUIRED和REQUIRES_NEW到底怎么选Spring事务的传播行为是一套比较容易上头的内容。最常用的两个是REQUIRED和REQUIRES_NEW。REQUIRED是默认值意思是如果当前已经存在事务就直接加入如果没有就新建一个。这适合绝大多数场景比如服务A调用服务BB的方法加了REQUIREDA的事务如果已经开启B就直接挂在A的事务里共用同一个事务。好处是数据一致性强坏处是一旦B出异常A也跟着回滚。REQUIRES_NEW则是挂起当前事务新建一个独立事务。典型使用场景是订单主流程失败要回滚但操作日志必须记录下来哪怕主流程失败日志也不能丢。它的代价是数据库连接会被占用更多两个事务之间的数据隔离也要考虑清楚别以为用了REQUIRES_NEW就万事大吉。传播行为选型时我的经验是默认无脑用REQUIRED只有明确要求“局部失败不影响整体”时才用REQUIRES_NEW。REQUIRES_NEW用多了事务边界混乱排查问题时会非常痛苦。3. 分布式事务订单与库存跨库时的一致性怎么保单机事务再复杂也只在同一个数据库实例内有效。一旦拆分到微服务订单在订单库库存在库存库积分在积分库本地事务就彻底无能为力了这就是分布式事务的诞生背景。3.1 分布式事务的核心矛盾分布式事务要解决的问题和本地事务本质相同都是原子性和一致性但难在参与方分散在不同进程、不同数据库中。没有办法用单一的数据库锁机制协调多方。更现实的问题是网络会超时、服务会宕机、消息可能丢失所有这些不确定性叠加起来一致性变得极为困难。面对这个矛盾业界有一个基本共识没有万能的分布式事务方案只有根据业务场景选择合适强度的一致性模型。3.2 强一致路线两阶段提交2PC与Seata AT模式两阶段提交是最经典的强一致方案分准备阶段和提交阶段。协调者先让所有参与者执行准备操作并锁定资源全部准备成功后再统一发出提交指令。听起来很美好但它的痛点也很明显准备阶段锁住资源的时间长性能差协调者如果挂了整个事务可能卡死参与者如果在提交阶段失败了数据一致性依然会被破坏。在实际项目中直接裸写2PC很少见更多是借助Seata这样的开源方案。Seata AT模式可以理解成对2PC的改良第一阶段直接执行业务SQL并生成undo log第二阶段根据全局状态决定提交还是回滚回滚时通过undo log反向补偿。用Seata时有一个容易踩的坑AT模式需要额外的全局锁如果多个事务并发操作同一行数据锁冲突会导致大量重试等待。高并发下Seata的吞吐量下降明显不适合写入极度密集的场景。我见过有团队把实时交易核心放在Seata上压测时发现全局锁等待比业务执行还慢最后不得不改造成异步消息方案。3.3 最终一致路线事务消息、本地消息表与可靠消息大多数互联网业务其实并不需要强一致。下单后库存扣减晚几百毫秒用户无感知但下单必须成功积分可以稍后到账。这类场景最终一致性是更务实的选择。事务消息是目前比较流行的方式。它的核心思路是先发一条“半消息”到消息中间件消息对消费者不可见然后执行本地事务本地事务成功后提交确认消息消费者才能看到并处理如果本地事务失败则回滚消息。RocketMQ的事务消息实现的就是这一套。要注意的是事务消息本身需要保证消息与业务操作的原子性中间件通常提供反查机制也就是本地事务提交后但消息确认丢失时Broker会反向调用生产者接口去查事务状态。生产者的反查接口必须实现得可靠否则消息就会一直处于半消息状态下游感知不到变化。本地消息表方案更朴素业务操作和写消息表放在同一个本地事务里由额外的异步任务轮询消息表把消息发到MQ或直接调用下游接口成功后标记消息为已发送。它的优点是不依赖MQ的事务消息特性缺点是消息表业务侵入性强、轮询可能存在延迟。3.4 分布式事务排查的心得在分布式事务场景里单纯看应用日志往往无法定位数据不一致的根因因为涉及多个服务、多个数据库必须把链路串起来。查问题时我一般这样做先确认事务上下文ID是否贯穿整个调用链没有TraceID先补上。再核对每个参与方的执行顺序和最终状态比如订单状态、日志状态、消息消费状态。最后用对账任务扫描数据找出不一致的脏数据再反查是哪个环节漏了补偿。有一点必须提醒任何分布式事务方案都必须有兜底的对账和补偿机制。哪怕用了Seata或者事务消息也不能保证100%不出现极端异常对账任务就是最后一道防线。不少团队忽略了对账出了事只能人工改库风险极大。4. 事务日志数据库事务日志与SQL Server报错实战事务日志这个话题除了概念层面的理解它更多是运维排查时绕不开的硬骨头。这里结合SQL Server的一次真实故障来说印象很深。4.1 那次数据库事务日志已满的报错某天线上突然报警业务接口大面积超时数据库错误日志抛出了这样一条消息消息 9002级别 17状态 2第 1 行数据库 ais20221123194008 的事务日志已满。这个报错的意思是数据库的事务日志文件.ldf增长到了配置上限或者磁盘空间已经被日志文件占满事务无法继续写入日志数据库被迫拒绝新的写操作。报错里的“级别 17”属于数据库资源不足类错误这类错误通常不会导致数据库实例崩溃但会让业务写操作无法执行。我当时的处理步骤先用SQL确认日志文件大小和增长限制SELECT name, size, growth, max_size, is_percent_growth FROM sys.database_files WHERE type 1;确认确属日志空间不足后检查日志的虚拟日志文件VLF分布和日志空间的复用情况DBCC SQLPERF(LOGSPACE);再查看当前事务、最早的活跃事务确认是不是有长事务阻碍了日志截断DBCC OPENTRAN;查下来发现有同事在凌晨跑的批处理脚本一直没有提交事务导致日志持续累积无法截断。处理方式是先跟业务方确认该事务是否可以回滚确认后执行回滚并补充作业的异常提交逻辑随后手动收缩日志DBCC SHRINKFILE(logical_log_file_name, target_size);4.2 事务日志满的常见诱因从这次故障以及后来排查过的多次类似问题来看事务日志满的诱因基本集中在四点诱因说明预防方案长事务未提交事务长时间不提交日志无法截断严格审查批处理作业增加超时机制日志增长限制日志文件设了max_size触顶后报9002合理设置自动增长比例与最大限制磁盘空间不足.ldf文件撑满磁盘无剩余空间监控磁盘使用率提前扩容或归档索引维护/大批量导入大操作产生海量日志瞬时写爆分批次提交、区分简单恢复模式/大容量日志恢复模式4.3 SQL Server日志查看与恢复模式的底层逻辑查看事务日志内容我常用DBCC LOGDBCC LOG(database_name, 2);它会输出大量日志记录包含操作类型、事务ID、时间戳等对定位特定事务很有帮助。但这个命令读的是原始日志结构可读性一般日常排查更多是确认日志增长区间内是否有异常大批量操作。比日志内容更值得关注的是恢复模式。SQL Server的恢复模式有三种事务日志的行为差异很大完整恢复模式日志记录所有操作支持任意时间点恢复代价是日志可能快速膨胀。简单恢复模式仅保留最近的日志用于崩溃恢复时的必要信息检查点之后会截断日志空间回收快但无法做时间点恢复。大容量日志恢复模式适合大批量导入日志量比完整模式小但也不能做时间点恢复。多数生产库用完整恢复模式于是定期备份事务日志就成了一项必须执行的运维任务。如果从不备份日志日志文件会只增不减最终就是9002报错。这里再提一个很容易踩的坑有的人以为把数据库切成简单恢复模式就能解决日志膨胀切完之后空间确实回收了但后续所有时间点恢复能力全部丢失出大事时根本找不到回退点。类似的误操作我在不同环境碰到过至少三次每一次都是灾难性的。4.4 日志排查里几个容易翻车的小细节排查日志问题时有几个细节容易被忽略第一SHRINKFILE不能随便执行。在业务高峰或事务活跃期收缩日志可能造成性能抖动甚至阻塞。一般建议在低峰期操作并先确认日志空间确实不再需要。第二DBCC OPENTRAN显示“No active open transactions”并不意味着日志就可以随意收缩还要检查日志复制的相关配置比如基于日志的Change Data CaptureCDC或者复制分发任务它们也会阻止日志截断。第三ALTER DATABASE修改恢复模式时会触发日志截断如果库很大这个操作本身可能耗时较长。生产环境变更前一定先在测试环境验证并预留窗口。5. 从JDBC到事务消息一组容易被忽略的细节聊到这里事务的宏观框架基本齐了但还有几个被高频搜索、实践中又常出问题的点需要单独拿出来说说。5.1 JDBC事务最底层的begin、commit与rollback很多框架把事务封装得太好以至于不少人已经忘了JDBC原生的样子。其实JDBC事务很简单用Connection来管理关闭自动提交后手动控制提交和回滚。Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); // 执行若干SQL conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.close(); }这里容易被忽略的是setAutoCommit(false)的时机。如果先执行了查询才发现忘了关闭自动提交那前期的子操作已经被自动提交了再回滚也回不到最初状态。另一个细节是同一个Connection不能在多个线程间共享否则事务边界会乱成一锅粥比如两个请求交替提交互相影响。5.2 MySQL行锁与事务并发间隙锁造成的“幻觉”MySQL InnoDB在可重复读隔离级别下为了解决幻读引入了间隙锁Gap Lock和临键锁Next-Key Lock。这意味着事务里的一次范围查询不只是锁定查到的行还会锁定查询条件范围内的“间隙”防止其他事务在这个间隙里插入数据。很多并发问题就挂在间隙锁上。比如订单表里有一个根据状态统计的查询一个事务在可重复读级别下执行了范围查询另一个事务想往这个范围内插入一条新记录结果被阻塞产生死锁或超时。表面上看两个操作互不相关但实际是在锁层面冲突了。遇到这种问题首先考虑降低隔离级别到“读已提交”因为很多场景根本不需要可重复读其次尽量让索引精确命中减少间隙锁的范围最后控制事务执行时间锁持有越短越好。5.3 事务消息与普通消息的生产顺序一个经典的对账陷阱使用事务消息时有一个经典的“先写库后发消息”和“先发消息后写库”的顺序之争。正确做法是先执行本地事务事务成功后再提交消息确认。RocketMQ事务消息的机制保证了这一点但前提是半消息要发得足够早让Broker能感知到否则本地事务完成后再半消息就失去了事务消息的意义。但有些人会在“事务内直接发普通MQ消息”以为本地事务提交后消息自然就发送了实际上Spring事务提交和MQ发送之间没有原子性保障。比如订单事务提交后MQ发送失败下游永远不知道有新订单。解决方式要么用事务消息要么采用“本地消息表 定时补偿”的老套路。后者虽然不够优雅但在很多低成本项目里反而是最稳的。6. 事务与并发的取舍以及如何正确设置事务级别事务级别和隔离级别是两个相关但不同的概念容易被混在一起。事务级别更偏向“这个事务的隔离强度和保护范围”而隔离级别只是事务级别的核心维度之一。6.1 事务级别的选择逻辑实践中事务级别影响的是锁粒度、隔离级别、超时时间、是否只读。核心原则是在保证数据正确的前提下让事务尽可能短、锁尽可能少。经验值大致可以参考单一写操作读已提交足够。金融转账、账务调整可重复读甚至可串行化。报表统计类查询可以开启只读事务降低锁开销。大批量定时任务应分批提交避免单个事务过大。6.2 如何避免死锁这是每一个写事务的人都该懂的死锁的成因很简单两个或以上事务各自持有对方需要的资源互相等待。排查时一般看数据库死锁日志或系统表的锁等待信息更直接的做法是把死锁图打开。规避死锁的操作建议多个事务以相同顺序访问同一组资源。比如更新订单后更新库存所有代码路径都按“订单 → 库存”的顺序不要有的先更新库存。缩短事务时间减少持有锁的时间窗口。使用合理的索引避免全表扫描导致大量行锁。不要在一个事务里做外部RPC调用网络延迟会把锁持住很长时间。设置合理的锁等待超时宁可让一次快速失败也不要长时间阻塞。第4条尤其值得强调。我见过不少线上死锁案例排查了一圈发现是事务方法里调了外部HTTP接口接口响应慢锁一直不释放其他事务全部堆起来了。把RPC调用挪到事务外面问题立刻消失。6.3 事务与缓存的双写一致性也是一个烫手山芋严格说事务本身不解决缓存一致性问题但因为很多人用事务时也顺手更新Redis这里必须提一下。最典型的错误是有事务保证的情况下先更新数据库再更新Redis结果Redis更新失败缓存里还是旧值读到旧数据的接口持续返回错误结果。常见方案是先更新数据库再删缓存这样即使删失败最多出现一次缓存穿透不会出现长时间脏读。更稳妥的是引入延迟双删事务提交后删一次Redis过几百毫秒再删一次。这个方案在并发偏高场景里实测有效代价是实现复杂了一点点。比这更简单的是每次读Redis时校验数据版本或者干脆让缓存短TTL自动过期很多场景下也够用。7. 事务调优与问题排查的个人工具箱事情聊到最后一个现实的问题摆在所有人面前事务的坑这么多线上出了问题从哪下手这里整理了一套自己平时反复用的排查路径和工具清单也算是对前面所有内容的一个串线。7.1 排查事务问题的四个步骤第一步把日志链路拉通。事务相关的问题往往不是单一服务的事要能顺着订单号、用户ID或自定义的TraceID把整个调用链串起来。没有链路信息排查分布式事务问题基本靠猜。第二步看数据库实时的锁和事务状态。MySQL可以查SELECT * FROM information_schema.INNODB_TRX; SELECT * FROM performance_schema.data_locks;SQL Server就查sys.dm_tran_locks和sys.dm_exec_sessions等动态管理视图。这一步主要确定有没有长事务、锁阻塞、事务久未提交的异常。第三步打开死锁日志和慢查询日志。死锁频繁出现时先把死锁信息捞出来分析事务的加锁顺序。慢查询日志用来补充确认是不是有个别SQL在事务内拖慢整体提交。第四步看代码里的事务边界。事务方法内是否做了耗时操作、是否有RPC调用、异常是否被吞、自调用是否绕过了代理。这一层的问题在日志里通常不明显只能靠代码走查。7.2 一些拿得出手的排查脚本信息量比较全的即时排除脚本MySQL端我常用这一套-- 查看正在运行的事务 SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_rows_modified, trx_mysql_thread_id FROM information_schema.INNODB_TRX; -- 查看当前会话 SHOW PROCESSLIST; -- 查看锁等待关系MySQL 8.0用performance_schema SELECT * FROM sys.innodb_lock_waits;SQL Server端则依赖DMVSELECT request_session_id, resource_type, resource_description, request_mode, request_status FROM sys.dm_tran_locks WHERE request_status wait; SELECT session_id, status, login_name, host_name, last_request_end_time FROM sys.dm_exec_sessions WHERE session_id IN (SELECT DISTINCT request_session_id FROM sys.dm_tran_locks);脚本本身不难写关键是要能读懂输出。比如看到某个事务持续几十分钟没提交基本可以认定它是日志膨胀或锁阻塞的源头接下来就是要联系业务方确认能否终止它。7.3 事务调优时最容易被低估的参数很多人调优事务只知道改隔离级别却忽略了事务超时和连接池大小的联动关系。比如数据库连接池maxActive设置得特别小而每个连接上又挂着长事务其他请求拿不到连接只能排队表现是系统响应变慢但数据库CPU又不高。MySQL的innodb_lock_wait_timeout和Spring的Transactional(timeout)要配合着看。Spring事务超时是客户端层面的数据库锁等待超时是服务端层面的两者取最短的那个作为真实超时时间。还有一点事务内如果有多个数据源操作比如多数据源项目里一个事务挂两个连接连接池很容易被提前打满这类项目建议能不跨库就不跨库或者明确拆分事务边界。8. 事务的未来和实际项目里的选型心得事务方案没有银弹这句话听了很多遍但每次遇到新的业务模块还是有人会踩一遍老坑。这里想分享几个踩过坑之后的个人体会不算结论更多是参考。一是不要一上来就上分布式事务框架。很多业务虽然拆了微服务但数据一致性完全可以通过业务流程设计来规避比如把强一致的操作收拢到同一个服务里用本地事务搞定跨服务的弱一致场景用事务消息 对账任务解决。引入Seata这类框架意味着全局锁、性能损耗、运维成本都会接踵而来必须评估值不值得。二是事务边界要在设计阶段就画清楚而不是写代码的时候随缘。一个事务里放多少个操作、涉及哪些表、是否需要调用外部服务这些问题在方案评审时就要确定。线上见过不少事故都是后来有人在旧事务里加了一段逻辑结果把锁范围扩大了引发连环阻塞。三是日志和监控要提前配置。事务日志的大小、数据库磁盘空间、活跃事务数量、死锁次数这些都是可以在问题爆发前提前预警的。很多团队等到9002报错才去查日志实际上在日志文件涨到80%的时候监控就应该报警了。事务这个词从JDBC的底层Connection到Spring的AOP切面再到分布式环境下的消息与补偿覆盖的层次极广。每一次故障排查、每一次代码走查、每一次数据库优化本质上都是在跟事务的原子性、隔离性、持久性这些老概念打交道。理解它背后的设计逻辑比记住某个框架的具体用法重要得多。