
写后端代码这些年“事务”这两个字我听了不下几百遍但真正把它从概念变成肌肉记忆是从一次线上资损事故开始的。那次问题非常简单两个账户之间做金额划拨更新余额的 SQL 执行到一半数据库连接断了结果一方金额已经扣减、另一方还没加上。整个团队排查了一整晚最后的修复方案不过是给这段逻辑套上一个数据库事务——但这也让我彻底看清楚了数据库事务解决的是并发和异常场景下的数据一致性它不是一个“锦上添花”的功能而是很多业务系统的底线。这篇内容我打算从最朴素的问题讲起把事务的原理、隔离级别、底层日志机制以及业务代码里最常见的事务坑一次说清楚。适合刚入行的开发也适合那些“知道事务但没系统梳理过”的同事。看完之后你会发现事务并不只是BEGIN和COMMIT两行关键字它背后是一整套关于“怎么安全地改变数据”的设计。1. 先搞清楚事务到底在防什么1.1 没有事务时一个转账都做不对假设你要给另一个账户转 100 元日常操作会被拆成两条 SQLUPDATE account SET balance balance - 100 WHERE id 1; UPDATE account SET balance balance 100 WHERE id 2;第一条 SQL 把账户 A 的余额减掉 100第二条把账户 B 的余额加上 100。在一个理想世界里两条 SQL 按顺序执行数据库永远不出错连接永远不中断那事务确实没多大存在感。但现实是SQL 可能执行失败应用正在发布重启网络抖动让连接超时磁盘满导致写入报错…… 只要第一条成功、第二条没成功资金就凭空消失了。这就是事务最核心的存在价值把一组操作绑定成一个不可分割的整体要么全部成功要么全部失败。用事务包住上面两条更新之后如果第二条失败数据库会自动把第一条的回滚掉账户 A 的钱不会少START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1; UPDATE account SET balance balance 100 WHERE id 2; COMMIT;你可能会想单条 SQL 本身不就是要么成功要么失败吗是的但业务从来不只是单条 SQL。一个下单动作可能要写订单表、扣库存表、写流水表一个审批动作可能要更新审批单、记录日志、通知下一节点。只要是多步写操作就有“写到一半出事”的可能就需要事务。1.2 ACID 四个属性一个一个拆教科书里喜欢把事务的特性总结成 ACID也就是原子性、一致性、隔离性、持久性。这四个词看着抽象其实对应到真实场景非常具体。原子性Atomicity指的是事务里的操作像一个原子一样不可再分。要么全做要么全不做。刚才转账例子里的两条 UPDATE在事务里就属于一个原子操作。原子性在底层主要靠 undo log 实现事务执行过程中如果出错或者主动回滚数据库通过 undo log 把已经修改的数据恢复原样。一致性Consistency说的是事务执行前后数据都必须满足业务规则。比如账户余额不能为负数、订单状态不能从“已支付”跳回“待支付”。这里要注意一致性其实有一部分是业务自己保证的数据库只能保证约束层面的“一致”像唯一索引、外键、非空约束等。至于“转账不能转出负数”这种逻辑如果你代码里没判断事务并不会替你纠正。很多新手容易把一致性和原子性混在一起实际上原子性保证“操作不中断”一致性保证“数据不失真”。隔离性Isolation针对的是并发场景多个事务同时在跑彼此之间不能互相干扰。但“不能干扰”到什么程度数据库提供了不同选项这就是后面要说的隔离级别。如果完全没有隔离性两个事务同时改一行数据最后结果取决于谁后提交另一个人的更新就丢了这在账务系统里是不可接受的。持久性Durability承诺事务一旦提交数据就不会丢哪怕数据库立刻宕机。持久性主要靠 redo log 来保证崩溃后数据库重启时会重放日志把已提交但还没来得及落盘的数据恢复出来。注意原子性的回滚只针对事务内已经执行的 SQL。如果你在事务里发了 HTTP 请求、写了 Redis、调了消息队列这些动作都不受数据库事务保护。事务回滚了外呼请求已经发出去了这种“跨系统一致性”问题事务解决不了需要引入别的方案。1.3 什么场景值得开事务不是所有代码都需要事务。如果你只是查一条数据、做一次简单的单行更新事务意义不大。但下面这些场景我建议你务必用事务包住多表更新比如订单主表和订单明细表必须同时写入。账户类操作余额变动、积分变动先查后改再记录流水。库存预占下单时扣减库存同时生成订单记录。状态机流转一个状态要经过多步骤更新任何一步失败都要回退。一句话总结判断标准如果这组操作只成功了一半数据还能见人吗不能见人就开事务。2. 隔离级别并发下的事务“视力”差异2.1 三个并发问题怎么理解先看一个最常见的场景两个事务同时操作同一行数据或者同一个范围的数据。这时如果隔离做得不够就会出现三类经典问题。脏读事务 A 修改了一行数据还没提交事务 B 读到了这个未提交的修改。然后 A 回滚了那 B 刚才读到的数据就是“脏数据”是根本不存在过的数据。比如你把订单状态改成“已支付”事务还没提交同事的报表任务已经把“已支付”统计进去了下一秒你回滚了报表数据就错了。不可重复读事务 A 先读一次某行数据得到值 X事务 B 在这期间修改了这行并提交事务 A 再读一次得到值 Y。同一个事务里两次读同一行结果不同。这在报表、统计类场景里非常致命——你按时间段统计金额第一次统计和第二统计结果不同报表就对不上账。幻读事务 A 查询某个范围的数据比如“查询状态为待支付的订单”第一次查出 10 条事务 B 插入了一条新的待支付订单并提交事务 A 再查一次查出 11 条。多出来的那条就像“幻觉”一样凭空出现。幻读和不可重复读的区别在于不可重复读是同一行内容变了幻读是整个集合的行数变了。这三个问题可以放在一张表里对比问题本质是否读到未提交数据同一行内容变化查询行数变化脏读读到未提交数据是可能可能不可重复读已提交数据被其他事务修改否是否幻读其他事务插入新行否否是2.2 四种隔离级别选哪个SQL 标准给出了四个隔离级别隔离强度从低到高Read Uncommitted读未提交允许读未提交数据基本等于不隔离脏读、不可重复读、幻读都可能发生。实际生产环境几乎没人用除非你对数据准确性完全不在乎。Read Committed读已提交只能读到已提交的数据解决了脏读。但事务 A 两次读同一行时如果事务 B 在中间修改并提交了A 第二次读到的值会变所以不可重复读依然存在。这是很多数据库的默认级别比如 PostgreSQL、Oracle。Repeatable Read可重复读保证了同一个事务内多次读取同一行结果一致。它解决了不可重复读但没有彻底解决幻读。MySQL 的 InnoDB 默认就是这个级别并且通过 next-key lock 在多数场景下连幻读也一起解决了。Serializable串行化最强隔离级别事务之间完全串行执行读写都要加锁并发能力极低。除非是银行对账、极度敏感的数据批处理否则不建议线上使用。实际项目里Read Committed 和 Repeatable Read 是两个最常见的选项。选哪个不只看“哪个更安全”还要看你愿不愿意为隔离付出锁和性能代价。级别越高通常意味着并发度越低接口延迟越容易飙上去。2.3 隔离级别不是靠锁硬扛出来的MVCC 才是主角很多人以为事务隔离是靠全程加锁实现的读数据之前锁住读完之后解锁。如果真这样并发性能会惨不忍睹。现代数据库普遍采用MVCC多版本并发控制来优化读性能。MVCC 的思路是同一行数据在数据库里不只有一个版本每次更新都会生成一个新版本旧版本保留一段时间。事务读数据时根据自己开启事务的时间快照选择一个“应该看到”的版本。所以读操作不会阻塞写操作写操作也不会阻塞读操作。举个例子事务 A 开启后它读某行时看到的是一份快照版本之后事务 B 修改了这行并提交A 再次读这行时根据隔离级别的规则A 可能仍然读快照里的旧版本这就是“可重复读”能重复的原因。底层实现涉及隐藏列、版本链和 Read View 机制这里先不展开后文讲 undo log 时会再提到。你只需要记住一句话MVCC 让“读”和“写”尽量并行隔离级别只是决定了一个事务能看到历史上哪些版本。3. 底层“记日志”机制事务是怎么做到能回滚又能恢复的3.1 redo log 与写入顺序事务提交时数据不可能立刻刷进磁盘。原因很简单磁盘随机写入太慢如果把每一行修改都实时刷盘数据库性能会低到没法用。所以数据库采用了一个经典策略先写日志再写数据。这个策略叫 WAL也就是 Write-Ahead Logging。事务执行过程中针对每一行修改数据库会先写一条 redo log记录“哪个页、哪个偏移量、改成了什么”。redo log 是顺序写入的性能远高于随机写盘。事务提交时只要 redo log 成功落盘事务就算提交成功了即使数据页还没写回磁盘也问题不大。万一数据库崩溃重启时会通过 redo log 把已提交的数据恢复出来保证持久性。你可以把 redo log 理解为快递的“运单记录”包裹数据还没送到但运单已经登记了。只要运单在哪怕中途车坏了总能按运单把包裹补送。如果没有运单丢哪件都查不出来。3.2 undo log 与回滚redo log 负责“向前恢复”undo log 则负责“向后回滚”。事务执行过程中每次修改数据前数据库会把修改前的值记录到 undo log 里。如果事务回滚数据库就根据 undo log 把数据倒回原来的样子。undo log 还有一个重要用途它保存了每一行的历史版本MVCC 正是通过 undo log 构成的版本链让读操作能找到符合快照条件的旧版本。可以说没有 undo log隔离级别里的“可重复读”就只能靠加锁硬实现性能会完全不同。回滚不是万能的。如果一个事务里执行了DROP TABLE这类 DDL 操作很多数据库会隐式提交当前事务之前的修改就无法撤销了。所以你在代码里要特别注意不要把 DDL 和 DML 混在同一个事务里还指望回滚救你。3.3 一条 UPDATE 语句的完整旅程把上面两块拼起来看一条 UPDATE 在事务里的生命周期大致是这样的事务开启拿到一个事务 ID。执行 UPDATE数据库先加载目标行到内存缓冲池。写 undo log记录修改前的值。修改内存中的数据页。写 redo log记录修改后的值和对应页信息此时日志处于 prepare 状态。事务继续执行其他 SQL重复上述过程。执行 COMMITredo log 进入 commit 状态并落盘。事务结束后续数据页会在合适的时机由后台线程刷入磁盘。如果第 7 步之前任何环节出错数据库会利用 undo log 回滚如果第 7 步之后数据库崩溃redo log 能保证已提交事务不丢。这套机制就是事务可靠性的地基。理解它之后很多面试题里的“为什么事务提交这么快”“为什么数据库宕机后数据不丢”就都有了答案。4. 业务代码里的实用事务操作与边界设计4.1 三种常见开启方式事务在代码里最常见的三种落地方式我一个个说。第一种是 SQL 层手动控制START TRANSACTION; -- 业务 SQL UPDATE account SET balance balance - 100 WHERE id 1; UPDATE account SET balance balance 100 WHERE id 2; COMMIT; -- 出错时执行 ROLLBACK;这种方式适合写存储过程、脚本或者需要精细控制提交时机的场景。但要注意START TRANSACTION之后如果忘记COMMIT事务会一直挂着连接也不释放这在连接池场景里会快速耗尽数据库连接。第二种是应用框架注解比如 Java 里常见的TransactionalTransactional public void transfer(Long fromId, Long toId, BigDecimal amount) { accountMapper.decrease(fromId, amount); accountMapper.increase(toId, amount); }注解方式让代码看着干净但它隐藏了大量细节。默认情况下只有运行时异常触发回滚受检异常不会回滚。也就是说如果你的方法里抛出的是业务检查异常事务可能照样提交了。很多线上数据不一致的 bug 就是这么来的。第三种是编程式事务手动在代码里开启、提交、回滚TransactionStatus status transactionManager.getTransaction(); try { // 业务逻辑 transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); throw e; }这种方式最灵活但代码侵入性也最强。简单业务用注解足够事务边界复杂的再考虑编程式。4.2 事务越短越好的边界原则我在实际 code review 里见过最多的问题不是不写事务而是“把太多东西塞进事务”。记住几条边界原则第一事务里不要做远程调用。HTTP 请求、RPC 调用、外部接口访问这些操作的耗时不可控。事务内请求时间越长持有的锁就越久其他事务等待的时间就越长。假设一个事务里调了外部接口接口超时 30 秒这 30 秒内相关数据行全部被锁住线上接口可能直接雪崩。正确做法是先本地落库提交事务再异步发消息或调外部接口。第二事务里不要做大批量循环写入。比如循环 5000 次执行 UPDATE每次都涉及锁和日志事务整体耗时可能好几秒。更合理的做法是拆分批次或者先预处理再批量写入让单个事务的持锁时间尽量短。第三事务里不要有太重的查询。虽然 MVCC 让普通查询不加锁但如果你在事务里用了SELECT ... FOR UPDATE这种加锁读或者查询本身特别慢锁的持有时间同样会被拖长。事务不是不能查询而是不要做与分析业务无关的重查询。4.3 事务传播行为与嵌套问题框架类事务通常还提供“传播行为”简单说就是当多个事务方法嵌套调用时是共用同一个事务还是各开各的事务。常见的有 REQUIRED、REQUIRES_NEW、NESTED 等。REQUIRED 是默认行为如果外层已经有事务内层方法直接加入外层事务内层抛异常会导致整个外层事务回滚。REQUIRES_NEW 则是内层方法强制新开一个独立事务外层事务跟它互不干扰适合“日志记录”这种不管主流程成败都要落库的场景。NESTED 是嵌套事务内层可以部分回滚但外层整体状态仍然参与全局提交或回滚。一个非常经典的坑是自调用失效。同一个类里的方法 A 调方法 BB 上有事务注解但 A 没有此时 B 的事务可能根本不生效。原因很简单事务是通过代理对象拦截的类内部this调用不会走代理。很多同事排查半天最后发现是自调用问题。解决办法是把 B 拆到另一个类或者注入自身代理调用再或者用编程式事务兜底。5. 线上问题排查实录与常见坑5.1 死锁两个事务互相等锁死锁是事务并发里最典型的“交通事故”。举一个最简单的例子事务 A 更新了 id1 的行事务 B 更新了 id2 的行接着 A 想更新 id2B 想更新 id1。两边都在等对方释放锁谁都不肯放手数据库只能靠死锁检测机制选择牺牲一个事务让它回滚并报错。排查死锁时我一般先看数据库的锁等待日志。以 MySQL 为例执行SHOW ENGINE INNODB STATUS可以看到最近一次死锁涉及的事务、执行的 SQL、等待的锁对象。看到日志后优化思路通常是让所有事务按相同顺序访问资源比如更新多行时先按主键排序。缩小事务范围减少持有锁的时间。避免事务里做太多无关操作。合理使用索引让锁落在尽量少的行上。如果你发现某个接口频繁报死锁先不要急着加锁或换隔离级别先看 SQL 的执行计划很多时候是没走索引导致锁了全表。5.2 长事务连接池耗尽和 undo 膨胀长事务是指“长时间不提交也不回滚”的事务。它的危害有两层一是长期持有锁阻塞其他事务二是不停产生 undo log导致历史版本堆积数据库文件膨胀查询性能下降。有一次排查线上接口变慢我查了information_schema.innodb_trx发现有事务跑了将近 40 分钟还没提交。顺着线索找到代码发现是一位同事在事务里调了外部接口外部接口超时 30 分钟事务就一直挂着。处理办法很简单把外部调用移出事务事务体只保留纯数据库操作。监控长事务时可以定期执行类似下面的查询找出长时间未提交的事务SELECT trx_id, trx_state, trx_started, trx_rows_locked FROM information_schema.innodb_trx WHERE trx_state RUNNING;看到trx_started距离当前时间超过阈值就该告警了。另外连接池耗尽很多时候也是长事务引起的连接被事务占住不放新的请求拿不到连接接口全部排队。5.3 快照读与当前读不一致这是 Repeatable Read 级别下一个非常隐蔽的问题。同一个事务里普通SELECT是快照读读到的是事务开启那一刻的快照而UPDATE、DELETE、SELECT ... FOR UPDATE是当前读读取的是最新已提交数据。两者混用时可能出现“先查询到旧数据再更新时却基于新数据计算”的诡异现象。举个实际例子事务里先SELECT balance拿到 100然后执行UPDATE ... SET balance balance - 50。这个 UPDATE 内部读到的 balance 可能是别人已经改过的 200最终算出 150和你期望的 50 完全不同。这类问题很难靠直觉发现建议在设计事务逻辑时明确自己用的是快照读还是当前读需要严格基于最新值计算时就直接用SELECT ... FOR UPDATE加锁读不要依赖“查一次再改一次”的默认行为。5.4 事务问题速查表症状可能原因排查方法处理建议数据更新丢失事务隔离级别过低或没有加锁检查隔离级别和 SQL 加锁情况提高隔离级别或使用SELECT ... FOR UPDATE接口报死锁多个事务锁顺序不一致SHOW ENGINE INNODB STATUS查看死锁日志统一资源访问顺序缩小事务范围连接池被占满长事务未提交查询information_schema.innodb_trx找到长事务对应的代码移除外呼操作事务回滚没生效异常类型不被事务管理器捕获确认异常是否为运行时异常检查Transactional配置显式指定回滚异常类型数据变了但日志没记录事务提交后外呼失败查看应用日志中提交顺序将外呼改为事务后异步发送6. 我最后想说的几句实在话带过这么多项目也帮同事处理过不少事务问题我最大的体会是事务本身不难难的是控制事务的边界。很多人写代码时只想着“这里要加事务”却很少想“这个事务应该多短、该不该包住这段外呼、回滚发生时旁边那些状态怎么办”。数据库事务是个好工具但它不是万能的更不是业务一致性的唯一保障。我还想多说一句事务的隔离级别、锁机制这些内容看起来像是 DBA 才需要研究的但作为开发只要你的代码里有并发写操作这些知识迟早会找上你。与其等线上出了资损、死锁、丢数据再回头补课不如趁早把事务的底层逻辑吃透。最后分享一个小习惯每写一段涉及事务的代码我都会问自己三个问题——这个事务最短能缩到多短如果它回滚了哪些数据会受影响如果它成功了但旁边系统失败数据还能不能对得上把这三个问题想清楚你就已经超过大多数“只会写 BEGIN”的同事了。