1. 一次"钱扣了、余额没加"的对账,把我拉回事务的本质
几年前处理过一个转账对账不平的问题,现象很怪:流水表里扣款记录写进去了,收款方的余额却没变,两边差了几十块钱,一天只出现一两次。第一反应是网络抖动导致的重试,加了幂等键之后问题还在。最后把执行链路一段段打日志,才发现问题根本不在数据库配置,而在业务代码里——扣款和加款分别调了两个独立的方法,每个方法各自有自己的@Transactional,中间抛了个被catch住的异常,第一个方法提交了,第二个方法回滚了。说白了,这两个操作压根就不在同一个 MySQL 事务里。
这件事让我重新把 MySQL 事务从头捋了一遍。很多人对事务的理解停留在"要么全成功要么全失败",考试背得出 ACID,写代码时加个@Transactional就以为万事大吉。但真正在线上跑起来,事务的边界在哪、锁加了多大范围、快照读看到的是哪个版本的数据、commit 的时候到底写了哪几个文件,这些细节才是决定系统稳不稳的东西。
这篇东西写的不是教科书目录,而是我在实际项目里反复踩过、反复验证过的 MySQL 事务知识:ACID 背后各自靠什么机制撑着、四种隔离级别到底能挡住哪类异常、MVCC 的版本链和 ReadView 是怎么算可见性的、为什么加锁范围总比你以为的大、redo log 和 binlog 的两阶段提交防的到底是什么、长事务为什么是慢性毒药、以及应用层那些"注解加了但事务没生效"的坑。不管你是刚学 MySQL 不久、能写 CRUD 但说不清隔离级别的同学,还是已经带项目、需要处理死锁和长事务的老手,下面这些内容都应该能对得上你的实际场景。
2. ACID 不是四个口号,而是四份互相打架的承诺
ACID 这四个字母,几乎每本数据库教材第一章就摆出来,但真正理解它们之间互相制约关系的人不多。原子性和持久性其实是一对矛盾体——要原子就要能回滚,要持久就要立刻落盘;隔离性和性能也是一对矛盾体——隔离得越狠,并发度越低。InnoDB 的设计本质上是在这四份承诺之间做精巧的平衡,理解了这个平衡,你才知道为什么某些配置项一改,性能曲线会突然掉下来。
2.1 原子性的底牌是 undo log,而不是"代码写得小心"
原子性说的是一个事务里的多条语句,要么全部生效,要么全部不生效。很多人下意识觉得这是应用层保证的,写代码时小心翼翼地判断每一步的结果,出错就手动补偿。这种做法不是不行,但它极其脆弱——进程被 kill、机器断电、连接超时断开,任何一种情况都会让你精心写的手动补偿逻辑跑不到。
InnoDB 的做法是在修改数据之前,先把"怎么撤销这次修改"记下来,写进 undo log。比如你执行一条UPDATE account SET balance = balance - 100 WHERE id = 1,InnoDB 会在 undo log 里记下"把 id=1 这行的 balance 改回原来的值"。这个过程有个细节值得注意:undo log 记录的是逻辑上的反向操作,而不是数据页的物理副本,所以它体积小、写入快。当事务执行ROLLBACK,或者连接异常断开、实例崩溃恢复时,InnoDB 就顺着 undo log 一条条把改动撤回去。
这里有个实操中很容易被忽略的点:undo log 本身也是要写磁盘的,但它跟数据页一样走缓冲池,不是每次修改都刷盘。真正保证崩溃后还能回滚的,是 redo log 对 undo log 修改的记录。这两者的配合关系我在第 6 节会详细拆。你只要先记住一句话:原子性不是靠代码纪律守出来的,是靠日志系统兜住的。
2.2 隔离性是用并发度换来的,不是越高越好
隔离性解决的是"多个事务同时跑,互相别看见对方没提交的中间状态"。听起来很美好,但代价是什么?最严格的隔离级别SERIALIZABLE,InnoDB 会把普通的SELECT也隐式转成SELECT ... LOCK IN SHARE MODE,也就是读也加锁。两条会话同时对同一行做读操作,后一个就得排队等。并发量一上来,等待链拉长,连接池瞬间被占满,接口超时雪崩。
所以真实的工程选择从来不是"选最安全的",而是"选业务能接受的最低隔离级别"。大部分互联网业务跑在READ COMMITTED上,原因是它没有间隙锁,锁冲突少,而且很多业务本身就能容忍同一事务内两次读结果不一样。而金融类、库存类业务更倾向REPEATABLE READ,因为它能保证同一事务内的快照读结果稳定。
这里需要纠正一个常见误解:不少人以为把隔离级别调低,事务就"不安全"了。其实隔离级别只约束"你能看到什么",不约束"你的写会不会丢"。写丢失的问题靠锁和唯一约束解决,跟隔离级别关系不大。把这两件事混在一起,就容易做出错误的架构决策。
2.3 持久性的代价:每次 commit 都在跟磁盘较劲
持久性说的是事务一提交,数据就不会丢,哪怕下一秒机房断电。InnoDB 靠的是 redo log:事务提交时,把这次修改对应的 redo 记录刷到磁盘,就算完事,数据页本身可以晚点再刷(这就是所谓的 WAL,Write-Ahead Logging)。
为什么不能直接刷数据页?因为数据页是离散的,一次事务可能改了好几个页,每个页刷盘都是一次随机 IO,成本极高;而 redo log 是顺序追加写,一次write就能把一批记录连续落盘,效率差着数量级。这个设计思路跟很多日志型系统是一样的:把随机写转成顺序写。
但持久性也不是白给的。控制它刷盘时机的参数是innodb_flush_log_at_trx_commit,取值 0、1、2:
| 取值 | 行为 | 丢数据风险 | 性能 |
|---|---|---|---|
| 0 | 每秒刷一次,提交时不刷 | 实例崩溃丢最近 1 秒 | 最高 |
| 1 | 每次提交都刷盘(默认) | 基本不丢 | 最低 |
| 2 | 每次提交写到 OS 缓存,不调 fsync | 实例崩溃不丢,机器断电丢 | 居中 |
我见过不少团队为了压测数据好看,把这个值改成 0 或者 2,压测报告确实漂亮了,QPS 涨了 30%,然后上线之后某次宿主机异常重启,丢了几个订单,最后对账对了三天。这种参数不是不能调,但你得清楚自己在拿什么换什么,而且要有配套的对账和补偿机制。没有对账能力的业务,老老实实保持 1。
3. 四种隔离级别,到底挡得住哪一类异常
隔离级别这个话题,最容易出现的情况是"背得出四个名字,说不清具体差异"。SQL 标准定义了三种异常现象——脏读、不可重复读、幻读,然后给出四个隔离级别,理论上后者依次解决前者。但 InnoDB 的实现跟标准并不完全一致,尤其是REPEATABLE READ下幻读的处理,这里面的细节值得单独说清楚。
3.1 三类异常的定义与现场复现
先把概念摆清楚,用具体的会话场景说明白,比抽象定义管用得多。
脏读:事务 A 读到了事务 B 还没提交的修改。如果 B 后来回滚了,A 读到的就是一份从未真实存在过的数据。在READ UNCOMMITTED下会发生。这个级别基本没人用,因为它的结果完全不可信。
不可重复读:事务 A 内两次读同一行,结果不一样,因为中间事务 B 修改并提交了。在READ COMMITTED下会发生。注意这里针对的是"同一行的内容被改了"。
幻读:事务 A 内两次执行同一个范围查询,第二次多出了几行,因为中间事务 B 插入并提交了新数据。同样在READ COMMITTED下会发生。它针对的是"结果集的行数变了"。
用两个会话复现一下不可重复读,在READ COMMITTED下:
-- 会话 A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM account WHERE id = 1; -- 结果 1000 -- 会话 B(此时执行) UPDATE account SET balance = 500 WHERE id = 1; COMMIT; -- 会话 A 再次查询 SELECT balance FROM account WHERE id = 1; -- 结果 500,两次不一致 COMMIT;把隔离级别换成REPEATABLE READ,同样的操作序列,会话 A 第二次查询仍然是 1000。这个差异的来源就是 MVCC 的快照机制,第 4 节会讲透。
3.2 可重复读并没有彻底消灭幻读
这是最容易出错的一个认知点。很多资料说REPEATABLE READ解决了幻读,严格来讲只说对了一半:它解决的是快照读下的幻读,没解决当前读下的幻读。
快照读就是普通的SELECT,走 MVCC,读的是事务开始时的那个版本,所以看不见别人新插入的行,自然不会幻读。当前读是SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE这类需要读最新数据的语句,它们走的是加锁读,靠的是临键锁(Next-Key Lock)来阻止其他事务在区间内插入。
问题在于,这两种读在同一个事务里混用的时候,幻读照样会出现。举个例子:
-- 会话 A,隔离级别 REPEATABLE READ START TRANSACTION; SELECT * FROM orders WHERE user_id = 100; -- 快照读,返回 3 行 -- 会话 B INSERT INTO orders (user_id, amount) VALUES (100, 99); COMMIT; -- 会话 A 试图更新这批数据 UPDATE orders SET status = 1 WHERE user_id = 100; -- 这条 UPDATE 是当前读,会看到 4 行,包括 B 刚插入的那行 SELECT * FROM orders WHERE user_id = 100; -- 快照读,还是 3 行这里出现了典型的"数据不一致":UPDATE明明影响到了 4 行,紧接着的SELECT却只看到 3 行。如果你在业务代码里先查一遍再更新,然后根据两次结果做判断,逻辑就会错乱。
提示:在一个事务里混用快照读和当前读,是幻读类 bug 的高发场景。如果业务逻辑需要"先查后改",建议在查询时就加
FOR UPDATE走当前读,保证前后看到的是同一份数据。
3.3 隔离级别怎么定:先看业务能不能容忍,再看性能
选隔离级别不是技术洁癖,是业务需求的直接映射。我的判断顺序一般是这样:
先问"同一个事务里,两次读同一份数据,结果不一样,业务能不能接受"。比如一个报表汇总任务,先统计总数再统计明细,两者对不上就是 bug,那必须REPEATABLE READ以上。再比如一个商品详情页的展示,前后读到的库存数差一点无所谓,那READ COMMITTED完全够用,还能省掉间隙锁带来的锁冲突。
然后看锁竞争情况。REPEATABLE READ因为有间隙锁,在并发插入密集的表上容易出死锁。典型场景是批量插入任务跟范围更新任务撞在一起。如果你的表写入非常频繁,而且业务上不需要事务内快照一致,把隔离级别降到READ COMMITTED是性价比很高的优化。
修改隔离级别有两个层级需要注意:SET GLOBAL只影响之后新建的连接,已有的连接不受影响;SET SESSION只影响当前连接。生产环境改全局隔离级别要谨慎,因为很多框架和中间件的默认行为是依赖REPEATABLE READ的,突然改掉可能引发意料之外的问题。比较稳妥的做法是在连接池配置里针对特定数据源设置,或者干脆在代码层面的特定事务上加注解指定。
4. MVCC:同一条记录为什么能同时看到两个版本
MVCC(多版本并发控制)是 InnoDB 实现高并发的核心机制,也是面试里被问得最多、实际工作中被理解得最少的部分。它的核心思想其实很朴素:写操作不覆盖原来的数据,而是生成一个新版本;读操作根据某种规则,决定自己该看哪个版本。这样读写之间就不需要互相阻塞了。
4.1 隐藏字段与 undo 版本链
InnoDB 的每一行记录,除了你定义的列之外,还藏着几个看不见的字段。跟事务相关的有两个:
DB_TRX_ID:6 字节,记录最后一次修改这行的事务 ID。DB_ROLL_PTR:7 字节,回滚指针,指向 undo log 里这条记录的上一个版本。
每当事务修改一行,InnoDB 会把旧值写进 undo log,然后让当前行的DB_ROLL_PTR指向这个旧版本,同时更新DB_TRX_ID。多次修改之后,就形成了一条版本链,链头是最新的数据,链尾是最老的版本。
这条链的意义在于:不同的事务可以根据自己的"视野",沿着链往回想找到自己该看的那个版本。比如一行数据被事务 100、事务 200、事务 300 依次修改过,现在事务 150 来读,它就应该看到事务 100 修改之后、事务 200 修改之前的状态——也就是沿着链找到DB_TRX_ID = 100的那个版本。
这里有个实践上的推论:一个行被修改的次数越多,版本链越长,读操作回溯的成本越高。这就是为什么长事务是灾难——它会阻止 undo log 被清理,导致版本链无限增长,后面所有读这行的操作都要沿着长链回溯。这个在第 7 节展开。
4.2 ReadView 的四条判断规则
版本链解决了"旧版本存在哪"的问题,ReadView 解决的是"该看哪个版本"。事务在执行快照读的那一刻,会生成一个 ReadView,里面有几个关键信息:
m_ids:当前系统中所有活跃(未提交)的事务 ID 列表。min_trx_id:m_ids里最小的事务 ID。max_trx_id:系统下一个将要分配的事务 ID(注意是下一个,不是当前最大)。creator_trx_id:创建这个 ReadView 的事务自己的 ID。
拿到一个版本的DB_TRX_ID之后,按下面四条规则判断可见性:
- 如果
DB_TRX_ID等于creator_trx_id,说明这行是自己改的,可见。 - 如果
DB_TRX_ID小于min_trx_id,说明修改它的事务在 ReadView 创建之前就提交了,可见。 - 如果
DB_TRX_ID大于等于max_trx_id,说明修改它的事务在 ReadView 创建之后才开启,不可见。 - 如果
DB_TRX_ID落在min_trx_id和max_trx_id之间,就看它在不在m_ids里:在,说明还没提交,不可见;不在,说明已经提交了,可见。
不可见怎么办?顺着DB_ROLL_PTR找上一个版本,重复这套判断,直到找到一个可见的版本或者链走到底。
这套规则听起来绕,但用一句话概括就是:你只能看到"在你生成 ReadView 之前就已经提交"的修改,以及你自己做的修改。理解了这句话,很多看似诡异的现象都能解释。
4.3 RR 和 RC 的差异只在一处:ReadView 什么时候生成
前面说的规则,REPEATABLE READ和READ COMMITTED完全一样,唯一的区别是 ReadView 的生成时机:
READ COMMITTED:每次快照读都生成一个新的 ReadView。REPEATABLE READ:只在事务中第一次快照读时生成一次,之后整个事务复用这一个。
差异虽小,效果天差地别。在READ COMMITTED下,第二次查询生成新 ReadView,此时别的事务如果已经提交,它的DB_TRX_ID就不在m_ids里了,于是新数据可见——这就是不可重复读的由来。在REPEATABLE READ下,整个事务用同一个 ReadView,别的事务即使后来提交了,它的 ID 依然在m_ids里,新数据对当前事务始终不可见。
这里有个坑值得单独提醒:REPEATABLE READ下,ReadView 的生成时机是"第一次快照读",不是"START TRANSACTION"。如果你执行了START TRANSACTION但迟迟没有执行第一条SELECT,中间隔了十秒,那么这十秒内其他事务提交的数据,你是能看到的。因为你的 ReadView 是在第一条SELECT时才生成的。想严格控制快照起点,可以用START TRANSACTION WITH CONSISTENT SNAPSHOT,它会立即生成 ReadView。
还有一点:显式开启事务(START TRANSACTION或BEGIN)跟直接执行SELECT的差别。如果你不开事务,直接执行SELECT,InnoDB 会为这条语句单独生成一个 ReadView,语句结束就释放。这种情况下,即使隔离级别是REPEATABLE READ,两次独立的SELECT也会看到不同结果,因为它们根本不在一个事务里。
5. 锁的范围由索引决定:行锁、间隙锁、临键锁的真实边界
事务的隔离性靠 MVCC 和锁共同实现。MVCC 管读,锁管写。很多人对锁的理解止步于"行锁锁一行,表锁锁全表",实际上 InnoDB 的锁粒度比这个复杂得多,而且加锁范围经常跟你的直觉不符——你写的是锁一行,实际可能锁了一片。
5.1 加锁的单位不是"行",是"索引区间"
这是理解 InnoDB 锁最关键的一句话。InnoDB 的行锁是加在索引记录上的,不是加在数据行本身。如果你的WHERE条件走的是索引,锁的就是索引上对应的记录;如果没走索引或者索引失效,那就只能全表扫描,扫描过程中碰到的所有记录都会被锁上——本来想锁一行,结果锁了整张表。
这个现象在实际项目中造成的后果非常严重。一个典型场景是:某张表加了个新字段,代码里按新字段做条件更新,忘了加索引。上线之后,这条UPDATE会锁住全表所有行,其他所有涉及这张表的操作全部排队等待,几秒钟内就会出现大面积超时。
具体加锁的时候,InnoDB 有三种锁形态:
| 锁类型 | 锁的范围 | 出现场景 |
|---|---|---|
| 记录锁 Record Lock | 单条索引记录 | 唯一索引等值查询,命中记录 |
| 间隙锁 Gap Lock | 两条索引记录之间的开区间 | REPEATABLE READ下范围查询、等值查询未命中 |
| 临键锁 Next-Key Lock | 记录锁 + 前面的间隙 | 范围查询的默认加锁方式 |
临键锁是左开右闭区间。举个例子,索引上有值 5、10、15,执行SELECT * FROM t WHERE id > 5 AND id < 15 FOR UPDATE,被锁住的是(5, 10]和(10, 15]两段,也就是锁住了 10 这条记录和它前面的间隙,以及 15 这条记录和它前面的间隙。
间隙锁有个非常特殊的地方:它之间不冲突。两个事务可以同时对同一个间隙加间隙锁,因为它们的目的不是互斥,而是阻止别人往这个间隙里插数据。这解释了为什么REPEATABLE READ下插入密集的表容易出死锁——两个事务各自持有一段间隙锁,然后都想插入,互相等待。
5.2 临键锁在 RR 下的额外代价
间隙锁是REPEATABLE READ才有的,READ COMMITTED下没有间隙锁(除了外键和唯一性检查的特殊场景)。这个差异带来的性能影响,在写密集的业务里非常明显。
我做过一个对比测试,同一张日志表,两个并发任务:任务 A 不断按时间范围更新状态,任务 B 不断插入新记录。在REPEATABLE READ下,任务 A 的范围更新会锁住一段间隙,任务 B 的插入如果落在这段间隙里,就得等,等待超时(默认innodb_lock_wait_timeout是 50 秒)之后报错回滚。跑了几分钟就积压了几百个等待。切到READ COMMITTED之后,同样压力下锁等待基本消失。
所以我的经验是:写密集、范围更新多、业务能容忍不可重复读的表,优先考虑READ COMMITTED。反过来,如果业务里有"先统计再明细必须对得上"的逻辑,那就老老实实REPEATABLE READ,靠索引优化和分批处理来降低锁冲突,而不是靠降隔离级别。
还有一种常见的优化思路:把范围更新改造成按主键批量更新。比如UPDATE orders SET status = 1 WHERE create_time < '2024-01-01',可以先用SELECT id捞出一批主键,再按主键分批UPDATE ... WHERE id IN (...)。主键等值查询走的是记录锁,范围小得多,锁冲突自然就降下来了。
5.3 死锁排查的完整链路
死锁在 InnoDB 里是必然会发生的,因为它是"检测到就回滚代价小的那个事务",而不是"避免发生"。所以你的系统里偶尔出现Deadlock found when trying to get lock是正常现象,关键是要能快速定位并确认这不是设计问题。
第一步,拿到死锁信息。执行:
SHOW ENGINE INNODB STATUS\G输出里找LATEST DETECTED DEADLOCK这一段,它会告诉你两个事务各自执行了什么 SQL、持有什么锁、等待什么锁。如果死锁发生得比较频繁,光看最近一次不够,可以打开全量记录:
SET GLOBAL innodb_print_all_deadlocks = ON;这样每次死锁都会被写进错误日志。
第二步,读懂锁信息。关键看TRANSACTION段落里的HOLDS THE LOCK(S)和WAITING FOR THIS LOCK TO BE GRANTED。前者说明已经拿到了什么锁,后者说明在等什么锁。两边对照,就能还原出循环等待的路径。
第三步,找根因。我处理过的死锁,绝大多数可以归到四类:
- 加锁顺序不一致。事务 A 先更新 id=1 再更新 id=2,事务 B 反过来。解决办法是统一加锁顺序,比如永远按主键升序更新。批量操作时先对 ID 列表排序,这个习惯能消掉一大半死锁。
- 间隙锁冲突。两个事务在同一个间隙上都想插入。解决办法是降低隔离级别,或者把插入操作串行化。
- 索引缺失导致锁范围扩大。这个前面说过,加索引就能解决。
- 唯一键冲突加锁。
INSERT ... ON DUPLICATE KEY UPDATE和INSERT IGNORE在并发下容易产生死锁,因为它们会先加共享锁检查唯一性,再升级成排他锁,中间有个窗口。
第四步,验证修复。改完之后不要只看"死锁没再出现",要把innodb_print_all_deadlocks打开跑一段时间,确认死锁日志确实清零了。有时候死锁频率下降但没消失,说明还有遗漏的路径。
注意:死锁回滚是 InnoDB 自动做的,应用层收到死锁异常时,正确的处理方式是重试,而不是直接抛给用户。重试次数建议 2 到 3 次,并且加随机退避,避免两个事务同时重试又撞上。
6. redo log、undo log、binlog 与两阶段提交
事务的四个特性背后,是三种日志在配合工作。这部分内容看起来偏底层,但它直接决定了两件事:崩溃之后数据能不能恢复,以及主从数据会不会不一致。理解了这套机制,你才知道为什么有些配置不能随便改。
6.1 三种日志各管什么
先做个清晰的职责划分:
| 日志 | 归属层 | 类型 | 作用 |
|---|---|---|---|
| redo log | InnoDB 引擎层 | 物理日志 | 崩溃恢复,保证持久性 |
| undo log | InnoDB 引擎层 | 逻辑日志 | 事务回滚 + MVCC 版本链 |
| binlog | Server 层 | 逻辑日志 | 主从复制 + 数据归档恢复 |
redo log 记录的是"在某个数据页的某个偏移量上做了什么修改",它是循环写的,空间固定,写满之后会覆盖最老的部分(前提是那部分对应的脏页已经刷盘了)。这个循环结构决定了 redo log 不能用来做长期归档,它只是崩溃恢复的工作区。
binlog 记录的是"执行了什么 SQL"或者"某行数据的前后值",它是追加写的,文件可以一直增长,可以归档、可以重放。主从复制就是靠从库拉取主库的 binlog 然后重放实现的。
两者的定位差异带来一个关键问题:一个事务提交时,既要写 redo log 又要写 binlog,这两个写入必须保证一致。如果 redo log 写了但 binlog 没写,崩溃恢复之后主库有数据但从库没有;如果 binlog 写了但 redo log 没写,主库恢复后没数据但从库有。两种都是数据不一致。
6.2 两阶段提交防的是主从数据不一致
InnoDB 用两阶段提交解决这个问题,流程是这样的:
- 事务执行过程中,redo log 不断写入,但状态是
prepare。 - 事务提交时,把 binlog 写入磁盘。
- binlog 写成功后,把 redo log 的状态改成
commit。
关键在于崩溃恢复时的判断逻辑:
- 如果 redo log 是
prepare状态,但 binlog 已经完整写入,那就提交事务。 - 如果 redo log 是
prepare状态,binlog 没有写或者写不完整,那就回滚事务。
怎么判断 binlog 完整?binlog 的每个事务结尾都有一个XID事件,redo log 的prepare状态里也存了XID,两者能对上就说明 binlog 完整。
这套机制保证了主库和从库的数据一致性。我见过有人为了"性能优化",把sync_binlog设成 1000(每 1000 次事务才刷一次 binlog),压测数据确实好看很多,但一旦机器异常重启,binlog 可能少了几百个事务,主从数据直接错位。这种不一致极难修复,因为从库上已经有了一部分数据,你没法简单重放。
6.3 双 1 配置与性能的取舍
所谓"双 1",指的是innodb_flush_log_at_trx_commit = 1和sync_binlog = 1。这两个都设成 1,意味着每次事务提交都要把 redo log 和 binlog 各刷一次盘,数据安全性最高,性能最低。
真实场景怎么选?我的经验是按数据重要性分层:
- 交易、订单、资金类:必须双 1,没有商量余地。丢了数据要赔钱,性能再差也得扛。
- 日志、埋点、行为记录类:可以放宽到
innodb_flush_log_at_trx_commit = 2,sync_binlog = 100。这类数据丢几秒可以接受,真有需要还能从上游重新采集。 - 中间状态、缓存类:甚至可以设成 0,因为数据本身可以从源数据重建。
还有一层优化叫组提交(group commit)。当多个事务同时提交时,InnoDB 会把它们的 redo log 合并成一次fsync,binlog 同理。这意味着并发越高,单次提交的实际刷盘成本越低,所以"双 1 很慢"这个结论在高并发下并不完全成立。真正慢的是低并发场景——每个事务都要单独等一次磁盘。
如果确实遇到写入瓶颈,正确的优化顺序是:先看是不是磁盘 IO 能力不足(换 NVMe 效果立竿见影),再看是不是大事务导致单次刷盘数据量过大,最后才考虑调整刷盘参数。倒过来做,先动参数再找原因,往往是把问题从"慢"变成了"数据丢了"。
7. 长事务:比死锁更隐蔽的慢性病
死锁会报错,会回滚,会被监控捕获,你能立刻知道出了问题。长事务不会。它安安静静地跑着,不报错不告警,但它在后台一点点耗掉实例的资源,直到某天系统突然大面积变慢,你才发现 undo 表空间已经涨到几十个 G,CPU 全耗在 purge 线程上。
7.1 把长事务揪出来的三条命令
第一条,查当前活跃事务:
SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec, trx_mysql_thread_id, trx_rows_locked, trx_rows_modified FROM information_schema.innodb_trx ORDER BY trx_started ASC LIMIT 20;trx_started是最关键的字段,duration_sec超过 60 的基本都值得看一眼。trx_rows_locked和trx_rows_modified能告诉你这个事务锁了多少行、改了多少行。
第二条,看是谁在跑:
SELECT * FROM information_schema.processlist WHERE id IN (SELECT trx_mysql_thread_id FROM information_schema.innodb_trx);这能拿到执行的 SQL、客户端地址、连接状态。有时候你会发现一个休眠连接(Command是Sleep)持有事务半小时没动,那就是典型的"开了事务忘了提交"。
第三条,看锁等待:
SELECT * FROM performance_schema.data_lock_waits\G能直接看到哪个事务在等哪个事务,被阻塞的 SQL 是什么。这个视图比innodb_trx更直观,尤其是在等待链比较长的时候。
7.2 长事务为什么会拖垮整个实例
长事务的危害不止"占着锁不放"这一条,它有多个层面的影响,而且这些影响会叠加。
第一层,undo log 无法清理。undo log 的清理(purge)有个前提:这个版本不能比任何活跃事务的 ReadView 更老。只要有一个长事务还开着,它可能还需要看很老的版本,那么从它开始之后的 undo log 全都不许清理。undo 表空间持续膨胀,磁盘被吃掉,更严重的是版本链变长,后续所有对同一行的读操作都要沿着链回溯,读性能断崖式下降。
第二层,锁持有时间过长。即使这个长事务只锁了几行,只要它不提交,这几行就一直被锁着。所有需要改这几行的操作全部排队,等待超时(默认 50 秒)之后失败。如果这几行是热点数据,比如某个热门商品的库存行,那基本等于把这部分业务打停了。
第三层,主从延迟。大事务在主库上执行完才写 binlog,从库拿到的是一整个大事务,重放的时候要花很长时间,期间主从延迟持续上涨。如果这个事务涉及百万行更新,从库可能追半小时。
第四层,连接池占满。事务不结束,连接就不释放。连接池里的连接被长事务占用,新的请求拿不到连接,接口超时,上游重试,雪崩。
我在实际项目里见过最典型的长事务来源是三种:一是批处理任务一次性UPDATE几十万行;二是应用层开了事务之后中间调了一个慢接口(比如第三方 API),把事务时间拉长到几秒;三是连接池开启了自动提交之外的模式,但代码里某个分支忘了commit。
7.3 拆分与兜底:从 SQL 到应用层的控制手段
对付长事务,思路是"预防为主,监控兜底"。
预防层面,最有效的做法是把大操作拆小。批量更新不要一条 SQL 干完,按主键分批,每批几百到几千行,每批单独提交。这样锁的范围可控,undo log 能及时清理,主从延迟也能控制住。批与批之间可以做短暂休眠,给其他事务让出资源。
应用层面,有几条硬性建议:
- 事务里绝对不要做远程调用。HTTP 请求、RPC 调用、消息发送,全部挪到事务外面。事务里只留数据库操作,而且要保证操作足够快。
- 显式设置事务超时。Spring 的
@Transactional(timeout = 5)可以设置超时秒数,超时会回滚。这个能防止某个异常分支把事务挂死。 - 数据库层面设置
innodb_lock_wait_timeout。默认 50 秒太长了,很多业务设置成 3 到 5 秒就够,等不到就快速失败,让用户重试,比一直挂着强。 - 给连接池加
maxLifetime和leakDetectionThreshold。前者是连接的最长存活时间,后者是连接被借出多久没归还会告警。这两个能兜住"忘了关连接"这类低级错误。
监控层面,我一般会在监控系统里加两条规则:innodb_trx中duration_sec > 30的事务数量持续大于 0 就告警,trx_rows_modified > 10000的事务出现就告警。前者是长事务,后者是大事务,两类都要管。
提示:如果真的遇到了已经跑飞的长事务,
KILL会话之前先想清楚——回滚一个大事务可能比它本身跑得还慢。InnoDB 的回滚是异步的,KILL之后事务进入回滚状态,期间依然占着资源。所以最好的处理是"止血 + 观察",而不是直接一刀切。
8. 应用层最容易翻车的地方:那些"加了注解却没生效"的事务
数据库这边的事务机制再完善,也架不住应用层压根没把操作放进同一个事务里。开头提到的那个对账问题就是这类。这一节我把实际项目里遇到过的、以及帮别人排查过的失效场景整理一下,基本覆盖了大部分情况。
8.1 Spring 声明式事务失效的几种典型写法
Spring 的@Transactional是基于 AOP 代理实现的,理解了这一点,很多失效场景就都能推导出来。
自调用失效。同一个类里,方法 A 调用方法 B,B 上有@Transactional,这个注解不生效。原因是 A 调用 B 走的是this.b(),绕过了代理对象,AOP 根本没机会介入。解决办法是把 B 挪到另一个 Bean 里,或者注入自己的代理对象,或者用AopContext.currentProxy()。
方法不是 public。Spring 的代理默认只拦截 public 方法,private、protected上加注解不生效,而且不会报错,静默失效。这个坑特别隐蔽,因为代码看起来完全正常。
异常被吞了。代码里try-catch把异常捕获了,没有重新抛出,事务管理器感知不到异常,自然就提交了。这是开头那个问题的直接原因。如果确实需要捕获异常做业务处理,记得处理完再抛出去,或者手动调TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
异常类型不匹配。@Transactional默认只对RuntimeException和Error回滚,受检异常(Exception的子类但不是RuntimeException)不回滚。如果你的业务代码抛的是自定义的受检异常,事务会照常提交。解决办法是显式写@Transactional(rollbackFor = Exception.class)。我建议所有业务事务都加上这个参数,省得后面踩坑。
传播行为用错。默认是REQUIRED,意思是"有事务就加入,没有就新建"。如果你的本意是"这个操作必须独立提交,不受外层事务回滚影响",那要用REQUIRES_NEW。用反了会导致外层回滚时把内层一起带走,或者内层回滚把外层标记成 rollback-only 导致外层提交时报UnexpectedRollbackException。这个异常特别容易让人困惑,因为日志显示事务明明成功了,最后却报错。
类没有被 Spring 管理。自己new出来的对象,或者没加@Service、@Component的类,代理不会生效。这个在工具类里比较常见。
我把这些整理成一张自查表,排查事务问题时对着过一遍,命中率很高:
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 明明抛异常但数据没回滚 | rollbackFor不匹配 / 异常被 catch | 看异常类型,检查 catch 块 |
| 部分操作生效部分没生效 | 自调用 / 方法非 public | 检查调用链路是否经过代理 |
报UnexpectedRollbackException | 内层REQUIRES_NEW回滚影响了外层标记 | 检查传播行为配置 |
| 事务完全不生效 | 类未被 Spring 管理 | 检查@Service注解和包扫描路径 |
8.2 事务里做远程调用和批量操作
即使@Transactional正常生效了,事务边界的设计也经常有问题。
最常见的是在事务里调远程接口。一个典型的写法是:开启事务 → 写订单表 → 调库存服务扣减 → 写流水表 → 提交。这个链路里,调库存服务可能耗时几百毫秒甚至几秒,如果对方超时,整个事务就挂在那里,数据库连接被占用,锁被持有,等待时间完全不可控。
正确的做法是把远程调用挪到事务外面,用"本地事务 + 最终一致"的方式来处理。比如先在本地事务里把订单状态置为"待扣减",提交事务,然后异步调库存服务,成功了再更新状态。这样本地事务很短,锁持有时间可控,远程调用的失败也有独立的重试机制。
另一个高频问题是批量操作。saveBatch这类方法在循环里执行上千条插入,全在一个事务里。这个事务的执行时间长、undo log 大、锁范围广。更好的做法是分批提交,每批 500 条左右,批与批之间独立事务。业务上如果允许部分成功,那每批单独处理;如果不允许,那就在最后做一次状态校验和补偿。
还有一个容易被忽略的点是事务里做查询。很多人觉得查询不加锁没关系,但查询本身也会占用连接、消耗资源。如果一个事务里先做一堆耗时的聚合查询,再执行写入,那写入之前的所有时间都是白占资源。把只读查询挪到事务外面,或者用独立的只读连接,是个很实用的优化。
8.3 一个可复用的自查清单
我给自己项目定了一套事务检查清单,新代码提交前过一遍,能挡掉大部分问题:
- 事务边界是否最小化——只包含必须原子生效的数据库操作。
- 事务内是否有 HTTP、RPC、消息发送、文件读写。
@Transactional是否加了rollbackFor = Exception.class。- 是否存在自调用,调用链路是否经过代理。
- 事务内是否有大循环、大批量操作,能否拆分。
- 涉及更新的 SQL,
WHERE条件是否走了索引。 - 批量更新是否统一了加锁顺序(按主键排序)。
- 是否有配套的超时设置和重试机制。
这套清单不复杂,但坚持执行下来,事务相关的线上问题能少一大半。我个人的体会是,事务问题的根源往往不在数据库,而在于写代码的人对"事务边界"这件事没有清晰的概念——他不知道事务从哪里开始、到哪里结束、中间包含了哪些操作。把这件事想清楚了,剩下的都是细节。
最后分享一个小习惯。我排查事务相关问题时,第一件事永远是打开general_log或者用抓包工具,把那个时间段的完整 SQL 序列抓出来,按时间戳排好。因为绝大多数事务问题,看代码是看不出来的——代码里可能调了三层封装,事务注解散落在不同位置,只有把真实的 SQL 执行序列摆出来,你才能确认哪些语句真的在同一个事务里,哪条语句执行了多久,中间有没有夹着别的操作。这个动作花不了几分钟,但比对着代码猜要高效得多。另外,把innodb_trx和data_lock_waits做成定时采集写进监控,比出事之后再登机器上查要主动得多,这套东西搭一次能用很久。