MySQL 锁:从全局到行,一文读懂所有锁的奥秘

目录

导语:不再“懵圈”!MySQL 锁的保姆级详解

第一章:为什么需要锁?—— 并发访问的挑战

第二章:最粗犷的“交通管制”—— 全局锁 (Global Lock)

2.1 什么是全局锁?

2.2 全局锁的典型用途:逻辑备份

2.3 如何施加全局锁?(SQL 示例)

2.4 全局锁的优化:InnoDB 存储引擎的魔法

第三章:缩小范围的“交通管制”—— 表级锁 (Table Lock)

3.1 什么是表级锁?

3.2 表级锁的分类与施加方式

3.3 释放表锁:UNLOCK TABLES

3.4 隐式表锁:DDL 操作与 MyISAM 引擎

第四章:理解锁的基本性质—— 共享锁 (S) 与 排他锁 (X)

4.1 什么是共享锁 (Shared Lock / S Lock)?

4.2 什么是排他锁 (Exclusive Lock / X Lock)?

4.3 锁的兼容性矩阵 (Compatibility Matrix)

4.4 共享锁与排他锁在表级锁中的体现

第五章:保护“结构”的锁—— 元数据锁 (Metadata Lock, MDL)

5.1 什么是元数据锁 (MDL)?

5.2 MDL 的作用机制

5.3 结合 cycling_teams 表的 SQL 示例与 MDL 阻塞

5.4 如何查看和解决 MDL 阻塞

第六章:行锁的“前置声明”—— 意向锁 (Intention Locks)

6.1 为什么需要意向锁?

6.2 意向锁的类型

6.3 意向锁的兼容性矩阵

6.4 结合 cycling_teams 表的 SQL 示例:意向锁的生效

第七章:精细化的“交通管制”—— 行级锁 (Row-Level Locks)

7.1 什么是行级锁?

7.2 行级锁的分类:记录锁、间隙锁与临界区锁

7.2.1 记录锁 (Record Lock) / 行锁

7.2.2 间隙锁 (Gap Lock)

7.2.3 临界区锁 (Next-Key Lock)

7.2.4 插入意向锁 (Insert Intention Lock)

7.3 死锁 (Deadlock):行级锁的“副作用”与 InnoDB 的处理

7.4 乐观锁与悲观锁:不同的并发控制哲学

第八章:总结与展望:MySQL 锁的艺术


 导语:不再“懵圈”!MySQL 锁的保姆级详解

在学习 MySQL 数据库时,是不是经常被各种“锁”搞得一头雾水?全局锁、表锁、行锁、意向锁、共享锁、排他锁、间隙锁、元数据锁……光是这些名词就足以让人望而却步,更别提理解它们各自的作用、适用场景以及它们之间错综复杂的关系了。

我完全理解这种“懵圈”的感觉!正是为了解决大家的这个痛点,我决定写下这篇博客。我的目标只有一个:用最清晰的语言、最直观的案例,彻底解密 MySQL 中的所有锁机制,让你一文读懂锁的奥秘,从此告别混淆!

我们将从最粗粒度的锁开始,逐步深入到最精细的行级锁,并结合实际的 SQL 操作,手把手地演示每种锁的原理和影响。

为了更好地演示,我们将贯穿使用下面这张简单的“车队表”:

-- 车队表:cycling_teams
CREATE TABLE cycling_teams (
    team_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '车队ID',
    team_name VARCHAR(50) NOT NULL COMMENT '车队名',
    country VARCHAR(50) NOT NULL COMMENT '所属国家',
    bike_brand VARCHAR(50) NOT NULL COMMENT '使用自行车品牌',
    contact_number VARCHAR(20) COMMENT '联系电话'
);

-- 插入一些初始数据,方便后续演示
INSERT INTO cycling_teams (team_id, team_name, country, bike_brand, contact_number) VALUES
(101, 'Team Sky', 'UK', 'Pinarello', '07700101010'),
(102, 'Jumbo-Visma', 'Netherlands', 'Cervélo', '07700102020'),
(103, 'Quick-Step Alpha Vinyl', 'Belgium', 'Specialized', '07700103030'),
(104, 'Trek-Segafredo', 'USA', 'Trek', '07700104040'),
(105, 'Bora-Hansgrohe', 'Germany', 'Specialized', '07700105050');

第一章:为什么需要锁?—— 并发访问的挑战

在深入各种具体的锁之前,我们首先要理解一个核心问题:为什么数据库需要锁?

想象一下,如果你的 cycling_teams 数据库同时有几百甚至几千个用户在访问:

  • 用户 A 正在修改 'Team Sky' 的联系电话。
  • 用户 B 同时想删除 'Jumbo-Visma' 这支车队。
  • 用户 C 正在统计所有车队的平均年龄(虽然我们表里没有年龄,但可以想象)。

如果没有锁,会发生什么?

  1. 数据不一致 (Consistency Issues): 如果用户 A 和用户 B 同时修改同一条记录,那么最终这条记录会变成什么样?是用户 A 的修改生效,还是用户 B 的修改生效?或者更糟糕,数据变得混乱不堪,既不是 A 的也不是 B 的。这就是并发修改带来的问题。
  2. 幻读 (Phantom Read): 用户 C 在统计车队数量,第一次查询得到 100 支。在他第二次查询之间,用户 D 插入了 5 支新车队。用户 C 第二次查询得到 105 支。对于用户 C 来说,凭空多出了 5 条记录,就像“幻影”一样。虽然不是数据错误,但在某些需要一致性视图的业务场景下,这可能导致逻辑错误。
  3. 脏读 (Dirty Read): 用户 A 开始一个事务修改了某条数据,但还未提交。用户 B 此时读取了用户 A 未提交的数据。如果用户 A 最终回滚了事务,那么用户 B 读取到的数据就是“脏”数据,因为它从未真正存在于数据库中。
  4. 不可重复读 (Non-Repeatable Read): 用户 A 在一个事务内两次读取同一条记录。在两次读取之间,用户 B 修改并提交了这条记录。那么用户 A 两次读取到的结果就不一样。

为了解决这些并发带来的问题,保证数据在并发访问下的一致性 (Consistency)隔离性 (Isolation)持久性 (Durability),数据库引入了锁 (Locks) 机制。锁就像是交通信号灯,协调着数据库中各个操作的顺序,确保数据在多方操作下依然井然有序。

理解了锁的必要性,我们就可以开始探索 MySQL 提供了哪些“信号灯”了。


第二章:最粗犷的“交通管制”—— 全局锁 (Global Lock)

我们首先从最粗粒度的锁开始讲解。全局锁,顾名思义,是针对整个数据库实例的锁。它就像是给整个 MySQL 服务器按下了“暂停键”,让它进入一个高度受限的状态。

2.1 什么是全局锁?

全局锁是 MySQL 数据库中最粗粒度的锁,它会锁定整个 MySQL 实例。一旦加上全局锁,整个数据库就处于只读状态,所有的 DDL (数据定义语言) 和 DML (数据操作语言) 操作(除了少数例外)都将被阻塞,直到全局锁被释放。

特点:

  • 粒度最大: 锁住整个数据库实例。
  • 并发度最低: 几乎所有操作都被阻塞,严重影响业务运行。
  • 开销小: 锁的开销本身很小,因为不需要管理细粒度的锁定对象。
2.2 全局锁的典型用途:逻辑备份

全局锁最典型的使用场景是进行逻辑备份

想象一下,你正在使用 mysqldump 工具备份你的 cycling_teams 数据库。如果在备份过程中,有用户插入了新的车队,或者修改了某个车队的联系方式,那么备份出来的数据就可能包含不一致的信息,甚至丢失一部分数据。

为了确保备份出来的数据是一致的,也就是一个特定时间点的数据快照,我们需要在备份期间阻止所有的数据修改操作。全局锁就是为此而生。它能保证在备份过程中,数据库中的所有数据都是静止的,从而得到一个可靠的、一致的备份。

2.3 如何施加全局锁?(SQL 示例)

MySQL 主要通过以下两种方式实现全局锁:

方式一:FLUSH TABLES WITH READ LOCK (FTWRL) (推荐)

这是 MySQL 官方推荐的全局锁方式,尤其适用于逻辑备份。

-- 会话 A:施加全局读锁
FLUSH TABLES WITH READ LOCK;
-- 执行这条命令后,整个 MySQL 实例进入只读状态。
-- 所有的写操作(INSERT, UPDATE, DELETE, DDL等)都会被阻塞。

演示效果:

