
做后端这行绕不开“数据库事务”四个字。从最简单的转账到订单、库存、支付、积分几乎每个核心链路都在跟事务打交道。我见过不少同学把事务当成一个“注解”来用写完Transactional就以为高枕无忧结果线上出现数据对不上、死锁、主从延迟才回头慢慢翻日志找问题。这篇文章以 MySQL InnoDB 为主背景把数据库事务是什么、ACID 怎么落地、隔离级别怎么选、代码里怎么声明、跨服务怎么做以及生产环境里怎么排查一次讲清楚。适合刚接触事务的新人也适合正在排查线上问题的老手。1. 事务到底解决了什么问题1.1 从一次转账看事务的价值A 给 B 转账 100 块业务上要拆成两步A 账户扣 100B 账户加 100。如果第一步执行成功、第二步因为网络波动或者 SQL 报错没执行账面上就会莫名其妙多出 100 块。没有事务机制的情况下这种情况会真实发生而且排查起来非常痛苦。事务的定义一句话就能说清楚一组逻辑上不可分割的操作要么全部成功要么全部失败。放到转账场景里就是“A 扣 100”和“B 加 100”要么一起生效要么一起回滚。成功之后数据处于一个确定的合法状态失败之后数据回到事务开始之前的状态中间过程不会被任何人观察到。这个保证对应用层来说极其重要。订单创建、库存扣减、余额变动这些操作天然就是多步的而业务要求的是“整体性”。事务就是数据库层面为这种整体性提供的保底机制。1.2 没有事务控制的并发场景有多可怕单条 SQL 的原子性不需要靠显式事务保证UPDATE stock SET count count - 1本身就是原子操作。但多个 SQL 组合在一起就不一样了尤其是出现并发时问题更明显。拿库存扣减举例。一件商品库存只剩 1 件两个用户同时下单。如果没有事务和锁保护两个请求可能都读到库存为 1然后各自执行扣减最终库存变成 -1。这种问题叫超卖。如果分别执行“查询库存、判断库存足够、执行扣减”三条 SQL即使每条 SQL 都是原子的组合起来仍然可能出错因为两个事务的查询和修改是穿插着执行的。事务的价值在这个时候才真正体现出来它把所有相关操作圈在一个边界里再配合隔离机制让两个事务看起来像是排队执行一样。没有这个边界并发场景下的数据正确性就只能靠应用程序到处加锁加来加去还是容易漏。1.3 事务边界的正确理解方式事务不是越久越好也不是所有 SQL 都该放进同一个事务。事务的边界由业务决定但原则是“能短则短、能少则少”。事务范围越大持有的锁越多、时间越长对其他事务的阻塞就越严重。一个常见误区是“把整个接口都包在事务里”。接口里如果有远程调用、文件上传、消息发送这些耗时操作把它们放进事务会让数据库连接和锁一直被占着。正确的姿势是只把真正涉及数据库写入、且需要保持一致性的操作放进事务所有耗时操作挪到事务外面在事务提交成功之后再执行。事务边界的设计其实是对业务和数据一致性的一次取舍。想清楚“哪些步骤失败必须一起撤销”事务里就放哪些步骤其余的一律不给事务注水。2. ACID 不是四个字母而是四种实现机制2.1 先聊 redo log 和 undo log原子性和持久性怎么落地ACID 四个特性很多人背得熟但问到 InnoDB 具体怎么实现就卡壳了。原子性和持久性主要靠两种日志undo log 和 redo log。undo log 是逻辑日志记录的是数据的逆操作。事务执行过程中每修改一条数据InnoDB 就会生成一条对应的 undo 记录。事务回滚时根据 undo log 把数据恢复成事务开始前的样子。原子性就是这样实现的要么把事务里的所有修改全部应用要么根据 undo log 全部撤销不存在只执行一半的状态。undo log 还有另一个重要作用MVCC 多版本并发控制依赖它来构造历史版本数据这一点后面会展开。redo log 是物理日志记录的是“数据页被改成了什么样”。它解决的是持久性问题。MySQL 不会每次修改都立刻把数据写回磁盘的数据文件因为磁盘随机写太慢了。InnoDB 采用 WALWrite-Ahead Logging机制先把数据变更写入 redo log 文件再异步把数据页刷到磁盘。redo log 是顺序写性能远高于随机写。如果数据库在提交之后、数据刷盘之前崩溃重启时会根据 redo log 重放所有已提交事务的修改保证提交过的数据不会丢。这里还有个细节很有价值redo log 的写入采用两阶段提交和 binlog 配合保证一致性。先写 redo log 的 prepare 阶段再写 binlog最后把 redo log 置为 commit 状态。这个设计保证了两个日志不会出现“一个认为事务成功、另一个认为事务失败”的不一致也是主从复制和崩溃恢复不出错的底层原因。2.2 隔离性锁和 MVCC 是一对搭档隔离性在 InnoDB 里靠两套机制配合实现锁和MVCC。锁机制负责处理“当前读”也就是SELECT ... FOR UPDATE、UPDATE、DELETE这类需要读取最新数据并加锁的操作。InnoDB 的锁可以分成共享锁S 锁和排他锁X 锁同一行上可以同时有多个 S 锁但 S 锁和 X 锁、X 锁和 X 锁之间互斥。按照锁粒度又分为记录锁Record Lock、间隙锁Gap Lock和临键锁Next-Key Lock。记录锁锁住某一行间隙锁锁住一个范围但不包括记录本身临键锁是记录锁加间隙锁的组合也是 InnoDB 在可重复读隔离级别下处理幻读的主要手段。MVCC 多版本并发控制则负责处理“快照读”也就是普通的SELECT不带FOR UPDATE的语句。MVCC 的核心是版本链加 ReadView。每一行数据除了业务字段外还有隐藏的trx_id最近修改它的事务 ID和roll_pointer指向 undo log 中上一版本数据的指针。事务执行快照读时会生成一个 ReadView里面记录了当前活跃事务的 ID 列表。判断一行数据是否可见就看它的trx_id是否满足可见性规则。MVCC 好在哪读操作不用加锁也不会被写操作阻塞读写互不干扰。一个用户正在更新订单状态时其他人仍然可以查到事务开始前的旧版本数据这叫一致性快照读。这是 InnoDB 在高并发下性能优于普通表锁的关键原因。2.3 一致性最被人误解的 ACID 特性一致性是整个 ACID 里最特殊的一个因为它主要不是靠数据库机制实现而是靠应用逻辑保证。原子性、隔离性、持久性是数据库自己做到的一致性更像一个“结果”——事务执行前后数据都不能违反业务规则。数据库层面的唯一约束、非空约束、外键约束勉强算是数据一致性的底线保障。但更贴近业务的规则比如“转账后余额不能为负”“订单金额必须等于商品单价乘以数量”数据库完全不知道需要应用在事务里通过条件判断、条件更新来处理。举个例子账户扣款时不想余额变负不能先查余额再判断因为并发下判断条件会过时。正确做法是写一条条件更新UPDATE account SET balance balance - 100 WHERE id ? AND balance 100。这条 SQL 在数据库层面原子地判断余额是否足够才能从根上避免负余额这也叫乐观锁思路。所以一致性既要有数据库的约束兜底也要靠应用把业务规则变成代码逻辑两者都到位才能真正保证数据合法。3. 隔离级别怎么选并发问题对照与案例3.1 并发事务下的三类典型异常两个或多个事务同时跑如果不加任何隔离控制会出现典型问题脏读、不可重复读、幻读。脏读一个事务读到了另一个事务尚未提交的数据。如果后者回滚前者就基于一个不存在的状态做了决策起不到事务的作用。在 READ UNCOMMITTED 级别下会出现。不可重复读同一个事务里两次执行同一条SELECT得到的结果不一样。原因是另一个事务在两次查询之间提交了对同一行数据的修改。在 READ COMMITTED 级别下快照读每次查询都会重新生成 ReadView所以可能看到新提交的数据前后结果不一致。幻读事务里两次范围查询返回的行数不同。跟不可重复读的区别在于它不是某一行数据变了而是有新的行插入进入这个范围。这个问题光靠行锁解决不了因为新插入的行本身还没有被锁住需要间隙锁或者串行化来兜底。这三类问题按严重程度看脏读最不能接受幻读相对难处理也是 MySQL 默认隔离级别选择 RR 的重要原因。3.2 四种隔离级别对照SQL 标准定义了四个隔离级别InnoDB 基本按标准实现但细节略有不同尤其是对幻读的处理。隔离级别是否存在脏读是否存在不可重复读是否存在幻读实现方式READ UNCOMMITTED是是是不加隔离控制直接读最新版本READ COMMITTED否是是每次查询生成新 ReadView行锁REPEATABLE READ否否基本解决事务首次查询生成 ReadView行锁间隙锁SERIALIZABLE否否否所有读都是当前读完全串行READ UNCOMMITTED 基本没人会用性能看似好一点但付出的代价是数据可信度大幅降低。READ COMMITTED 是很多数据库的默认级别像 Oracle、PostgreSQL 用的就是 RC它能避免脏读但不可重复读依然存在对有些业务是不可接受的。REPEATABLE READ 是 MySQL 默认隔离级别对不可重复读做了严格控制。InnoDB 在 RR 下通过 MVCC 的 ReadView 复用机制让同一个事务内的普通查询结果保持一致通过 next-key lock 解决当前读下的幻读问题。这也是为什么 MySQL 里默认 RR 也能比较安全地处理事务并发。SERIALIZABLE 把所有普通查询都升级成加锁读并发性能下降明显适合对一致性要求极高、并发很低或者纯读多写少的系统。3.3 一个真实的可重复读并发场景之前我在订单系统里遇到过一个现象可以很好说明 RR 和 next-key lock 的实际行为。商品表goods里某次促销活动中一个商品 ID 为 10 的记录被锁定。事务 A 开启事务执行SELECT * FROM goods WHERE id 10 FOR UPDATE拿到当前读结果并锁定了 ID 大于 10 的记录范围包括间隙。事务 B 同时尝试INSERT INTO goods (id, name) VALUES (11, new item)结果发现它被阻塞了直到事务 A 提交或回滚才继续。这就是 next-key lock 在起作用。RR 级别下FOR UPDATE这样的当前读会通过间隙锁把 10 到正无穷这个范围内的插入全部挡住防止事务 A 重复执行同一条查询时看到新插入的行。如果没有这个锁事务 B 的插入能成功事务 A 再查一次就会多出一行也就是幻读。这个案例对做秒杀、抢购类业务特别有参考价值依赖FOR UPDATE保证数据一致性时不仅要考虑行锁还要关注间隙锁对外部插入的阻塞否则一个热门商品的查询可能把相近 ID 的商品插入全部堵住。4. SpringTransactional用对了吗4.1 注解方式的事务管理与传播行为在 Java 生态里绝大多数事务都是通过 Spring 声明式事务完成的。最简单的写法就是在方法上加Transactional由 Spring 在方法前后自动开启、提交、回滚事务。这里面有一个非常关键的默认行为只有 RuntimeException 和 Error 会触发回滚受检异常不会。很多人写代码时习惯把异常包装成业务异常但如果没有正确设置事务可能根本没回滚。正确做法是显式指定回滚异常类型Transactional(rollbackFor Exception.class) public void createOrder(Order order) { orderMapper.insert(order); stockService.deduct(order.getGoodsId(), order.getNum()); }Spring 事务传播行为是针对“事务方法嵌套调用”场景的。最常用的是默认的REQUIRED如果外层已经有事务内层方法就加入这个事务如果没有就新建一个。这个传播级别在绝大多数业务场景下是合理的但有几个坑要注意。传播行为含义典型使用场景REQUIRED有事务则加入没有则新建默认级别嵌套业务方法REQUIRES_NEW无论外层有没有事务都挂起外层事务新建一个日志记录、异步任务独立提交NESTED有事务则创建嵌套事务可以部分回滚复杂业务里的子流程独立回滚SUPPORTS有事务则加入没有也不新建查询方法MANDATORY外层没有事务则抛异常强制在事务内执行的代码NOT_SUPPORTED挂起当前事务以非事务方式执行事务中发通知等非数据库操作NEVER外层有事务则抛异常禁止在事务里执行的代码REQUIRES_NEW是个双刃剑。它能让内层事务独立提交不受外层回滚影响适合记录操作日志这种“即使业务失败日志也要留下来”的场景。但它会额外占用一个数据库连接如果外部方法大量使用会显著增加数据库连接池的压力并发高时容易把连接池打满需要谨慎使用。4.2 事务失效的场景与原因分析Transactional不是写上就一定生效。这些年我踩过印象最深的几个坑几乎都是事务“暗自失效”导致的。第一个是自调用失效。类内部某个方法调用同类里的另一个被Transactional标注的方法事务不生效。原因是 Spring 的声明式事务基于 AOP 代理只有通过代理对象调用方法时才能触发事务增强类内部this调用直接走原始方法绕过了代理。解决办法是把事务方法拆到另一个 Service 里注入调用或者使用AopContext.currentProxy()。第二个是方法不能是 private、final。代理模式下非 public 方法不会做事务增强final方法无法被子类代理覆盖。有些人还会把事务注解写在接口方法上这也能生效但建议直接写在实现类方法上更直观。第三个是异常被吞掉。方法内部 catch 住异常但没有重新抛出Spring 感知不到异常自然不会回滚。正确写法是捕捉后重新抛出或者不 catch 让上层处理。如果需要记录日志可以 catch 后先记录日志再throw出去。第四个是数据库引擎不支持。MySQL 的 MyISAM 引擎就没有事务能力只有 InnoDB 支持。这个坑在新老项目交接时最容易出现建表时没注意引擎后面代码里写再多Transactional也白搭。第五个是跨线程调用导致事务失效。Spring 事务默认绑定在当前线程的数据库连接上如果通过Async或者手动创建新线程去执行数据库操作新线程并没有继承外层事务外层事务回滚时新线程里的操作不会一起回滚。分布式下的补偿机制往往就是从这里开始的。5. 分布式事务别一上来就上 Seata5.1 单库事务和多服务事务的本质区别单体应用、单数据库时事务很好处理一切都是 InnoDB 内部的事。到了微服务阶段订单服务和库存服务各自拥有独立数据库本地事务就没法跨库回滚了。A 服务的订单插入成功B 服务的库存扣减失败A 服务的事务只能管到自己的库管不到 B。分布式事务就是解决跨服务、跨数据库的一致性问题。但它的方案思路和本地事务完全不同。本地事务依赖数据库日志和锁分布式事务则要在多个服务之间建立“共同的决定”要么大家一起成功要么大家一起回滚中间还要处理网络故障、服务宕机、消息丢失等各种异常。这里有个常见的误区一提到分布式事务就想到 Seata。实际上很多业务场景并不需要强一致用可靠消息最终一致就够了而且成本远低于框架型方案。选型前应该先问一个问题这一笔业务是否允许在很短的时间窗口内出现数据暂时不一致允许的话优先消息方案不允许才考虑强一致方案。5.2 本地消息表可靠消息最终一致的经典做法本地消息表是一种非常朴素的思路但至今仍是很多核心链路的兜底方案。核心思想是把“业务操作”和“写消息”放进同一个本地事务里保证它们要么同时成功要么同时失败。具体流程是订单服务在本地事务里同时执行两张表的操作——订单表插入一条订单数据消息表插入一条事件消息。事务提交后通过异步任务把消息表里待发送的消息投递到 MQ。下游库存服务消费消息执行扣库存执行完发一条确认消息回来。如果投递过程中 MQ 挂了定时任务会重新扫描未确认的消息反复重试。下游消费时需要做幂等同一个消息多次消费不能产生副作用。这个方案的优点是完全依赖普通的数据库事务没有额外依赖容易理解和维护。缺点是消息表要跟业务表一起维护数据量大了以后需要定时清理而且重试多多少少有延迟不适合强实时场景。在我实际维护过的项目里这个方案配合消息状态字段加定时对账任务稳定性非常能打。5.3 事务消息与 Seata AT 模式的实际取舍RocketMQ 的事务消息是本地消息表的一种正规化升级。发送方先把一条“半消息”发给 MQMQ 存着但不让消费者看到发送方紧接着在本地事务里执行业务操作然后根据事务结果 commit 或 rollback 半消息。如果本地事务执行过程中宕机了commit 或 rollback 一直没来MQ 会通过回查接口询问本地事务最终结果确认后再决定消息是否投递。这个机制省去了自己维护消息表的麻烦但要求发送方提供事务回查实现引入 MQ 本身也增加基础设施复杂度。Seata AT 模式的思路更像“分布式事务代理”。它对业务代码侵入很小通过拦截GlobalTransactional和 SQL 解析自动记录数据的前后镜像。事务提交前各分支资源先上报事务状态全局事务协调器统一判断全部成功再各自提交如果某个分支失败其他分支根据 undo_log 里的前后镜像自动回滚。听起来很完美但要注意两点一是全局锁会阻塞并发高并发下单热点商品的更新会明显变慢二是它依赖数据库事务来管理分支数据库连接占用时间比本地事务长一圈。选型上我给一个自己的经验判断订单、支付、库存这类链路优先考虑事务消息加幂等消费如果历史系统改造不想动业务代码再考虑 Seata AT涉及资金等强一致、且并发可控的场景才需要 2PC 或 TCC。2PC 存在协调者单点和同步阻塞问题TCC 则需要业务自己实现 try、confirm、cancel 三段逻辑还要处理空回滚和防悬挂代码成本都不低。6. 生产环境里的事务排查与避坑6.1 长事务和大事务的坑相信不少人遇到过数据库连接池被打满应用假死重启就好了但过段时间又犯。排查后发现罪魁祸首往往是某个事务里做了逼近分钟的耗时操作比如调用第三方接口、循环几十万次 update、在一个事务里发 MQ 消息。事务长时间不提交会带来一串连锁反应事务持有的锁不释放其他事务只能排队等锁undo log 不断膨胀影响 MVCC 版本链的读取效率redo log 和 binlog 都会变大主从同步延迟飙升。更危险的是数据库连接一直被占用连接池耗尽后所有需要连接的新请求全部阻塞整个应用看起来就像死了。我的建议是给事务编程定几条硬规矩事务里只放必要的数据库写操作远程调用、RPC、文件处理、消息发送一律在事务提交后做一次事务处理的条数要有限制批量数据操作就分页提交能提前校验的规则先查完校验完再开事务写数据。6.2 死锁和锁等待现场分析与解决死锁的经典案例是两个事务按相反顺序更新两张表。事务 A 先更新订单表再更新库存表事务 B 先更新库存表再更新订单表。两边各持一把锁又都在等对方的锁MySQL 的死锁检测机制会发现这种情况自动回滚代价较小的那个事务应用程序就会收到Deadlock found when trying to get lock; try restarting transaction异常。锁等待则不同它是一个事务一直等另一个事务释放锁直到超过innodb_lock_wait_timeout默认的 50 秒都没等到直接报锁等待超时。这种问题排查起来有个常用三板斧-- 查看当前正在运行的事务 SELECT * FROM information_schema.innodb_trx; -- 查看当前锁等待情况 SELECT * FROM performance_schema.data_lock_waits; -- 查看 InnoDB 引擎状态中的锁信息 SHOW ENGINE INNODB STATUS;innodb_trx里能看到事务的trx_started时间如果某个事务长时间停留在 RUNNING 状态八成就是长事务。data_lock_waits能直接告诉你哪个事务在等哪个事务的哪把锁。SHOW ENGINE INNODB STATUS输出里的LATEST DETECTED DEADLOCK一段会记录最近一次死锁的完整事务和 SQL是分析死锁原因的第一手资料。解决死锁的思路很朴素统一更新顺序、缩小事务范围、减少锁的持有时间。实在避免不了时在应用层给可能死锁的方法加个重试机制捕获死锁异常后快速回滚重试一次这个策略屡试不爽。6.3 事务编程规范里的几条实用建议最后整理几条我平时写代码时一直在用的经验。查询只读方法尽量标注Transactional(readOnly true)让数据库知道这不会写入可以走更轻量的路径连接层面也有优化空间。防超卖不能用“先查询再判断”要用条件更新。一条UPDATE stock SET count count - 1 WHERE id ? AND count 0就解决了库存扣减的原子性问题配合affected rows判断是否扣成功就行。外部调用和消息发送要放在事务提交后执行。如果确实需要事务提交成功后再发消息用TransactionSynchronizationManager.registerSynchronization注册事务同步回调而不是在事务代码块里直接发送。幂等设计必须在数据库层面有唯一约束兜底。消息重试、定时任务重跑、网络超时重发这些都是常态下游消费时先查记录判断是否已经处理过同时业务表上建唯一索引做到双保险。这篇文章写得比较长但每一条几乎都是我在项目里撞过墙之后换来的。最后再分享一个小细节排查线上锁问题别急着看 SQL 慢不慢先查information_schema.innodb_trx看看哪些事务一直开着。很多时候问题不是某条 SQL 慢而是一个开着不提交的长事务把别人全部堵住了。先把长事务杀掉往往比调索引立竿见影得多。