4、MySql锁机制
定义
锁是数据库中保证共享数据并发一致性的关键机制,但其冲突也严重影响性能。
举例
在淘宝上买商品,商品只有一件库存,这时两个人同时买如何界定谁买到?
这里要用到事务,
- 查询库存表中商品数量
- 插入订单
- 付款后插入付款表信息
- 更新库存表商品数量
在这个过程中,使用锁可以对有限的资源进行保护,解决隔离和并发的矛盾。
锁的分类
按锁的粒度分类(从大到小)
全局锁 (Global Lock)
全局读锁 (FTWRL)
- 命令:
FLUSH TABLES WITH READ LOCK; - 效果:整个库处于只读状态。任何写操作(DML、DDL)和更新类事务都会被阻塞。
- 用途:主要用于全库物理备份。
表级锁 (Table-level Lock)
表锁 (Table Lock)
- 分为“读锁”和“写锁”。
- 显式地锁住整张表:
LOCK TABLES t READ/WRITE;
元数据锁 (MDL:Metadata Lock)
- 自动加锁:不需要手动写命令。当查询时加 MDL 读锁,修改表结构时加 MDL 写锁。
- 作用:防止你在查询数据的同时,别人把表结构删了。
意向锁 (Intention Lock)
为什么要有意向锁?(解决效率痛点)
想象一下,如果没有意向锁,当一个事务想要申请 表锁(Table Lock)(例如执行 ALTER TABLE)时,它必须确保表中的每一行都没有被其他事务锁住。
- 笨办法:遍历全表 100 万行记录,逐一检查是否有行锁。这显然是性能灾难。
- 聪明办法(意向锁):如果某个事务锁住了其中一行,它必须先在表级别插一面“小旗子”。
- 当有人想锁整张表时,只需看一眼表上的“旗子”,就知道表内是否有行正在被占用。
本质: 意向锁是为了在行锁和表锁共存的环境下,快速判断冲突,提高检测效率。
- 当有人想锁整张表时,只需看一眼表上的“旗子”,就知道表内是否有行正在被占用。
意向锁的分类与兼容性
意向锁分为两种,它们都是由 InnoDB 自动加的,用户无法手动干预:
- 意向共享锁 (IS):事务准备给某些行加 S 锁(共享锁/读锁)之前,必须先获得该表的 IS 锁。
- 意向排他锁 (IX):事务准备给某些行加 X 锁(排他锁/写锁)之前,必须先获得该表的 IX 锁。
关键特性:意向锁之间是兼容的
意向锁之间(IS 与 IX,IX 与 IX)互不冲突。
- 逻辑:表上有个 IX 标志,只代表“表里某些行被改了”,并不代表“具体哪行被改了”。所以,事务 A 修改第一行(拿 IX),事务 B 修改第二行(也拿 IX),这是完全允许的。
兼容性表(重点看表级冲突)
| IS (表级) | IX (表级) | S (表级) | X (表级) | |
|---|---|---|---|---|
| IS (表级) | 兼容 | 兼容 | 兼容 | 冲突 |
| IX (表级) | 兼容 | 兼容 | 冲突 | 冲突 |
| S (表级) | 兼容 | 冲突 | 兼容 | 冲突 |
| X (表级) | 冲突 | 冲突 | 冲突 | 冲突 |
意向锁与行锁的区别
这是最容易混淆的地方:意向锁是表级锁,但它不阻塞行锁。
- 意向锁 (IX/IS):锁的是表。它的对手是“整表操作”(如
DROP TABLE,LOCK TABLES)。 - 行锁 (X/S):锁的是行索引记录。它的对手是“其他事务对同一行的操作”。
实际场景模拟:
- 事务 A:执行
UPDATE users SET name = 'A' WHERE id = 1;- InnoDB 自动获取
users表的 IX 锁(意向排他)。 - InnoDB 获取
id = 1这一行的 X 行锁。
- InnoDB 自动获取
- 事务 B:执行
UPDATE users SET name = 'B' WHERE id = 2;- 尝试获取
users表的 IX 锁。因为 IX 与 IX 兼容,成功! - 尝试获取
id = 2的 X 行锁。成功! - 结论:意向锁不阻止不同行的并发修改。
- 尝试获取
- 事务 C:执行
DROP TABLE users;- 尝试获取
users表的 X 表锁。 - 发现表上已经挂了 IX 锁,于是事务 C 阻塞,直到 A 和 B 提交。
- 尝试获取
总结与补全
- 自动性:你不需要在 SQL 中写
LOCK INTENTION...。只要你用了普通的SELECT ... FOR SHARE(IS) 或UPDATE/DELETE(IX),InnoDB 就会自动帮你打好招呼。 - 层级结构:它建立了“表-行”的层级锁定协议。
- 唯一职责:它存在的唯一目的,就是为了告诉别人:“这表里有人在干活,别想把整张表搬走(加表锁)”。
自增锁 (AUTO-INC Lock)
- 专门针对
AUTO_INCREMENT列。在插入数据时获取,保证 ID 递增。
行级锁 (Row-level Lock) —— InnoDB 核心
记录锁 (Record Lock)
- 锁定具体的单行索引记录。
间隙锁 (Gap Lock)
- 锁定索引记录之间的间隙(不含记录本身),防止幻读。
邻键锁 (Next-Key Lock)
- 记录锁 + 间隙锁的组合,锁定一个范围并锁定记录本身(左开右闭区间)。
插入意向锁 (Insert Intention Lock)
- 特殊的间隙锁。插入行之前,由
INSERT操作产生。多个事务在同一间隙插入不同位置时,互不冲突。 - “间隙锁”是死对头,互相冲突。
按锁的模式/兼容性分类
共享锁 (Shared Lock / S 锁)
- 也称“读锁”。允许事务读取一行数据,多个事务可以同时持有同一行的 S 锁。
- SQL 触发:
SELECT ... LOCK IN SHARE MODE(8.0 改为FOR SHARE)。
排他锁 (Exclusive Lock / X 锁)
- 也称“写锁”。允许事务删除或更新一行数据。如果一个事务持有某行的 X 锁,其他事务不能再获取该行的任何锁(S 或 X)。
- SQL 触发:
UPDATE,DELETE,INSERT或SELECT ... FOR UPDATE。
特殊类型的锁与机制
谓词锁 (Predicate Locks)
- 空间索引锁:专门用于地理信息系统(GIS)数据的 R-tree 索引。由于空间数据没有明确的线性顺序,因此不使用 Next-Key 锁,而使用谓词锁来支持隔离级别。
乐观锁与悲观锁(逻辑概念而非物理锁)
- 悲观锁:假定冲突一定会发生,直接使用数据库提供的锁机制(如
FOR UPDATE)。 - 乐观锁:假定冲突不会发生,通过版本号(Version)或时间戳在应用层实现,提交时检查数据是否被修改。
锁的执行步骤
当执行一条 UPDATE lock_test SET status = 1 WHERE id = 3; 时,MySQL 内部的锁层级申请顺序如下:
- 元数据锁层:申请
lock_test表的 MDL 读锁(保证没人动表结构)。 - 表锁层:申请
lock_test表的 IX (意向排他锁)(告诉别人里面有行要改)。 - 行锁层:申请
id = 3的 X 记录锁(真正的修改动作)。
锁申请层级
| 层级 | 锁类型 | 已持锁 (Session A) | 请求锁 (Session B) | 结果 | 佐证逻辑与现象 |
|---|---|---|---|---|---|
| 1. MDL 层 | MDL 锁 | ALTER TABLE lock_test ADD COLUMN age INT; (未提交) |
UPDATE lock_test SET status = 1 WHERE id = 3; |
阻塞 | Metadata Lock 冲突:Session A 正在修改表结构,B 无法获取 MDL 读锁,导致整个语句挂起。 |
| 2. 表级 | 意向锁 (IX) | LOCK TABLES lock_test WRITE; |
UPDATE lock_test SET status = 1 WHERE id = 3; |
阻塞 | 表锁与意向锁冲突:Session A 显式锁定了全表,B 无法在表上申请到 IX 锁。 |
| 3. 行级 | 记录锁 (X) | SELECT * FROM lock_test WHERE id = 3 FOR UPDATE; |
UPDATE lock_test SET status = 1 WHERE id = 3; |
阻塞 | Record Lock 冲突:MDL 和 IX 都申请成功了,但在最后一步申请具体的 id=3 行锁时被 A 拦住。 |
锁冲突完整矩阵
| 请求锁 \ 已持锁 | 间隙锁 (Gap) | 记录锁 (Record) | Next-Key Lock | 插入意向锁 (I-I) |
|---|---|---|---|---|
| 间隙锁 (Gap) | 兼容 | 兼容 | 兼容 | 冲突 |
| 记录锁 (Record) | 兼容 | 冲突 | 冲突 | 兼容 |
| 邻键锁(Next-Key Lock) | 兼容 | 冲突 | 冲突 | 冲突 |
| 插入意向锁 (I-I) | 冲突 | 兼容 | 冲突 | 兼容 |
补充说明:隐式锁 (Implicit Lock)
InnoDB 在 INSERT 时,通常不显式加锁,而是通过一种“延迟加锁”机制。如果一个事务插入记录后,另一个事务尝试读取或修改该记录,隐式锁会转换成显示锁。
全局锁 (Global Lock)
- FTWRL (
Flush tables with read lock):锁定整个数据库实例,使整个库处于只读状态。 - 使用场景:主要用于全库逻辑备份(mysqldump)。
表锁(偏读)
特点:偏向MyISAM存储引擎
- 开销小、加锁快:不需要遍历索引去寻找具体行,直接在表元数据层面加标志。
- 无死锁:通常是一次性获取所需表的全部锁(或者按顺序获取),不满足循环等待条件,因此不会产生死锁。
- 粒度大、冲突高:锁住的是整张表,后续任何针对该表的写操作(甚至读操作,取决于锁类型)都必须排队。
- 并发度最低:适合以 查询(读) 为主、极少更新的业务场景。
底层逻辑:MyISAM 引擎不记录行号信息,也没有 Undo Log,无法处理复杂的并发事务,因此只能通过“锁全表”来简单粗暴地保证一致性。
案例分析
建表Sql
CREATE TABLE mylock
(
`id` INT NOT NULL PRIMARY KEY auto_increment,
`name` VARCHAR(20)
) ENGINE MyISAM;
INSERT INTO mylock (`name`) VALUES ('a');
INSERT INTO mylock (`name`) VALUES ('b');
INSERT INTO mylock (`name`) VALUES ('c');
INSERT INTO mylock (`name`) VALUES ('d');
INSERT INTO mylock (`name`) VALUES ('e');
SELECT * FROM mylock;
手动增加表锁
lock table (表名1) read(write), (表名2) read(write);
加读锁(共享锁)