在会话 A 执行 FLUSH TABLES WITH READ LOCK; 后:

  • 会话 B (尝试 DML 操作):

    -- 会话 B:尝试插入新车队
    INSERT INTO cycling_teams (team_name, country, bike_brand) VALUES ('New Team', 'France', 'Pinarello');
    -- 结果:会被阻塞,等待会话 A 释放全局锁。
    
    -- 会话 B:尝试更新车队信息
    UPDATE cycling_teams SET contact_number = '99999' WHERE team_id = 101;
    -- 结果:同样会被阻塞。
    
  • 会话 C (尝试 DDL 操作):

    -- 会话 C:尝试修改表结构
    ALTER TABLE cycling_teams ADD COLUMN established_year INT COMMENT '成立年份';
    -- 结果:也会被阻塞。
    
  • 会话 D (尝试 SELECT 操作):

    -- 会话 D:尝试查询车队信息
    SELECT * FROM cycling_teams WHERE team_id = 101;
    -- 结果:可以正常执行,因为 FTWRL 是读锁,允许并发读。
    

释放全局锁:

全局锁的释放方式有两种:

  1. 主动释放: 会话 A 执行 UNLOCK TABLES; 命令。 

    -- 会话 A:释放全局读锁
    UNLOCK TABLES;
    -- 释放后,之前被阻塞的写操作会恢复执行。
    
  2. 会话断开: 会话 A 断开与 MySQL 的连接,全局锁也会自动释放。

方式二:SET GLOBAL READ ONLY = TRUE (不推荐用于备份)

这种方式也可以让整个数据库变为只读状态,达到全局锁的效果。

-- 会话 A:将全局只读变量设置为 TRUE
SET GLOBAL READ ONLY = TRUE;
-- 此时,所有非 SUPER 权限的用户都无法进行写操作。
-- 拥有 SUPER 权限的用户仍然可以写操作。

缺点:

  • 副作用更大: 这种设置会持续生效,直到你手动将其设置为 FALSE,或者 MySQL 实例重启。在业务运行中直接设置,可能对业务造成严重影响。
  • SUPER 权限例外: 拥有 SUPER 权限的用户仍然可以进行写操作,这意味着无法保证备份期间的完全数据一致性(如果 SUPER 用户还在操作的话)。这使得它不适合作为精确数据备份的手段。

为什么 FTWRL 更推荐用于备份?

  • FTWRL 确保了所有写操作都被阻塞,包括拥有 SUPER 权限的用户,从而获得一个真正的全量一致性快照。
  • 它是一种临时的、会话级别的锁,会话断开即释放,对系统影响可控。
2.4 全局锁的优化:InnoDB 存储引擎的魔法

看到这里你可能会想,为了备份,让整个数据库停止写操作,这在生产环境中简直是噩梦!尤其对于 24 小时运行的在线系统,业务是无法忍受长时间停顿的。

好在,对于 MySQL 中最常用的 InnoDB 存储引擎,我们有更“温柔”的备份方式,可以在不加全局锁的情况下,实现数据一致性备份:

使用 mysqldump 工具的 --single-transaction 参数

mysqldump --single-transaction -h[host] -u[user] -p[password] [database_name] > backup.sql

mysqldump 工具带上 --single-transaction 参数时:

  • 它只对 InnoDB 存储引擎的表生效。
  • 它会在备份开始时启动一个一致性读事务 (consistent read)
  • 由于 InnoDB 支持 MVCC (多版本并发控制) 机制,在这个事务中,即使其他事务修改了数据并提交,当前备份事务仍然可以看到事务开始时的数据版本。这就好比你在一个特定时间点拍了一张照片,之后无论外界如何变化,这张照片的内容是固定的。
  • 所以,mysqldump --single-transaction 实际上不需要加全局锁,就能获得一个一致性的快照。备份过程中,其他会话仍然可以正常进行读写操作,对业务的影响降到最低。

思考: 为什么 --single-transaction 只对 InnoDB 有效?因为它依赖于 InnoDB 的 MVCC 特性。对于 MyISAM 这种不支持事务和 MVCC 的存储引擎,如果需要一致性备份,你仍然需要使用 FLUSH TABLES WITH READ LOCK

本章小结:

全局锁是 MySQL 中最粗粒度的锁,主要通过 FLUSH TABLES WITH READ LOCK 实现,用于保证全库逻辑备份时的数据一致性。但它会阻塞所有写操作,对业务影响大。对于 InnoDB 存储引擎,推荐使用 mysqldump --single-transaction 来替代全局锁,实现无阻塞的逻辑备份。


第三章:缩小范围的“交通管制”—— 表级锁 (Table Lock)

了解了全局锁这个“一刀切”的方案后,我们来看看粒度小一点的锁:表级锁 (Table Lock)。表锁是针对单个表的锁定,它会锁定整个数据表。

3.1 什么是表级锁?

表级锁是作用于整个表级别的锁,它锁住的是整个数据表而不是某一行或某几行数据。

特点:

  • 粒度适中: 比全局锁细,比行级锁粗。
  • 开销小: 对表进行加锁和解锁的开销非常小,因为每次锁定的对象是整个表,不需要遍历行或维护复杂的锁结构。
  • 并发度低: 由于锁定的是整个表,当一个会话持有表锁时,其他会话对该表的特定操作会受到很大限制,导致并发度较低。
  • 通常不会出现死锁: 因为每次操作只会锁住一个表,一个事务或者连接拿到锁之后,就会独占整个表(或特定访问类型),不会出现多个事务互相等待资源的情况(除非涉及到多个表的加锁顺序问题,我们后面会再提)。
3.2 表级锁的分类与施加方式

MySQL 的表锁主要分为两种:表读锁表写锁,它们可以通过 LOCK TABLES 语句显式地施加。

类型一:表读锁 (Table Read Lock)

表读锁也称为共享表锁。当一个会话对表加了读锁后,它的目的是告诉其他会话:“这个表我可以读,你们也可以读,但都不能写!”

  • 加锁方式: LOCK TABLES table_name READ;
  • 特性:
    • 当前会话:可以读 (SELECT) 该表。
    • 其他会话:可以读 (SELECT) 该表(因为读锁是共享的)。
    • 当前会话不能写 (INSERT/UPDATE/DELETE) 该表,会报错或等待。
    • 其他会话不能写 (INSERT/UPDATE/DELETE) 该表,会被阻塞直到读锁释放。
  • 典型用途: 确保在某些批处理查询期间,表的数据不被修改。但实际中,由于 InnoDB 的 MVCC 机制,在大多数情况下,你无需手动加表读锁来保证读一致性。

示例:LOCK TABLES cycling_teams READ;

  • 会话 A:

    -- 会话 A:对 cycling_teams 表施加表读锁
    LOCK TABLES cycling_teams READ;
    -- 成功加锁。现在会话 A 和其他会话都可以读。
    
  • 会话 A (继续操作):

    -- 会话 A:尝试读取
    SELECT * FROM cycling_teams WHERE team_id = 101; -- 成功
    
    -- 会话 A:尝试写入
    UPDATE cycling_teams SET contact_number = '99999' WHERE team_id = 101;
    -- 结果:报错! ERROR 1099 (HY000): Table 'cycling_teams' was locked with a READ lock and can't be updated
    
  • 会话 B (同时操作):

     
    -- 会话 B:尝试读取
    SELECT * FROM cycling_teams WHERE team_id = 102; -- 成功
    
    -- 会话 B:尝试写入
    INSERT INTO cycling_teams (team_name, country, bike_brand) VALUES ('New Team R', 'Italy', 'Cannondale');
    -- 结果:会被阻塞,等待会话 A 释放读锁。
    

类型二:表写锁 (Table Write Lock)

表写锁也称为排他表锁。当一个会话对表加了写锁后,它的目的是告诉其他会话:“这个表现在我独占了,你们谁都不能动,无论读还是写,都得等我!”

  • 加锁方式: LOCK TABLES table_name WRITE;
  • 特性:
    • 当前会话:可以读和写 (SELECT/INSERT/UPDATE/DELETE) 该表。
    • 其他会话不能读 (SELECT)不能写 (INSERT/UPDATE/DELETE) 该表,都会被阻塞直到写锁释放。
    • 写锁是独占锁
  • 典型用途: 当你需要对某个表进行修改,并且希望在修改过程中,其他任何会话都不能访问该表(无论是读还是写),以确保数据的一致性。例如,在 MyISAM 存储引擎中进行批量数据修改时。

