引言
- 事务 :是我们的基本游戏规则(同生共死)。
- 幻读 :是并发游戏中遇到的大BOSS(数据见鬼了)。
- MVCC :是数据库为了打败BOSS而练就的绝世武功(时光倒流术)。
事务 (Transaction) ------ "同生共死"
1. 什么是事务?
想象一下银行转账 :A 给 B 转 100 块。
这其实包含两个动作:
- A 的账户减 100。
- B 的账户加 100。
如果在第1步执行完,电闸拉了,数据库挂了。结果就是:A 的钱扣了,B 没收到。这在金融系统里是灾难。
事务 就是为了解决这个问题。它把一组操作打包成一个整体:要么全部成功,要么全部失败。
2. ACID 四大特性(背诵版)
- A (Atomicity) 原子性:原子不可分割。转账的两个动作,要么都做,要么都不做(回滚)。
- C (Consistency) 一致性:转账前 A+B=1000,转账后 A+B 还是 1000。钱不会凭空消失或产生。
- I (Isolation) 隔离性 :这是今天的重点! 多个事务同时跑(并发),不能互相干扰。
- D (Durability) 持久性:一旦提交,数据就永久保存在磁盘上,断电也不怕。
并发带来的麻烦
当很多个事务同时在跑(并发)时,如果没有隔离机制,就会出现三种怪事。我们假设有一个表 user。
- 脏读 (Dirty Read):
-
- 事务A写了数据但还没提交,事务B就读到了。万一A回滚了,B读的就是"脏数据"。
- 不可重复读 (Non-repeatable Read):
-
- 事务A先查 id=1,名字叫"张三"。
- 事务B把 id=1 改成了"李四"并提交。
- 事务A再查 id=1,发现名字变了。
- 重点 :针对的是修改(Update)。
- 幻读 (Phantom Read):
-
- 事务A查询所有
age > 10的人,查到了 5个人。 - 事务B突然插入了一条新数据
(name:王五, age: 20)并提交。 - 事务A再次查询
age > 10,发现变成了 6个人。 - 重点 :针对的是插入(Insert)或删除(Delete)。你会觉得产生幻觉了,怎么多出来一个人?
- 事务A查询所有
MVCC ------ 绝世武功"时光倒流"
为了解决上面的脏读、不可重复读,如果不加锁,数据库就会很慢。InnoDB 发明了 MVCC (多版本并发控制)。
1. 核心思想:版本链
MVCC 的意思就是:每一行数据,在底层其实都有很多个"影子"版本。
数据库里的每一行,除了你看到的字段,还有两个隐藏字段:
trx_id:最近一次修改这行数据的事务ID。roll_pointer:回滚指针,指向上一个版本的数据(在 undo log 里)。
举例:
- 事务1 (ID=100) 把名字从 "A" 改成 "B"。
-
- 当前行:
name="B", trx_id=100。 - 旧版本(Undo Log):
name="A", trx_id=99。
- 当前行:
- 事务2 (ID=200) 又把名字从 "B" 改成 "C"。
-
- 当前行:
name="C", trx_id=200。 - 旧版本链:
C(200) -> B(100) -> A(99)。
- 当前行:
2. 核心机制:ReadView (快照)
当一个事务启动并开始查询时,InnoDB 会给它拍一张照片,叫 ReadView。
这个 ReadView 包含此时此刻所有**"活跃中"(还没提交)**的事务列表。
可见性规则(人话版) :
当你去读一行数据时,你会拿着这行数据的 trx_id 跟你的 ReadView 比对:
- 如果是你自己 改的? -> 可见。
- 如果是已经提交 的老事务改的? -> 可见。
- 如果是在你启动之后 才新来的事务改的? -> 不可见(哪怕它提交了)。
- 如果是跟你同时跑 (但在你的活跃列表中)的事务改的? -> 不可见。
如果不可见怎么办?
顺着 roll_pointer 往回找旧版本,直到找到一个符合规则的版本为止。
这就是为什么叫 "多版本" 。事务A看是C,事务B看是B,互不干扰,不用加锁!
隔离级别与幻读的终极对决
MySQL InnoDB 默认隔离级别是 RR (Repeatable Read - 可重复读)。它是怎么解决幻读的?
这要分两种情况,很多人在这里会晕:
情况一:快照读 (Snapshot Read) ------ 靠 MVCC 解决
场景 :普通的 SELECT * FROM table WHERE ...
过程:
- 事务A启动,生成 ReadView。此时表里有2行数据。
- 事务B插入第3行,提交。
- 事务A再次 Select。
-
- 虽然物理上表里有3行,但第3行的
trx_id是事务B的(比事务A大,属于"未来")。 - 根据 ReadView 规则,事务A看不见第3行。
- 事务A依然只读到2行。
结论 :MVCC 在"快照读"层面完美解决了幻读。
- 虽然物理上表里有3行,但第3行的
情况二:当前读 (Current Read) ------ 靠 Next-Key Lock 解决
场景 :SELECT ... FOR UPDATE 或 UPDATE / DELETE。
为什么 MVCC 不管用了?
因为当你执行 UPDATE 或 FOR UPDATE 时,你必须基于数据的最新版本进行操作(你不能修改历史快照吧?)。既然要读最新版,MVCC 的"看旧版本"逻辑就失效了。
过程(如果没锁):
- 事务A:
SELECT * FROM user WHERE age > 10 FOR UPDATE;(查到2人) - 事务B:
INSERT INTO user (name, age) VALUES ('Jack', 20); - 事务A:
UPDATE user SET name='X' WHERE age > 10;
-
- 如果不加锁,事务A就会把事务B刚插进去的 'Jack' 也改成 'X'。
- 事务A一看:"咦?我明明只查到2人,怎么Update影响了3行?" ------ 这就是幻读。
InnoDB 的解决方案(Next-Key Lock) :
在 RR 级别下,执行第1步时:
- InnoDB 不仅锁住查到的那2行记录。
- 还会加上 间隙锁 (Gap Lock) ,锁住
age > 10的所有空隙。 - 此时,事务B 想插入
age=20,发现该区间被锁住,阻塞等待。 - 直到事务A提交,事务B才能插入。
结论 :Next-Key Lock 在"当前读"层面通过"堵住插入"解决了幻读。
总结串联
- 事务 保证了我们要么全赢要么全输,还要隔离互斥。
- MVCC 是 InnoDB 的读写不冲突神器。
-
- 它利用 Undo Log (版本链) 和 ReadView (规则) ,让你在 RR隔离级别 下,普通的
SELECT永远看到的是事务启动时的一致性快照。
- 它利用 Undo Log (版本链) 和 ReadView (规则) ,让你在 RR隔离级别 下,普通的
- 幻读 是并发下的"见鬼"现象(多了新行)。
-
- 对于普通查询:MVCC 让你根本"看不见"新插入的鬼(读旧版本)。
- 对于修改/加锁查询:间隙锁(Gap Lock)直接把门堵死,不让鬼进来(阻止插入)。
这就是为什么我们说 MySQL 的 RR 级别很大程度上解决了幻读问题(虽然理论上标准 RR 级别是不解决幻读的,但 MySQL 做到了)。既然您觉得之前的解释不够详细,那我们这次来一次深度拆解 。我们将深入到 InnoDB 引擎的内核实现层面 ,结合B+树索引结构 、加锁规则(Locking Rules) 以及 复杂的并发时序图来剖析。
锁全局视角的底层逻辑
在深入具体锁之前,必须先建立两个核心认知:
- 锁是加在索引(Index)上的,而不是数据行上的。
-
- 如果查询没有走索引,InnoDB 会进行全表扫描,此时会锁住所有记录(虽然在 Server 层过滤后可能会释放,但在引擎层确实都锁过)。
- InnoDB 默认的隔离级别是 RR (Repeatable Read)。
-
- 在这个级别下,为了解决幻读 ,InnoDB 引入了复杂的间隙锁(Gap Lock) 和 临键锁(Next-Key Lock)。
从上帝视角看锁的层级架构
1. 全局锁 (Global Lock) - 极端的"暂停键"
- 命令 :
FLUSH TABLES WITH READ LOCK (FTWRL) - 底层行为:MySQL Server 层会关闭所有打开的表,并强制拒绝所有的更新操作。
- 致命场景:如果在主库执行,业务全停;在从库执行,会导致主从延迟剧增。
- 正确姿势 :现在的逻辑备份(mysqldump)推荐使用
--single-transaction。它利用 MVCC 开启一个一致性视图,不加锁也能备份到一致的数据。
2. 表级锁 (Table Lock) - 轻量级的大门
除了 LOCK TABLES 这种显式锁,最重要的是下面两个隐式锁:
- MDL (Metadata Lock) - 元数据锁
-
- 痛点:这是MySQL最容易造成"雪崩"的地方。
- 机制:
-
-
- 当你
SELECT/UPDATE(DML)时,加 MDL读锁。 - 当你
ALTER TABLE(DDL)时,加 MDL写锁。
- 当你
-
-
- 互斥:读写互斥,写写互斥。
- 灾难场景:
-
-
- 事务A正在查询大表(耗时久,持有 MDL读锁)。
- DBA 想给表加个字段(DDL,申请 MDL写锁,被阻塞)。
- 后续所有 进来的查询(DML,申请 MDL读锁),发现队列里有一个写锁在等待,为了公平,它们全部被阻塞。
- 结果:数据库连接池瞬间打满,服务挂掉。
-
-
- 解决 :在
ALTER TABLE时设置等待超时时间,拿不到锁就放弃。
- 解决 :在
- IS / IX (Intention Lock) - 意向锁
-
- 本质 :表级别的标记 。不是用来锁数据的,而是用来提高加表锁效率的。
- 逻辑:
-
-
- 如果没有意向锁:事务A想给表加"写锁",必须遍历几百万行数据,确认没有一行被事务B锁住。效率极低。
- 有了意向锁:事务B锁住某一行(行锁)之前,必须先去表上挂一个"意向锁(IX)"。
- 此时事务A想加表锁,一看门头上有个 IX 标记,直接阻塞等待,不用遍历了。
-
InnoDB 行锁的深度剖析(核心中的核心)
行锁算法是基于索引类型的。为了讲清楚,我们构建一个具体的模型:
表结构:
CREATE TABLE t (
id INT PRIMARY KEY, -- 主键索引
c INT, -- 普通索引 (Non-Unique Index)
d INT -- 无索引
) ENGINE=InnoDB;
INSERT INTO t VALUES (0,0,0), (5,5,5), (10,10,10), (15,15,15);
现有记录(id) :0, 5, 10, 15
现有区间(对于c索引) :(-∞,0], (0,5], (5,10], (10,15], (15,+∞)
1. 锁的分类(属性)
- S锁 (Shared) :
LOCK IN SHARE MODE。读锁,允许别人读,不允许别人改。 - X锁 (Exclusive) :
FOR UPDATE/UPDATE/DELETE。写锁,生人勿进。
2. 锁的算法(Algorithm)
InnoDB 在 RR 级别下,遵循 "两原则、两优化、一Bug" 的加锁规则(出自丁奇《MySQL实战45讲》总结):
- 原则 1 :加锁的基本单位是 Next-Key Lock(左开右闭区间)。
- 原则 2:查找过程中访问到的对象才会加锁。
- 优化 1 :索引上的等值查询,给唯一索引加锁时,Next-Key Lock 退化为 Record Lock(记录锁)。
- 优化 2 :索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,Next-Key Lock 退化为 Gap Lock(间隙锁)。
硬核场景实战(结合MVCC与锁)
场景一:唯一索引等值查询(Record Lock)
SQL : UPDATE t SET d=1 WHERE id = 5;
- 分析:
-
id是主键(唯一)。- 根据优化1 ,Next-Key Lock
(0, 5]退化为 Record Lock。 - 结果 :只锁住
id=5这一行。其他事务可以插入id=3,可以修改id=0。
场景二:非唯一索引等值查询(Gap Lock 登场)
SQL : SELECT id FROM t WHERE c = 5 LOCK IN SHARE MODE;
- 分析:
-
c是普通索引。- 步骤1 :先定位到
c=5。基本单位是 Next-Key Lock,锁住(0, 5]。 - 步骤2 :因为是普通索引,必须向后扫描,直到碰到第一个不等于 5 的值为止(也就是
c=10)。 - 步骤3 :对
c=10这个范围加 Next-Key Lock(5, 10]。 - 步骤4 :根据优化2 ,等值查询向右遍历不满足条件(10!=5),退化为 Gap Lock ,即
(5, 10)。
- 最终锁范围:
-
- 索引 c 上的
(0, 5](Next-Key) 和(5, 10)(Gap)。 - 效果:
- 索引 c 上的
-
-
- 不能插入
c=2(被第一段锁住)。 - 不能插入
c=7(被第二段锁住)。 - 目的 :这就是为了防止幻读!如果不锁住
(5, 10),别人插入一个c=6,你再次查c=5虽然没变,但业务逻辑上如果涉及范围统计就会出错。
- 不能插入
-
场景三:主键范围查询(Next-Key Lock)
SQL : SELECT * FROM t WHERE id >= 10 AND id < 11 FOR UPDATE;
- 分析:
-
- 步骤1 :找到
id=10。Next-Key Lock 是(5, 10]。 - 步骤2 :因为
id是主键,等值查询退化为 Record Lock ,只锁id=10。 - 步骤3 :范围查找继续往后,找到
id=15。Next-Key Lock 是(10, 15]。 - 注意 :这里不会触发优化2(因为不是等值查询,是范围查询),所以 Next-Key Lock 不退化。
- 步骤1 :找到
- 最终锁范围:
-
- Record Lock:
id=10 - Next-Key Lock:
(10, 15] - 后果 :虽然你只查了 id < 11,但是 id=15 这一行也被锁住了!别人想 update id=15 会被阻塞。
- Record Lock:
场景四:插入意向锁与死锁(Insert Intention)
背景:插入意向锁是一种特殊的 Gap Lock,表示"我想插进去,但我很有礼貌,我在排队"。
流程:
- 事务A :
UPDATE t SET d=1 WHERE c=5;
-
- 持有 c 索引上的 Gap Lock
(0, 10)(简化描述)。
- 持有 c 索引上的 Gap Lock
- 事务B :
INSERT INTO t VALUES(6, 6, 6);
-
- 事务B 试图在 c=6 处插入。
- 检测到
(0, 10)之间有事务A的 Gap Lock。 - 事务B 生成一个 插入意向锁,进入阻塞状态 (Wait)。
死锁高发场景:
- 事务A :
SELECT * FROM t WHERE c=5 FOR UPDATE;(持有 Gap Lock 0~10) - 事务B :
SELECT * FROM t WHERE c=5 FOR UPDATE;
-
- 关键点 :Gap Lock 之间是不冲突的! 事务B 也成功获取 Gap Lock 0~10。
- 事务A :
INSERT INTO t VALUES(2, 2, 2);
-
- 事务A 试图插入 c=2。发现事务B持有 Gap Lock,被阻塞。
- 事务B :
INSERT INTO t VALUES(3, 3, 3);
-
- 事务B 试图插入 c=3。发现事务A持有 Gap Lock,被阻塞。
- 结果 :互相等待 -> Deadlock。
MVCC 与 锁的爱恨情仇
MVCC(多版本并发控制)是 InnoDB 不加锁也能读到数据的核心。
1. 两种读模式
- 快照读 (Snapshot Read):
-
- SQL:
SELECT * FROM t WHERE id=1;(无后缀) - 机制 :读取 Undo Log 中的历史版本。
- ReadView:
- SQL:
-
-
- 在 RR 级别,事务启动时生成一次 ReadView,之后都用这个,实现"可重复读"。
- 在 RC (Read Committed) 级别,每次查询都重新生成 ReadView。
-
- 当前读 (Current Read):
-
- SQL:
SELECT ... FOR UPDATE,UPDATE,INSERT... - 机制 :必须读最新版本,并且加锁(Next-Key/Gap/Record)。
- 悖论:在 RR 级别下,快照读看不到别人的更新(因为看的是旧版本),但当前读能看到(因为要读最新并加锁)。
- 示例:
- SQL:
-
-
- A 开启事务。
- B 开启事务,Update id=1, Commit。
- A
Select id=1-> 看到旧值(MVCC生效)。 - A
Select id=1 For Update-> 看到新值(当前读,强行读最新)。
-
总结 - 对照您的全景图
- 悲观锁/乐观锁:
-
- MySQL 原生的锁(S/X)都是悲观锁。
- 乐观锁 通常是业务层加个
version字段实现的,MySQL 本身没有"乐观锁"这个实体对象。
- 幻读 (Phantom Read):
-
- 指在同一个事务中,先后两次查询,第二次看到了第一次没看到的新插入的行。
- 解决:MVCC 解决了快照读的幻读;Next-Key Lock 解决了当前读的幻读。
- 锁的选择:
-
- 能用主键更新最好,锁粒度最小(Record Lock)。
- 用普通索引更新,会加 Gap Lock,容易误伤并发。
- 不带索引更新,全表加锁,并发直接归零。