上周我这边有一个服务报警,支付回调接口的P99耗时常年稳定在80ms左右,突然某一天跳到3秒以上。排查到最后,发现罪魁祸首不是慢SQL,不是连接池打满,而是一个几乎被我忽略的细节:有人在调试环境里手动执行了一条UPDATE,没提交,然后挂着事务去忙别的了。就这一条未提交的事务,堵住了后续所有要写相同行数据的会话,连锁反应一路传导到线上接口。
MySQL事务这个主题,看起来是基础中的基础,但说实话,绝大多数线上数据库事故,都能和事务的某个环节扯上关系——事务边界没控制好、隔离级别理解偏差、锁的范围超出预期、日志刷盘策略没按业务量级调。这篇就把事务从理论到实操完整串一遍,包括我踩过的坑和现场排查的思路。不写教科书,写实际怎么用、怎么定位、怎么避雷。
1. 一次线上事故:一条未提交的UPDATE堵住了整个支付回调
1.1 事故现场复盘:从慢接口到锁等待
当时我先看了监控,数据库CPU不算高,磁盘IO也正常,但活跃会话数明显上涨。show processlist一拉,大量线程卡在同一个状态:Waiting for lock,等一把行锁。再往下看,源头是一个长期Sleep的连接,但它里面那个事务还活着。
这个事务在干什么?就是一条简单的UPDATE,改了某条订单记录,然后一直没提交。后面所有要改这条记录的业务线程都在等它释放锁。等了多久?事务空闲超时还没到,锁等待超时先到了,一批批应用报错,接口自然就慢了。
顺手查了一下这个事务的信息:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx;看到trx_started已经是几个小时前,trx_state是RUNNING,但对应的线程已经Sleep。我当时直接KILL掉了那个线程,事务回滚,锁释放,接口立刻恢复。
这个事故本身不算高级,但它暴露了两个值得每个后端开发者记住的问题:
- 事务边界没管好。手动执行DML之后忘记COMMIT或ROLLBACK,这在调试环境太常见了,可一旦连接被应用池复用,影响范围会被放大。
- 很多人以为UPDATE只锁住被修改的一行,其实根据WHERE条件和索引使用情况,锁的范围可能远超预期。后面第三章详细展开。
1.2 ACID不是口号,每条特性背后都有引擎机制
事务的四个特性——原子性、一致性、隔离性、持久性,大部分人都能背出来,但能讲清楚"InnoDB分别靠什么机制实现"的就不多了。
- 原子性:事务内所有操作要么全部成功要么全部回滚,InnoDB靠的是undo log。执行修改前,先把旧值写入undo,一旦事务回滚,就按undo记录恢复。
- 一致性:事务从一个一致状态到另一个一致状态,约束靠数据库,但业务逻辑一致性靠应用自己。数据库只能保证它管辖范围内的完整性和约束。
- 隔离性:多个事务并发执行时互不干扰,InnoDB靠锁和MVCC配合实现。
- 持久性:事务提交后,即使宕机数据也不能丢,InnoDB靠redo log实现。
这里面最容易误导人的是"一致性"。很多人以为只要开启了事务,数据就一定不会乱。其实数据库只保证它理解的约束,比如主键唯一、外键、非空。跨表、跨业务的金额一致性,数据库管不了,那需要应用在事务里自行保证。
1.3 你根本没在开事务?autocommit与隐式提交的边界陷阱
还有一个高频问题:你以为自己在开事务,其实没有。MySQL的默认autocommit=1,也就是每条语句执行完自动提交。你用BEGIN或START TRANSACTION之后,事务才真正开始;但如果你直接执行UPDATE而不显式开启事务,那它就是一个独立事务,执行完就提交了。
更隐蔽的是"隐式提交"。MySQL里有一批语句执行时会强制提交当前事务,比如DDL语句(CREATE、ALTER、DROP)、LOCK TABLES、GRANT等。所以事务里混入DDL,事务边界会直接断掉。我见过一个案例,某同事在一个事务里先UPDATE,再ALTER TABLE加索引,结果UPDATE被隐式提交了,后续代码想回滚,根本回滚不了。
经验法则:
- 事务范围尽量小,能一条语句搞定就不开显式事务。
- 事务里不要混DDL。
- 长事务要盯紧,超过几秒就要告警。
现在很多公司会额外部署长事务监控,定期查information_schema.innodb_trx,就是为了避免这种"事务悬挂"拖垮业务。这个表虽然轻量,但在大并发的库上不要频繁去查,建议几秒钟一次就好。
2. 隔离级别到底在隔离什么:脏读、不可重复读、幻读逐个演一遍
2.1 四级隔离与三类读异常速查表
隔离级别本质上是在"并发能力"和"数据一致性"之间做取舍。SQL标准定义了四级,MySQL InnoDB默认是REPEATABLE READ,这一点也和很多其他数据库不一样,后面细说。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 不加锁直接读最新版本 |
| READ COMMITTED | 不会 | 可能 | 可能 | 每次SELECT生成新快照 |
| REPEATABLE READ | 不会 | 不会 | 基本不会(InnoDB下) | 事务内首次SELECT生成快照并复用 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 读加共享锁,串行化 |
"脏读"是读到别人还没提交的数据;"不可重复读"是同一个事务里两次读同一行,值不一样;"幻读"是同一个事务里两次查询同样的条件,行数不一样。幻读比不可重复读更隐蔽,因为它不是改值,而是"多出几行"或"少了几行"。
2.2 把三类读异常各演一遍
假设一张简单的账户表:
CREATE TABLE account ( id INT PRIMARY KEY, name VARCHAR(50), balance INT ); INSERT INTO account VALUES (1, 'A', 100), (2, 'B', 200);先看脏读。会话A开启事务,把id=1的balance改成90,但不提交。会话B在READ UNCOMMITTED级别下查询,读到90。如果A后面回滚,B读到的90就是一个从未存在过的值。业务如果在90的基础上继续做计算,就会出大问题。
再看不可重复读。会话A在事务内第一次查询balance是100,之后会话B提交了更新,把balance改成90。如果A再次查询,在READ COMMITTED级别下会读到90。同一个事务里同一个条件,两次结果不一致。
最后看幻读。会话A执行SELECT COUNT(*) FROM account WHERE balance >= 100,结果是1。假设刚好满足条件的记录在1到5之间,此时会话B插入了一条balance=150的新记录并提交。A再次执行同样的查询,在READ COMMITTED下会查到2条。行数变了,这就是幻读。
2.3 MySQL默认RR,不代表其它库也这样
很多人是从其他数据库转过来的。比如某主流数据库默认隔离级别是READ COMMITTED,而MySQL默认是REPEATABLE READ。这个差异会直接影响线上行为:在MySQL下,一条普通的SELECT在同一个事务里多次执行,结果是一致的;而到了默认RC的库里,同一个事务里再查就可能不一致。
反过来的坑也有。有人从MySQL转到默认RC的库,发现同一个事务里两次查询结果不一样,第一反应是"数据出问题了",其实只是隔离级别语义不同。
InnoDB在RR级别下还用了一个特殊机制——Next-Key Lock和MVCC快照读,让幻读在绝大多数场景下不会发生。这也是下一章的重点。MySQL的RR比SQL标准里定义的RR要严格一些,所以不要拿标准条文硬套MySQL行为。
3. 锁的边界比你想的大:行锁、间隙锁与Next-Key锁的加锁范围推演
3.1 共享锁与排他锁:读写之间的兼容性
InnoDB锁分两大类:共享锁(S锁)和排他锁(X锁)。S锁之间兼容,S锁和X锁互斥,X锁之间也互斥。翻译成人话:多个事务可以同时读同一行,但写之前要等所有读锁释放。
普通SELECT不加锁,走MVCC快照读;SELECT ... FOR UPDATE加X锁;SELECT ... LOCK IN SHARE MODE加S锁。UPDATE和DELETE会自动对涉及到的记录加X锁。还有插入意向锁、自增锁这类特殊锁,日常排查时遇到频率相对低,本文先不展开。
3.2 间隙锁和Next-Key锁:锁的"范围"是怎么圈定的
行锁锁住的是"已经存在的记录"。但幻读要防的是"别人在你查询范围内插入新记录"。只锁已存在的行,防不住插入。所以InnoDB引入了间隙锁(Gap Lock),锁住的是记录之间的"空隙"。
光有间隙锁还不够,InnoDB实际用的是Next-Key Lock,也就是"记录锁+记录前面的间隙锁"。比如有记录id=1、5、9,那么Next-Key Lock覆盖的区间大概是(-∞, 1]、(1, 5]、(5, 9]、(9, +∞)这种形式。
一个经典例子,表里有 id=5、9两条记录:
SELECT * FROM t WHERE id BETWEEN 5 AND 9 FOR UPDATE;你以为锁住的只有5和9两行,实际上InnoDB会锁住(5, 9]这个区间以及9后面的间隙。此时另一个事务想插入id=6或id=10,都会被阻塞;插入id=4可能不受影响。
这才是最容易被忽略的地方:锁的是范围,不是单行。判断加锁范围时,不能只看"结果集有哪些行",还要看条件有没有走索引,以及隔离级别是什么。
3.3 一个实际DELETE语句的加锁范围推演
某天我排查过一个死锁,简化后的表结构:
CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT, status TINYINT, KEY idx_user_id (user_id) );事务1执行:
DELETE FROM orders WHERE user_id = 123 AND status = 1;事务2执行:
INSERT INTO orders (id, user_id, status) VALUES (1001, 123, 1);结果两个事务都卡住了。
为什么?user_id=123走了二级索引idx_user_id。DELETE在RR级别下,除了对命中的二级索引记录加Next-Key Lock,还会对对应的主键记录加行锁。status=1其实只是普通条件,但InnoDB在锁定二级索引时,锁定的是整个索引项范围,不会只锁status=1的行。于是(123上一个值, 123]之间的插入都被间隙锁挡住,事务2的INSERT要等间隙锁释放,死锁就这样产生了。
如果这个DELETE走的是主键等值查询,比如WHERE id = 10086,那么加锁范围就小得多,几乎只锁这一行。这也是为什么我一直强调:事务里的SQL一定要让WHERE条件走上索引,最好走上唯一索引。否则一个UPDATE可能锁全表,并发直接归零。
顺带说明一下,修改表隔离级别为READ COMMITTED后,间隙锁会消失,只保留记录锁,死锁率会明显下降。这是很多团队压测后选择RC的原因,但代价是应用层要能接受不可重复读和幻读。没有银弹,只有取舍。
4. MVCC版本链与快照读:为什么你SELECT到的可能是旧数据
4.1 快照读与当前读:两种读的语义完全不同
MySQL的读分两种,理解这个能解决大半"为什么读到旧数据"的困惑。
- 快照读:普通
SELECT,不加任何锁,读取的是事务开始时的快照版本。读操作不会阻塞写操作。 - 当前读:
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE内部的行读取。读到的是最新已提交版本,并且会对记录加锁。
所以一个事务里,先普通SELECT,再UPDATE,读到的数据可能不一样。前者是快照,后者是最新。这不是BUG,是设计如此。
4.2 隐藏列、undo版本链、ReadView:快照读的底层原理
InnoDB的每一行记录都带有几个隐藏列,对理解MVCC最核心的是两个:trx_id(最近修改该行的事务ID)和roll_pointer(指向undo log中的旧版本)。
每次UPDATE,InnoDB不是直接改值,而是先把旧行写到undo log,然后生成一个新版本,新版本的roll_pointer指向上一个版本。这样一行数据在内部形成了一条版本链:版本1 -> 版本2 -> 版本3。
快照读的时候,InnoDB会生成一个ReadView,里面记录了当前活跃事务列表等信息。判断一个版本是否可见的规则,可以粗略理解为:只有"在我这个快照生成时已经提交的事务"产生的版本,才对我可见;我自己还没提交的修改,自己能看到;其他未提交事务的修改,我看不到。
这个机制的最大价值是:读写之间不再互相阻塞。读不等待写,写不等待读,这是高并发场景下InnoDB的核心竞争力。
4.3 RR为什么能防不可重复读:ReadView的复用时机
到了这里就能解释隔离级别的本质区别了。
- 在
READ COMMITTED下,每次SELECT都会生成一个新的ReadView,所以两次SELECT看到的数据可能不一样,因为第二次能看到别的会话刚刚提交的内容。 - 在
REPEATABLE READ下,事务内第一次SELECT时生成ReadView,之后的SELECT都复用同一个ReadView。别人的修改就算已经提交,只要版本对当前快照不可见,就读不到。这就是RR消除不可重复读的原理。
我遇到过一个问题:RR级别下,事务A先查了一个列表,去跑一段耗时的业务逻辑,再查同一个列表,发现数据没变化,于是判断"这个区间没有新数据",直接执行了INSERT。结果插入时被阻塞,最后死锁。原因就是:快照读看到的是旧版本,但INSERT走的是当前读和锁,别人已经在区间里插了数据,间隙锁挡住了新插入。快照读的结果,不能代表当前数据库的实时状态。需要做"检测再插入"这种逻辑时,要么用当前读,要么接受并处理锁冲突。
5. 崩溃恢复与双写风险:commit之后你的数据到底在哪
5.1 WAL:先写日志,再动数据
事务提交后数据就安全了吗?不一定。如果每次提交都立刻把数据页刷到磁盘,性能会低到无法接受。InnoDB的做法是WAL(Write-Ahead Logging):先在redo log里记录这次修改,然后再更新内存中的缓冲池数据页。数据页可以等之后某个时间点再刷盘,但redo log必须先行。
这样即使数据页还没刷盘时数据库崩溃,重启后也能靠redo log重放,把数据补回来。redo log是物理逻辑日志,记录"某个页某个偏移量改成了什么",对崩溃恢复来说足够可靠。
5.2 刷盘参数对比:性能与可靠性的三个档位
牵扯到性能的关键参数是innodb_flush_log_at_trx_commit:
| 参数值 | 行为 | 可靠性 | 性能 |
|---|---|---|---|
| 1 | 每次事务提交都把redo log刷入磁盘 | 最高,最多丢失一个事务(实际上几乎不丢) | 最慢 |
| 2 | 每次提交只写入OS缓存,每秒批量刷盘 | 数据库崩溃不丢,操作系统崩溃可能丢1秒内数据 | 较快 |
| 0 | 每秒刷一次,提交时不主动刷 | 数据库或系统崩溃都可能丢最近1秒数据 | 最快 |
默认值是1,也就是最安全。很多团队为了压测数据好看,会把值改成2,这在大部分业务场景下是可以接受的,但如果业务涉及支付、订单资金流转,建议还是老老实实用1。另一个配合参数是sync_binlog,和binlog刷盘相关,双1配置(innodb_flush_log_at_trx_commit=1和sync_binlog=1)才是真正最稳妥的持久性组合。
5.3 两阶段提交与组提交:redo和binlog如何保持一致
这里有个容易被忽略的问题:MySQL除了InnoDB的redo log,还有binlog,两者都要写。如果先写binlog再写redo,或者反过来,都可能出现"日志不一致"导致数据恢复时对不上的情况。
InnoDB用内部两阶段提交解决:事务提交时先把redo log标记为PREPARE状态,然后写binlog,最后把redo标记为COMMIT。崩溃恢复时,如果binlog完整,就认为事务已提交并重放;如果binlog不完整,redo处于PREPARE状态,就回滚这个事务。这套机制保证了redo和binlog最终一致。
"组提交"则是优化手段:多个事务可以共享一次刷盘,把每次提交都刷盘的消耗摊薄。所以并发高的场景下,事务提交的瓶颈很多时候不在锁,而在日志刷盘。理解了这一点,再去看"为什么并发一高P99就涨",思路会清晰很多。
6. 死锁排查实录:从status输出到一条决定性修复SQL
6.1 一个经典死锁场景
死锁不是数据库故障,是并发事务相互等待形成的环。最常见的场景是AB-BA问题:
-- 事务1 UPDATE t SET v=1 WHERE id=1; UPDATE t SET v=2 WHERE id=2; -- 事务2 UPDATE t SET v=3 WHERE id=2; UPDATE t SET v=4 WHERE id=1;如果两个事务同时执行,事务1锁住id=1,事务2锁住id=2。接着事务1想锁id=2,被事务2挡住;事务2想锁id=1,被事务1挡住。两个都在等对方释放,环形成,死锁产生。
InnoDB有个后台线程持续检测死锁,发现后会自动回滚其中一个事务,释放资源。所以死锁不会无限卡住,但会导致其中一个事务失败,应用层如果没有重试机制,用户会看到报错。
6.2 死锁报告怎么看
遇到死锁,第一步是抓现场:
SHOW ENGINE INNODB STATUS\G重点看LATEST DETECTED DEADLOCK部分。里面会列出:
- 两个事务的ID、执行的SQL语句
- 各自持有的锁:锁在哪个表、哪个索引、哪条记录
- 等待的锁是什么
我排查过的多数死锁,报告里都会出现一个共同点:至少有一个事务的锁定范围比预期大,或者两个事务访问资源的顺序不统一。锁定范围大的原因,又多半回到第三章说的:间隙锁、二级索引范围锁、非唯一索引条件。
6.3 根治死锁的实操清单
从实际经验出发,解决死锁的优先级是这样的:
- 调整SQL执行顺序。多个事务里需要锁多个资源时,尽量按相同的顺序访问,比如都先处理id小的。这个能从根上消除AB-BA环。
- 缩小事务范围。事务里只放必要操作,尽快提交。事务活得越久,与其他事务重叠的时间越长。
- 让WHERE条件走最优索引。尽量用唯一索引等值条件,避免二级索引范围扫描。少锁间隙,死锁概率大幅下降。
- 降低隔离级别。业务允许的话,从RR改到RC,去掉间隙锁。这个调整对死锁率的影响通常立竿见影。
- 加索引或改索引。如果业务无法避免范围查询,考虑把范围查询改成等值查询,或者把联合索引设计得能精确命中目标行。
- 应用层加重试。死锁发生时,捕获错误码,做一定次数的重试。这个成本最低,但在高并发下相当有效。
有一条很容易被忽略:死锁回滚的事务是"代价较小"的那个,但业务上可能是你不想回滚的那个。所以不能指望数据库帮你选,应用层该重试就重试,该补偿就补偿。还有innodb_deadlock_detect这个参数,在超高并发场景下,死锁检测本身也会消耗CPU,有些团队会关掉它改用锁等待超时兜底。但这是极端优化的范畴,常规业务不建议动它。
最后再分享一点个人经验
MySQL事务的知识点很散,但串起来看就是一条线:事务边界决定操作范围,隔离级别决定读的语义,锁和MVCC决定并发行为,redo/undo决定可靠性。线上大部分事务相关事故,都能落到这四个环节中的某一个。
如果你觉得自己对事务的理解还不够"实战",我的建议是找一个测试库,开两个会话,手动执行工具脚本不需要,就用几条SQL把脏读、不可重复读、死锁都复现一遍,再打开SHOW ENGINE INNODB STATUS看锁信息。这个过程比看十篇文档都有用。我在带人的时候,第一课从来不是讲语法,而是让新人自己制造一个死锁,亲手看完报告再杀掉一个事务。经历过一次,印象会深很多。