示例:LOCK TABLES cycling_teams WRITE;

  • 会话 A:

    -- 会话 A:对 cycling_teams 表施加表写锁
    LOCK TABLES cycling_teams WRITE;
    -- 成功加锁。现在会话 A 独占该表。
    
  • 会话 A (继续操作):

     
    -- 会话 A:尝试写入
    UPDATE cycling_teams SET contact_number = '88888' WHERE team_id = 101; -- 成功
    
    -- 会话 A:尝试读取
    SELECT * FROM cycling_teams WHERE team_id = 101; -- 成功
    
  • 会话 B (同时操作):

    -- 会话 B:尝试读取
    SELECT * FROM cycling_teams WHERE team_id = 102;
    -- 结果:会被阻塞,等待会话 A 释放写锁。
    
    -- 会话 B:尝试写入
    INSERT INTO cycling_teams (team_name, country, bike_brand) VALUES ('New Team W', 'Germany', 'Cube');
    -- 结果:同样会被阻塞。
    
3.3 释放表锁:UNLOCK TABLES

无论加的是读锁还是写锁,都通过 UNLOCK TABLES; 命令来释放。当会话断开连接时,所有会话持有的表锁也会自动释放。

-- 会话 A:释放表锁
UNLOCK TABLES;
-- 释放后,之前被阻塞的操作会恢复执行。

重要注意事项:

  • 隐式提交事务: LOCK TABLES 语句会自动提交当前会话中未提交的事务。这意味着如果你在一个事务中执行了 DML 操作,然后又执行 LOCK TABLES,那么之前的 DML 操作会立即生效。
  • 会话限制: 一旦会话持有表锁,它就不能访问未被锁定的表。这是因为 LOCK TABLES 语句实现了一个“两阶段锁定”的原则:要么一次性获取所有需要的锁,要么在锁释放之前不能访问其他资源。
3.4 隐式表锁:DDL 操作与 MyISAM 引擎                   

除了通过 LOCK TABLES 语句显式加锁外,MySQL 在某些情况下也会自动(隐式地)加表锁: 

  1. DDL (数据定义语言) 操作:

    大多数 DDL 操作(如 ALTER TABLE, DROP TABLE, CREATE INDEX 等)都会对表加隐式的表级锁。这是为了保证在这些结构性操作进行时,表的结构不被其他操作破坏。

    • 例如,ALTER TABLE 操作通常会加一个排他表锁,防止其他会话在表结构变化期间进行读写。
    • 思考: 在这里,我们其实已经接触到了“共享锁”和“排他锁”的概念。DDL 操作可能加共享表锁(允许并发读但阻止写)或排他表锁(阻止所有读写)。我们将在下一章详细解释“共享”和“排他”的通用概念。
  2. MyISAM 存储引擎:

    MyISAM 存储引擎是 MySQL 早期默认的存储引擎,它的并发控制机制主要就是依靠表级锁。

    • 当对 MyISAM 表进行 SELECT 操作时,MySQL 会自动对该表加共享读锁
    • 当对 MyISAM 表进行 INSERT/UPDATE/DELETE 操作时,MySQL 会自动对该表加排他写锁。 这解释了为什么 MyISAM 在高并发读写混合的场景下性能会比较差,因为写操作会阻塞所有的读和写。这也是为什么在现代应用中,我们更倾向于使用 InnoDB 存储引擎的原因。

本章小结:

表级锁是对整个表进行锁定,分为读锁(共享)和写锁(排他)。它们通过 LOCK TABLES 语句显式施加,并通过 UNLOCK TABLES 释放。虽然开销小,但并发度低,在生产环境中应谨慎使用。此外,DDL 操作和 MyISAM 存储引擎也会隐式地使用表级锁。


第四章:理解锁的基本性质—— 共享锁 (S) 与 排他锁 (X)

在前面的章节中,我们已经多次提到了“共享”和“排他”这两个词。它们是所有锁(无论是表锁、行锁,还是后面将要讲到的其他锁)最基本的两种模式,理解它们是理解整个锁机制的关键。

4.1 什么是共享锁 (Shared Lock / S Lock)?

共享锁,顾名思义,是“共享”的。它的核心特性是:

  • 互不阻塞读操作: 多个事务可以同时获得同一个资源的共享锁。这就像图书馆里一本书,可以同时被多个人阅读(但不能同时修改)。
  • 阻塞写操作: 任何事务一旦持有资源的共享锁,其他事务就不能再获取该资源的排他锁(即修改该资源),直到所有共享锁都被释放。

特点:

  • 读读兼容
  • 读写互斥
4.2 什么是排他锁 (Exclusive Lock / X Lock)?

排他锁,是“独占”的。它的核心特性是:

  • 阻塞所有其他操作: 任何事务一旦持有资源的排他锁,其他事务就不能再获取该资源的任何锁(无论是共享锁还是排他锁),直到排他锁被释放。这就像图书馆里一本书被一个人借走了,其他人既不能读也不能借。
  • 独占性: 在一个时间点,一个资源只能被一个事务持有排他锁。

特点:

  • 写写互斥
  • 读写互斥
4.3 锁的兼容性矩阵 (Compatibility Matrix)

为了更直观地理解 S 锁和 X 锁的关系,我们可以用一个兼容性矩阵来表示。矩阵中的“√”表示兼容(可以共存),“×”表示不兼容(会阻塞)。

持有者 / 请求者 S 锁 X 锁
S 锁 ×
X 锁 × ×

解释:

  • 事务 A 持有 S 锁,事务 B 请求 S 锁: (兼容)。两个事务都可以读。
  • 事务 A 持有 S 锁,事务 B 请求 X 锁: × (不兼容)。事务 A 正在读,事务 B 不能写。事务 B 会被阻塞。
  • 事务 A 持有 X 锁,事务 B 请求 S 锁: × (不兼容)。事务 A 正在独占资源,事务 B 不能读。事务 B 会被阻塞。
  • 事务 A 持有 X 锁,事务 B 请求 X 锁: × (不兼容)。事务 A 正在独占资源,事务 B 不能独占。事务 B 会被阻塞。
4.4 共享锁与排他锁在表级锁中的体现

我们回顾一下第三章中表锁的例子,现在用 S/X 锁的视角来理解:

  • 表读锁 (Table Read Lock):本质上就是对整个表施加了一个 S 锁 (共享锁)
    • 多个会话可以同时获得表的 S 锁,并发读取。
    • 但任何会话都不能获取表的 X 锁(即不能写入)。
  • 表写锁 (Table Write Lock):本质上就是对整个表施加了一个 X 锁 (排他锁)
    • 只有一个会话可以获得表的 X 锁,它独占该表。
    • 其他会话不能再获取表的 S 锁(不能读取)或 X 锁(不能写入)。

示例:

  • 会话 A:加表读锁 (S)

    -- 会话 A
    LOCK TABLES cycling_teams READ; -- 相当于对整个表加 S 锁
    
  • 会话 B:尝试加表写锁 (X)

     
    -- 会话 B
    LOCK TABLES cycling_teams WRITE; -- 尝试对整个表加 X 锁
    -- 结果:阻塞,因为 S 锁和 X 锁不兼容。
    
  • 会话 C:加表写锁 (X)

    -- 会话 C
    LOCK TABLES cycling_teams WRITE; -- 相当于对整个表加 X 锁
    
  • 会话 D:尝试加表读锁 (S)

    -- 会话 D
    LOCK TABLES cycling_teams READ; -- 尝试对整个表加 S 锁
    -- 结果:阻塞,因为 X 锁和 S 锁不兼容。
    

本章小结:

共享锁 (S Lock) 和排他锁 (X Lock) 是数据库锁最基本的两种模式。S 锁允许并发读,但阻止写;X 锁阻止一切并发操作,实现独占。理解它们是理解所有后续锁的基础。表级锁的读写锁就是 S/X 锁在表层面的应用。


第五章:保护“结构”的锁—— 元数据锁 (Metadata Lock, MDL)

在了解了 S/X 锁的基本概念后,我们来重新审视一个在之前的全局锁和表锁中偶尔提过,但对并发影响非常大的锁:元数据锁 (Metadata Lock, MDL)。MDL 并不是用来保护数据本身的,而是用来保护数据库对象的元数据(Metadata),也就是它们的结构信息。

5.1 什么是元数据锁 (MDL)?

元数据锁 (MDL) 是 MySQL 内部自动管理的一种锁,它用于保护数据库对象的元数据(如表结构、存储过程、函数等)的一致性。MDL 确保了在对数据库对象进行 DDL (数据定义语言) 操作时,相关的元数据不被其他会话修改或访问,从而避免数据字典的不一致性。

特点:

  • 自动管理: 用户无法手动施加或释放 MDL 锁,它由 MySQL 服务器自动根据操作类型管理。
  • 保护元数据: 而不是保护数据本身。
  • 防止 DDL 和 DML 冲突: 确保 DDL 操作在稳定的元数据环境下进行,同时避免 DDL 与正在进行的 DML 操作冲突。
  • 粒度: 通常是表级或更粗粒度(例如数据库级)。
