Mysql数据库相关 事务 MVCC与锁的爱恨情仇 锁的层次架构 InnoDB锁分析

引言

  1. 事务 :是我们的基本游戏规则(同生共死)。
  2. 幻读 :是并发游戏中遇到的大BOSS(数据见鬼了)。
  3. MVCC :是数据库为了打败BOSS而练就的绝世武功(时光倒流术)。

事务 (Transaction) ------ "同生共死"

1. 什么是事务?

想象一下银行转账 :A 给 B 转 100 块。

这其实包含两个动作:

  1. A 的账户减 100。
  2. B 的账户加 100。

如果在第1步执行完,电闸拉了,数据库挂了。结果就是:A 的钱扣了,B 没收到。这在金融系统里是灾难。

事务 就是为了解决这个问题。它把一组操作打包成一个整体:要么全部成功,要么全部失败

2. ACID 四大特性(背诵版)
  • A (Atomicity) 原子性:原子不可分割。转账的两个动作,要么都做,要么都不做(回滚)。
  • C (Consistency) 一致性:转账前 A+B=1000,转账后 A+B 还是 1000。钱不会凭空消失或产生。
  • I (Isolation) 隔离性这是今天的重点! 多个事务同时跑(并发),不能互相干扰。
  • D (Durability) 持久性:一旦提交,数据就永久保存在磁盘上,断电也不怕。

并发带来的麻烦

当很多个事务同时在跑(并发)时,如果没有隔离机制,就会出现三种怪事。我们假设有一个表 user

  1. 脏读 (Dirty Read)
    • 事务A写了数据但还没提交,事务B就读到了。万一A回滚了,B读的就是"脏数据"。
  1. 不可重复读 (Non-repeatable Read)
    • 事务A先查 id=1,名字叫"张三"。
    • 事务B把 id=1 改成了"李四"并提交。
    • 事务A再查 id=1,发现名字变了。
    • 重点 :针对的是修改(Update)
  1. 幻读 (Phantom Read)
    • 事务A查询所有 age > 10 的人,查到了 5个人
    • 事务B突然插入了一条新数据 (name:王五, age: 20) 并提交。
    • 事务A再次查询 age > 10,发现变成了 6个人
    • 重点 :针对的是插入(Insert)或删除(Delete)。你会觉得产生幻觉了,怎么多出来一个人?

MVCC ------ 绝世武功"时光倒流"

为了解决上面的脏读、不可重复读,如果不加锁,数据库就会很慢。InnoDB 发明了 MVCC (多版本并发控制)

1. 核心思想:版本链

MVCC 的意思就是:每一行数据,在底层其实都有很多个"影子"版本。

数据库里的每一行,除了你看到的字段,还有两个隐藏字段

  • trx_id:最近一次修改这行数据的事务ID
  • roll_pointer:回滚指针,指向上一个版本的数据(在 undo log 里)。

举例

  1. 事务1 (ID=100) 把名字从 "A" 改成 "B"。
    • 当前行:name="B", trx_id=100
    • 旧版本(Undo Log):name="A", trx_id=99
  1. 事务2 (ID=200) 又把名字从 "B" 改成 "C"。
    • 当前行:name="C", trx_id=200
    • 旧版本链:C(200) -> B(100) -> A(99)
2. 核心机制:ReadView (快照)

当一个事务启动并开始查询时,InnoDB 会给它拍一张照片,叫 ReadView

这个 ReadView 包含此时此刻所有**"活跃中"(还没提交)**的事务列表。

可见性规则(人话版)

当你去读一行数据时,你会拿着这行数据的 trx_id 跟你的 ReadView 比对:

  1. 如果是你自己 改的? -> 可见
  2. 如果是已经提交 的老事务改的? -> 可见
  3. 如果是在你启动之后 才新来的事务改的? -> 不可见(哪怕它提交了)。
  4. 如果是跟你同时跑 (但在你的活跃列表中)的事务改的? -> 不可见

如果不可见怎么办?

顺着 roll_pointer 往回找旧版本,直到找到一个符合规则的版本为止。

