目录
导语:不再“懵圈”!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.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 中的所有锁机制,让你一文读懂锁的奥秘,从此告别混淆!
我们将从最粗粒度的锁开始,逐步深入到最精细的行级锁,并结合实际的 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
数据库同时有几百甚至几千个用户在访问:
如果没有锁,会发生什么?
为了解决这些并发带来的问题,保证数据在并发访问下的一致性 (Consistency)、隔离性 (Isolation) 和持久性 (Durability),数据库引入了锁 (Locks) 机制。锁就像是交通信号灯,协调着数据库中各个操作的顺序,确保数据在多方操作下依然井然有序。
理解了锁的必要性,我们就可以开始探索 MySQL 提供了哪些“信号灯”了。
我们首先从最粗粒度的锁开始讲解。全局锁,顾名思义,是针对整个数据库实例的锁。它就像是给整个 MySQL 服务器按下了“暂停键”,让它进入一个高度受限的状态。
全局锁是 MySQL 数据库中最粗粒度的锁,它会锁定整个 MySQL 实例。一旦加上全局锁,整个数据库就处于只读状态,所有的 DDL (数据定义语言) 和 DML (数据操作语言) 操作(除了少数例外)都将被阻塞,直到全局锁被释放。
特点:
全局锁最典型的使用场景是进行逻辑备份。
想象一下,你正在使用 mysqldump
工具备份你的 cycling_teams
数据库。如果在备份过程中,有用户插入了新的车队,或者修改了某个车队的联系方式,那么备份出来的数据就可能包含不一致的信息,甚至丢失一部分数据。
为了确保备份出来的数据是一致的,也就是一个特定时间点的数据快照,我们需要在备份期间阻止所有的数据修改操作。全局锁就是为此而生。它能保证在备份过程中,数据库中的所有数据都是静止的,从而得到一个可靠的、一致的备份。
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 是读锁,允许并发读。
释放全局锁:
全局锁的释放方式有两种:
UNLOCK TABLES;
命令。 -- 会话 A:释放全局读锁
UNLOCK TABLES;
-- 释放后,之前被阻塞的写操作会恢复执行。
方式二:SET GLOBAL READ ONLY = TRUE
(不推荐用于备份)
这种方式也可以让整个数据库变为只读状态,达到全局锁的效果。
-- 会话 A:将全局只读变量设置为 TRUE
SET GLOBAL READ ONLY = TRUE;
-- 此时,所有非 SUPER 权限的用户都无法进行写操作。
-- 拥有 SUPER 权限的用户仍然可以写操作。
缺点:
FALSE
,或者 MySQL 实例重启。在业务运行中直接设置,可能对业务造成严重影响。SUPER
权限的用户仍然可以进行写操作,这意味着无法保证备份期间的完全数据一致性(如果 SUPER
用户还在操作的话)。这使得它不适合作为精确数据备份的手段。为什么 FTWRL
更推荐用于备份?
FTWRL
确保了所有写操作都被阻塞,包括拥有 SUPER
权限的用户,从而获得一个真正的全量一致性快照。看到这里你可能会想,为了备份,让整个数据库停止写操作,这在生产环境中简直是噩梦!尤其对于 24 小时运行的在线系统,业务是无法忍受长时间停顿的。
好在,对于 MySQL 中最常用的 InnoDB 存储引擎,我们有更“温柔”的备份方式,可以在不加全局锁的情况下,实现数据一致性备份:
使用 mysqldump
工具的 --single-transaction
参数
mysqldump --single-transaction -h[host] -u[user] -p[password] [database_name] > backup.sql
当 mysqldump
工具带上 --single-transaction
参数时:
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)。表锁是针对单个表的锁定,它会锁定整个数据表。
表级锁是作用于整个表级别的锁,它锁住的是整个数据表而不是某一行或某几行数据。
特点:
MySQL 的表锁主要分为两种:表读锁和表写锁,它们可以通过 LOCK TABLES
语句显式地施加。
类型一:表读锁 (Table Read Lock)
表读锁也称为共享表锁。当一个会话对表加了读锁后,它的目的是告诉其他会话:“这个表我可以读,你们也可以读,但都不能写!”
LOCK TABLES table_name READ;
示例: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;
示例: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');
-- 结果:同样会被阻塞。
UNLOCK TABLES
无论加的是读锁还是写锁,都通过 UNLOCK TABLES;
命令来释放。当会话断开连接时,所有会话持有的表锁也会自动释放。
-- 会话 A:释放表锁
UNLOCK TABLES;
-- 释放后,之前被阻塞的操作会恢复执行。
重要注意事项:
LOCK TABLES
语句会自动提交当前会话中未提交的事务。这意味着如果你在一个事务中执行了 DML 操作,然后又执行 LOCK TABLES
,那么之前的 DML 操作会立即生效。LOCK TABLES
语句实现了一个“两阶段锁定”的原则:要么一次性获取所有需要的锁,要么在锁释放之前不能访问其他资源。除了通过 LOCK TABLES
语句显式加锁外,MySQL 在某些情况下也会自动(隐式地)加表锁:
DDL (数据定义语言) 操作:
大多数 DDL 操作(如 ALTER TABLE, DROP TABLE, CREATE INDEX 等)都会对表加隐式的表级锁。这是为了保证在这些结构性操作进行时,表的结构不被其他操作破坏。
ALTER TABLE
操作通常会加一个排他表锁,防止其他会话在表结构变化期间进行读写。MyISAM 存储引擎:
MyISAM 存储引擎是 MySQL 早期默认的存储引擎,它的并发控制机制主要就是依靠表级锁。
本章小结:
表级锁是对整个表进行锁定,分为读锁(共享)和写锁(排他)。它们通过 LOCK TABLES
语句显式施加,并通过 UNLOCK TABLES
释放。虽然开销小,但并发度低,在生产环境中应谨慎使用。此外,DDL 操作和 MyISAM 存储引擎也会隐式地使用表级锁。
在前面的章节中,我们已经多次提到了“共享”和“排他”这两个词。它们是所有锁(无论是表锁、行锁,还是后面将要讲到的其他锁)最基本的两种模式,理解它们是理解整个锁机制的关键。
共享锁,顾名思义,是“共享”的。它的核心特性是:
特点:
排他锁,是“独占”的。它的核心特性是:
特点:
为了更直观地理解 S 锁和 X 锁的关系,我们可以用一个兼容性矩阵来表示。矩阵中的“√”表示兼容(可以共存),“×”表示不兼容(会阻塞)。
持有者 / 请求者 | S 锁 | X 锁 |
S 锁 | √ | × |
X 锁 | × | × |
解释:
√
(兼容)。两个事务都可以读。×
(不兼容)。事务 A 正在读,事务 B 不能写。事务 B 会被阻塞。×
(不兼容)。事务 A 正在独占资源,事务 B 不能读。事务 B 会被阻塞。×
(不兼容)。事务 A 正在独占资源,事务 B 不能独占。事务 B 会被阻塞。我们回顾一下第三章中表锁的例子,现在用 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 锁在表层面的应用。
在了解了 S/X 锁的基本概念后,我们来重新审视一个在之前的全局锁和表锁中偶尔提过,但对并发影响非常大的锁:元数据锁 (Metadata Lock, MDL)。MDL 并不是用来保护数据本身的,而是用来保护数据库对象的元数据(Metadata),也就是它们的结构信息。
元数据锁 (MDL) 是 MySQL 内部自动管理的一种锁,它用于保护数据库对象的元数据(如表结构、存储过程、函数等)的一致性。MDL 确保了在对数据库对象进行 DDL (数据定义语言) 操作时,相关的元数据不被其他会话修改或访问,从而避免数据字典的不一致性。
特点:
MDL 就像一个“结构维护员”,它会在你需要访问或修改数据库对象的结构时自动出现。
DML 操作中的 MDL (共享 MDL):
当你执行 SELECT、INSERT、UPDATE、DELETE 等 DML 操作时,MySQL 会自动为涉及的表获取一个共享 MDL 锁。
ALTER TABLE
、DROP TABLE
等)对该表进行操作。DDL 操作中的 MDL (排他 MDL):
当你执行 ALTER TABLE、DROP TABLE、CREATE INDEX 等 DDL 语句时,MySQL 会尝试获取涉及表的排他 MDL 锁。
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)阻塞”的死循环(虽然不是严格意义上的死锁,但表现类似)。它会导致数据库连接大量堆积,最终可能拖垮整个服务。
当你的数据库出现响应缓慢,并且怀疑是 MDL 阻塞时,可以通过以下方式进行排查和解决:
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 状态,说明事务未提交但暂时没有操作)。
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_STATUS
,GRANTED
表示已获得,PENDING
或 WAITING
表示正在等待) 以及持有锁和请求锁的会话 ID (OWNER_THREAD_ID
)。
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 阻塞的策略:
pt-osc
或 Gh-ost 等工具。它们通过创建新表、同步数据、重命名等方式模拟在线 DDL,从而避免长时间持有 MDL 锁阻塞业务。ALTER TABLE
操作支持 ALGORITHM=INSTANT
或 ALGORITHM=INPLACE
,可以显著减少 MDL 锁的持有时间或影响范围。本章小结:
元数据锁 (MDL) 是 MySQL 内部用于保护数据字典(表结构)一致性的重要机制。它由 MySQL 自动管理,在 DML 和 DDL 操作时加锁。长事务是 MDL 阻塞的常见原因,会导致 DDL 和 DML 语句长时间等待。理解 MDL 的工作原理和排查方法,对于数据库的稳定运行至关重要。
在深入到 MySQL 最精细的行级锁之前,我们还需要理解一个非常重要的概念:意向锁 (Intention Locks)。意向锁是 InnoDB 存储引擎特有的,它本身不直接锁定数据,而是作为一种表级锁,用来指示事务“打算”在表中的某些行上加行级锁。
我们已经知道,MySQL 有表级锁(锁住整个表)和行级锁(锁住特定行)。当一个事务想要对表中的某一行加行级锁时,MySQL 怎么知道这个表上有没有其他事务持有表级锁,或者有没有其他事务也在行上加了锁呢?
如果没有意向锁,当一个事务想要给整个表加表级写锁 (X Lock) 时,它必须扫描整个表,检查每一行是否有被其他事务锁定的行级锁。这显然是非常低效的!
意向锁的作用就是解决这个问题: 它充当了表级锁和行级锁之间的“桥梁”或“前置声明”。当一个事务想要对表中的行加行级锁时,它首先会在表上加一个意向锁,表明它的“意图”。这样,其他事务在尝试对表加表级锁时,只需要检查表上是否存在意向锁,而无需遍历所有行。
核心作用:
意向锁分为两种,它们都是表级锁:
意向共享锁 (Intention Shared Lock, IS Lock):
SELECT ... FOR SHARE
语句时,会首先在表上加一个 IS 锁。意向排他锁 (Intention Exclusive Lock, IX Lock):
INSERT
、UPDATE
、DELETE
或 SELECT ... FOR UPDATE
语句时,会首先在表上加一个 IX 锁。为了更清晰地展示意向锁如何协调不同粒度锁的冲突,我们来扩展一下之前共享锁和排他锁的兼容性矩阵。这个矩阵展示了当一个事务持有某种锁时,另一个事务能否获得某种锁(针对同一个资源,即同一个表):
持有者 / 请求者 | IS (意向共享) | IX (意向排他) | S (表级共享) | X (表级排他) |
IS | √ | √ | √ | × |
IX | √ | √ | × | × |
S | √ | × | √ | × |
X | × | × | × | × |
理解这个矩阵:
IS
),事务 B 也可以打算读某些行 (IS
)。IX
),事务 B 也可以打算读某些行 (IS
) 或写某些行 (IX
)。这仅仅是意图声明,具体的行级锁会在行层面冲突。X
(表级排他锁) 与任何意向锁都不兼容 (×): 这是因为表级排他锁意味着独占整个表,所以不允许任何事务再声明对表内任何行的操作意图。S
(表级共享锁) 只与 IS
(意向共享锁) 兼容 (√),不与 IX
(意向排他锁) 兼容 (×): 如果表已经被表级共享锁锁定(只能读),那么任何事务都不能再表达修改行的意图(IX
),但可以表达读取的意图(IS
)。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 最精细、也最复杂的行级锁的世界。
现在,我们终于来到了 MySQL 锁机制的核心:行级锁 (Row-Level Locks)。这是 InnoDB 存储引擎能够实现高并发、高性能的关键所在。与全局锁和表锁不同,行级锁只锁定受影响的行,从而最大限度地支持并发处理,尤其是在多用户并发访问同一张表时。
行级锁是粒度最小的锁,它只锁定数据库表中的一行或一部分行记录。
特点:
InnoDB 提供了多种行级锁,以满足不同的并发控制需求和隔离级别。
记录锁,顾名思义,是锁住索引记录本身。它作用于具体的某一行数据。
SELECT ... FOR SHARE
语句加持。INSERT
、UPDATE
、DELETE
或 SELECT ... FOR UPDATE
语句加持。结合 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 锁不兼容。
间隙锁是 InnoDB 独有的,并且只在 Repeatable Read (可重复读) 或更高隔离级别下生效。它不锁定具体的记录,而是锁定索引记录之间的“间隙”,或者第一个记录之前的间隙,或者最后一个记录之后的间隙。
(102, 103)
之间的间隙。结合 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=101
或 team_id=103
的记录,这就是“幻读”。间隙锁正是为了防止这种情况的发生。
临界区锁是 InnoDB 在 Repeatable Read 隔离级别下默认的锁定方式,它是记录锁和间隙锁的组合。
(前一个记录的索引值, 当前记录的索引值]
。它既包含当前记录本身,也包含该记录之前的间隙。结合 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:设置隔离级别为可重复读
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:尝试插入 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 如何通过锁定间隙来防止幻读,即使查询结果为空,锁定依然发生。
这是一个特殊的间隙锁,当事务执行 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,则都可以成功)。插入意向锁确保了这种竞争能够有序进行,而不是立即死锁。
行级锁提高了并发性,但也带来了更高的死锁风险。死锁发生在两个或多个事务互相等待对方释放资源时,形成一个循环依赖。
死锁示例:更新 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;
死锁发生过程:
team_id = 101
。team_id = 102
。team_id = 102
,但该行已被会话 B 锁定,所以会话 A 被阻塞。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 (...)
代替循环逐行更新。在锁的语境下,我们前面讨论的所有 MySQL 数据库提供的锁(包括行级锁、表级锁、全局锁)都属于悲观锁 (Pessimistic Lock) 的范畴。
SELECT ... FOR UPDATE
,UPDATE ...
,LOCK TABLES
等。与之相对的是乐观锁 (Optimistic Lock):
乐观锁示例:结合 cycling_teams
表添加 version
字段
修改表结构,增加版本字段:
ALTER TABLE cycling_teams ADD COLUMN version INT DEFAULT 1 COMMENT '乐观锁版本号';
事务 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。
-- 此时,应用程序需要捕获这个情况,选择提示用户数据已过期,或者重新读取最新数据并重试。
事务 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 数据库中所有核心的锁类型。从粗犷的全局锁,到细致入微的行级锁,我们一步步揭开了它们的面纱。
让我们来一个快速回顾:
FLUSH TABLES WITH READ LOCK
)。LOCK TABLES
显式加锁,或由 DDL 操作和 MyISAM 存储引擎隐式加锁。为什么会有这么多锁?
这些不同粒度的锁,以及它们复杂的协作机制,都是为了解决数据库在并发环境下保持数据一致性和提高并发性这一对矛盾的挑战。
如何更好地理解和运用锁?
SHOW ENGINE INNODB STATUS
或 sys
库),以及如何在应用层面处理死锁重试。展望未来:
随着数据库技术的发展,MySQL 也在不断优化其锁机制。例如,MySQL 8.0 对原子 DDL 和即时 DDL 的支持,进一步减少了 DDL 对 MDL 的依赖和阻塞时间。理解锁的底层原理,不仅能帮助你解决当前的数据库问题,也能让你更好地适应未来的技术演进。
希望通过这篇详尽的博客,你已经彻底摆脱了对 MySQL 锁的困惑,能够自信地在数据库的世界中驰骋!如果你有任何疑问,或者想进一步探讨某个知识点,欢迎随时留言交流。