5.2 MDL 的作用机制

MDL 就像一个“结构维护员”,它会在你需要访问或修改数据库对象的结构时自动出现。

  1. DML 操作中的 MDL (共享 MDL):

    当你执行 SELECT、INSERT、UPDATE、DELETE 等 DML 操作时,MySQL 会自动为涉及的表获取一个共享 MDL 锁。

    • 这个共享 MDL 锁允许其他会话也获得共享 MDL 锁(即并发地对该表进行 DML 操作)。
    • 但它会阻止任何需要排他 MDL 锁的 DDL 操作(如 ALTER TABLEDROP TABLE 等)对该表进行操作。
    • 关键点: 对于事务性存储引擎(如 InnoDB),这个共享 MDL 锁会持续到整个事务提交或回滚后才释放。这就是 MDL 最容易引发问题的关键!
  2. DDL 操作中的 MDL (排他 MDL):

    当你执行 ALTER TABLE、DROP TABLE、CREATE INDEX 等 DDL 语句时,MySQL 会尝试获取涉及表的排他 MDL 锁。

    • 这个排他 MDL 锁是独占的,它会阻止其他会话对该表执行任何 DDL 或 DML 操作,直到排他 MDL 锁释放。
    • 如果表上已经有其他会话持有的共享 MDL 锁(例如,有未提交的长事务),那么 DDL 语句就会被阻塞,长时间等待排他 MDL 锁。
5.3 结合 cycling_teams 表的 SQL 示例与 MDL 阻塞

我们来演示 MDL 阻塞的经典场景,这也是生产环境中常见的“坑”:

场景:长事务导致 DDL 阻塞,进而 DML 也被阻塞

  • 前提: 我们的 cycling_teams 表已经有一些数据。

  • 会话 A (模拟长事务):

    -- 会话 A:开始一个事务,并查询 cycling_teams 表
    START TRANSACTION;
    SELECT team_name, country FROM cycling_teams WHERE team_id = 101;
    -- 在此期间,会话 A 持有 cycling_teams 表的共享 MDL 锁。
    -- 假设这个事务因为某些业务逻辑或网络延迟,长时间未提交。
    -- 此时,会话 A 只是读取,所以对其他读取操作没有影响。
    
  • 会话 B (尝试 DDL 操作):

    -- 会话 B:尝试给 cycling_teams 表添加一个新列
    ALTER TABLE cycling_teams ADD COLUMN established_year INT COMMENT '成立年份';
    -- 结果:会话 B 的 ALTER TABLE 语句会尝试获取 cycling_teams 表的排他 MDL 锁。
    -- 但由于会话 A 正在持有共享 MDL 锁且事务未提交,排他锁无法获得。
    -- 所以,会话 B 的 ALTER TABLE 语句会被阻塞,状态显示为 `Waiting for metadata lock`。
    
  • 会话 C (尝试 DML 操作):

    -- 会话 C:尝试插入新车队
    INSERT INTO cycling_teams (team_name, country, bike_brand) VALUES ('New Team', 'France', 'Pinarello');
    -- 结果:会话 C 的 INSERT 语句也需要获取 cycling_teams 表的共享 MDL 锁。
    -- 但是,由于会话 B 正在等待排他 MDL 锁(排他 MDL 锁是最高优先级,会阻止所有其他 MDL 锁的获取),
    -- 所以会话 C 的 INSERT 语句也会被阻塞,同样显示为 `Waiting for metadata lock`。
    

这就是一个典型的“DDL 阻塞 DML,而 DDL 又被之前的 DML(或 SELECT)阻塞”的死循环(虽然不是严格意义上的死锁,但表现类似)。它会导致数据库连接大量堆积,最终可能拖垮整个服务。

5.4 如何查看和解决 MDL 阻塞

当你的数据库出现响应缓慢,并且怀疑是 MDL 阻塞时,可以通过以下方式进行排查和解决:

  1. SHOW PROCESSLIST; (快速查看)

    在 MySQL 客户端中执行:

    SHOW PROCESSLIST;
    

    你会看到类似这样的输出:

    Id   User Host   db           Command Time State                  Info
    ---- ---- ------ ------------ ------- ---- ---------------------- ----------------------------------------------------
    101  root localhost test         Sleep   60   NULL                   NULL  -- 对应会话 A
    102  root localhost test         Query   20   Waiting for metadata lock ALTER TABLE cycling_teams ADD COLUMN established_year INT -- 对应会话 B
    103  root localhost test         Query   15   Waiting for metadata lock INSERT INTO cycling_teams (...) -- 对应会话 C
    

    这里,State 列的 Waiting for metadata lock 是关键信息。Id 101 可能是那个持有锁的“罪魁祸首”(如果它在 Sleep 状态,说明事务未提交但暂时没有操作)。

  2. performance_schema.metadata_locks (详细信息)

    SELECT
        OBJECT_TYPE,
        OBJECT_SCHEMA,
        OBJECT_NAME,
        LOCK_TYPE,
        LOCK_DURATION,
        LOCK_STATUS,
        SOURCE,
        OWNER_THREAD_ID,
        OWNER_EVENT_ID
    FROM
        performance_schema.metadata_locks
    WHERE
        OBJECT_NAME = 'cycling_teams';
    

    这条查询会显示当前所有与 cycling_teams 表相关的 MDL 锁信息,包括锁的类型 (LOCK_TYPE,如 SHARED_READ 共享读锁,SHARED_EXCLUSIVE 共享排他锁,EXCLUSIVE 排他锁等)、锁的状态 (LOCK_STATUSGRANTED 表示已获得,PENDINGWAITING 表示正在等待) 以及持有锁和请求锁的会话 ID (OWNER_THREAD_ID)。

  3. sys.schema_table_lock_waits 视图 (更友好的视图,推荐)

    sys 库提供了一些便利的视图,可以更直观地查看哪些表正在等待 MDL 锁,以及是哪个会话在阻塞它们。

    SELECT
        waiting_pid,           -- 正在等待的会话ID
        waiting_query,         -- 正在等待的查询
        waiting_state,         -- 正在等待的状态
        blocking_pid,          -- 阻塞者的会话ID
        blocking_query,        -- 阻塞者的查询
        blocking_state,        -- 阻塞者的状态
        waiting_schema_name,   -- 正在等待的表所在库
        waiting_table_name,    -- 正在等待的表名
        waiting_lock_type,     -- 正在等待的锁类型
        blocking_lock_type     -- 阻塞者的锁类型
    FROM
        sys.schema_table_lock_waits;
    

    通过这个视图,你可以迅速定位到阻塞会话 (blocking_pid)。

解决 MDL 阻塞:

一旦定位到阻塞者(通常是那个长时间未提交的事务所在的会话),最直接的解决办法就是:

-- 假设阻塞会话的 ID 是 101
KILL 101;

KILL 命令会强制终止该会话,并回滚其未提交的事务,从而释放它持有的 MDL 锁。被阻塞的 DDL 和后续 DML 操作就能继续执行了。

预防 MDL 阻塞的策略:

  • 避免长事务: 尤其是在线业务,尽量缩短事务持续时间,避免长时间不提交。
  • 合理安排 DDL: 在业务低峰期执行 DDL 操作。
  • 使用在线 DDL 工具: 对于大表,可以使用 Percona Toolkit 的 pt-osc 或 Gh-ost 等工具。它们通过创建新表、同步数据、重命名等方式模拟在线 DDL,从而避免长时间持有 MDL 锁阻塞业务。
  • 利用 MySQL 8.0+ 的在线 DDL 特性: MySQL 8.0 之后,对许多 ALTER TABLE 操作支持 ALGORITHM=INSTANTALGORITHM=INPLACE,可以显著减少 MDL 锁的持有时间或影响范围。

本章小结:

元数据锁 (MDL) 是 MySQL 内部用于保护数据字典(表结构)一致性的重要机制。它由 MySQL 自动管理,在 DML 和 DDL 操作时加锁。长事务是 MDL 阻塞的常见原因,会导致 DDL 和 DML 语句长时间等待。理解 MDL 的工作原理和排查方法,对于数据库的稳定运行至关重要。


第六章:行锁的“前置声明”—— 意向锁 (Intention Locks)

在深入到 MySQL 最精细的行级锁之前,我们还需要理解一个非常重要的概念:意向锁 (Intention Locks)。意向锁是 InnoDB 存储引擎特有的,它本身不直接锁定数据,而是作为一种表级锁,用来指示事务“打算”在表中的某些行上加行级锁。

