搞了十来年 MySQL,从 MyISAM 时代的表锁熬到 InnoDB 成为默认引擎,再到现在帮别人排查各种线上事务问题,我越来越觉得“mysql 事务”这五个字,几乎是所有数据库问题的交汇点。很多人面试背了一堆 ACID、隔离级别,可真到了线上,一条 UPDATE 锁住整个表、Spring 事务莫名回滚、分布式订单对不上账,这些问题全得回到事务本质上找答案。这篇东西我不打算写成文档式的科普,而是把这些年踩过的坑、调过的参、背过的锅,结合 MySQL 事务的核心原理和实操经验,一次性说透。
这篇文章适合谁?刚入职的 Java 开发、自己搭服务用 MySQL 的独立开发者、准备跳槽刷 mysql 面试题的同学,还有那些被“订单与库存分布式事务一致性”折磨过的架构师候选人,都能从这里找到对应的那部分内容。
1. 事务的底层逻辑:先从原子性和持久性说起
很多人一提到事务就条件反射地说 ACID,但如果你问他“回滚到底是怎么实现的”,他大概率会愣住。先记住一句话:InnoDB 靠 undo log 实现原子性,靠 redo log 实现持久性,这两样东西才是事务的地基。
1.1 undo log 与原子性
某个事务里连续执行了三条 UPDATE,前两条成功,第三条失败,这时候 MySQL 要把前两条的操作撤销掉。它靠什么撤销?靠的就是 undo log——在修改数据页之前,先把旧值写进 undo log。如果事务回滚,就拿这些旧值把数据页还原回去。
这里有个大家容易忽略的点:undo log 不只是回滚用的,它还是 MVCC(多版本并发控制)的重要支撑。后面要讲的“快照读”里,那些历史版本数据就是从 undo log 里捞出来的。也就是说,即便一个事务已经提交,只要还有更早的事务需要读到旧版本,undo log 里的历史版本就不能立即清理。这也是为什么长事务会导致 undo log 膨胀,最后撑爆磁盘或者让历史版本链过长,查询越来越慢。
1.2 redo log 与持久性
redo log 解决的则是“数据还没落盘但事务已经提交”的问题。InnoDB 的数据页是随机写的,如果每次提交都把数据页刷回磁盘,性能会被随机 IO 拖死。所以 InnoDB 采用 WAL(Write-Ahead Logging)策略:提交事务时,只把 redo log 顺序写入磁盘,数据页在内存里稍后再慢慢刷。
只要 redo log 写成功了,这个事务就算持久化了。哪怕下一秒机房断电,MySQL 重启时也会根据 redo log 重放操作,把数据恢复出来。这就像写日记的人先把关键事项记在便利贴上,等有空再把内容誊抄到正经本子上,不小心本子被水泡了也没关系,便利贴还在就能重新誊一遍。
1.3 提交为什么是两阶段的?binlog 与 redo log 的配合
很多人会忽略 redo log 和 binlog 的协同关系,直到某天发现主从数据不一致才回过味来。redo log 是 InnoDB 引擎层的日志,binlog 是 MySQL Server 层的日志,两者都记录着事务的变更。为了保证崩溃恢复后两份日志能对上,InnoDB 采用了内部两阶段提交:先把 redo log 标记为 prepare 状态,写入 binlog 成功后再把 redo log 标记为 commit。
这一步看着不起眼,实际作用是防止主从环境中数据不一致。比如事务提交到一半宕机,如果 binlog 里没有这条记录,从库就不会执行这个事务,而主库在重启后通过 redo log 回滚了它,两边还算统一。但如果反过来——主库提交了、binlog 没来得及写,从库就少了一笔操作,这是绝对不能接受的。手写代码可能感受不到这个问题,直到你用 mysqlbinlog 工具回放日志做数据恢复时,会发现两阶段提交保证了“要么 binlog 里有完整记录,要么主库也不承认这个事务”,这条准则是整个 MySQL 一致性的底线。
2. 隔离级别与并发控制:脏读、幻读是怎么来的
事务的 I(Isolation)隔离性,在 InnoDB 里靠的是锁和 MVCC 的组合拳。这也是 mysql 事务面试题里最容易把人大脑绕晕的部分,但其实只要抓住一条主线就好:隔离级别决定了一个事务能看到多少别人还没提交或刚提交的数据。
2.1 四个隔离级别,一张表说清
MySQL 默认的隔离级别是 REPEATABLE READ(可重复读),四档级别在 ANSI SQL 标准里是这么定义的:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现原理 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 裸读,不加任何限制 |
| READ COMMITTED | 不会 | 可能 | 可能 | 读已提交版本 |
| REPEATABLE READ | 不会 | 不会 | 可能 | 快照读 + 锁 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 全串行化 |
注意到没有,标准的 REPEATABLE READ 是允许幻读的,但 InnoDB 的 REPEATABLE READ 通过间隙锁做到了很大程度上防幻读。所以你在 MySQL 里用 RR 隔离级别,基本不用担心经典意义上的幻读问题。
你需要记住的一点是:隔离级别越高,并发能力越差,一致性越强。SERIALIZABLE 会让每条读都变成当前读加锁,性能直线下滑,生产环境极少使用。
2.2 MVCC 与快照读:读不加锁的秘密
InnoDB 的快照读,依赖的是每行记录里的隐藏列:DB_TRX_ID(最近修改该行的事务 ID)、DB_ROLL_PTR(指向 undo log 中上一个版本)、DB_ROLL_ID(行标识)。当一个普通的 SELECT 执行时,它会根据当前事务的快照信息,沿着 undo log 构建出一个一致性视图,读到的是一份“历史快照”,所以不阻塞其他事务的写。
这就像一个公司里,你在项目开始那天拍了一张团队合照(快照),之后别人不管怎么离职入职,你手里的合照都不会变,直到你重新拍照。在 REPEATABLE READ 下,这个快照是事务第一次执行查询时生成的,之后整个事务内都用同一个快照;在 READ COMMITTED 下,每次查询都会重新生成快照,所以能读到别的事务刚提交的数据。
2.3 当前读与锁的关系
快照读不加锁,但你执行 UPDATE、DELETE、SELECT ... FOR UPDATE 这类操作时走的是当前读,拿到的一定是最新的数据,并且要给相关记录加锁。锁的类型分成三类:记录锁(Record Lock)锁单行、间隙锁(Gap Lock)锁区间、临键锁(Next-Key Lock)同时锁住记录和它前面的间隙。
间隙锁是很多新手踩坑的源头。RR 隔离级别下,MySQL 为了防止幻读,会在你查询的范围内加上间隙锁,把区间内的“空位”都锁住,这样新插入的数据也进不来。但这同时带来了一个后果:两个事务如果分别持有不同间隙的锁,再互相插入数据到对方间隙里,就很容易构成死锁。后面排查死锁时会重点聊这个。
3. 事务的实操:从一条 UPDATE 到完整的业务事务
原理说再多,落地才是硬本事。写 mysql 事务代码时最怕的不是不会写,而是不知道什么时候该用事务,什么时候不该用。这一节我给出一套可复用的实践思路。
3.1 手动事务的标准姿势
除非你用的框架已经接管了事务,否则手动控制 MySQL 事务的代码套路始终是这三步:
START TRANSACTION; -- 或 BEGIN,后者不会立即开启,前者立即开启 -- 业务 SQL,至少包含一条 DML UPDATE account SET balance = balance - 100 WHERE user_id = 1; UPDATE account SET balance = balance + 100 WHERE user_id = 2; COMMIT; -- 或者回滚有一个容易踩坑的小细节:START TRANSACTION 之后如果执行出错,你有两条路可选,一是直接 ROLLBACK,二是先 SAVEPOINT 再局部回滚。直接回滚意味着整个事务里所有操作都白干了,而 SAVEPOINT 允许你只撤销某一段操作,保留此前的成果。这在批处理场景里非常实用,比如循环导入一万条数据,每条数据独立成一个带 savepoint 的事务点,遇到脏数据处理完再继续,省的整批重新跑。
3.2 分布式环境下事务边界怎么划
事务边界是代码里最容易被画错的部分。我见过不少同事把整个接口的内部逻辑全部包在一个大事务里,结果一个接口里有大量远程调用,事务挂时间超过一秒甚至几秒,数据库连接被占着不放,连接池被拖垮,最终整个服务雪崩。
我的实践经验是:事务内只做数据库操作,把远程调用、消息发送、文件上传全部移到事务提交之后。如果你非要在事务里做远程调用,至少得问自己一个问题:远程服务失败了,数据库要不要回滚?如果要,那只能继续扛着连接等超时,这是架构层面的权衡;如果不要,就老老实实把调用挪出去。
3.3 Spring 事务注解的正确打开方式
Spring 的 @Transactional 注解改变了事务的打开方式,但也带来了无数“事务失效”的坑。最典型的场景我列三个:
第一,自调用问题。同类中方法 A 调用方法 B,而 B 上有 @Transactional,事务是不生效的。原因是 Spring 事务是基于 AOP 动态代理实现的,自调用时走的是 this 对象直接调用,绕过了代理层。解决办法是把调用拆到不同的类里,或者注入自身代理。
第二,异常被吞掉。@Transactional 默认只在 RuntimeException 和 Error 时回滚,如果你自定义的异常是 checked exception,或者代码里 catch 住异常没往外抛,事务根本感知不到,于是数据照常提交。要么在 @Transactional 的 rollbackFor 属性里指定异常类型,要么别吞异常。
第三,事务方法里开了新连接。比如在事务方法里用 JdbcTemplate 新开了一个连接去执行 SQL,这个新连接默认不受当前事务管理,所以“一个事务里写了两份数据,一份回滚了一份没回滚”。很多人排查半天以为是 MySQL 的问题,最后发现是连接根本不归同一个事务管。
4. 事务失效与常见大坑排查实录
我遇到过很多“说是用了事务,数据却还是乱”的线上事故。这些坑如果不提前知道,排查起来会非常痛苦。
4.1 大事务与长事务的代价
所谓大事务,指的是更新行数多、执行时间长、持有的锁多的事务。这种事务一旦提交失败回滚,会重新执行一遍 undo log,耗时极长;提交成功也没好到哪去,binlog 和 redo log 都会产生巨大的写入压力,主从复制延迟跟着飙升。
更隐蔽的问题是长事务会阻止 purge 线程清理 undo log 历史版本。线上常见现象是:有一个很老的会话一直没有提交,哪怕它是空闲的,也有可能导致 undo log 一直处于“活跃状态”,占用大量空间,并且被它引用的旧版本行一直不能被清理,最终拖垮整个实例。
排查方法很简单,用这条 SQL 看当前有哪些长事务:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;凡是 trx_started 时间过长的,基本都能抓到“元凶”。
4.2 死锁的成因与查看方法
死锁的典型场景是 AB-BA:事务 A 锁住记录 1 想等记录 2,事务 B 锁住记录 2 想等记录 1,两边互不相让。InnoDB 的解决办法是检测到死锁后自动回滚代价更小的一方事务,同时报错Deadlock found when trying to get lock; try restarting transaction。
查看死锁原因,最直接的方式是:
SHOW ENGINE INNODB STATUS\G重点关注输出里的LATEST DETECTED DEADLOCK段,它会详细列出两个事务各自持有和等待的锁。这里我的经验是:光看锁还不够,你得回头审视业务 SQL,把两条 SQL 的操作顺序规整成全局一致,比如所有涉及用户资金的更新都按 user_id 从小到大处理,死锁概率会立刻降下来。另一个办法是缩短事务持锁的时间,把耗时的查询移到事务外,把 UPDATE 集中在事务末端执行。
4.3 事务日志满与 9002 错误
排查过程中也会遇到“消息 9002, 级别 17, 状态 2”这类错误,意思是目标数据库的事务日志已满。虽然这个场景更多出现在 SQL Server,但 MySQL 也有类似的教训:当事务非常大,redo log 写入能力跟不上时,实际体验就是“提交卡住”或者干脆报空间不足。遇到这种情况,先别急着加磁盘,看几个问题:这个事务是不是太大了?能不能拆成多个小批事务?redo log 的文件大小和数量是不是配得太小?生产环境一般把 innodb_log_file_size 配到 1G 以上不稀奇,太小的情况下,大事务狂刷日志会很容易撞到上限。
5. 分布式事务:从本地事务到订单与库存的一致性
如果是一个单体应用,一个数据库,本地事务就够用了。但现在的业务经常拆分成多个服务、多个数据库,下单流程里订单库写订单、库存库扣库存,只有本地事务的话,订单写成功库存扣失败的情况必然出现。这就是分布式事务要解决的问题。
5.1 为什么分布式事务这么难
单机事务靠 redo log、undo log、意向锁和隔离级别就能搞定一致性,但分布式环境里没有共享存储,没有统一时钟,没有全局的快照概念。你不能要求两个数据库在毫秒级范围内保持一致,只能通过协调机制达到“最终一致”。
常用的方案有三类:2PC/XA(两阶段提交)、TCC(Try-Confirm-Cancel)、可靠消息最终一致。XA 的强一致性最好,但性能差、扩张性差,互联网公司一般不直接用于高并发核心链路;TCC 侵入性强,需要为每个业务写 Try、Confirm、Cancel 三个方法,代码量和维护成本都不小;真正用得最多、技术上最符合实际业务的,是可靠消息 + 本地消息表这种方式。
5.2 本地消息表 + 定时任务的经典路线
这个方案的核心思路:把“发消息”和“写业务数据”放在同一个本地事务里。比如订单服务在创建订单的事务里,同时往本地消息表插入一条“扣减库存”的消息。事务提交后,由后台定时任务不断扫描消息表,把消息投递到 MQ,再由库存服务消费处理。
如果消息投递失败,或者库存服务处理失败,消息状态还是“未处理”,定时任务会在下一轮继续重试。直到超过最大重试次数,进入死信队列由人工介入。这个方案的优点是落地简单、不需要额外的框架支持,理解成本低;缺点是需要自己维护一张消息表、处理重复投递和幂等消费。
5.3 事务消息:把本地消息表交给 MQ
可靠消息最终一致性的线上主流实现,是依赖 RocketMQ 这类自带事务消息能力的 MQ。模式也很直观:先发送一条“半消息”(prepared 状态),此时消费者看不到这条消息;接着执行本地事务,提交订单数据;然后根据本地事务执行结果,Commit 或 Rollback 那条半消息。如果半消息一直处于中间态,MQ 会反查业务方的事务状态,拿不到结果就一直追问,确保消息最终一定被确认。
这套方案把分布式事务从数据库层抬升到消息中间件层,让一致性问题的处理更集中、更工程化。订单和库存的场景里,订单库是主,库存库是从,只要确保订单事务一成功,库存操作的消息迟早能被可靠消费,最终两边数据就能对齐。
但无论用本地消息表还是 RocketMQ 事务消息,有个底线不能破:消费者必须保证幂等。因为消息可能重复投递,或者说“至少一次”传递语义下,重复是无法避免的。每个消费端程序,都要用业务唯一键去重。比如扣库存消费里用“订单号 + 商品 SKU”做幂等判断,处理过的直接丢弃,否则会扣两次库存,那时候就不是事务问题,而是逻辑 bug 了。
5.4 分布式事务的取舍心得
我最后补充一点分布式事务的设计建议:能拆则拆,尽量避免跨服务建立强一致。比如有些系统所谓“订单与库存”,其实可以把库存预占这一步放到下单接口里同步去做,先扣再创建订单,订单失败再释放库存,这本质上还是本地事务能覆盖的。只有当你确实无路可退,必须拆服务,才去考虑分布式事务方案。
另外,TCC 虽然代码多,但它在资金类、强一致要求的场景里有不可替代的位置。比如账户余额扣减和冻结,TCC 的 Cancel 方法能明确返还冻结资金,这种能力是最终一致方案做不到的。我见过太多团队一上来就上 MQ,结果对账时差一分钱都找不到原因,反而是用 TCC 把每一步都记录得清清楚楚,出问题能追溯到具体分支事务。
6. 写在最后:一点个人经验
做 mysql 事务这件事,我的体会兜兜转转就一句话:不要指望事务解决所有问题,事务只是工具,真正的设计在你画事务边界的那一刻就已经开始了。本地事务解决单库问题,分布式事务解决跨库问题,快照读解决一致性读问题,锁解决写冲突问题,把这些工具放到合适的位置,任何并发业务都能理得清。
最后再分享一个小技巧:排查线上事务问题的时候,别光盯着代码看,先去看 information_schema.innodb_trx 里挂着哪些事务、持有哪些锁,很多时候答案就在这张表里。把事务当成一个有生命周期的东西去追踪,你会发现自己对 mysql 的理解会上一个台阶。