最开始接触MySQL的时候,我一直觉得"事务"是个挺玄乎的词。老看到文章里写"事务保证数据一致性",但敲了半年SQL,INSERT、UPDATE、SELECT一通操作下来,也没觉得哪里需要特别小心。直到前阵子帮朋友排查一个线上Bug——两条几乎同时提交的订单,库存偶尔会变成负数,几个人围着查了一下午,最后定位到是事务隔离级别和锁等待的问题。那是我第一次意识到:MySQL里那些平时看不见的机制,才是真正决定线上数据对错的关键。
今晚是Day02,我把收藏夹里囤的资料翻出来重新捋了一遍,核心围绕事务与锁。这篇文章记录的是我自己的理解过程,包括做过的实验、踩过的坑,以及读死锁日志时的那段痛苦经历。适合正在学MySQL、对事务和锁的概念还处于"好像懂了但不敢说懂"阶段的同学,也适合想系统复习一遍隔离级别与加锁规则的开发朋友。里面所有例子我都实际跑过,SQL脚本也给了,你可以直接复制到自己的环境里验证。
1. 事务四大特性:ACID不只是一张截图,每个字母背后都对应一次真实事故
1.1 从转账场景看ACID,以及最常见的理解误区
事务的四大特性——原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),教科书里通常用转账举例。A给B转账500块,A扣款、B收款必须同时成功或同时失败,这就是原子性;转账前后总额不变,这就是一致性;转账过程中别人看不到中间状态,这就是隔离性;一旦提交成功,就算数据库当场断电,钱也不能丢,这就是持久性。
但我要说一个容易被忽略的误区:很多人以为"原子性"是靠锁实现的,其实不对。原子性主要靠的是回滚日志(undo log)。事务执行过程中,每一条数据修改都会先记录"改之前的值"到undo log。如果事务中途失败,InnoDB会根据undo log把数据一步步还原到事务开始之前的样子。锁在隔离性里起作用,而原子性是undo log的功劳,这两个机制千万别混。
1.2 redo log和undo log,崩溃恢复时分工完全不同的两个角色
Day01我在看InnoDB存储引擎资料时,看到过一句话:"先写日志,再写数据",当时没太在意。今天认真看了一遍,发现这是整个持久性设计的核心。
InnoDB的数据是存在磁盘上的,但如果你每次修改都直接改磁盘那个页,效率极低——因为一个页里有好多行,你改一行也得把这个页整体读出来、改完再写回去,涉及大量随机IO。所以InnoDB的做法是:先在内存里的缓冲池(Buffer Pool)修改对应的页,然后生成一条redo log,把这次修改记录成物理级别的日志(写到哪个页、哪个偏移量、改成了什么),顺序写入磁盘。等事务提交时,保证redo log落盘就行,数据页本身可以留在内存里慢慢刷。
这就解释了为什么MySQL宕机重启后数据不丢:重启时会读取redo log,把上次崩溃前已经提交但还没来得及刷盘的操作,重新应用一遍。这个机制叫WAL(Write-Ahead Logging),日志先行,数据靠后。凡是说"只要commit了,数据就一定已经在磁盘数据文件里"的说法,都是不准确的——commit时保证的是redo log在磁盘上,而不是数据页在磁盘上。
1.3 为什么我说理解和区分这两种日志,比背概念有用得多
redo log管"重做",undo log管"回滚",这两者配合起来,才构成了事务完整的生命周期。
实际排查问题的时候,区分这两种日志特别有用。比如我遇到过一台MySQL服务异常断电后重启,启动速度特别慢,查看错误日志发现是在做崩溃恢复。那会儿我就靠检查redo log的大小和checkpoint的位置来判断恢复进度。反过来,如果你发现一个事务运行了很久始终不提交,那么它的undo log会一直占着空间,甚至拖垮其他查询——这个我在后面"长事务"场景里还会专门讲。
所以Day02我给自己立的一个规矩就是:凡是涉及"数据为什么不丢"和"数据为什么能回滚"的问题,先问自己一句——这归redo管还是归undo管?想清楚这一层,很多概念就不再是死记硬背了。
2. 隔离级别逐档推演:拿一个虚拟小票系统复现脏读、不可重复读和幻读
2.1 四个隔离级别,本质上是一道"你对中间状态容忍度多高"的选择题
隔离级别定义了一个事务在读取数据时,允许看到其他事务的哪些中间状态。MySQL的四种级别,从宽松到严格,分别是:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 默认情况 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 几乎不用 |
| READ COMMITTED | 不会 | 可能 | 可能 | Oracle等默认 |
| REPEATABLE READ | 不会 | 不会 | 可能(InnoDB实际解决了快照读下的幻读) | MySQL默认 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 并发极低时用 |
从字面理解,READ COMMITTED的意思是"只读别人已提交的数据",所以脏读被杜绝;REPEATABLE READ的意思是"同一个事务里多次读取,结果保持一致";SERIALIZABLE最狠,直接把并发执行变成串行执行。
这里有个我要特别提醒的点:事务隔离级别设得越高,并发能力通常越低,但数据一致性越强,没有免费的午餐。
2.2 三个异常现象的实验脚本:在自己的MySQL里跑一遍
光看定义不够直观,我建了一张简单的表来自测,表结构如下:
CREATE TABLE `ticket` ( `id` int NOT NULL AUTO_INCREMENT, `order_no` varchar(32) DEFAULT NULL, `amount` decimal(10,2) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO ticket (order_no, amount) VALUES ('A0001', 100.00);实验前先确认当前隔离级别:
SELECT @@transaction_isolation;我的环境默认是REPEATABLE-READ,需要临时改成READ UNCOMMITTED来测试脏读:
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;脏读复现过程,需要开两个终端。事务1执行:
BEGIN; UPDATE ticket SET amount = 200.00 WHERE order_no = 'A0001';注意此时事务1还没提交。事务2在另一个终端执行:
BEGIN; SELECT amount FROM ticket WHERE order_no = 'A0001';如果隔离级别是READ UNCOMMITTED,事务2能查到200.00,但这个值事务1随时可能回滚——读到的是一个"不存在的中间值",这就是脏读。我把隔离级别改成READ COMMITTED后再重复一遍,事务2查到的还是100.00,脏读消失。
不可重复读复现过程,隔离级别设为READ COMMITTED,事务2先查询:
BEGIN; SELECT amount FROM ticket WHERE order_no = 'A0001'; -- 此时查到 100.00事务1执行更新并提交:
BEGIN; UPDATE ticket SET amount = 300.00 WHERE order_no = 'A0001'; COMMIT;事务2再查询一次,同一个事务内两次结果分别是100.00和300.00,这就是不可重复读。等问题出现后,我重新开一个事务,把会话设为REPEATABLE READ,重复同样的操作,第二次查询结果仍然是100.00——因为MVCC让这个事务内看到的是同一个快照。
幻读比不可重复读更隐蔽。不可重复读关注的是"同一行数据内容变了",幻读关注的是"符合条件的记录条数变了"。比如事务2执行:
BEGIN; SELECT COUNT(*) FROM ticket WHERE amount > 50; -- 返回 1事务1插入一条amount=150的新订单并提交。事务2再执行同样的统计,如果能看到2条,说明出现了幻读。在REPEATABLE READ级别下,如果事务2走的是普通SELECT(快照读),InnoDB通过MVCC让查询结果保持在事务开始时的快照上,新插入的行不可见,所以不出现幻读。但如果事务2走的是带FOR UPDATE的当前读,还是可能看到新插入的行。这也是很多人在RR级别下仍然被幻读困扰的深层原因。
2.3 一个让我纠结很久的问题:既然RR已经解决了快照读的幻读,为什么很多团队还要改成RC
我以前一直不理解,MySQL官方默认RR,那大家为什么还要改成RC(READ COMMITTED)?查了一圈资料、又看了些生产案例后,我总结出几个实际原因:
- 间隙锁带来的死锁风险。RR级别会对范围条件加间隙锁,两个事务同时往同一个范围插入数据时,更容易互相等待形成死锁。
- 主从复制的要求。在基于binlog的复制场景里,RC级别配合row格式的binlog,比RR级别少很多不确定的加锁行为,管理起来更省心。
- RC级别下间隙锁被禁用,加锁范围更小,并发度更高,很多互联网公司的高并发交易系统选择RC就是基于这个考量。
但是注意,RC级别放弃了快照读的一致性,不可重复读会重新出现。所以这个选择本质上是一次业务场景的权衡,没有绝对的对错。我给自己的判断标准是:如果业务对同事务内一致性读要求高,选RR;如果追求高并发、对一行数据"读到最新提交值"也接受,RC也完全可行。
3. 锁的分类地图与死锁日志排查:从一数到八,再亲手制造一个死锁
3.1 锁的两种维度:粒度与读写模式,不要混为一谈
说到锁,很多人会冒出十几个名词:表锁、行锁、共享锁、排他锁、意向锁、记录锁、间隙锁、Next-Key Lock……其实它们分别属于两个维度,搞清楚维度就不会乱:
- 按粒度分:表级锁、行级锁。
- 按读写模式分:共享锁(S锁)、排他锁(X锁)。
- 它们组合出具体类型:行锁细分又包括记录锁(Record Lock)、间隙锁(Gap Lock)、Next-Key Lock(记录锁+间隙锁的组合)。
另外有个容易忽视的角色——意向锁。它的作用是告诉别人"我这个事务打算在表里的某些行加锁"。比如事务要在某一行加X锁,首先得在表级别加一个意向排他锁(IX)。这样另一个事务想直接给整张表加X锁时,就能通过检测意向锁快速判断"表里已经有行被锁了",没必要一行一行扫描去确认。意向锁的存在是为了提高锁检查效率,它本身不阻塞任何行级操作。
共享锁和排他锁的兼容关系是:S锁和S锁兼容,S锁和X锁不兼容,X锁和X锁不兼容。可以理解为"读读不互斥,读写才互斥"。
日常开发里,SELECT ... FOR UPDATE加的是行级X锁,SELECT ... LOCK IN SHARE MODE加的是行级S锁。普通的SELECT不加锁,走MVCC快照读。
3.2 间隙锁与Next-Key Lock:RR级别下最被低估的"隐形锁"
Record Lock锁的是已经存在的索引记录。但问题是,如果锁只锁已有记录,两个事务完全可能同时给一个不存在的区间插入新数据——幻读就会出现。间隙锁(Gap Lock)就是用来填补这个漏洞的:当你在RR级别下对一个范围条件加锁时,InnoDB不仅锁住命中的记录,还会锁住这些记录之间的"空隙",防止其他事务往里面插入新行。
举例说明,表里id分别为1、3、5、7,执行:
SELECT * FROM ticket WHERE id BETWEEN 3 AND 5 FOR UPDATE;InnoDB除了给id=3和id=5的记录加记录锁,还会在(3,5)这个区间以及可能的相关间隙加间隙锁。另一个事务插入id=4的记录会被阻塞,插入id=6的记录取决于间隙锁范围也可能被阻塞。
这里有个经典的坑:间隙锁锁的是索引记录之间的空隙,即使你在业务上没有显式使用事务,多个并发操作在RR级别下也可能因为间隙锁互相等待。很多"为什么我的INSERT突然卡住不动"的问题,最后定位出来都是间隙锁在作怪。这个我后面会有专门的案例复盘。
3.3 亲手制造死锁,然后通过show engine innodb status找到元凶
理论学习半天,不如自己制造一次死锁印象深刻。我用上面那张ticket表,准备了两条id不同的记录,开了两个事务模拟经典死锁:
事务A先锁id=1的行:
BEGIN; UPDATE ticket SET amount = 10.00 WHERE id = 1;事务B先锁id=2的行:
BEGIN; UPDATE ticket SET amount = 20.00 WHERE id = 2;接着事务A尝试锁id=2:
UPDATE ticket SET amount = 10.00 WHERE id = 2;此时A会阻塞,因为id=2的X锁被B持有。然后事务B尝试锁id=1:
UPDATE ticket SET amount = 20.00 WHERE id = 1;此时B也会阻塞,因为id=1的X锁被A持有。两个事务互相等待,死锁形成。几秒后MySQL的死锁检测机制会触发,其中一个事务被回滚,报错信息大致是Deadlock found when trying to get lock; try restarting transaction。
排查死锁的标准操作是执行:
SHOW ENGINE INNODB STATUS\G在输出的LATEST DETECTED DEADLOCK段落里,能看到死锁的事务ID、持有锁和等待锁的详细信息、涉及的SQL语句。我第一次看这个输出时特别懵,全是十六进制的锁地址和内部编号,后来慢慢习惯了,核心就抓三个要素:谁持有锁、谁在等锁、两条SQL分别是什么。只要能回答这三个问题,死锁根因基本就清楚了。
3.4 我的一点体会:死锁不能只靠"重启事务"糊弄
不少人遇到死锁的第一反应是把事务重试一下就算了。我觉得这不太够。死锁的本质是锁的获取顺序不合理,重试只是补救,优化才是正解。我在项目里总结出的措施包括:多个事务对同一批数据的操作顺序保持一致;尽量缩短事务的执行时间;在RR级别下谨慎使用大范围更新;如果场景允许,把隔离级别降到RC来规避间隙锁引发的死锁。这些不需要全部上,但至少要有意识地排查。
4. 一条UPDATE语句背后的完整加锁路径:从B+树定位到记录锁,再聊间隙锁
4.1 没有索引时,行锁是如何一步步变成全表锁的
很多人认为"行锁"就一定是锁一行,但InnoDB的加锁是基于索引的。如果你执行UPDATE时的WHERE条件没有命中任何索引,InnoDB只能走全表扫描。扫描过程中它会访问每一行,为了不让其他事务在扫描期间修改数据,它会对扫描到的每一行都加锁——虽然理论上是行锁,但实际效果跟锁全表几乎一样。这就是"行锁变表锁"的真相。
所以判断一个UPDATE会锁多少行,不是看表有多少行,而是看扫描了多少行。条件能精准命中索引时,锁的范围可能只是一两条记录;条件没法用索引时,锁的范围就是整张表。这也是为什么我在建表时特别重视索引设计——索引不只是查询加速,它直接关系到大事务的锁范围。
4.2 手动实测一条UPDATE的加锁范围
我用ticket表做了一次实测,表里有id=1到id=5共5条记录,执行:
BEGIN; UPDATE ticket SET amount = 0 WHERE id > 3;由于主键索引的存在,这条UPDATE会锁住id=4和id=5两条记录,而且因为RR隔离级别,还会在(3, +∞)范围加上间隙锁。在另一个事务执行INSERT INTO ticket (id, order_no, amount) VALUES (6, 'A0006', 50.00)时,插入被阻塞——虽然id=6这条新记录本身不存在,但间隙锁覆盖了它要插入的位置。
如果换成id=2这样具体的主键值:
BEGIN; UPDATE ticket SET amount = 0 WHERE id = 2;则只对id=2这条记录加X锁,不会阻塞插入id=6。这个实验直观地让我理解了记录锁和间隙锁的作用范围差异。
4.3 回表与锁的传递:为什么索引命中也可能锁更多行
还有一个细节值得留意——二级索引加锁后,还要不要给主键索引加锁?答案是要。InnoDB更新数据时,最终需要通过主键找到真正的记录。所以当你用二级索引查询并加锁时,InnoDB会在二级索引上加对应的锁,同时回表到主键索引,在主键索引的对应记录上也加锁。也就是说,一条SQL可能同时锁了两棵索引树的记录。如果二级索引本身不够精准,比如有大量重复值的普通字段,那么锁定的记录数量会远超你的预期。
理解了这条路径,再看那些"我只更新了一条记录为什么锁了好多行"的问题就清楚了:要么是索引没建对,扫描范围过大;要么是二级索引列值有大量重复,导致回表行数膨胀;要么是RR级别下附带了大范围的间隙锁。把这几个因素按顺序排查,基本能找到大部分锁范围异常问题的根源。
4.4 关于"UPDATE没匹配到任何行,还会不会加锁"的实验结论
我自己有过一个疑问:UPDATE一条不存在的记录,是不是就不用加锁了?实验结果是——在RR隔离级别下,即使没有任何匹配行,InnoDB依然会对WHERE条件对应的范围加间隙锁,防止其他事务在更新期间插入匹配该条件的记录。这个行为在特殊场景下能保证数据一致性,但也容易被滥用成"通过锁定一个区间来防并发插入"的手段。不过我不建议在生产环境这么搞,间隙锁的代价远高于业务上几个乐观锁字段或者唯一约束能解决的方案。
5. 实操最容易翻车的三个场景与常用诊断命令清单
5.1 场景一:长事务拖垮Undo Log,MySQL的磁盘空间凭空蒸发
有一次我操作的MySQL实例出现了诡异现象:数据文件没怎么涨,但磁盘空间一直在减少,最后查出来是undo log膨胀。原因很简单:一个事务打开后长时间不提交,期间执行了大量UPDATE,这些修改对应的undo信息一直无法清理。因为InnoDB有MVCC机制,未提交事务期间,之前的快照版本必须保留下来供其他事务读取。
这个坑的核心教训是:事务开了就尽快提交,不要在事务里做耗时的外部接口调用。像那种"BEGIN之后先HTTP请求第三方接口,再UPDATE数据库"的写法,就是长事务的高发区。排查长事务可以用:
SELECT * FROM information_schema.innodb_trx;重点看trx_started字段,凡是启动时间很久、状态为RUNNING的事务,都要警惕。如果确认某个事务卡死了,可以通过trx_mysql_thread_id找到对应的会话,再决定是等待结束还是手动终止。
5.2 场景二:间隙锁范围失控,INSERT突然全部卡住
前阵子跟朋友排查过一个线上问题:某个核心表的INSERT操作在高峰期突然大面积超时,应用日志里全是Lock wait timeout exceeded。当时第一反应是行锁冲突,但查看信息后发现在RR级别下,有一个事务对大范围数据执行了UPDATE,产生的间隙锁覆盖了业务要插入的大部分区间。所有插入都被阻塞,直到那个事务提交或回滚。
排查步骤我整理过一套:
- 先查当前锁等待情况:
SELECT * FROM performance_schema.data_lock_waits; -- 或者老版本用 SELECT * FROM information_schema.innodb_lock_waits;- 再查持锁事务信息:
SELECT * FROM information_schema.innodb_trx;- 必要时打开
SHOW ENGINE INNODB STATUS看细节。
如果是间隙锁拖垮了并发插入,最直接的解决办法是:确认业务能不能接受把隔离级别从RR改成RC;如果必须保留RR,那么拆分大事务成小批量,避免一次UPDATE覆盖太多数据。还有一个小技巧是让INSERT的数据落在间隙锁覆盖范围之外——这需要结合业务数据分布来设计,不能机械照搬。
5.3 场景三:隔离级别和并发目标不匹配,误把RC当RR用,或者反过来
我也见过有团队在并发量很高的支付流水表上使用RR级别,导致死锁频率上升,后来改成RC,死锁立刻减少。反过来,有些报表类的业务场景需要在一个长事务里多次读取同一批数据做聚合计算,结果被配置成了RC,导致两次计算结果不一致,生产上出现"对不上账"的诡异现象。
隔离级别的选择,我的思路比较简单粗暴:先看业务对一致性的敏感度,再看并发压力。一致性要求高、事务里多次读取必须一致,优先RR;高并发、短事务、单行操作的场景,RC往往更合适。记住,MySQL的RR不是唯一正确答案,Oracle默认RC也不意味着RC更优,关键是匹配业务模型。
5.4 我平时用到的诊断命令清单
下面这些命令,我几乎每次怀疑数据库数据不对或者性能骤降时都会用,整理在这里方便自己下次翻阅:
- 查看当前会话隔离级别:
SELECT @@transaction_isolation; - 查看运行中的事务:
SELECT * FROM information_schema.innodb_trx\G - 查看锁等待关系:
SELECT * FROM performance_schema.data_lock_waits\G - 查看死锁详情:
SHOW ENGINE INNODB STATUS\G - 查看表上是否有锁等待:
SHOW PROCESSLIST; - 修改锁等待超时时间:
SET SESSION innodb_lock_wait_timeout = 5;
我发现,只要遇到"数据莫名其妙对不上""接口突然卡死""MySQL磁盘空间异常减少"这三类问题,从事务隔离级别、锁等待、长事务三个方向入手排查,命中率非常高。这套方法我已经用顺手了,也希望能在你排错时帮上忙。
写在最后:Day02的收获
今晚我最大的收获不是背熟了事务和锁的分类,而是真正理解了它们为什么存在:事务的ACID保证了业务逻辑的可靠性,隔离级别决定了你对并发状态下数据中间状态的容忍度,锁机制则是隔离性落地的具体手段。这三层环环相扣,任何一层选错,线上都会以各种意想不到的方式报复你。
学到这里,我对Day03的规划也有方向了:MySQL的索引底层结构(B+树为什么快)和SQL执行计划分析,是继续深入事务与锁绕不开的基础。每次学习新概念时问一句"它到底解决了什么问题,不解决会有什么后果",是我觉得目前最有效的学习方法。Day02的记录就先到这里,下一篇见。