6.1 为什么需要意向锁?

我们已经知道,MySQL 有表级锁(锁住整个表)和行级锁(锁住特定行)。当一个事务想要对表中的某一行加行级锁时,MySQL 怎么知道这个表上有没有其他事务持有表级锁,或者有没有其他事务也在行上加了锁呢?

如果没有意向锁,当一个事务想要给整个表加表级写锁 (X Lock) 时,它必须扫描整个表,检查每一行是否有被其他事务锁定的行级锁。这显然是非常低效的!

意向锁的作用就是解决这个问题: 它充当了表级锁和行级锁之间的“桥梁”或“前置声明”。当一个事务想要对表中的行加行级锁时,它首先会在表上加一个意向锁,表明它的“意图”。这样,其他事务在尝试对表加表级锁时,只需要检查表上是否存在意向锁,而无需遍历所有行。

核心作用:

  • 提高表级锁的判断效率: 避免了全表扫描来检查行级锁。
  • 协调表锁与行锁的冲突: 意向锁的存在确保了事务在获取行级锁之前,先在表上声明自己的意图,从而让表级锁的判断变得高效。
6.2 意向锁的类型

意向锁分为两种,它们都是表级锁

  1. 意向共享锁 (Intention Shared Lock, IS Lock):

    • 含义: 表示事务打算在表中的某些行上设置行级共享锁 (S Lock)
    • 加锁时机: 当事务执行 SELECT ... FOR SHARE 语句时,会首先在表上加一个 IS 锁。
    • 兼容性: IS 锁与 IS 锁、IX 锁、S 锁(表级共享锁)兼容,但与 X 锁(表级排他锁)不兼容。
  2. 意向排他锁 (Intention Exclusive Lock, IX Lock):

    • 含义: 表示事务打算在表中的某些行上设置行级排他锁 (X Lock)
    • 加锁时机: 当事务执行 INSERTUPDATEDELETESELECT ... FOR UPDATE 语句时,会首先在表上加一个 IX 锁。
    • 兼容性: IX 锁与 IS 锁、IX 锁兼容,但与 S 锁(表级共享锁)、X 锁(表级排他锁)不兼容。
6.3 意向锁的兼容性矩阵

为了更清晰地展示意向锁如何协调不同粒度锁的冲突,我们来扩展一下之前共享锁和排他锁的兼容性矩阵。这个矩阵展示了当一个事务持有某种锁时,另一个事务能否获得某种锁(针对同一个资源,即同一个表):

持有者 / 请求者 IS (意向共享) IX (意向排他) S (表级共享) X (表级排他)
IS ×
IX × ×
S × ×
X × × × ×

理解这个矩阵:

  • 行与行之间的兼容性 (通过意向锁表达):
    • 如果事务 A 打算读某些行 (IS),事务 B 也可以打算读某些行 (IS)。
    • 如果事务 A 打算写某些行 (IX),事务 B 也可以打算读某些行 (IS) 或写某些行 (IX)。这仅仅是意图声明,具体的行级锁会在行层面冲突。
  • 表级锁与意向锁的兼容性:
    • X (表级排他锁) 与任何意向锁都不兼容 (×): 这是因为表级排他锁意味着独占整个表,所以不允许任何事务再声明对表内任何行的操作意图。
    • S (表级共享锁) 只与 IS (意向共享锁) 兼容 (√),不与 IX (意向排他锁) 兼容 (×): 如果表已经被表级共享锁锁定(只能读),那么任何事务都不能再表达修改行的意图(IX),但可以表达读取的意图(IS)。
6.4 结合 cycling_teams 表的 SQL 示例:意向锁的生效

意向锁是 InnoDB 自动管理的,我们无法直接看到“意向锁”这个对象,但我们可以通过它的行为来理解其作用。

示例 1:DML 操作触发意向排他锁 (IX)

  • 会话 A: 启动一个事务,并更新 cycling_teams 表中的一行。

    -- 会话 A
    START TRANSACTION;
    -- 这条 UPDATE 语句会首先在 cycling_teams 表上自动添加一个 IX 锁(意向排他锁),
    -- 然后在 team_id = 101 的记录上添加一个行级排他锁 (X Lock)。
    UPDATE cycling_teams SET contact_number = '1112233' WHERE team_id = 101;
    -- 事务尚未提交。
    
  • 会话 B (同时执行):

    -- 会话 B:尝试给整个 cycling_teams 表加一个表级写锁
    LOCK TABLES cycling_teams WRITE;
    -- 结果:会话 B 的 LOCK TABLES WRITE 语句会被阻塞。
    -- 为什么?因为 LOCK TABLES WRITE 需要获取整个表的表级排他锁 (X Lock)。
    -- 根据意向锁兼容性矩阵,表级 X 锁与 IX 锁是不兼容的。
    -- 会话 B 必须等待会话 A 提交或回滚事务,释放其持有的 IX 锁和行级 X 锁。
    

    思考: 在没有意向锁的情况下,MySQL 要判断 LOCK TABLES WRITE 能否执行,就得遍历 cycling_teams 表的每一行,看有没有行级锁。有了 IX 锁,只需要快速检查表上是否存在 IX 锁即可。

示例 2:SELECT ... FOR SHARE 触发意向共享锁 (IS)

  • 会话 A: 启动事务,并查询一行,并加共享锁。

    -- 会话 A
    START TRANSACTION;
    -- 这条 SELECT ... FOR SHARE 语句会首先在 cycling_teams 表上自动添加一个 IS 锁(意向共享锁),
    -- 然后在 team_id = 102 的记录上添加一个行级共享锁 (S Lock)。
    SELECT team_name FROM cycling_teams WHERE team_id = 102 FOR SHARE;
    -- 事务尚未提交。
    
  • 会话 B (同时执行):

    -- 会话 B:尝试给整个 cycling_teams 表加一个表级读锁
    LOCK TABLES cycling_teams READ;
    -- 结果:会话 B 的 LOCK TABLES READ 语句可以成功执行。
    -- 为什么?因为 LOCK TABLES READ 需要获取整个表的表级共享锁 (S Lock)。
    -- 根据意向锁兼容性矩阵,表级 S 锁与 IS 锁是兼容的。
    -- 这表明,当其他事务在行上加共享锁时(IS 意图),表仍然可以被整体读(S 锁)。
    
  • 会话 C (同时执行):

    -- 会话 C:尝试给整个 cycling_teams 表加一个表级写锁
    LOCK TABLES cycling_teams WRITE;
    -- 结果:会话 C 的 LOCK TABLES WRITE 语句会被阻塞。
    -- 为什么?因为 LOCK TABLES WRITE 需要获取整个表的表级排他锁 (X Lock)。
    -- 根据兼容性矩阵,表级 X 锁与 IS 锁不兼容。
    -- 会话 C 必须等待会话 A 提交或回滚事务,释放其持有的 IS 锁和行级 S 锁。
    

本章小结:

意向锁是 InnoDB 存储引擎内部的表级锁,分为 IS 锁和 IX 锁。它们是事务在对行加共享锁或排他锁时,自动在表上加的“前置声明”。意向锁的核心作用是提高表级锁与行级锁之间兼容性判断的效率,避免了不必要的全表扫描。理解了意向锁,我们就可以更顺利地进入到 MySQL 最精细、也最复杂的行级锁的世界。


第七章:精细化的“交通管制”—— 行级锁 (Row-Level Locks)

现在,我们终于来到了 MySQL 锁机制的核心:行级锁 (Row-Level Locks)。这是 InnoDB 存储引擎能够实现高并发、高性能的关键所在。与全局锁和表锁不同,行级锁只锁定受影响的行,从而最大限度地支持并发处理,尤其是在多用户并发访问同一张表时。

7.1 什么是行级锁?

行级锁是粒度最小的锁,它只锁定数据库表中的一行或一部分行记录。

特点:

  • 粒度最小: 理论上并发度最高,因为只锁定了需要操作的少量数据,其他不相关的数据可以自由访问。
  • 开销最大: 每次操作都需要对特定的行进行加锁和解锁,以及维护复杂的锁数据结构和管理。
  • 容易出现死锁: 由于多个事务可能以不同的顺序尝试锁定不同的行,因此行级锁比表级锁更容易发生死锁。不过 InnoDB 有死锁检测机制来处理这种情况。
  • 只存在于支持事务的存储引擎: 如 InnoDB。MyISAM 不支持行级锁。
7.2 行级锁的分类:记录锁、间隙锁与临界区锁

InnoDB 提供了多种行级锁,以满足不同的并发控制需求和隔离级别。