这就是为什么叫 "多版本" 。事务A看是C,事务B看是B,互不干扰,不用加锁


隔离级别与幻读的终极对决

MySQL InnoDB 默认隔离级别是 RR (Repeatable Read - 可重复读)。它是怎么解决幻读的?

这要分两种情况,很多人在这里会晕:

情况一:快照读 (Snapshot Read) ------ 靠 MVCC 解决

场景 :普通的 SELECT * FROM table WHERE ...

过程

  1. 事务A启动,生成 ReadView。此时表里有2行数据。
  2. 事务B插入第3行,提交。
  3. 事务A再次 Select。
    • 虽然物理上表里有3行,但第3行的 trx_id 是事务B的(比事务A大,属于"未来")。
    • 根据 ReadView 规则,事务A看不见第3行。
    • 事务A依然只读到2行。
      结论MVCC 在"快照读"层面完美解决了幻读。
情况二:当前读 (Current Read) ------ 靠 Next-Key Lock 解决

场景SELECT ... FOR UPDATEUPDATE / DELETE

为什么 MVCC 不管用了?

因为当你执行 UPDATEFOR UPDATE 时,你必须基于数据的最新版本进行操作(你不能修改历史快照吧?)。既然要读最新版,MVCC 的"看旧版本"逻辑就失效了。

过程(如果没锁)

  1. 事务A:SELECT * FROM user WHERE age > 10 FOR UPDATE; (查到2人)
  2. 事务B:INSERT INTO user (name, age) VALUES ('Jack', 20);
  3. 事务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 在"当前读"层面通过"堵住插入"解决了幻读。


总结串联

  1. 事务 保证了我们要么全赢要么全输,还要隔离互斥。
  2. MVCC 是 InnoDB 的读写不冲突神器。
    • 它利用 Undo Log (版本链)ReadView (规则) ,让你在 RR隔离级别 下,普通的 SELECT 永远看到的是事务启动时的一致性快照。
  1. 幻读 是并发下的"见鬼"现象(多了新行)。
    • 对于普通查询:MVCC 让你根本"看不见"新插入的鬼(读旧版本)。
    • 对于修改/加锁查询:间隙锁(Gap Lock)直接把门堵死,不让鬼进来(阻止插入)。

这就是为什么我们说 MySQL 的 RR 级别很大程度上解决了幻读问题(虽然理论上标准 RR 级别是不解决幻读的,但 MySQL 做到了)。既然您觉得之前的解释不够详细,那我们这次来一次深度拆解 。我们将深入到 InnoDB 引擎的内核实现层面 ,结合B+树索引结构加锁规则(Locking Rules) 以及 复杂的并发时序图来剖析。

锁全局视角的底层逻辑

在深入具体锁之前,必须先建立两个核心认知:

  1. 锁是加在索引(Index)上的,而不是数据行上的。
    • 如果查询没有走索引,InnoDB 会进行全表扫描,此时会锁住所有记录(虽然在 Server 层过滤后可能会释放,但在引擎层确实都锁过)。
  1. 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写锁
    • 互斥:读写互斥,写写互斥。
    • 灾难场景
      1. 事务A正在查询大表(耗时久,持有 MDL读锁)。
      2. DBA 想给表加个字段(DDL,申请 MDL写锁,被阻塞)。
      3. 后续所有 进来的查询(DML,申请 MDL读锁),发现队列里有一个写锁在等待,为了公平,它们全部被阻塞
      4. 结果:数据库连接池瞬间打满,服务挂掉。
    • 解决 :在 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=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 不退化
  • 最终锁范围
    • Record Lock: id=10
    • Next-Key Lock: (10, 15]
    • 后果 :虽然你只查了 id < 11,但是 id=15 这一行也被锁住了!别人想 update id=15 会被阻塞。
场景四:插入意向锁与死锁(Insert Intention)

背景:插入意向锁是一种特殊的 Gap Lock,表示"我想插进去,但我很有礼貌,我在排队"。

