前几天晚高峰线上出了一次故障支付成功回调里同时做了三件事——扣库存、生成订单、累计积分。结果接口超时重试之后一部分用户库存被扣了但订单没生成另一部分用户订单生成了积分却加了两遍。查到最后问题出在一个写了Transactional却被同类调用悄悄绕过的方法上。这让我重新把事务这件事从头到尾捋了一遍从ACID的理论底子到并发下的隔离级别再到注解失效、长事务、分布式边界这些实战坑其实是一条完整的链路。这篇就把我踩过的坑和验证过的经验完整记录下来。1. ACID不是四个字母是四层独立的防线1.1 原子性Undo Log背后的撤回能力原子性的官方定义是事务内的操作要么全部成功要么全部不生效但大多数人对它的理解停留在失败会回滚这五个字上很少追问到底靠什么回滚。在主流关系型数据库的存储引擎里回滚靠的是Undo Log。执行事务时引擎会把修改前的行数据写入Undo Log如果事务中途失败或者被主动回滚就沿着Undo Log把数据恢复到执行之前的样子。这个机制保证的不只是我们手动执行ROLLBACK还包括进程崩溃、掉电之后数据库重启时的恢复动作。这里有一个特别容易误判的点原子性不等于单条SQL天然具备的性质。一条UPDATE语句本身确实只会全改或全不改但当业务需要同时更新两张表、且第二张表的更新失败时SQL层面的原子性帮不了你——必须靠事务把两条SQL打包为一个逻辑单元。所以判断这里要不要开事务的标准不是SQL条数而是一个业务动作是否涉及多个数据变更点以及这些变更是否要同生共死。1.2 一致性数据库只守底限业务规则得自己扛一致性是ACID里最容易被误解的一条。很多人以为它指的是数据库保证数据不出错实际上数据库只保证它自己能识别的约束主键唯一、外键有效、CHECK条件成立。至于转账后总金额不变积分不能为负这类业务规则数据库根本不知道它们的存在。换句话说一致性是目标原子性、隔离性、持久性是手段。你写了一个把金额从A账户扣掉100、往B账户加上100的事务就算原子性保证了要么都执行要么都不执行如果业务代码里漏写了B账户加上100这一步数据库不会给你任何提示——事务正常提交数据依旧一致地错误。所以在设计阶段就要把业务一致性规则显式写进事务里并在提交前做校验。一个稳妥的做法是任何涉及资金、库存、配额的计算尽量在事务内完成读改写并用约束或前置检查挡住非法状态。不要把一致性寄托在数据库应该会帮我兜底这种幻觉上。1.3 隔离性让并发事务看起来像串行隔离性解决的是并发事务互相干扰的问题。两个事务同时改同一行如果放任不管后写的人会覆盖先写的人的结果两个事务同时读同一行如果其中一方未提交就暴露数据另一方可能读到最终不存在的数据。数据库实现隔离性的核心武器是锁和MVCC多版本并发控制。锁让写入互斥MVCC则让读取尽量不阻塞写入。但这两者都有代价锁多了并发度下降MVCC维护版本链需要额外空间和清理开销。这也是为什么数据库提供多个隔离级别——它把隔离到什么程度的选择权交给开发者。你要么接受更严格的隔离带来的性能损失要么接受更宽松的隔离带来的数据风险。这个权衡是事务设计里最重要、也最容易被忽略的决策点。1.4 持久性WAL、刷盘与提交成功的真正含义持久性承诺的是一旦事务提交成功数据不能因为宕机而丢失。工程上实现持久性的主流方案是WALWrite-Ahead Logging预写日志先写日志、后写数据页。因为日志是顺序写入的比随机写数据页快得多数据库几乎总是把日志落盘当作提交成功的标志。注意几乎这个词。很多数据库的默认配置是日志落盘而非数据页立即落盘这意味着数据页可能还在内存里但日志已经持久化了。事务提交成功之后崩溃数据库启动时可以根据日志重放数据所以数据不丢。但如果日志本身的刷盘策略被调得过于激进比如为了性能延后刷盘提交成功就意味着可能会丢数据。这一点在做高可靠系统时一定要去读对应数据库的文档确认刷盘参数的含义不要想当然。2. 隔离级别背后的取舍脏读、不可重复读、幻读与MVCC2.1 三种异常读能用语言描述的都不算难以理解隔离级别从低到高分别解决三种读异常。先看一张对照表隔离级别脏读不可重复读幻读READ UNCOMMITTED可能可能可能READ COMMITTED不可能可能可能REPEATABLE READ不可能不可能可能部分场景SERIALIZABLE不可能不可能不可能脏读读到另一个事务尚未提交的数据。对方一旦回滚你读到的数据就成了无源之水。READ UNCOMMITTED允许这种读极端场景下做粗略统计也许能接受但业务数据一概不建议用。不可重复读事务内第一次读到一行事务还没结束另一个事务修改了这行并提交你再读同一行结果变了。这不是脏读因为对方已提交数据是真实的只是你的两次读结果不一致。幻读按同一个条件查询事务执行过程中另一个事务插入或删除了满足条件的新行导致两次查询的行集合不一样。幻影指的就是这种凭空出现或消失的记录。理解这三种异常读之后再选隔离级别就有的放矢了。2.2 MVCC和快照为什么同样的SELECT有时看不见别人刚提交的数据MVCC为每一行维护多个版本。读操作根据事务的可见性规则决定看到哪个版本从而在不加锁的情况下获得一致读。在InnoDB里REPEATABLE READ下事务内第一次执行普通SELECT时生成一个一致性快照之后同一事务内的普通SELECT都读这个快照。所以另一事务即使在你的事务中途提交了新数据你依然看不到。READ COMMITTED则每次SELECT都生成新快照于是能看到其他事务已提交的最新数据也就可能出现不可重复读。这里藏着实战里最关键的一个坑普通SELECT是快照读不需要锁但SELECT ... FOR UPDATE、UPDATE、DELETE这类当前读会读取最新版本并加锁。所以在REPEATABLE READ下快照读和当前读混用会产生一种看似奇怪的现象——你用普通SELECT查不到别人刚插入的行但用UPDATE去改那条不存在的行时却可能触发行锁甚至锁等待。这种读和写看到的世界不一样的情况经常让排查了很久都找不到原因。2.3 隔离级别选型越严格不总越好选隔离级别的核心原则是够用就好。SERIALIZABLE虽然从语义上最干净但所有读都加锁并发大打折扣READ UNCOMMITTED性能最好但数据质量很难保证。实际生产中最常用的是READ COMMITTED和REPEATABLE READ两个档位。以MySQL为例默认是REPEATABLE READ配合间隙锁可以规避绝大多数幻读而很多其他数据库默认是READ COMMITTED通过更短的锁时间换取更低死锁概率。我的建议是如果业务对同一事务内的重复读一致性要求不高优先用READ COMMITTED降低锁冲突如果是要做先查后写的一致性判断与其依赖隔离级别不如在事务内加SELECT ... FOR UPDATE把未来的并发风险显式锁住思路更清晰。3. 事务实战中的五个失效陷阱与对策3.1 自调用绕过代理注解还在事务没了Spring的Transactional基于AOP代理实现。你调用一个加了注解的方法时真正执行的是代理对象的方法代理负责在方法前开启事务、方法后提交或回滚。但如果在一个类内部outer方法调用同类里的inner()用的是this引用根本没有经过代理对象注解就形同虚设。我遇到的那次线上故障就是典型例子一个类里的public方法调用同类的private方法后者标着Transactional结果事务从未生效。库存扣了、积分加了、订单没建三件事各自为政最终数据对不上。定位手段很简单在方法里抛一个运行时异常看数据库里前一步写的数据是否还留着或者直接查日志里有没有TransactionInterceptor相关的入站记录。解决自调用有几个常用手法把被调用方法拆到另一个Spring Bean里或者注入自身代理也可以用AopContext.currentProxy()取得代理后调用。最省心的是直接拆到别的Service职责更清晰也方便单独测试。3.2 被吞掉的异常和默认不回滚的受检异常另一个高频失效原因是异常处理方式不对。Spring默认只在抛出RuntimeException和Error时回滚受检异常比如IOException这类继承自Exception的默认不会触发回滚。很多团队在Service层写catch块捕获异常后打日志就返回事务方法正常结束事务正常提交之前写的半截数据全部落库。正确做法有这么几条要么捕获异常后重新抛出运行时异常要么在Transactional上显式声明rollbackFor Exception.class让所有异常触发回滚。我更倾向于显式声明不在事务方法内吞异常。代码里最要不得的模式是这样Transactional public void processOrder(OrderDTO dto) { try { orderDao.insert(dto); inventoryDao.deduct(dto.getSkuId()); } catch (Exception e) { log.error(处理订单失败, e); // 异常被吞掉事务照样提交 } }改成这样才有意义Transactional(rollbackFor Exception.class) public void processOrder(OrderDTO dto) { orderDao.insert(dto); inventoryDao.deduct(dto.getSkuId()); }如果确实要在事务内捕获并记录异常捕获之后记得重新抛出让事务感知到失败。需要特别提醒不要再业务代码里用try-catch包住整个事务方法后继续往下执行后续逻辑那等于亲手拆掉了回滚机制。3.3 传播行为滥用REQUIRES_NEW不是万能解药传播行为定义的是事务方法被另一个事务方法调用时当前事务如何边界。最常用的REQUIRED表示加入当前事务没有就新建REQUIRES_NEW表示挂起当前事务新建独立事务NESTED表示在当前事务里建保存点。很多人的第一反应是用REQUIRES_NEW解决内层回滚影响外层的问题。但它有代价内层方法会占用一个新的数据库连接外层事务挂起等待。如果一个请求里串行调用十几处REQUIRES_NEW连接池会迅速被耗尽。更常见的错误是外层回滚后内层独立事务已经提交造成主业务失败但附属数据已落地的不一致。我一般只在两种场景用REQUIRES_NEW一种是写操作日志日志本身不该随业务回滚而消失另一种是调用第三方接口后记录调用凭据必须独立持久化。其余情况优先考虑调整事务边界或者把附属操作放到事务提交后的事件监听里执行而不是依赖传播行为硬撑。3.4 长事务把网络IO请出事务边界事务时间越长它持有的锁就越久连接占用也越久回滚时扫描的Undo也越多。最忌讳的做法是在事务里做远程调用发HTTP请求、调RPC、等消息确认、传文件。一个外部接口超时三秒整个事务就多锁三秒并发上来后所有碰到同一行的请求都在等这把锁。调整顺序是最简单的优化手段把所有远程调用、不可控等待移到事务开启之前或提交之后。事务里只保留纯数据库读写和必要的内存计算。另一个常见长事务来源是循环里逐条写数据一万条数据放在一个大事务里还不如拆成每两百条一个小事务既降低锁持有时间也能让单条失败的影响面变小。3.5 跨库跨消息单库事务扛不住分布式边界单库事务解决不了跨数据源问题。你操作了数据库又要发消息、又要调另一个系统的接口本地事务提交后消息才发出去这中间如果另一个系统处理失败本地数据已经提交两边就处于不一致状态。反过来把消息发送放进事务里消息系统不参与数据库的提交协议事务提交失败时消息可能已经发出去了还是不一致。业界可用的方案大致有几种可靠消息本地消息表配合定时任务扫描重发、事务消息消息队列参与两阶段提交、TCCTry-Confirm-Cancel以及最朴素的对账补偿。选择哪种取决于一致性的紧迫程度和团队能力。我的经验是优先缩减分布式操作的范围能在一个服务、一个库里完成的操作尽量不分拆实在要跨系统提前设计好幂等键和补偿任务不要依赖先发消息试试看。4. 事务故障排查与性能调优的实战路径4.1 先找到活的长事务排查事务问题第一步是找出有哪些事务正在运行、开了多久。以MySQL为例information_schema库下有一张innodb_trx表记录当前活跃事务。SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_seconds FROM information_schema.innodb_trx WHERE trx_state RUNNING ORDER BY duration_seconds DESC;看到duration_seconds特别大的记录再结合应用日志就能定位到具体线程和方法。我习惯把这条SQL做成监控平台的快捷查询线上一有连接池耗尽锁等待超时告警先跑它十次里有九次能找到元凶。还有一种隐蔽情况事务其实早就执行完业务逻辑了但因为连接归还前还有别的内容在跑事务迟迟不提交。这种要把事务开始时间和业务方法调用日志对应起来看单靠数据库侧的查询不一定能发现程序结构问题。4.2 锁等待和死锁怎么看、怎么解锁等待超过阈值会报Lock wait timeout exceeded。这个阈值由innodb_lock_wait_timeout控制默认50秒实际生产环境经常调小到5秒以内让失败快速暴露。死锁出现时InnoDB会自动回滚其中一个事务并在错误日志里输出详细信息。定位时找日志中的LATEST DETECTED DEADLOCK段落里面会列出两个事务各持有哪些锁、在等待哪把锁以及造成死锁的SQL语句。根据这段信息调整事务内SQL的执行顺序让所有请求以固定顺序拿锁死锁基本能消除。之前遇到过一个典型死锁两个事务都要先更新账户表再更新订单表但A事务先账户后订单B事务先订单后账户交叉等待形成环。统一改成先账户后订单后再没出现过这类死锁。4.3 降低锁粒度与死锁频率的五个常规操作缩小锁范围UPDATE语句的条件尽量走索引走不上索引就是全表扫描等于把整张表的写都锁住。缩短事务时间把查询、远程调用、复杂计算挪出事务事务里只做必要的写操作。统一SQL访问顺序多个事务操作同一组资源时固定执行顺序避免交叉等待。尽量小批量操作大批量UPDATE/DELETE按主键分批执行每批几百条减少单事务持有的锁数量。读多写少场景考虑读写分离报表、导出这类长查询走只读实例不占用主库事务连接的锁时间。隔离级别也可以配合调整。如果在排查中发现大量间隙锁等待且业务能接受READ COMMITTED下的语义可以直接改隔离级别减少锁范围。这是一项见效快但需要业务评估的改动别在没解读业务事务逻辑的情况下贸然切。4.4 对账事务之外的最后一道防线事务保证的是单次操作原子保证不了业务流程最终正确。网络抖动、人为误操作、缓存与数据库短暂不一致总会有事务体系覆盖不到的角落。所以我一直主张核心业务必须带对账任务。对账不需要太复杂定期扫描状态异常的数据结合幂等表把重复和缺失捞出来。比如订单表里已支付但未发货超过一小时的记录或者积分流水里同一订单号出现多次的记录。对账任务发现异常后进入补偿流程而不是靠人肉翻数据库。能自动化就自动化这是事务机制之外真正兜底的工程手段。5. 把事务能力沉淀成工程规范5.1 从代码层面明确事务边界个人经验再丰富也不如团队规范可靠。我在团队里推动了三条硬性约定第一所有涉及多表写操作的方法必须显式写Transactional(rollbackFor Exception.class)不允许使用默认回滚策略第二事务方法内禁止任何远程调用、线程等待、文件操作代码评审看到直接打回第三事务方法命名统一加前缀比如doXxxInTx让调用方一眼知道这里不能塞进慢操作。同时还会配合静态检查工具扫描事务注解方法里的非数据库调用提前在构建阶段发现隐患而不是等线上出故障。这些规则执行一年后和事务相关的线上故障确实大幅下降。5.2 用监控指标代替感觉没问题事务健康度要靠数据判断不是靠好像没出过事。我建议重点盯四类指标事务平均耗时和P99耗时、活跃事务数、锁等待与死锁次数、回滚率。回滚率高不一定坏事可能说明业务冲突确实存在但长期异常偏高就需要分析事务边界是否合理、前置校验是否到位。活跃事务数和连接池使用率要放在同一个监控面板上看二者联动时最容易发现长事务拖垮数据库连接的场景。设置了告警之后很多问题能在用户感知前被处理掉。5.3 我踩过之后才理解的三条教训第一条注解不能代替思考。Transactional只是声明了事务边界不解决事务内逻辑是否合理更不解决分布式一致性。第二条隔离级别是性能与一致性的天平没有绝对正确的选择只有当前业务场景下更合理的选择改了之后必须测试并发行为。第三条别把数据库原子性等同于业务正确性提交成功只代表数据库层面的操作完成业务状态是否合法最终还得靠业务代码和约束一起保证。每次出事务相关的Bug最后复盘几乎都能回到这三点。把这些想透了事务才真正从会写注解变成能掌控。