7.2.1 记录锁 (Record Lock) / 行锁

记录锁,顾名思义,是锁住索引记录本身。它作用于具体的某一行数据。

  • 锁住对象: 锁定的是索引记录。如果表没有定义任何索引,InnoDB 会创建一个隐藏的聚簇索引,并使用它来锁定记录。
  • 锁模式: 记录锁可以是共享锁 (S Lock)排他锁 (X Lock)
    • S Lock (共享记录锁):
      • 加锁时机: 通常由 SELECT ... FOR SHARE 语句加持。
      • 作用: 允许持有 S 锁的事务读取该行,也允许其他事务获取 S 锁读取该行。但阻止任何事务获取 X 锁修改该行。
    • X Lock (排他记录锁):
      • 加锁时机: 通常由 INSERTUPDATEDELETESELECT ... FOR UPDATE 语句加持。
      • 作用: 允许持有 X 锁的事务读取和修改该行。阻止任何其他事务获取 S 锁或 X 锁来读取或修改同一行。

结合 cycling_teams 表的 SQL 示例:记录锁

-- 确保有这些数据,方便演示
INSERT INTO cycling_teams (team_id, team_name, country, bike_brand, contact_number) VALUES
(101, 'Team Sky', 'UK', 'Pinarello', '07700101010'),
(102, 'Jumbo-Visma', 'Netherlands', 'Cervélo', '07700102020');

场景 1:排他记录锁 (X Lock) 的冲突

  • 会话 A: 开启事务,更新 team_id = 101 的记录。

    -- 会话 A
    START TRANSACTION;
    UPDATE cycling_teams SET contact_number = 'A_New_Contact' WHERE team_id = 101;
    -- 此时,team_id=101 这条记录被加了排他记录锁 (X Lock)。
    -- 事务尚未提交。
    
  • 会话 B (同时执行):

    -- 会话 B:尝试更新同一条记录
    UPDATE cycling_teams SET contact_number = 'B_New_Contact' WHERE team_id = 101;
    -- 结果:会被阻塞,直到会话 A 提交或回滚事务并释放 X 锁。
    
    -- 会话 B:尝试读取同一条记录,并加共享锁
    SELECT * FROM cycling_teams WHERE team_id = 101 FOR SHARE;
    -- 结果:同样会被阻塞,因为 X 锁与 S 锁不兼容。
    
    -- 会话 B:尝试普通读取(不加锁)
    SELECT * FROM cycling_teams WHERE team_id = 101;
    -- 结果:在 Read Committed 隔离级别下,可以读取到会话 A 未提交前的旧版本数据。
    -- 在 Repeatable Read 隔离级别下,由于 MVCC,也可以读取到事务开始时的数据快照,不会被阻塞。
    -- 这里我们讨论的是显式加锁的阻塞。
    

场景 2:共享记录锁 (S Lock) 的冲突

  • 会话 A: 开启事务,查询 team_id = 102 的记录并加共享锁。

    -- 会话 A
    START TRANSACTION;
    SELECT * FROM cycling_teams WHERE team_id = 102 FOR SHARE;
    -- 此时,team_id=102 这条记录被加了共享记录锁 (S Lock)。
    -- 事务尚未提交。
    
  • 会话 B (同时执行):

    -- 会话 B:尝试读取同一条记录,并加共享锁
    SELECT * FROM cycling_teams WHERE team_id = 102 FOR SHARE;
    -- 结果:不会被阻塞,会话 B 也能成功读取并加 S 锁,因为 S 锁与 S 锁兼容。
    
    -- 会话 B:尝试更新同一条记录(加排他锁)
    UPDATE cycling_teams SET contact_number = 'B_Another_Contact' WHERE team_id = 102;
    -- 结果:会被阻塞,直到会话 A 提交或回滚事务并释放 S 锁,因为 S 锁与 X 锁不兼容。
    
7.2.2 间隙锁 (Gap Lock)

间隙锁是 InnoDB 独有的,并且只在 Repeatable Read (可重复读) 或更高隔离级别下生效。它不锁定具体的记录,而是锁定索引记录之间的“间隙”,或者第一个记录之前的间隙,或者最后一个记录之后的间隙。

  • 锁住对象: 是一个范围,而不是具体的记录。例如,锁定 (102, 103) 之间的间隙。
  • 作用: 主要用于防止幻读 (Phantom Read)。在 Repeatable Read 隔离级别下,当使用范围查询并加锁时,间隙锁会阻止其他事务在该范围内插入新的记录,从而保证再次查询时,结果集不会发生变化。
  • 特点:
    • 间隙锁本身是共享的:即多个事务可以同时持有同一个间隙的间隙锁。它们共享的目的都是为了阻止该间隙内的新增操作。
    • 间隙锁只在事务提交时释放。
    • 它不包含记录本身,只锁定间隙。

结合 cycling_teams 表的 SQL 示例:间隙锁

为了演示间隙锁,我们先准备一些数据,确保 team_id 存在“间隙”。

-- 清空表数据,重新插入,方便演示
TRUNCATE TABLE cycling_teams;
INSERT INTO cycling_teams (team_id, team_name, country, bike_brand) VALUES
(100, 'Test Team A', 'USA', 'Specialized'),
(102, 'Test Team B', 'UK', 'Pinarello'),
(104, 'Test Team C', 'Germany', 'Cervélo');
-- 此时 team_id 存在间隙: (..., 100), (100, 102), (102, 104), (104, ...)

场景:防止幻读的间隙锁

  • 会话 A: 设置隔离级别为 Repeatable Read,并对一个范围进行查询并加锁。

    -- 会话 A:设置隔离级别为可重复读
    SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
    START TRANSACTION;
    
    -- 查询 team_id 在 101 到 103 之间的记录,并加排他锁
    SELECT * FROM cycling_teams WHERE team_id BETWEEN 101 AND 103 FOR UPDATE;
    -- 数据库中目前只有 team_id=102 这一条记录符合条件。
    -- 在这个查询中,InnoDB 不仅会给 team_id=102 的记录加排他记录锁 (X Lock),
    -- 还会给索引中的以下间隙加间隙锁 (Gap Lock):
    -- 1. (100, 102) 之间的间隙,因为 101 在这个间隙里。
    -- 2. (102, 104) 之间的间隙,因为 103 在这个间隙里。
    -- 具体来说,它会加 next-key lock (我们后面会讲),会锁住 (100, 102] 和 (102, 104]
    -- 事务尚未提交。
    
  • 会话 B (同时执行):

    -- 会话 B:尝试在被锁定的间隙中插入新记录
    INSERT INTO cycling_teams (team_id, team_name, country, bike_brand) VALUES (101, 'New Ghost A', 'France', 'Factor');
    -- 结果:会被阻塞,直到会话 A 提交或回滚事务。
    -- 为什么?因为 101 位于 (100, 102) 间隙中,该间隙被会话 A 的间隙锁锁住了。
    
     
    -- 会话 B:尝试在另一个间隙中插入新记录
    INSERT INTO cycling_teams (team_id, team_name, country, bike_brand) VALUES (103, 'New Ghost B', 'Italy', 'Look');
    -- 结果:同样会被阻塞,因为 103 位于 (102, 104) 间隙中,该间隙也被会话 A 的间隙锁锁住了。
    

    意义: 如果没有间隙锁,会话 B 就可以成功插入新记录。当会话 A 提交前再次执行 SELECT * FROM cycling_teams WHERE team_id BETWEEN 101 AND 103 FOR UPDATE; 时,就会多出一条 team_id=101team_id=103 的记录,这就是“幻读”。间隙锁正是为了防止这种情况的发生。

7.2.3 临界区锁 (Next-Key Lock)

临界区锁是 InnoDB 在 Repeatable Read 隔离级别下默认的锁定方式,它是记录锁和间隙锁的组合

  • 锁住对象: 锁定的是一个左开右闭的区间 (前一个记录的索引值, 当前记录的索引值]。它既包含当前记录本身,也包含该记录之前的间隙。
  • 作用: 它是 InnoDB 在 Repeatable Read 隔离级别下解决幻读的默认方式。通过锁定间隙和记录,它既防止了在锁定范围内插入新记录(间隙锁的作用),也防止了修改或删除已有记录(记录锁的作用),从而提供更强的一致性保证。
  • 加锁时机:
    • 在 Repeatable Read 隔离级别下,当查询涉及范围或非唯一索引的等值查询时,通常会使用 Next-Key Lock。
    • 如果查询的条件是唯一索引的等值查询,并且该记录存在,Next-Key Lock 会退化为记录锁(因为不需要保护间隙,不会有幻读问题)。
    • 如果查询的条件是唯一索引的等值查询,并且该记录不存在,Next-Key Lock 会退化为间隙锁(只锁定不存在记录的间隙,以防止插入)。