流程

  1. 事务AUPDATE t SET d=1 WHERE c=5;
    • 持有 c 索引上的 Gap Lock (0, 10) (简化描述)。
  1. 事务BINSERT INTO t VALUES(6, 6, 6);
    • 事务B 试图在 c=6 处插入。
    • 检测到 (0, 10) 之间有事务A的 Gap Lock。
    • 事务B 生成一个 插入意向锁,进入阻塞状态 (Wait)。

死锁高发场景

  1. 事务ASELECT * FROM t WHERE c=5 FOR UPDATE; (持有 Gap Lock 0~10)
  2. 事务BSELECT * FROM t WHERE c=5 FOR UPDATE;
    • 关键点Gap Lock 之间是不冲突的! 事务B 也成功获取 Gap Lock 0~10。
  1. 事务AINSERT INTO t VALUES(2, 2, 2);
    • 事务A 试图插入 c=2。发现事务B持有 Gap Lock,被阻塞。
  1. 事务BINSERT INTO t VALUES(3, 3, 3);
    • 事务B 试图插入 c=3。发现事务A持有 Gap Lock,被阻塞。
  1. 结果 :互相等待 -> Deadlock

MVCC 与 锁的爱恨情仇

MVCC(多版本并发控制)是 InnoDB 不加锁也能读到数据的核心。

1. 两种读模式
  • 快照读 (Snapshot Read)
    • SQL: SELECT * FROM t WHERE id=1; (无后缀)
    • 机制 :读取 Undo Log 中的历史版本。
    • ReadView
      • RR 级别,事务启动时生成一次 ReadView,之后都用这个,实现"可重复读"。
      • RC (Read Committed) 级别,每次查询都重新生成 ReadView。
  • 当前读 (Current Read)
    • SQL: SELECT ... FOR UPDATE, UPDATE, INSERT...
    • 机制 :必须读最新版本,并且加锁(Next-Key/Gap/Record)。
    • 悖论:在 RR 级别下,快照读看不到别人的更新(因为看的是旧版本),但当前读能看到(因为要读最新并加锁)。
    • 示例
      • A 开启事务。
      • B 开启事务,Update id=1, Commit。
      • A Select id=1 -> 看到旧值(MVCC生效)。
      • A Select id=1 For Update -> 看到新值(当前读,强行读最新)。

总结 - 对照您的全景图

  1. 悲观锁/乐观锁
    • MySQL 原生的锁(S/X)都是悲观锁
    • 乐观锁 通常是业务层加个 version 字段实现的,MySQL 本身没有"乐观锁"这个实体对象。
  1. 幻读 (Phantom Read)
    • 指在同一个事务中,先后两次查询,第二次看到了第一次没看到的新插入的行。
    • 解决:MVCC 解决了快照读的幻读;Next-Key Lock 解决了当前读的幻读。
  1. 锁的选择
    • 能用主键更新最好,锁粒度最小(Record Lock)。
    • 用普通索引更新,会加 Gap Lock,容易误伤并发。
    • 不带索引更新,全表加锁,并发直接归零。
相关推荐
大数据在线2 小时前
技术的终极善意:抹平集中式和分布式边界
数据库·信创·pingcap·国产数据库·平凯数据库
Henry Zhu1232 小时前
数据库(三):关系代数
数据库
历程里程碑2 小时前
Linux 16 环境变量
linux·运维·服务器·开发语言·数据库·c++·笔记
流㶡2 小时前
mysql学习笔记之创建表、导入导出数据
数据库·mysql
LateFrames2 小时前
“蚯蚓涌动” 的屏保: DirectX 12 + ComputeSharp + Win32
windows·ui·gpu算力
cyforkk2 小时前
15、Java 基础硬核复习:File类与IO流的核心逻辑与面试考点
java·开发语言·面试
Monkey的自我迭代2 小时前
实战项目数据桥agent复盘
数据库·python·oracle
李少兄2 小时前
解决 org.springframework.context.annotation.ConflictingBeanDefinitionException 报错
java·spring boot·mybatis
大飞哥~BigFei2 小时前
整数ID与短字符串互转思路及开源实现分享
java·开源