加写锁(排他锁)
- 加表锁写锁时不能查询其他表信息,会报错


表锁结论
1. 核心加锁行为
MyISAM 不支持事务,使用的是 表级锁(Table-level Locking),由系统自动触发:
- SELECT:自动加 读锁(S 锁)。
- INSERT / UPDATE / DELETE:自动加 写锁(X 锁)。
2. 读写冲突规则
| 锁类型 | 冲突情况 | 行为描述 |
|---|---|---|
| 读锁 (Read) | 阻写,不阻读 | 允许多个进程同时读;但在读锁释放前,任何写操作都会被阻塞。 |
| 写锁 (Write) | 读写全阻 | 只要有一个进程在写,其他进程的读、写请求都会排队等待。 |
- 读操作:并发性好。由于读锁相互兼容,多个客户端可以同时读取同一张表。
- 写操作:具有排他性。写锁会独占整张表,在高并发写场景下容易导致大量的排队和性能瓶颈。
简而言之
读锁: 阻写,不阻读
写锁: 读和写都阻
表锁分析
查看加锁的表
show open tables
释放表锁
unlock tables;

分析表锁定
可以通过检查table_locks_waited和table_locks_immediate状态变量来分析系统上的表锁定:
show status like 'table%';
# 输出如下
+--------------------------+-----+
|Variable_name |Value|
+--------------------------+-----+
|Table_locks_immediate |44 |
|Table_locks_waited |0 |
|Table_open_cache_hits |16496|
|Table_open_cache_misses |253 |
|Table_open_cache_overflows|0 |
+--------------------------+-----+
核心监控指标
通过 SHOW STATUS LIKE 'table%'; 监控表锁争用情况:
Table_locks_immediate:成功次数。表示能够立即获得表锁的次数。Table_locks_waited:竞争次数。表示因锁争用而被迫等待的次数。
运维准则:若
Table_locks_waited值较高,说明存在严重的表级锁竞争,通常建议将引擎由 MyISAM 转换为 InnoDB。
调度机制:写优先 (Write First)
MyISAM 的调度机制默认认为写操作比读操作更重要:
- 优先级排队:即使读请求先到达锁等待队列,后到的写请求也会被插入到读请求之前。
- 读锁饥饿:在一个写密集的系统中,大量的更新操作会使读请求由于排队靠后而产生“永远阻塞”的现象。
- 读写互斥:
- 读锁:不阻碍其他读,但阻碍所有写。
- 写锁:阻碍所有的读和写。
性能瓶颈总结
- 不适合写为主:由于写操作会独占整张表并具有高优先级,频繁的 DML 会导致系统整体并发能力骤降。
- 适用场景:仅适用于以只读为主(SELECT)且写操作极少的业务,或对数据一致性要求较低的日志系统。
优化建议
- 调节优先级:若业务必须以读为主,可设置
low_priority_updates = 1,将写操作的优先级降到读操作之后。 - 单条控制:在特定的
INSERT/UPDATE后添加LOW_PRIORITY关键字。 - 强制整理:定期使用
OPTIMIZE TABLE整理碎片,提高锁的释放效率。
行锁(偏写)
特点:InnoDB 引擎特有
InnoDB 引擎特有、💡行锁必须加在索引上。
- 开销大、加锁慢:需要维护大量的锁状态信息,且加锁前需先通过索引定位到具体的记录。
- 会出现死锁:事务通常是逐行加锁的。如果事务 A 锁了行 1 后去锁行 2,而事务 B 锁了行 2 后去锁行 1,就会形成循环等待,导致死锁。
- 粒度小、冲突极低:只锁定被操作的那几行。只要不同事务操作的是不同的行,就可以完美并行。
- 并发度最高:适合高频率增删改查的互联网业务。
1、MySQL架构介绍#MyISAM vs InnoDB 核心区别表
SQL 准备
DROP TABLE IF EXISTS `lock_test`;
CREATE TABLE `lock_test`
(
`id` INT(11),
`text` VARCHAR(16),
`status` tinyint(1)
) ENGINE = INNODB COMMENT '锁';
INSERT INTO `lock_test` VALUES (1, 'b2', 0);
INSERT INTO `lock_test` VALUES (3, '3', 0);
INSERT INTO `lock_test` VALUES (9, '9000', 0);
# 建索引
CREATE UNIQUE INDEX `ux_id` ON `lock_test` (`id`);
CREATE INDEX `idx_text` ON `lock_test` (`text`);
排他锁
在 MySQL 的 InnoDB 引擎中,排他锁(Exclusive Lock)并非简单地“锁定所有读写”。理解其阻塞行为的关键在于区分锁定读与快照读。
核心概念:什么是排他锁?
排他锁又称写锁(X 锁)。当事务 A 对某行数据加上 X 锁后:
- 独占性:其他事务无法再对该行加任何类型的锁(S 锁或 X 锁)。
- 自动加锁:执行
UPDATE、DELETE、INSERT或SELECT ... FOR UPDATE时,InnoDB 会自动为涉及的记录加上 X 锁。
阻塞行为速查表
| 事务 B 的操作类型 | 是否被事务 A 的 X 锁阻塞 | 原因分析 |
|---|---|---|
| 增删改 (INSERT/UPDATE/DELETE) | 是 | 写操作必须获取 X 锁,锁冲突。 |
| 锁定读 (SELECT ... FOR UPDATE/LOCK IN SHARE MODE) | 是 | 显式请求加锁,锁冲突。 |
| 快照读 (普通 SELECT) | 否 | MVCC 机制允许读取历史版本,无需加锁。 |
注意: 如果隔离级别为
SERIALIZABLE,普通SELECT会隐式转为锁定读,此时会被阻塞。
实验佐证:还原阻塞与非阻塞现场
为了验证上述理论,我们通过两个窗口模拟并发事务。
准备工作
- 确保表引擎为
InnoDB - 取消自动提交
SET autocommit = 0; - 隔离级别为默认的
REPEATABLE-READ
场景演示
| 步骤 | 事务 A (窗口 1) | 事务 B (窗口 2) | 现象与原理解析 |
|---|---|---|---|
| 1 | BEGIN; |
BEGIN; |
开启两个独立事务。 |
| 2 | UPDATE lock_test SET text = '变更中' WHERE id = 3; |
事务 A 获取了 id=3 的 排他锁 (X 锁)。此时数据在 A 内存中已变。 |
|
| 3 | SELECT * FROM lock_test WHERE id = 3; |
非阻塞「快照读」:通过 MVCC 读取 Undo Log 中的历史版本。 | |
| 4 | SELECT * FROM lock_test WHERE id = 3 FOR UPDATE; |
阻塞「获取 X 锁」:因为 A 尚未提交,B 必须等待 A 释放锁。 | |
| 5 | SELECT * FROM lock_test WHERE id = 3 LOCK IN SHARE MODE; |
阻塞「获取 S 锁」:因为 A 尚未提交,B 必须等待 A 释放列。 | |
| 6 | UPDATE lock_test SET text = '并发修改' WHERE id = 3; |
阻塞「执行写操作」:同样需要获取 X 锁,进入锁等待队列。 | |
| 7 | COMMIT; |
事务 A 提交,释放 id=3 上的排他锁。 |
|
| 8 | 事务 B 自动解除阻塞 | 事务 B 获取到锁,执行成功。若步骤 4 被解除,则返回最新值;若步骤 5 被解除,则完成更新。 |
核心知识点补充
1. 阻塞根源:排他锁冲突
在 InnoDB 存储引擎中,所有的写操作(如 UPDATE、DELETE)以及显式的加锁读(FOR UPDATE)都会向目标行申请 排他锁(X 锁)。
- 冲突规则:排他锁具有排他性,一旦被某个事务持有,其他事务申请任何类型的锁都会失败。
- 现象描述:由于事务 A 已经通过
UPDATE占有了该行锁,事务 B 在执行同行的修改或锁定操作时,必须进入 锁等待(Lock Wait) 队列。
2. 自动释放:超时机制
如果事务 A 长期未提交(例如程序挂起或人为漏掉 COMMIT),事务 B 不会无限期等待,而是受以下参数限制:
- 核心参数:
innodb_lock_wait_timeout(默认值为 50 秒)。 - 报错反馈:超过等待阈值后,系统会抛出:
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
技术核心:为什么普通查询不被阻塞?
InnoDB 能够实现读写并行的核心技术是 MVCC(Multi-Version Concurrency Control)。
- Undo Log(回滚日志):当事务 A 修改数据时,InnoDB 会将旧数据写入 Undo Log。
- Read View(一致性视图):事务 B 执行普通查询时,会根据事务 ID 生成一个视图,发现当前行被锁定后,会顺着 Undo Log 找到修改前的版本。
- 意义:这种设计极大地提高了数据库的并发性能,避免了读操作因为写操作而长时间挂起。
常见误区:为什么我的普通查询被阻塞了?
如果在测试中发现普通 SELECT 也被阻塞,请检查以下三点:
- 隔离级别:是否执行了
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;? - 表级锁:是否意外触发了表级排他锁(如使用了
LOCK TABLES或非索引字段更新导致锁全表)? - 元数据锁 (MDL):事务 A 是否执行了修改表结构的 DDL 语句?MDL 锁会阻塞一切读写。
总结
排他锁遵循“写-写冲突”和“写-带锁读冲突”的原则,但通过 MVCC 技术完美避开了“写-快照读冲突”。这正是 MySQL 能够在高并发场景下保持高效的原因。
死锁
在 MySQL 中,当多个事务 并发更新同一行数据 时,由于 InnoDB 存储引擎的 行级锁机制,可能会出现死锁 的问题。
问题复现与原理分析
如果多个事务交叉等待对方持有的锁(例如:事务 A 等待事务 B 的锁,事务 B 等待事务 A 的锁),就会触发死锁检测,MySQL 会回滚其中一个事务。
如果事务涉及 多行或多表,并且多个事务以 不同顺序 加锁,就可能形成死锁。
-- 事务 A
START TRANSACTION;
UPDATE lock_test SET text = '并发修改' WHERE id = 1;
UPDATE lock_test SET text = '并发修改' WHERE id = 3;
-- 事务 B
START TRANSACTION;
UPDATE lock_test SET text = '并发修改' WHERE id = 3;
UPDATE lock_test SET text = '并发修改' WHERE id = 1;
- 事务 A 持有
id=1的锁,事务 B 持有id=3的锁。 - 事务 A 等待事务 B 的
id=3锁,事务 B 等待事务 A 的id=1锁,从而造成死锁。
解决方案与优化建议
避免死锁
- 统一加锁顺序:所有事务按照相同的顺序更新资源(例如:先更新
table1再更新table2)。 - 使用乐观锁(Optimistic Locking):在表中添加
version字段,更新时检查版本号是否匹配。
START TRANSACTION;
SELECT * FROM lock_test WHERE `id` = 1 FOR UPDATE;
IF version == expected THEN
UPDATE lock_test SET `text` = 'kkkk', `version` = version + 1 WHERE `id` = 1;
END IF;
COMMIT;
重试机制
- 应用程序层面重试:捕获
Lock wait timeout或Deadlock错误后,使用redis原子性重试事务。
class RedisLock
{
private static \Redis $redis;
private static string $value = ''; // 简化命名 $lockValue -> $value
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 100;
// 初始化:连接和生成唯一值
public static function init(\Redis $r): void
{
self::$redis = $r;
self::$value = bin2hex(random_bytes(16));
}
// 1. 尝试加锁 (原子SETNX+EX)
private static function tryLock(string $k, int $exp = 30): bool
{
return self::$redis->set($k, self::$value, ['EX' => $exp, 'NX']);
}
// 2. 重试获取锁
public static function acquire(string $k, int $exp = 30): bool
{
for ($i = 0; $i < self::MAX_RETRIES; $i++) {
if tryLock($k, $exp) {
return true;
}
if ($i < self::MAX_RETRIES - 1) {
// 递增延迟
usleep(($i + 1) * self::RETRY_DELAY_MS * 1000);
}
}
return false;
}
// 3. 原子释放锁 (Lua脚本)
public static function release(string $k): bool
{
$lua = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end";
// KEYS[1] = $k, ARGV[1] = self::$value
return $redis->eval($lua, [$k, self::$value], 1;
}
}
减少锁等待时间
- 缩短事务生命周期:尽量将事务控制在业务逻辑的最小范围内,避免长时间持有锁。
START TRANSACTION;
UPDATE lock_test SET `text` = 'kkkk' WHERE `id` = 1;
COMMIT; -- 立即提交
- 优化查询性能:确保
WHERE条件字段(如id)有 索引,减少锁扫描范围。 - 调整数据库配置
- 降低锁超时时间:适当调小
SET innodb_lock_wait_timeout = 10; -- 10秒(默认 50 秒),减少长时间等待导致的超时错误。 - 启用死锁检测:MySQL 默认会自动检测死锁并回滚最小事务,无需手动干预。
- 降低锁超时时间:适当调小
死锁保留机制
Mysql 默认会开启死锁检测机制
# 开启死锁检测
SET GLOBAL innodb_deadlock_detect = ON;
# 查看死锁检测状态
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
当多个会话竞争同一资源并形成死锁时,数据库遵循以下生存法则:
- 牺牲权重最低者(The Weight Law) 数据库计算每个事务产生的
undo log(回滚日志)大小。谁改动的数据行数最少,谁就被优先回滚。
本质: 为了以最低的 IO 成本解开死锁。
- 强制打破循环(The Loop-Breaking Law) 一旦检测到锁等待环路(如 A 等 B,B 等 A),数据库不会等待超时,而是主动干预。它会立即杀死其中一个事务,释放其持有的所有锁资源。
本质: 牺牲局部,保全大局的吞吐量,防止全线卡死。
- 幸存者获胜(The Survivor Law) 当“牺牲品”释放锁后,原本阻塞的“幸存者”会自动获得资源。在“S 锁升级 X 锁”的死锁中,只要有一方被干掉,另一方就能立刻完成升级并提交成功。
本质: 只要死锁环路断开,剩下的事务将按照优先级顺序恢复通行。
案例1: update 权重
| 步骤 | 会话 A (权重高) | 会话 B (权重低) |
|---|---|---|
| 1 | BEGIN; |
BEGIN; |
| 2 | UPDATE lock_test SET text = 'AA' WHERE id = 1; |
UPDATE lock_test SET text = 'BB2' WHERE id = 3; |
| 3 | UPDATE lock_test SET text = 'CC' WHERE id = 9; (此时已改 2 行) |
(保持沉默,只改了 1 行) |
| 4 | UPDATE lock_test SET text = 'BB' WHERE id = 3;结果: 会话 A 进入等待状态(等会话 B 释放 id=2) |
|
| 5 | UPDATE lock_test SET text = 'BB2' WHERE id = 1;结果: 触发死锁检测,回滚权重更低的会话B |
案例2: insert 权重
| 顺序 | Session 1 (事务 A) | Session 2 (事务 B)(权重低) | Session 3 (事务 C) (权重高) | 结果分析 |
|---|---|---|---|---|
| 1 | INSERT INTO lock_test(id) VALUES(10); |
A 持有 id=10 的 X 锁 | ||
| 2 | INSERT INTO lock_test(id) VALUES(10); |
B 被阻塞,并申请 S 锁 | ||
| 3 | INSERT INTO lock_test(id) VALUES(11); |
|||
| 4 | INSERT INTO lock_test(id) VALUES(10); |
C 被阻塞,并申请 S 锁 | ||
| 5 | ROLLBACK; |
死锁预警:B 和 C 同时获得 S 锁,又同时想升级为 X 锁去执行插入。 结果: 触发死锁检测,回滚权重更低的 Session 2 |
总结
死锁保留机制就是 “定向清除最弱竞争者,腾出通道给幸存者” 。
死锁监控
默认状态:内存瞬时记录
- 行为:当发生死锁时,MySQL 会自动回滚成本最低的事务,并向客户端返回错误码
1213。 - 存储:死锁信息仅存储在内存中,且只保留1;;最近一次记录。
- 查看命令:
# 查询锁信息
SHOW ENGINE INNODB STATUS;
# 输出如下
LATEST DETECTED DEADLOCK
------------------------
...
Transaction 1: ... holds lock on table1
Transaction 2: ... holds lock on table2
... deadlock detected
注意:若发生新的死锁,旧信息会被覆盖;数据库重启后该信息也会丢失。
持久化配置:开启日志打印
为了留存历史证据排查间歇性死锁,需将信息同步至物理磁盘。
- 开启命令:
-- 实时开启(重启失效)
SET GLOBAL innodb_print_all_deadlocks = ON;
- 验证状态:
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';
- 结果:开启后,所有死锁轨迹将自动记录到 MySQL 的 错误日志 (
.error.log) 中。
运维排查路径
当收到死锁报错时,按以下步骤定位源头:
- 确定日志路径:
SHOW VARIABLES LIKE 'log_error';
- 查看物理日志(以 Linux 为例):
tail -f /var/log/mysql/error.log
- 日志核心解析指标:
HOLDS THE LOCK(S):当前事务已占有的锁。WAITING FOR THIS LOCK TO BE GRANTED:正在申请并导致阻塞的锁。WE ROLL BACK TRANSACTION:最终被系统判定牺牲(回滚)的事务。
死锁案例
案例1:间隙锁 (Gap Lock) 导致的插入意向锁冲突「IODKU」
这种场景最常见于并发插入表中不存在的记录。此时,InnoDB 会锁定记录之间的“间隙”。
前提:
- 表中已有
id记录:1, 3, 9。 - 事务 A 和 B 尝试插入
id在 4 到 8 之间的不同记录。
| 步骤 | 事务 A (Transaction A) | 事务 B (Transaction B) |
|---|---|---|
| 1 | BEGIN; |
BEGIN; |
| 2 | INSERT INTO lock_test (id, text, status) VALUES (5, 'A', 1) ON DUPLICATE KEY UPDATE status=1; |
- |
| 3 | [A 持锁情况]:由于 id=5 不存在,A 获得了间隙锁 (3, 9)。 |
- |
| 4 | - | INSERT INTO lock_test (id, text, status) VALUES (6, 'B', 1) ON DUPLICATE KEY UPDATE status=1; |
| 5 | - | [B 持锁情况]:由于 id=6 不存在,B 也获得了间隙锁 (3, 9)(间隙锁不互斥)。 |
| 6 | [A 尝试写入]:A 准备执行插入,申请 插入意向锁 (Insert Intention Lock)。但因为 B 持有 (3, 9) 的 Gap Lock,A 进入等待。 |
- |
| 7 | - | [B 尝试写入]:B 准备执行插入,申请 插入意向锁。但因为 A 持有 (3, 9) 的 Gap Lock,B 进入等待。 |
| 结果 | Deadlock:A 等待 B,B 等待 A,MySQL 触发死锁检测并回滚。 |
核心逻辑总结
- S 锁是万恶之源:当 IODKU 遇到唯一键冲突时,会先申请 S Next-Key Lock。因为 S 锁与 S 锁兼容,多个事务可以同时持有,导致随后申请 X 锁进行更新时出现互相等待。
- 插入意向锁的定位:它是一种特殊的间隙锁。它不阻止其他事务的插入意向锁,只会被已存在的 Gap Lock 或 Next-Key Lock 阻塞。
- 冲突演变:
- 已有记录:S 锁冲突 共同持有 申请 X 锁 死锁。
- 无记录(并发插入):X 锁占位 其他事务转为 S 锁等待 最终演变为已有记录的情况。
如何避免 IODKU 死锁?
- 降低并发度:在应用层通过 Redis 分布式锁控制同一唯一键的并发操作。
- 拆分操作:如果业务允许,先
SELECT判断是否存在,再决定INSERT或UPDATE(但在高并发下仍需注意事务隔离级别)。 - 清理无效数据:减少事务回滚的情况,因为
ROLLBACK是触发场景二死锁的诱因。
案例2:唯一键冲突引起的 S 锁升级死锁「IODKU」
当并发事务同时对表中已存在的同一行进行 IODKU 操作时,会发生锁升级冲突。
初始状态
前提:表中已有记录:id=1, text='b2', status=0
| 步骤 | 事务 A (Transaction A) | 事务 B (Transaction B) |
|---|---|---|
| 1 | BEGIN; |
BEGIN; |
| 2 | INSERT INTO lock_test (id, text, status) VALUES (1, 'b2', 1) ON DUPLICATE KEY UPDATE status=1; |
- |
| 3 | [A 持锁情况]:发现 id=1 冲突,A 对该行加了 S 锁 (共享锁)。 |
- |
| 4 | - | INSERT INTO lock_test (id, text, status) VALUES (1, 'b2', 2) ON DUPLICATE KEY UPDATE status=2; |
| 5 | - | [B 持锁情况]:B 也发现冲突,对该行加了 S 锁(S 锁相互兼容)。 |
| 6 | [A 尝试更新]:A 准备执行 UPDATE,需要将 S 锁升级为 X 锁 (排他锁)。但 B 持有 S 锁,A 阻塞等待。 | - |
| 7 | - | [B 尝试更新]:B 也准备执行 UPDATE,需要将 S 锁升级为 X 锁。但 A 持有 S 锁,B 阻塞等待。 |
| 结果 | Deadlock:双方都在等待对方释放共享锁以完成升级。 |
案例3:唯一索引冲突导致的 S 锁升级
场景:多个事务同时插入同一条具有唯一索引(Unique Index)的记录。
核心原理:当事务 A 插入记录但未提交时,事务 B 尝试插入相同记录会触发唯一性检查。此时事务 B 会请求在该行加一个共享锁(S 锁)。
| 顺序 | Session 1 (事务 A) | Session 2 (事务 B) | Session 3 (事务 C) | 结果分析 |
|---|---|---|---|---|
| T1 | INSERT INTO lock_test(id) VALUES(10); |
A 持有 id=10 的 X 锁 | ||
| T2 | INSERT INTO lock_test(id) VALUES(10); |
B 被阻塞,并申请 S 锁 | ||
| T3 | INSERT INTO lock_test(id) VALUES(10); |
C 被阻塞,并申请 S 锁 | ||
| T4 | ROLLBACK; |
死锁预警:B 和 C 同时获得 S 锁,又同时想升级为 X 锁去执行插入。 |
笔记要点:回滚(Rollback)操作并不总是安全的,在高并发唯一键竞争下,回滚可能导致其他等待事务瞬间触发死锁。
1. 深度拆解:为什么 B 和 C 会申请 S 锁?
在普通的 INSERT 中,事务确实只申请插入意向锁。但当存在唯一性约束时,逻辑会发生变化:
- 第一阶段(T2/T3):B 和 C 尝试插入
id=10。它们首先去检查索引,发现id=10已经被 A 占用了。 - 第二阶段(关键转换):为了确认 A 是否真的会提交这个
id=10(如果 A 提交,B/C 必须报错;如果 A 回滚,B/C 可以插入),B 和 C 不能只挂起。根据 InnoDB 规则,此时 B 和 C 会对该行(或该位置)申请一个 S 锁(共享锁)。 - 原因:S 锁允许读取,但禁止他人修改。这是一种“监视”状态。
2. 为什么 T4 会触发死锁?
当 A 执行 ROLLBACK 时,A 持有的 X 锁释放。此时:
- S 锁生效:由于 S 锁与 S 锁是相互兼容的,B 和 C 同时获得了 id=10 这个位置的 S 锁。
- 转换 X 锁:B 和 C 发现 A 撤了,都想立刻完成自己的
INSERT。执行INSERT动作本身需要获取 X 锁(排他锁)。 - 循环等待形成:
- B 想拿 X 锁,但必须等 C 释放 S 锁(S 与 X 互斥)。
- C 想拿 X 锁,但必须等 B 释放 S 锁(S 与 X 互斥)。
- 结果:B 等 C,C 等 B,形成死锁。MySQL 此时会抛出
Deadlock found并回滚其中一个。
3. 这与“插入意向锁”矛盾吗?
不矛盾。 请看下表的逻辑分层:
| 动作类型 | 正常插入 (无冲突) | 唯一键冲突插入 (有占用) |
|---|---|---|
| 初始申请 | 插入意向锁 (Insert Intention) | 插入意向锁 (被挡住后立即转换) |
| 等待状态 | 无需等待 | S 锁 (共享锁) |
| 执行插入瞬间 | X 锁 (记录锁) | X 锁 (记录锁) |
- 插入意向锁:只在“确定没有相同 ID”且“没有间隙锁挡路”时才起作用,让并发变快。
- S 锁:是在“发现有人可能跟我冲突”时的一种保护性监测。
- X 锁:是任何写操作(包括插入成功的一瞬间)必须拿到的最终权限。
共享锁
简介
共享锁(S 锁)允许多个事务同时读取同一份数据,但会阻止其他事务对该数据进行修改或加排他锁(X 锁)。
当一个事务持有共享锁时,其他事务可以继续获取共享锁进行读取,但任何想要修改数据的事务都必须等待。
在高并发场景下,如果读写操作频繁交错,S 锁与 X 锁的竞争可能导致死锁。
使用场景
共享锁适用于需要确保数据在读取期间保持不变的场景,尤其常见于主从表数据一致性的维护。
示例:
在向从表插入记录时,应先对主表关联行加 S 锁(共享锁),以防止其他事务删除或修改主表父记录,从而保障数据间的引用完整性和一致性。
共享锁事务之间的读取
取消自动提交
SET autocommit = 0;
session1: 添加共享锁
START TRANSACTION;
SELECT * FROM lock_test WHERE `id` = 1 LOCK IN SHARE MODE;
session2: 添加共享锁
START TRANSACTION;
SELECT * FROM lock_test WHERE `id` = 1 LOCK IN SHARE MODE;
此时 session1 和 session2 都可以正常获取结果,那么再加入 session3 排他锁读取尝试
session3: 添加排它锁
START TRANSACTION;
SELECT * FROM lock_test WHERE `id` = 1 FOR UPDATE;
在 session3 中则无法获取数据,直到超时或其它事务 commit
Lock wait timeout exceeded; try restarting transaction
结论
- 允许其它事务也增加共享锁读取
- 不允许其它事务增加排他锁 (for update)
- 当事务同时增加共享锁时候,事务的更新必须等待先执行的事务 commit 后才行,如果同时并发太大可能很容易造成死锁
间隙锁
间隙锁(Gap Lock)是加在索引记录之间的间隙上的锁,或者加在第一条索引记录之前或最后一条索引记录之后的间隙上的锁。
- 触发机制:在 RR 隔离级别下,执行任何带锁读(
FOR UPDATE/LOCK IN SHARE MODE)或写操作(UPDATE/DELETE)时,若扫描范围内存在“空隙”,就会产生间隙锁。 - 主要目的:防止其他事务在该间隙插入数据,从而解决幻读。
间隙锁的三大位置
以数据 [1, 3, 9] 为例
- 记录间隙 (中间区域):
- 位置:相邻两条记录
与 之间的开区间。 - 示例:
(1, 3)和(3, 9)。 - 触发条件:锁定不存在的中间值(如
WHERE id = 5)。
- 位置:相邻两条记录
- 首部间隙 (左侧边缘):
- 位置:负无穷大到最小记录之间的开区间。
- 示例:
(-∞, 1)。 - 触发条件:锁定比最小值还小的区域(如
WHERE id = -10)。
- 尾部间隙 (右侧边缘):
- 位置:最大记录到正无穷大(
Supremum)之间的开区间。 - 示例:
(9, +∞)。 - 触发条件:锁定比最大值还大的区域(如
WHERE id = 15)。
- 位置:最大记录到正无穷大(
关键特性与行为对照
| 特性分类 | 核心逻辑 | 业务影响 |
|---|---|---|
| 锁定目标 | 锁定 “不存在” 的空间,而非具体的行记录。 | 阻止 INSERT 操作,不影响 SELECT。 |
| 兼容性 | 间隙锁与间隙锁兼容。多个事务可同时锁定同一间隙。 | 不会因为两个事务都查 id=15 而互相阻塞。 |
| 排他性 | 间隙锁与插入意向锁 (I-I Lock)冲突。 | 只要间隙被锁,任何在此区间的 INSERT 都会挂起。 |
| 范围确定 | 由查询值在索引树中的左右邻居动态决定。 | 即使只查一个点,也可能锁住一个大范围。 |
案例1:范围查询、命中空值
前提:表中现有数据 [1, 3, 9]
| 顺序 | 场景 A:范围查询 (Range) | 场景 B:命中空记录 (Non-existent) | 锁分析与结果 |
|---|---|---|---|
| T1 | BEGIN; |
BEGIN; |
事务初始化 |
| T2 | SELECT * FROM lock_test WHERE id > 3 AND id < 9 FOR UPDATE; |
范围查询,产生间隙锁:两者都会锁定区间 (4, 8)。 |
|
| T3 | INSERT INTO lock_test(id) VALUES(6); |
插入排他锁与间隙锁冲突,导致 Session 2 挂起。 | |
| T4 | UPDATE lock_test SET status=1 WHERE id = 5; |
命中空值,产生间隙锁:两者都会锁定区间 (4, 5)。 |
|
| T5 | INSERT INTO lock_test(id) VALUES(4); |
插入意向锁与间隙锁冲突,导致 Session 1 挂起。 | |
| T6 | COMMIT; |
COMMIT; |
释放间隙锁,阻塞解除。 |
案例2:左侧无限间隙锁测试
前提:假设当前表中 id 的最小值是 1。
| 顺序 | Session 1 (事务 A) | Session 2 (事务 B) | 锁分析与结果 |
|---|---|---|---|
| T1 | BEGIN; |
BEGIN; |
开启事务。 |
| T2 | UPDATE lock_test SET status=1 WHERE id = -5; |
锁定左侧间隙:由于 -5 小于表中最小值 1,锁被加在 (-∞, 1) 区间。 |
|
| T3 | INSERT INTO lock_test(id, text) VALUES(0, 'zero'); |
阻塞:0 落在 (-∞, 1) 区间内,触发插入意向锁等待。 |
|
| T4 | UPDATE lock_test SET status=5 WHERE id = 9; |
非阻塞:修改 id=9 的数据,它在右侧区域,不受左侧间隙锁影响。 |
|
| T5 | COMMIT; |
A 提交后,B 的插入操作(T3)立即执行。 |
深入理解左侧无限间隙
- 判定区间:当查询
id = -5时,MySQL 发现它小于索引树中的最小记录1。 - 锁定下限:在 RR 级别下,为了防止幻读,InnoDB 会锁定第一条记录
1及其左侧的所有空间。 - 日志表现:在
SHOW ENGINE INNODB STATUS中,这种锁通常显示为在第一条记录(heap no 2,通常是最小值)上加了gap lock。
核心知识点补充:为什么 INSERT 互不影响
- INSERT 行为:当你插入
id=100和id=200时,它们各自申请自己的行锁,互不干扰(只要不触发主键冲突)。 - UPDATE/DELETE/SELECT...FOR UPDATE 行为:当你针对一个不存在的范围执行这些语句时,InnoDB 不知道你想保护哪个点,为了保险起见,它必须封锁整个间隙,这往往会导致“误伤”其他无辜的插入操作。
案例3:右侧无限间隙锁测试
| 顺序 | Session 1 (事务 A) | Session 2 (事务 B) | 锁分析与结果 |
|---|---|---|---|
| T1 | BEGIN; |
BEGIN; |
开启事务。 |
| T2 | UPDATE lock_test SET status=1 WHERE id = 15; |
锁定右侧间隙:由于 15 大于表中最大值 9,锁被加在 (9, +∞)。 |
|
| T3 | INSERT INTO lock_test(id, text) VALUES(20, 'new'); |
阻塞:20 落在 (9, +∞) 区间内,无法插入。 |
|
| T4 | UPDATE lock_test SET status=5 WHERE id = 1; |
非阻塞:修改 id=1 的数据,因为它不在 (9, +∞) 区间,操作成功。 |
|
| T5 | COMMIT; |
A 提交后,Session 2 的插入操作(T3)立即恢复执行。 |
为什么这里会阻塞?
- 判定区间:当 MySQL 发现你查询的
id = 15在索引树中找不到对应的行时,它会定位到当前索引的最右末端——即特殊的 Supremum 记录。 - 锁定上限:由于是 RR 隔离级别,为了防止“幻读”(即防止你刚才搜 15 没有,过一会儿别人插了个 15 导致你搜到了),它会将最后一个记录
9到正无穷大+supremum之间的所有空位全部锁死。 - 影响范围:此时,任何试图插入
id = 10、id = 100甚至id = 9999的操作都会被阻塞。
核心知识点
- Supremum 伪记录:它是 InnoDB 内部为每个索引页定义的虚拟上限,用于标记索引的边界。
- 如何规避:
- 精准匹配:尽量在业务层保证
UPDATE针对的是已存在的行。 - 降级隔离级别:如果业务允许,使用
READ COMMITTED (RC),该级别下没有间隙锁。 - 使用唯一索引:在唯一索引上通过主键精准更新一个存在的记录时,只会加行锁,不会加间隙锁。
- 精准匹配:尽量在业务层保证
核心避坑指南
间隙锁的“副作用”
- 无辜锁定:即使你只是想更新
id=5这一行,且这一行根本不存在,MySQL 也会把(3, 9)整个范围封死,导致正常的INSERT失败。 - 性能损耗:在大范围
UPDATE时,间隙锁会导致大面积的插入延迟。
如何规避
- 改用 RC 隔离级别:如果业务允许,将
transaction_isolation设置为READ-COMMITTED,此级别下间隙锁几乎不存在(极大提升并发插入性能)。 - 精准操作:尽量保证
UPDATE的条件是存在的、且通过主键定位,避免触发范围扫描。 - 离散化数据:如果插入非常频繁,尽量避免大范围的空隙,减少间隙锁锁定的区间长度。
锁冲突矩阵
| 请求锁 \ 已持锁 | 间隙锁 (Gap) | 记录锁 (Record) | 邻键锁 (Next-Key Lock) | 插入意向锁 (I-I) |
|---|---|---|---|---|
| 间隙锁 (Gap) | 兼容 | 兼容 | 兼容 | 冲突 |
| 记录锁 (Record) | 兼容 | 冲突 | 冲突 | 兼容 |
| 邻键锁 (Next-Key Lock) | 兼容 | 冲突 | 冲突 | 冲突 |
| 插入意向锁 (I-I) | 冲突 | 兼容 | 冲突 | 兼容 |
案例
环境准备:
- 隔离级别:
REPEATABLE READ(RR) - 数据分布:
id索引包含记录1, 3, 9 - 间隙分布:
(-∞, 1),(1, 3),(3, 9),(9, +∞)
| 场景 | 已持锁 (Session A) | 请求锁 (Session B) | 结果 | 佐证逻辑说明 |
|---|---|---|---|---|
| Gap vs Gap | SELECT * FROM lock_test WHERE id = 5 FOR UPDATE; |
SELECT * FROM lock_test WHERE id = 6 FOR UPDATE; |
成功 | 两个事务都在 (3, 9) 间隙加 Gap Lock。间隙锁是防御性的,互相不排斥。 |
| Gap vs Record | SELECT * FROM lock_test WHERE id = 2 FOR UPDATE; |
UPDATE lock_test SET status = 1 WHERE id = 3; |
成功 | A 持有 (1, 3) 的 Gap Lock。两者互不干涉。B 持有 id=3 的 Record Lock; |
| **Gap vs I-I ** | SELECT * FROM lock_test WHERE id = 5 FOR UPDATE; |
INSERT INTO lock_test VALUES (4, 'new', 0); |
阻塞 | A 在 id=5 不存在时持有 (3, 9) 的 Gap Lock;B 请求 id=4 的 I-I Lock,因落在该间隙内被拦截。 |
| I-I vs I-I | INSERT INTO lock_test VALUES (4, 'a', 0); (未提交) |
INSERT INTO lock_test VALUES (5, 'b', 0); |
成功 | 只要插入的 ID 不冲突,多个 I-I Lock 可以共存于同一个间隙 (3, 9)。 |
| Next-Key vs I-I | SELECT * FROM lock_test WHERE id <= 3 FOR UPDATE; |
INSERT INTO lock_test VALUES (2, 'new', 0); |
阻塞 | A 持有 (1, 3] 的 Next-Key Lock(包含间隙锁);B 在该间隙插入需申请 I-I Lock,产生冲突。 |
| Record vs Record | UPDATE lock_test SET status = 1 WHERE id = 9; |
DELETE FROM lock_test WHERE id = 9; |
阻塞 | 针对同一行 id=9 的 Record Lock(X 锁)互斥。 |
死锁经典复现
| 步骤 | 时间线 | Session A (事务 A) | Session B (事务 B) | 锁状态说明 |
|---|---|---|---|---|
| 1 | T1 | BEGIN; |
BEGIN; |
开启事务 |
| 2 | T2 | SELECT * FROM lock_test WHERE id = 5 FOR UPDATE; |
A 获取了 (3, 9) 的 Gap Lock | |
| 3 | T3 | SELECT * FROM lock_test WHERE id = 6 FOR UPDATE; |
B 也获取了 (3, 9) 的 Gap Lock (间隙锁互兼容) | |
| 4 | T4 | INSERT INTO lock_test VALUES (5, 'A', 0); |
A 申请 Insert Intention Lock,被 B 的 Gap Lock 阻塞,进入等待 | |
| 5 | T5 | INSERT INTO lock_test VALUES (6, 'B', 0); |
B 申请 Insert Intention Lock,被 A 的 Gap Lock 阻塞。触发死锁! | |
| 6 | T6 | 结果 | 结果 | InnoDB 检测到循环等待,回滚其中一个事务(通常是权重较小的) |
T5 为什么不是“B 等 B”?
在数据库锁逻辑中,锁是加在事务上的,而不是加在语句上的。
- 当 Session B 执行
SELECT...FOR UPDATE时,它代表 事务 B 持有了(3, 9)的间隙锁。 - 当 Session B 随后执行
INSERT时,它是以 事务 B 的身份请求插入意向锁。 - 自兼容性: 事务不会被自己持有的锁阻塞。如果一个间隙内只有事务 B 持有的间隙锁,那么事务 B 可以在该间隙内自由插入。
所以,如果没有 Session A 的存在,Session B 永远不会等自己。
关键结论
间隙锁是“损人利己”的锁:它保护了当前事务不会读到“幻影数据”,但代价是牺牲了其他事务的插入并发度。
RC隔离级别
RC(Read Committed)与 RR(Repeatable Read)在不同实战场景下的锁行为对比。
- 数据:
id=1,id=3,id=9 - 索引:
id为唯一索引,text为普通索引。
案例1:删除不存在的范围(间隙锁差异)
场景:尝试删除 id 在 4 到 8 之间的记录(不存在),同时并发插入 id=5。
| 步骤 | Session A (事务 A) | Session B (事务 B) | RR 级别表现 | RC 级别表现 |
|---|---|---|---|---|
| 1 | BEGIN; |
BEGIN; |
- | - |
| 2 | DELETE FROM lock_test WHERE id BETWEEN 4 AND 8; |
- | 持有 (3, 9) 的 Gap Lock |
不加锁(无匹配记录) |
| 3 | - | INSERT INTO lock_test VALUES (5, 'new', 0); |
阻塞(等待 A 的 Gap Lock) | 立即成功 |
| 4 | COMMIT; |
- | B 此时才完成插入 | A、B 均顺利完成 |
| 结论:RC 级别通过消除非必要的间隙锁,显著提升了这种“落空删除”场景下的并发插入性能。 |
案例2:半一致性读 (Semi-Consistent Read)
什么是半一致性读?
在 MySQL InnoDB 中,半一致性读是一种针对 UPDATE 语句的优化手段。当一个 UPDATE 语句在 RC 级别下运行,且它搜索到的行已经被其他事务锁定时,InnoDB 会返回该行最新的提交版本给 MySQL Server 层,由 Server 层判断该行是否符合 UPDATE 的 WHERE 条件。
| 步骤 | Session A (事务 A) | Session B (Session B) | RR 级别表现 | RC 级别表现 |
|---|---|---|---|---|
| 1 | BEGIN; |
BEGIN; |
- | - |
| 2 | UPDATE lock_test SET status = 1 WHERE id = 3; |
- | 锁定 id=3 的 Record Lock |
锁定 id=3 的 Record Lock |
| 3 | - | UPDATE lock_test SET text = 'X' WHERE status = 0; |
阻塞。必须等 A 释放锁才能读到最新行。 | 立即返回 (0 rows)。通过快照读发现 status=0 不符条件,直接跳过。 |
结论:RC 下的“半一致性读”让 UPDATE 更加智能,避免了在明知不符合条件时的无效锁等待。 |
案例3:唯一性检查与外键(RC 依然加锁的例外)
注意:尽管 RC 减少了间隙锁,但在约束检查(如唯一键检查、外键检查)时,为了防止幻读破坏完整性,RC 依然会加锁。
场景:A 删除 id=3 但未提交。B 尝试插入 id=3。
| 步骤 | Session A (事务 A) | Session B (Session B) | RR 级别表现 | RC 级别表现 |
|---|---|---|---|---|
| 1 | BEGIN; |
BEGIN; |
- | - |
| 2 | DELETE FROM lock_test WHERE id = 3; |
- | 持有 id=3 的 X 锁 |
持有 id=3 的 X 锁 |
| 3 | - | INSERT INTO lock_test VALUES (3, 'tmp', 1); |
阻塞 | 阻塞(必须进行唯一性检查) |
总结:RC vs RR 锁特性速查表
| 特性 | Read Committed (RC) | Repeatable Read (RR) |
|---|---|---|
| 间隙锁 (Gap Lock) | 基本禁用(外键/唯一性检查除外) | 开启(用于防止幻读) |
| Next-Key Lock | 弱化为纯记录锁 (Record Lock) | 默认模式 |
| 半一致性读 | 支持 | 不支持 |
| 锁释放时机 | 不匹配条件的记录,锁会立即释放 | 事务提交或回滚后才统一释放 |
邻键锁
邻键锁是 InnoDB 在 Repeatable Read (RR) 隔离级别下为了解决幻读问题而引入的锁机制。
核心定义
邻键锁实质上是记录锁 (Record Lock) 与 间隙锁 (Gap Lock) 的组合。它不仅锁定记录本身,还锁定该记录之前的“间隙”。
为什么需要邻键锁?
在 RR 隔离级别下,单纯的记录锁只能防止别人修改现有的行,但无法防止别人在空隙中插入新行(幻读)。邻键锁通过“封锁记录及其前方区域”,确保在这个范围内既不能改,也不能增。
锁范围公式:非唯一索引
在 Repeatable Read (RR) 隔离级别下,InnoDB 锁定非唯一索引记录
公式拆解与定义
为了实现防幻读,InnoDB 将两种锁算法进行了组合:
- Next-Key Lock:
- 左开:不包含前一个记录
本身,仅锁定其后的间隙。 - 右闭:包含当前命中的记录
本身。
- 左开:不包含前一个记录
- Gap Lock(间隙锁):
- 全开区间:不包含当前记录
,也不包含下一个记录 。 - 作用:仅封锁
与 之间的“荒地”,防止他人插入新数据。
最终效果:从之后到 之前的所有空间被全量封锁,呈现为 的整体效果。
- 全开区间:不包含当前记录
锁范围公式:唯一索引
在 RR 隔离级别下,锁定唯一索引
记录存在(等值命中)
- 公式退化:
- 逻辑拆解:
- 不再锁定
的左侧间隙。 - 不再锁定
的右侧间隙。 - 最终效果:仅封锁记录
本身。
- 不再锁定
记录不存在(等值未命中)
- 公式退化:
- 逻辑拆解:
- 左开:不含
。 - 右开:不含
。 - 最终效果:封锁
到 之间的所有空隙,防止 被插入。
- 左开:不含
锁范围公式:范围查询
当执行如 WHERE id > 3 或 WHERE id >= 3 这种范围锁定读/写时,InnoDB 会沿着索引树向后扫描,直到触碰到索引的尽头。
唯一索引、非唯一索引范围写
假设记录为 1, 3, 9:
- 操作:
UPDATE ... WHERE id >= 3 - 锁定范围:
+ - 逻辑:包含
且从 id=3开始,包含 3 本身,以及 3 之后的所有记录和所有间隙(直到最高上限Supremum)。
案例1:非唯一索引等值查询(记录存在)
假设数据库已有记录:1, 3, 9且id 字段建有普通索引。
| 步骤 | 事务 A (Session A) | 事务 B (Session B) | 结果 | 逻辑深度分析 |
|---|---|---|---|---|
| 1 | SET autocommit = 0;BEGIN; |
SET autocommit = 0;BEGIN; |
- | 开启事务,关闭自动提交。 |
| 2 | SELECT * FROM lock_test WHERE id = 3 FOR UPDATE; |
- | 持有 |
包含记录 3,以及 1~3 和 3~9 之间的空隙。 |
| 3 | - | INSERT INTO lock_test(id) VALUES(0); |
成功 | 佐证左开:锁从 1 之后开始,0 不在范围内。 |
| 4 | - | INSERT INTO lock_test(id) VALUES(1); |
阻塞 | 落在左侧间隙 |
| 5 | - | INSERT INTO lock_test(id) VALUES(2); |
阻塞 | 落在左侧间隙 |
| 6 | - | INSERT INTO lock_test(id) VALUES(4); |
阻塞 | 落在右侧扩展间隙 |
| 7 | - | INSERT INTO lock_test(id) VALUES(9); |
成功 | 佐证右侧间隙全开:间隙锁 9。 |
| 8 | COMMIT; |
- | 释放所有锁 | 事务 A 提交,Session B 的阻塞语句会立即执行。 |
事务 B 为什么在 id = 1 处阻塞?
这涉及到索引记录的物理排列顺序。当你执行 INSERT INTO lock_test(id) VALUES(1); 时:
- 索引排序:在
idx索引树中,已经存在一个id = 1的节点。 - 插入位置:新插入的
id = 1会被排在现有的id = 1记录之后。 - 触碰间隙:现有的
id = 1之后、id = 3之前的空间,正是事务 A 持有的间隙锁覆盖的范围。 - 冲突:事务 B 尝试获取该间隙的 插入意向锁(Insert Intention Lock),但这与事务 A 已经持有的 间隙锁(Gap Lock) 互斥。
案例2:唯一索引等值查询(记录存在)
假设数据库已有记录:1, 3, 9 且 id 字段为 唯一索引(如 Primary Key)。
| 步骤 | 事务 A (Session A) | 事务 B (Session B) | 结果 | 逻辑深度分析 |
|---|---|---|---|---|
| 1 | SET autocommit = 0;BEGIN; |
SET autocommit = 0;BEGIN; |
- | 开启事务,关闭自动提交。 |
| 2 | SELECT * FROM lock_test WHERE id = 3 FOR UPDATE; |
- | 仅持有记录锁 [3] |
触发退化:由于命中唯一索引且记录存在,邻键锁退化为记录锁。 |
| 3 | - | INSERT INTO lock_test(id) VALUES(2); |
成功 | 间隙未锁:退化后不再持有 |
| 4 | - | INSERT INTO lock_test(id) VALUES(4); |
成功 | 间隙未锁:退化后不再持有 |
| 5 | - | UPDATE lock_test SET name='x' WHERE id=3; |
阻塞 | 记录冲突:事务 B 尝试修改被 A 锁定的唯一行。 |
| 6 | COMMIT; |
- | 释放所有锁 | 事务 A 提交,事务 B 的更新操作立即执行。 |
案例3:非唯一索引或唯一索引等值查询(记录不存在)
假设数据库已有记录:1, 3, 9 且 id 字段为 非唯一索引或唯一索引。查询一个不存在的记录 id=5。
| 步骤 | 事务 A (Session A) | 事务 B (Session B) | 结果 | 逻辑深度分析 |
|---|---|---|---|---|
| 1 | SET autocommit = 0;BEGIN; |
SET autocommit = 0;BEGIN; |
- | 开启事务。 |
| 2 | SELECT * FROM lock_test WHERE id = 5 FOR UPDATE; |
- | 仅持有间隙锁 |
触发退化:记录不存在,邻键锁退化为间隙锁,防止幻读。 |
| 3 | ||||
| 4 | - | INSERT INTO lock_test(id) VALUES(6); |
阻塞 | 间隙命中:6 落在 |
| 5 | - | INSERT INTO lock_test(id) VALUES(10); |
成功 | 端点未锁:间隙锁不包含右边界 10。 |
| 6 | COMMIT; |
- | 释放所有锁 | 事务 A 提交,事务 B 的插入操作立即执行。 |
案例4:范围写操作实验表
假设存量记录:1, 3, 9,且 id 为 非唯一索引或唯一索引。
| 步骤 | 事务 A (Session A) | 事务 B (Session B) | 结果 | 逻辑深度分析 |
|---|---|---|---|---|
| 1 | BEGIN; |
BEGIN; |
- | 事务开启。 |
| 2 | UPDATE lock_test SET text='new' WHERE id >= 3; |
- | 包含记录 3, 1~3 和 3~ |
|
| 3 | - | INSERT INTO lock_test(id) VALUES(2); |
阻塞 | 落在左侧间隙 |
| 4 | - | INSERT INTO lock_test(id) VALUES(5); |
阻塞 | 落在范围写的间隙锁内。 |
| 5 | - | INSERT INTO lock_test(id) VALUES(10); |
阻塞 | 锁定延伸至索引尽头(Supremum)。 |
| 6 | COMMIT; |
- | 释放所有锁 | 事务 B 的操作得以继续。 |
退化规则
在 Repeatable Read (RR) 隔离级别下,InnoDB 为了优化性能,会对唯一索引的等值查询进行锁降级:
1. 唯一索引 (Unique Index) —— 准确定位,锁粒度变小
- 记录存在:退化为 记录锁 (Record Lock)。
- 现象:只锁住该行,不锁间隙。
- 原因:唯一性保证了不可能插入重复记录,锁住行即可防幻读。
- 记录不存在:退化为 间隙锁 (Gap Lock)。
- 现象:只锁住该 ID 所在的区间。
- 原因:记录本身不存在,只需封锁区间防止他人插入该 ID。
2. 非唯一索引 (Secondary Index) —— 为了防幻读,维持原状
- 不退化:无论记录是否存在,都会持有 邻键锁 (Next-Key Lock)。
- 额外加锁:除了锁定当前区间 ,通常还会对下一个间隙加锁,形成“面”级防御。
💡总结
RR 级别,MySQL 8.0.34
核心锁定公式表
| 索引类型 | 查询/操作类型 | 记录是否存在 | 最终锁定物理范围 | 逻辑解析 |
|---|---|---|---|---|
| 非唯一索引 | 等值 (=) |
无论是否存在 | 记录本身及两侧间隙全锁(全开区间)。 | |
| 唯一索引 | 等值 (=) |
存在 | 唯一能退化为记录锁的场景,仅锁点,不锁间隙。 | |
| 唯一索引 | 等值 (=) |
不存在 | 退化为间隙锁(全开区间)。 | |
| 任何索引 | 范围 (>= n) |
无论是否存在 | 包含起始记录 |
|
| 任何索引 | 范围 (> n) |
无论是否存在 | 从记录 |
终极避坑与实战指南
- 区间特性: 左开右闭是基础(Next-Key Lock),但由于 “普通索引等值查询” 和 “唯一索引记录不存在” 都会产生扩展锁,最终往往呈现出全开区间
的锁定效果。 - 范围操作: 范围写(
UPDATE/DELETE)与范围读(FOR UPDATE)是并发杀手。它们通常会一路锁到天黑(/ Supremum),在长事务中极易引发大面积锁等待。 - 唯一索引优势: 在开发中,尽量通过主键或唯一索引进行等值更新。这是唯一能让锁降级为“记录锁”的方式,是提升系统并发能力的性能救星。
- 索引防退化: 若 SQL 没走索引,InnoDB 会被迫执行全表扫描,此时全表记录及间隙都会被加上锁。这等同于“锁表”,在生产环境下必须绝对禁止。
插入意向锁
核心定义:什么是插入意向锁?
插入意向锁(Insert Intention Lock) 是一种特殊的间隙锁(Gap Lock)。当一个事务准备在某个索引间隙(Gap)中插入一条新记录时,它会先在这个间隙上加一个意向信号。
- 本质:它不是为了锁定数据,而是为了声明插入意图。
- 特性:多个事务可以在同一个间隙内同时插入不同的数据,彼此之间不互斥。
工作原理:双阶段锁定
当你执行一个 INSERT 语句时,InnoDB 处理锁的逻辑如下:
第一阶段:间隙检查(Gap Check)
InnoDB 首先检查你要插入的位置,是否被其他事务所持有的 间隙锁(Gap Lock) 或 Next-Key Lock 覆盖。
- 如果没有冲突:事务直接获取插入意向锁(这步极快,通常无感)。
- 如果有冲突:事务进入等待状态。此时在系统监控中表现为
lock_mode X locks gap before rec insert intention waiting。
第二阶段:记录锁转化(Lock Conversion)
一旦间隙检查通过,插入意向锁的使命就完成了。在数据真正写入后,该锁会立即“升级”或“转化为”记录锁(Record Lock),锁住新插入的那一行,防止其他事务对这行进行修改。
锁定机制与兼容性
- 与自身兼容:多个事务可以在同一个间隙(比如 3 到 6 之间)同时持有插入意向锁。只要 A 插入 4,B 插入 5,它们可以并行执行,无需等待。
- 被间隙锁阻塞:如果一个事务已经持有了某个间隙的 Gap Lock 或 Next-Key Lock(如你之前例子中的事务 A),那么其他事务想在该间隙获取“插入意向锁”时就会被阻塞。
- 互斥性:插入意向锁会等待间隙锁释放,但它本身并不会阻止其他事务获取该间隙的间隙锁(即它不具备排他性,除非已经成功转换为记录锁)。
维度对比:插入意向锁 vs 排他锁 vs 间隙锁
| 维度 | 插入意向锁 (I-I) | 记录排他锁 (X) | 间隙锁 (Gap) |
|---|---|---|---|
| 锁定对象 | 索引间隙(逻辑位置) | 具体的行记录 | 索引记录之间的空间 |
| 主要目的 | 提高并发插入性能 | 防止其他事务修改同一行 | 防止幻读(禁止插入) |
| 并发行为 | 多个 I-I 锁可并存 | 绝对互斥 | 多个 Gap 锁可并存 |
| 持有场景 | 执行 INSERT 时自动触发 |
执行 UPDATE/DELETE 时触发 |
RR 级别下查询不存在的值 |
为什么不能用普通的排他锁代替它?
如果将插入操作设计为直接加“排他锁”或“间隙锁”,会引发性能灾难:
- 假设场景:表中已有 ID 1 和 100。现在有 10 个用户同时下单(生成 ID 2-11)。
- 如果使用排他/间隙锁:第一个用户下单时会锁住 (1, 100) 这个大区间,其余 9 个用户必须串行排队,数据库退化为单线程插入。
- 使用插入意向锁:10 个用户发现 (1, 100) 之间没有间隙锁,于是同时获取插入意向锁并完成插入。
总结:它到底是什么?
插入意向锁可以被形象地理解为 “排队入场前的信号”:
- 它服从于间隙锁:如果有人在做范围扫描(加了间隙锁),它会乖乖在门口等着。
- 它包容同类:如果没有人扫描,它允许无数个同类在同一个大房间(间隙)里各自找空位坐下。
行锁升级表锁
核心底层原理
InnoDB 的行锁(Row Lock)本质上是索引记录锁。
- 理想状态:通过索引定位目标行,仅锁定该行记录(及相关间隙)。
- 崩溃状态:若
UPDATE/DELETE的WHERE条件无法利用索引,InnoDB 必须通过聚簇索引进行全表扫描来寻找目标。此时,为了保证并发事务的一致性,系统不得不锁定扫描过的所有行记录,表现为“表级锁”。
案例1:无索引字段触发全表锁定
| 顺序 | Session 1 (事务 A) | Session 2 (事务 B) | 结果说明 |
|---|---|---|---|
| T1 | BEGIN; |
BEGIN; |
开启事务。 |
| T2 | UPDATE lock_test SET text='locked' WHERE status = 99; |
全表扫描加锁:由于 status 没索引,InnoDB 会扫描全表并在所有行上加 X 锁(即使没有 status=99 的行)。 |
|
| T3 | UPDATE lock_test SET status=2 WHERE id = 1; |
阻塞:Session 2 试图修改 id=1 的行,但该行已被 Session 1 锁住。 |
|
| T4 | COMMIT; |
释放全表锁。 | |
| T5 | (执行成功) |
事务 B 解除阻塞,完成更新。 |
案例2:隐式转换导致索引失效 (高频坑)
| 顺序 | Session 1 (事务 A) | Session 2 (事务 B) | 结果说明 |
|---|---|---|---|
| T1 | BEGIN; |
BEGIN; |
|
| T2 | UPDATE lock_test SET status=1 WHERE text = 123; |
索引失效:text 是字符串,传入数字 123。MySQL 会对每行 text 执行 CAST(text AS SIGNED),索引失效,锁定全表。 |
|
| T3 | UPDATE lock_test SET status=2 WHERE id = 3; |
阻塞:即使 ID 不同,由于 A 扫描全表加了锁,B 无法执行。 |
隐式转换失效口诀:“左函数,索引废;右转换,可走位”
- 索引失效:
WHERE 字符串字段 = 数字(字段被隐式调用了转换函数) - 索引有效:
WHERE 数字字段 = '字符串'(常量被转换,字段本身没动)
案例3:范围查询导致全表扫描
场景:使用 !=、NOT IN 或 OR 导致优化器放弃索引。
核心原理:如果 MySQL 优化器预估全表扫描比走索引更快(通常是结果集超过全表 20%~30%),它会放弃行锁,直接执行全表扫描并锁定整表。
| 顺序 | Session 1 (事务 A) | Session 2 (事务 B) | 风险点 |
|---|---|---|---|
| T1 | UPDATE lock_test SET status=1 WHERE id > 0; |
范围过大,索引失效 | |
| T2 | UPDATE lock_test SET status=2 WHERE id = 10; |
被阻塞:A 已经锁定了所有行 |
锁风险避坑与排查
1. 核心风险预警
- 并发崩盘:因索引失效导致行锁升级为全表扫描,所有写操作被阻塞,连接池瞬间爆满。
- 死锁爆发:多个事务进行大范围扫描或唯一键竞争(X 锁转 S 锁)时,极易诱发死锁。
- 间隙锁陷阱:更新不存在的数据会锁定记录间的“间隙”,导致无辜的
INSERT操作被阻塞。
2. 性能避坑准则 (金句)
- 类型匹配:字符串字段必须加引号,严防“隐式转换”导致全表扫描。
- 主键优先:坚持使用
WHERE id = ?更新,这是最精准、安全的加锁方式。 - 索引裸奔:禁止在
WHERE条件的索引字段上使用函数或计算(如DATE(time)),否则索引直接失效。 - 范围受控:避免大范围更新(如
id > 0),必要时分批次处理,防止优化器“变心”弃用索引。
3. 实战排查决策表
| 阶段 | 操作命令 | 关注重点 |
|---|---|---|
| 预防 | EXPLAIN UPDATE ... |
检查 key 列是否为 NULL (索引失效) |
| 诊断 | SHOW ENGINE INNODB STATUS; |
搜 TRANSACTIONS 栏的 row lock(s) 数量 |
| 监控 | SELECT * FROM performance_schema.data_lock_waits; |
查找 blocking_pid 定位阻塞源头 (MySQL 8.0+) |
| 运行 | SELECT * FROM information_schema.INNODB_TRX; |
检查是否有长时间未提交的“僵尸事务” |
行锁结论
核心结论:高并发的实现
| 场景/操作 | 机制类型 | 锁类型 | 核心冲突/互斥性 | 关键依赖与备注 |
|---|---|---|---|---|
普通读取 (SELECT) |
MVCC | 无锁 | 无阻塞(读写不冲突)。 | 始终读取历史快照,保障高并发。 |
共享锁 (S Lock) (FOR SHARE) |
行锁 | S 锁 | 阻写,阻加排他锁 | S 锁与 S 锁兼容,但必须基于 索引。 |
排他锁 (X Lock) (UPDATE/FOR UPDATE) |
行锁 | X 锁 | 阻一切锁 (S 锁和 X 锁)。 | X 锁排斥所有加锁操作,但不阻塞普通 MVCC 读取。 |
| 索引缺失 | 锁升级 | 表锁 | 严重降低并发,整个表被锁定。 | 行锁的前提是查询条件命中了索引。 |
注意事项(索引与 MVCC 协作)
- 锁的默认行为
SELECT语句默认不加锁,除非你明确使用FOR SHARE(共享锁)或FOR UPDATE(排他锁)。INSERT、UPDATE、DELETE(CUD 操作) 默认会加排他锁。
- 行锁的生效条件(关键):
- 有索引:InnoDB 使用 行锁 (Locking Row),实现高并发。
- 无索引:InnoDB 锁粒度退化为表锁,并发性急剧下降。
- 读操作的默认行为(MVCC 协作):
- 普通的
SELECT默认是 快照读 (MVCC),它不加锁,因此不会被任何行锁阻塞,也不会阻塞其他事务。 - 只有使用
FOR SHARE(S 锁)或FOR UPDATE(X 锁)时,才会请求并施加行锁(当前读)。
- 普通的
行锁分析
通过什么命令检查状态变量来分析系统上的行锁的争夺情况?
show status like 'innodb_row_lock%';
# 输出如下
+-----------------------------+------+
|Variable_name |Value |
+-----------------------------+------+
|Innodb_row_lock_current_waits|0 |
|Innodb_row_lock_time |196964|
|Innodb_row_lock_time_avg |5793 |
|Innodb_row_lock_time_max |23683 |
|Innodb_row_lock_waits |34 |
+-----------------------------+------+
对各个状态量的说明如下:
Innodb_row_lock_current_waits # 当前正在等待锁定的数量;
Innodb_row_lock_time # 💡从系统启动到现在锁定总时间长度;
Innodb_row_lock_time_avg # 💡每次等待所花平均时间;
Innodb_row_lock_time_max # 系统启动到现在最长等待时间;
Innodb_row_lock_waits # 💡系统启动后到现在总共等待的次数;
锁的实时分布
当数据库出现卡顿时,可以通过系统视图实时监控并“追凶”,找到导致阻塞的根源。
查看锁等待源头
SELECT waiting_pid, waiting_query, blocking_pid, blocking_query FROM sys.innodb_lock_waits;
查看当前锁占用
# 查看当前锁占用
SELECT engine_transaction_id AS tid, object_name AS table_name, index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks;
# 查看谁阻塞了谁
SELECT
r.engine_transaction_id AS waiting_tid,
r.object_name AS table_name,
b.engine_transaction_id AS blocking_tid,
r.lock_mode AS waiting_lock,
b.lock_mode AS blocking_lock
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks r ON w.requesting_engine_lock_id = r.engine_lock_id
JOIN performance_schema.data_locks b ON w.blocking_engine_lock_id = b.engine_lock_id;
字段信息
| 序号 | 字段名称 | 中文注释 | 详细说明 |
|---|---|---|---|
| 1 | ENGINE_TRANSACTION_ID |
事务 ID | 内部事务的唯一编号。用于在 data_lock_waits 表中关联谁在等谁。 |
| 2 | OBJECT_NAME |
表名 | 发生加锁操作的具体数据表名称。 |
| 3 | INDEX_NAME |
索引名 | 锁所依附的索引。如果是 PRIMARY 则表示锁在聚簇索引(行)上。 |
| 4 | LOCK_TYPE |
锁类型 | 通常为 TABLE(表级锁)或 RECORD(行级/间隙锁)。 |
| 5 | LOCK_MODE |
锁模式 | 核心字段。标记是 S(共享)、X(排他)、GAP(间隙锁)或 INSERT_INTENTION(插入意向锁)。 |
| 6 | LOCK_STATUS |
锁状态 | GRANTED 表示已持有锁;WAITING 表示正在等待该锁。 |
| 7 | LOCK_DATA |
锁数据 | 锁定的具体记录值(如主键值)。如果是间隙锁,则显示该间隙的上限值。 |
| 8 | THREAD_ID |
线程 ID | 执行该事务的后台线程 ID,可配合 performance_schema.threads 查看具体连接。 |
LOCK_MODE 值
| 序号 | 锁模式 (LOCK_MODE) | 中文注释 | 涵盖范围与说明 |
|---|---|---|---|
| 1 | X |
Next-Key Lock | 默认模式。锁住记录本身以及该记录之前的间隙(左开右闭区间,如 (3, 9])。 |
| 2 | X, GAP |
间隙锁 (Gap Lock) | 只锁间隙,不锁记录。通常发生在非唯一索引或在 RR 级别下查询不存在的记录时(如 (3, 9))。 |
| 3 | X, REC_NOT_GAP |
记录锁 (Record Lock) | 只锁记录,不锁间隙。通常发生在 RC 级别,或 RR 级别下使用唯一索引命中了具体行时。 |
| 4 | X, INSERT_INTENTION |
插入意向锁 | 插入操作时产生的特殊间隙锁。它会与同区间的 GAP 锁冲突,但多个插入意向锁之间互不冲突。 |
| 5 | S |
共享 Next-Key Lock | 读锁模式下的 Next-Key Lock(如 LOCK IN SHARE MODE)。 |
| 6 | S, GAP |
共享间隙锁 | 读锁模式下的纯间隙锁。 |
| 7 | S, REC_NOT_GAP |
共享记录锁 | 读锁模式下的纯记录锁。 |
在 RR 级别,你会看到
LOCK_MODE出现X,GAP或X(Next-key);而在 RC 级别,你会发现大部分时候只有简单的X,REC_NOT_GAP(纯记录锁)。
强制释放锁
KILL [阻塞者的 进程 ID];
数据库优化建议
1. 索引与锁的精准控制
- 严防索引失效:确保所有写操作(
UPDATE/DELETE)必须精准命中索引。禁止在字段上做运算,且 VARCHAR 类型必须加单引号(防止隐式转换导致全表扫描,行锁变表锁)。 - 缩小锁范围:设计索引时尽量覆盖常用查询条件,避免大范围扫描。
2. 规避间隙锁(Gap Lock)风险
- 减少范围检索:尽量使用主键或唯一索引进行等值查询。
- 避免空值查询:更新前先确认记录存在,减少因命中“间隙”而导致的无辜插入受阻。
3. 事务行为优化
- 快进快出:严格控制事务大小,将耗时长的非数据库操作(如调用 API、复杂计算)移出事务块。
- 资源有序访问:在多个事务中,尽量以相同的顺序访问涉及的多张表,从而降低死锁概率。
4. 架构与策略调整
- 合理降低隔离级别:若业务允许(如不追求极致的幻读防护),将隔离级别设置为
READ COMMITTED (RC),可规避绝大部分间隙锁带来的性能损耗。
补充概念:页锁 (Page Lock)
- 定义:锁定粒度介于表锁(锁定整个表)和行锁(锁定单行)之间的一种锁,通常以 16KB 的数据页为锁定单位。
- 特性:
- 折中方案:开销比行锁小,并发度比表锁高。
- 死锁风险:与行锁一样,页锁也可能会出现死锁。
- 现状:主要存在于 BDB 等存储引擎中。目前主流的 InnoDB 引擎通过高效的行锁(Row Lock)已经能很好地平衡性能,因此页锁在现代开发中仅需作为背景知识了解。