结合 cycling_teams 表的 SQL 示例:临界区锁

继续使用之前的 cycling_teams 数据:100, 102, 104

场景:Next-Key Lock 锁定区间

  • 会话 A:

    -- 会话 A:设置隔离级别为可重复读
    SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
    START TRANSACTION;
    
    -- 查询 team_id = 102 的记录,并加排他锁
    SELECT * FROM cycling_teams WHERE team_id = 102 FOR UPDATE;
    -- 由于 team_id 是主键(唯一索引),且 102 存在,
    -- 此时 InnoDB 会将 Next-Key Lock 退化为对 team_id=102 这条记录的排他记录锁 (X Lock)。
    
  • 会话 B (同时执行):

    -- 会话 B:尝试插入 101
    INSERT INTO cycling_teams (team_id, team_name, country, bike_brand) VALUES (101, 'New Team A', 'France', 'Factor');
    -- 结果:可以成功插入。因为会话 A 的锁退化成了记录锁,没有锁住 (100, 102) 这个间隙。
    

    注意: 如果 team_id 不是主键或唯一索引,那么即使是 WHERE team_id = 102 FOR UPDATE,也可能会加 Next-Key Lock,锁住 (100, 102] 这个区间。这是为了防止在非唯一索引上的幻读。

  • 会话 C (Next-Key Lock 真正生效的场景):

    现在我们删除 102:

    DELETE FROM cycling_teams WHERE team_id = 102;
    -- 数据现在是 100, 104
    
    • 会话 A: SQL
      -- 会话 A:设置隔离级别为可重复读
      SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
      START TRANSACTION;
      
      -- 查询 team_id > 100 且 team_id < 104 的记录,并加排他锁
      SELECT * FROM cycling_teams WHERE team_id > 100 AND team_id < 104 FOR UPDATE;
      -- 此时数据库中没有记录符合这个条件!
      -- 但 InnoDB 会锁定 (100, 104) 之间的 Next-Key 锁(实际上是锁定下一个存在的记录 104 之前的间隙)
      -- 即锁定 (100, 104]。
      -- 事务尚未提交。
      
    • 会话 B (同时执行): 
      -- 会话 B:尝试插入 101
      INSERT INTO cycling_teams (team_id, team_name, country, bike_brand) VALUES (101, 'New Team A', 'France', 'Factor');
      -- 结果:会被阻塞,因为 101 落在被锁定的 (100, 104] 区间内。
      
      -- 会话 B:尝试插入 103
      INSERT INTO cycling_teams (team_id, team_name, country, bike_brand) VALUES (103, 'New Team B', 'Italy', 'Look');
      -- 结果:同样会被阻塞。
      

    这个例子清楚地展示了 Next-Key Lock 如何通过锁定间隙来防止幻读,即使查询结果为空,锁定依然发生。

7.2.4 插入意向锁 (Insert Intention Lock)

这是一个特殊的间隙锁,当事务执行 INSERT 操作时,如果插入的位置被其他事务加了间隙锁,那么当前事务会生成一个插入意向锁

  • 特点: 插入意向锁本身是一种特殊的间隙锁,它表明事务打算在某个间隙中插入新记录。
  • 兼容性: 插入意向锁之间是兼容的,即多个事务可以在同一个间隙中生成插入意向锁。但它与普通的间隙锁是互斥的。
  • 作用: 它的主要作用是,当多个事务并发插入到同一个间隙时,它们会等待持有间隙锁的事务释放锁,而不是直接死锁或报错。当间隙锁释放后,插入意向锁之间会根据插入的物理位置,形成一个排他锁链,最终只有一个事务能成功插入,其他事务则需要继续等待或处理唯一约束冲突。

结合 cycling_teams 表的 SQL 示例:插入意向锁

-- 清空表数据,重新插入,方便演示
TRUNCATE TABLE cycling_teams;
INSERT INTO cycling_teams (team_id, team_name, country, bike_brand) VALUES
(100, 'Test Team A', 'USA', 'Specialized'),
(104, 'Test Team C', 'Germany', 'Cervélo');
-- 此时 team_id 存在间隙: (100, 104)

场景:插入意向锁的等待

  • 会话 A: 锁定 (100, 104) 这个间隙。

    -- 会话 A:设置隔离级别为可重复读
    SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
    START TRANSACTION;
    
    -- 锁定 (100, 104) 之间的间隙(通过范围查询)
    SELECT * FROM cycling_teams WHERE team_id > 100 AND team_id < 104 FOR UPDATE;
    -- 此时会话 A 持有 (100, 104] 的 Next-Key Lock,包含了 (100, 104) 的间隙锁。
    -- 事务尚未提交。
    
  • 会话 B (同时执行):

    -- 会话 B:尝试向 (100, 104) 间隙中插入一条记录
    INSERT INTO cycling_teams (team_id, team_name, country, bike_brand) VALUES (101, 'New Team X', 'France', 'Factor');
    -- 结果:会话 B 会生成一个插入意向锁,并进入等待状态,等待会话 A 释放其对 (100, 104) 的间隙锁。
    
  • 会话 C (同时执行):

    -- 会话 C:同时尝试向 (100, 104) 间隙中插入另一条记录
    INSERT INTO cycling_teams (team_id, team_name, country, bike_brand) VALUES (103, 'New Team Y', 'Italy', 'Look');
    -- 结果:会话 C 也会生成一个插入意向锁,并进入等待状态。
    -- 插入意向锁之间是兼容的,所以会话 B 和会话 C 都可以同时等待。
    

当会话 A 提交事务并释放间隙锁后,会话 B 和会话 C 会开始竞争。由于 team_id 是主键,最终只会有一个插入操作成功,另一个会因为主键冲突而失败(或者如果插入不同的 id,则都可以成功)。插入意向锁确保了这种竞争能够有序进行,而不是立即死锁。

7.3 死锁 (Deadlock):行级锁的“副作用”与 InnoDB 的处理

行级锁提高了并发性,但也带来了更高的死锁风险。死锁发生在两个或多个事务互相等待对方释放资源时,形成一个循环依赖。

死锁示例:更新 cycling_teams

  • 会话 A:

    -- 会话 A
    START TRANSACTION;
    -- 步骤 1:更新 team_id = 101 的联系电话,获取 team_id=101 的行级排他锁。
    UPDATE cycling_teams SET contact_number = 'A_Contact_101' WHERE team_id = 101;
    -- 模拟业务逻辑处理,暂停一下
    -- SELECT SLEEP(10);
    -- 步骤 2:尝试更新 team_id = 102 的联系电话,尝试获取 team_id=102 的行级排他锁。
    UPDATE cycling_teams SET contact_number = 'A_Contact_102' WHERE team_id = 102;
    COMMIT;
    
  • 会话 B (几乎同时执行,在会话 A 暂停时):

    -- 会话 B
    START TRANSACTION;
    -- 步骤 1:更新 team_id = 102 的联系电话,获取 team_id=102 的行级排他锁。
    UPDATE cycling_teams SET contact_number = 'B_Contact_102' WHERE team_id = 102;
    -- 模拟业务逻辑处理,暂停一下
    -- SELECT SLEEP(10);
    -- 步骤 2:尝试更新 team_id = 101 的联系电话,尝试获取 team_id=101 的行级排他锁。
    UPDATE cycling_teams SET contact_number = 'B_Contact_101' WHERE team_id = 101;
    COMMIT;
    

死锁发生过程:

  1. 会话 A 成功锁住了 team_id = 101
  2. 会话 B 成功锁住了 team_id = 102
  3. 会话 A 尝试锁定 team_id = 102,但该行已被会话 B 锁定,所以会话 A 被阻塞。
  4. 会话 B 尝试锁定 team_id = 101,但该行已被会话 A 锁定,所以会话 B 也被阻塞。

此时,两个会话互相等待对方释放资源,形成了循环依赖,这就是经典的死锁。

InnoDB 如何处理死锁:

InnoDB 内部有一个死锁检测机制。当它检测到死锁发生时,会选择一个事务作为“牺牲者” (victim),强制其回滚 (ROLLBACK) 并释放所有锁,从而打破死锁循环,让另一个事务得以继续执行。

被选为牺牲者的事务会收到错误信息(例如 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction)。应用程序通常需要捕获这个错误,并进行事务重试。

避免死锁的策略:

  • 以固定的顺序访问资源: 始终按照相同的顺序访问和锁定多个资源(例如,总是先锁定 team_id=101,再锁定 team_id=102)。这是最有效的策略。
  • 小事务,快提交: 尽量缩短事务的执行时间,减少锁定的时间,从而降低死锁发生的概率。
  • 批量操作代替逐行操作: 使用 UPDATE ... WHERE IN (...)DELETE ... WHERE IN (...) 代替循环逐行更新。
  • 添加适当索引: 确保 WHERE 条件能利用索引,减少扫描的行数,避免不必要的行锁升级或间隙锁。
  • 降低隔离级别: 在某些情况下,如果业务允许,降低隔离级别(例如从 Repeatable Read 到 Read Committed)可以减少间隙锁的使用,从而降低死锁风险,但这会牺牲一部分隔离性。
7.4 乐观锁与悲观锁:不同的并发控制哲学

在锁的语境下,我们前面讨论的所有 MySQL 数据库提供的锁(包括行级锁、表级锁、全局锁)都属于悲观锁 (Pessimistic Lock) 的范畴。

  • 悲观锁:
    • 哲学: 假设会发生冲突,所以在操作数据之前就预先加锁,阻止其他事务访问,从而保证数据操作的强一致性。
    • 特点: 依赖数据库的锁机制,并发性较低,但数据一致性高。
    • 示例: SELECT ... FOR UPDATEUPDATE ...LOCK TABLES 等。

与之相对的是乐观锁 (Optimistic Lock)

  • 乐观锁:
    • 哲学: 假设冲突不常发生。它不依赖数据库的锁机制,而是在应用程序层面进行并发控制。在操作时不加锁,而是在提交更新时检查数据是否被其他事务修改过。
    • 实现方式: 最常见的是使用版本号 (version)时间戳 (timestamp) 字段。
    • 特点: 提高了并发性,因为没有真正的数据库锁竞争。但需要应用程序自己处理冲突检测和重试逻辑。适用于读多写少的场景,冲突频繁时性能反而下降。

乐观锁示例:结合 cycling_teams 表添加 version 字段

  1. 修改表结构,增加版本字段:

    ALTER TABLE cycling_teams ADD COLUMN version INT DEFAULT 1 COMMENT '乐观锁版本号';
    
  2. 事务 A 读取并尝试更新:

    -- 事务 A:
    SELECT team_name, version FROM cycling_teams WHERE team_id = 101;
    -- 假设查询得到 team_name='Team Sky', version=1
    
    -- 模拟业务逻辑处理...
    -- 准备更新数据
    
    UPDATE cycling_teams SET team_name = 'Team Sky Pro', version = version + 1
    WHERE team_id = 101 AND version = 1; -- 只有当 version 仍为 1 时才更新
    -- 如果更新成功,说明在读取到版本 1 后,没有其他事务修改过。
    -- 如果更新失败(影响行数为 0),说明其他事务已经修改了这条记录,version 不再是 1。
    -- 此时,应用程序需要捕获这个情况,选择提示用户数据已过期,或者重新读取最新数据并重试。
    
  3. 事务 B 同时更新(导致冲突):

    -- 事务 B:
    SELECT team_name, version FROM cycling_teams WHERE team_id = 101;
    -- 假设查询也得到 team_name='Team Sky', version=1
    
    -- 模拟业务逻辑处理...
    -- 事务 B 先于事务 A 提交了更新
    UPDATE cycling_teams SET team_name = 'Team Sky New', version = version + 1
    WHERE team_id = 101 AND version = 1;
    -- 事务 B 成功更新,此时 team_id=101 的 version 变为 2。
    

    当事务 A 再次执行其 UPDATE 语句时,WHERE team_id = 101 AND version = 1 条件将不满足,因为 version 已经变为 2。所以事务 A 的 UPDATE 将不会影响任何行。

本章小结:

行级锁是 InnoDB 实现高并发的关键,它通过锁定精确的行记录,最大限度地减少了锁粒度。记录锁、间隙锁和临界区锁是 InnoDB 默认在 Repeatable Read 隔离级别下用于实现数据一致性和防止幻读的基石。然而,细粒度锁也带来了死锁的风险,需要 DBA 和开发人员在设计应用时予以考虑。乐观锁则提供了一种无数据库锁的并发控制方案,适用于特定场景。


第八章:总结与展望:MySQL 锁的艺术

恭喜你!到这里,你已经成功地探索了 MySQL 数据库中所有核心的锁类型。从粗犷的全局锁,到细致入微的行级锁,我们一步步揭开了它们的面纱。

让我们来一个快速回顾:

  1. 全局锁 (Global Lock): 最粗粒度,锁定整个数据库实例,主要用于全库逻辑备份(FLUSH TABLES WITH READ LOCK)。
  2. 表级锁 (Table Lock): 锁定整个表,分为读锁(共享)和写锁(排他),通过 LOCK TABLES 显式加锁,或由 DDL 操作和 MyISAM 存储引擎隐式加锁。
  3. 共享锁 (S Lock) 与 排他锁 (X Lock): 两种基本锁模式。S 锁读读兼容,读写互斥;X 锁读写、写写都互斥,实现独占。它们是所有其他锁的基础。
  4. 元数据锁 (Metadata Lock, MDL): MySQL 内部自动管理的锁,用于保护数据库对象的元数据(结构)一致性,在 DDL 和 DML 操作时生效。长事务可能导致 MDL 阻塞。
  5. 意向锁 (Intention Locks): InnoDB 特有的表级锁,作为行级锁的“前置声明”,分为 IS 锁(打算加行 S 锁)和 IX 锁(打算加行 X 锁),提高表级锁判断效率,协调表锁与行锁冲突。
  6. 行级锁 (Row-Level Locks): InnoDB 核心,粒度最小,并发度最高,但开销大,易死锁。
    • 记录锁 (Record Lock): 锁定具体的索引记录(S 或 X 模式)。
    • 间隙锁 (Gap Lock): 锁定索引记录之间的“间隙”,防止幻读,只在 Repeatable Read 隔离级别下出现。
    • 临界区锁 (Next-Key Lock): 记录锁 + 间隙锁的组合,InnoDB 默认在 Repeatable Read 隔离级别下使用,提供最强一致性。
    • 插入意向锁 (Insert Intention Lock): 特殊的间隙锁,表示插入意图,多个插入意向锁兼容,但与普通间隙锁互斥。

为什么会有这么多锁?

这些不同粒度的锁,以及它们复杂的协作机制,都是为了解决数据库在并发环境下保持数据一致性和提高并发性这一对矛盾的挑战。

  • 锁的粒度选择: 粒度越粗(全局锁、表锁),管理越简单,开销越小,但并发度越低。粒度越细(行级锁),并发度越高,但管理越复杂,开销越大,死锁风险越高。MySQL 通过提供不同粒度的锁,让用户或系统可以根据实际需求进行选择。
  • 隔离级别与锁: MySQL 的不同事务隔离级别(如 Read Committed, Repeatable Read)正是通过不同的锁策略来实现的。例如,Repeatable Read 通过引入间隙锁和 Next-Key Lock 来防止幻读,提供了更强的一致性保证。
  • 存储引擎的特性: 不同的存储引擎对锁的支持不同。MyISAM 只有表锁,而 InnoDB 则提供了强大的行级锁和 MVCC(多版本并发控制)机制,这也是它成为 MySQL 默认存储引擎的主要原因。

如何更好地理解和运用锁?

  1. 明确场景: 记住每种锁是为了解决什么问题而存在的。例如,全局锁为备份,MDL 为 DDL,行级锁为 DML 高并发。
  2. 理解兼容性: 掌握不同锁模式之间的兼容性是避免阻塞和死锁的关键。
  3. 关注隔离级别: 不同的隔离级别决定了 InnoDB 如何使用行级锁(特别是间隙锁和 Next-Key Lock)。
  4. 死锁处理: 学习如何排查死锁(SHOW ENGINE INNODB STATUSsys 库),以及如何在应用层面处理死锁重试。
  5. 选择合适的并发控制: 针对读多写少的场景,可以考虑乐观锁来提高性能。

展望未来:

随着数据库技术的发展,MySQL 也在不断优化其锁机制。例如,MySQL 8.0 对原子 DDL 和即时 DDL 的支持,进一步减少了 DDL 对 MDL 的依赖和阻塞时间。理解锁的底层原理,不仅能帮助你解决当前的数据库问题,也能让你更好地适应未来的技术演进。

希望通过这篇详尽的博客,你已经彻底摆脱了对 MySQL 锁的困惑,能够自信地在数据库的世界中驰骋!如果你有任何疑问,或者想进一步探讨某个知识点,欢迎随时留言交流。

你可能感兴趣的:(数据库,mysql,数据库)