4、MySql锁机制

定义

锁是数据库中保证共享数据并发一致性的关键机制,但其冲突也严重影响性能。

举例

在淘宝上买商品,商品只有一件库存,这时两个人同时买如何界定谁买到?
这里要用到事务,

  1. 查询库存表中商品数量
  2. 插入订单
  3. 付款后插入付款表信息
  4. 更新库存表商品数量
    在这个过程中,使用锁可以对有限的资源进行保护,解决隔离和并发的矛盾。

锁的分类

按锁的粒度分类(从大到小)

全局锁 (Global Lock)

全局读锁 (FTWRL)

表级锁 (Table-level Lock)

表锁 (Table Lock)

元数据锁 (MDL:Metadata Lock)

意向锁 (Intention Lock)

为什么要有意向锁?(解决效率痛点)

想象一下,如果没有意向锁,当一个事务想要申请 表锁(Table Lock)(例如执行 ALTER TABLE)时,它必须确保表中的每一行都没有被其他事务锁住。

意向锁的分类与兼容性

意向锁分为两种,它们都是由 InnoDB 自动加的,用户无法手动干预:

  1. 意向共享锁 (IS):事务准备给某些行加 S 锁(共享锁/读锁)之前,必须先获得该表的 IS 锁。
  2. 意向排他锁 (IX):事务准备给某些行加 X 锁(排他锁/写锁)之前,必须先获得该表的 IX 锁。
关键特性:意向锁之间是兼容的

意向锁之间(IS 与 IX,IX 与 IX)互不冲突

兼容性表(重点看表级冲突)
IS (表级) IX (表级) S (表级) X (表级)
IS (表级) 兼容 兼容 兼容 冲突
IX (表级) 兼容 兼容 冲突 冲突
S (表级) 兼容 冲突 兼容 冲突
X (表级) 冲突 冲突 冲突 冲突
意向锁与行锁的区别

这是最容易混淆的地方:意向锁是表级锁,但它不阻塞行锁。

  1. 事务 A:执行 UPDATE users SET name = 'A' WHERE id = 1;
    • InnoDB 自动获取 users 表的 IX 锁(意向排他)。
    • InnoDB 获取 id = 1 这一行的 X 行锁
  2. 事务 B:执行 UPDATE users SET name = 'B' WHERE id = 2;
    • 尝试获取 users 表的 IX 锁。因为 IX 与 IX 兼容,成功!
    • 尝试获取 id = 2X 行锁。成功!
    • 结论:意向锁不阻止不同行的并发修改。
  3. 事务 C:执行 DROP TABLE users;
    • 尝试获取 users 表的 X 表锁
    • 发现表上已经挂了 IX 锁,于是事务 C 阻塞,直到 A 和 B 提交。
总结与补全

自增锁 (AUTO-INC Lock)

行级锁 (Row-level Lock) —— InnoDB 核心

记录锁 (Record Lock)

间隙锁 (Gap Lock)

邻键锁 (Next-Key Lock)

插入意向锁 (Insert Intention Lock)

按锁的模式/兼容性分类

共享锁 (Shared Lock / S 锁)

排他锁 (Exclusive Lock / X 锁)

特殊类型的锁与机制

谓词锁 (Predicate Locks)

乐观锁与悲观锁(逻辑概念而非物理锁)

锁的执行步骤

当执行一条 UPDATE lock_test SET status = 1 WHERE id = 3; 时,MySQL 内部的锁层级申请顺序如下:

  1. 元数据锁层:申请 lock_test 表的 MDL 读锁(保证没人动表结构)。
  2. 表锁层:申请 lock_test 表的 IX (意向排他锁)(告诉别人里面有行要改)。
  3. 行锁层:申请 id = 3X 记录锁(真正的修改动作)。

锁申请层级

层级 锁类型 已持锁 (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)

表锁(偏读)

特点:偏向MyISAM存储引擎

  1. 开销小、加锁快:不需要遍历索引去寻找具体行,直接在表元数据层面加标志。
  2. 无死锁:通常是一次性获取所需表的全部锁(或者按顺序获取),不满足循环等待条件,因此不会产生死锁。
  3. 粒度大、冲突高:锁住的是整张表,后续任何针对该表的写操作(甚至读操作,取决于锁类型)都必须排队。
  4. 并发度最低:适合以 查询(读) 为主、极少更新的业务场景。

底层逻辑: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);

加读锁(共享锁)

|915

加写锁(排他锁)

  1. 加表锁写锁时不能查询其他表信息,会报错

表锁结论

1. 核心加锁行为

MyISAM 不支持事务,使用的是 表级锁(Table-level Locking),由系统自动触发:

2. 读写冲突规则

锁类型 冲突情况 行为描述
读锁 (Read) 阻写,不阻读 允许多个进程同时读;但在读锁释放前,任何写操作都会被阻塞。
写锁 (Write) 读写全阻 只要有一个进程在写,其他进程的读、写请求都会排队等待。

简而言之

读锁: 阻写,不阻读
写锁: 读和写都阻

表锁分析

查看加锁的表

show open tables

释放表锁

unlock tables;

分析表锁定

可以通过检查table_locks_waitedtable_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_waited 值较高,说明存在严重的表级锁竞争,通常建议将引擎由 MyISAM 转换为 InnoDB。

调度机制:写优先 (Write First)

MyISAM 的调度机制默认认为写操作比读操作更重要

  1. 优先级排队:即使读请求先到达锁等待队列,后到的写请求也会被插入到读请求之前
  2. 读锁饥饿:在一个写密集的系统中,大量的更新操作会使读请求由于排队靠后而产生“永远阻塞”的现象。
  3. 读写互斥
    • 读锁:不阻碍其他读,但阻碍所有写。
    • 写锁:阻碍所有的读和写。

性能瓶颈总结

优化建议

行锁(偏写)

特点:InnoDB 引擎特有

InnoDB 引擎特有、💡行锁必须加在索引上

  1. 开销大、加锁慢:需要维护大量的锁状态信息,且加锁前需先通过索引定位到具体的记录。
  2. 会出现死锁:事务通常是逐行加锁的。如果事务 A 锁了行 1 后去锁行 2,而事务 B 锁了行 2 后去锁行 1,就会形成循环等待,导致死锁。
  3. 粒度小、冲突极低:只锁定被操作的那几行。只要不同事务操作的是不同的行,就可以完美并行。
  4. 并发度最高:适合高频率增删改查的互联网业务。

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 锁后:

阻塞行为速查表

事务 B 的操作类型 是否被事务 A 的 X 锁阻塞 原因分析
增删改 (INSERT/UPDATE/DELETE) 写操作必须获取 X 锁,锁冲突。
锁定读 (SELECT ... FOR UPDATE/LOCK IN SHARE MODE) 显式请求加锁,锁冲突。
快照读 (普通 SELECT) MVCC 机制允许读取历史版本,无需加锁。

注意: 如果隔离级别为 SERIALIZABLE,普通 SELECT 会隐式转为锁定读,此时会被阻塞。

实验佐证:还原阻塞与非阻塞现场

为了验证上述理论,我们通过两个窗口模拟并发事务。

准备工作

场景演示

步骤 事务 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 存储引擎中,所有的写操作(如 UPDATEDELETE)以及显式的加锁读(FOR UPDATE)都会向目标行申请 排他锁(X 锁)

2. 自动释放:超时机制

如果事务 A 长期未提交(例如程序挂起或人为漏掉 COMMIT),事务 B 不会无限期等待,而是受以下参数限制:

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

技术核心:为什么普通查询不被阻塞?

InnoDB 能够实现读写并行的核心技术是 MVCC(Multi-Version Concurrency Control)

常见误区:为什么我的普通查询被阻塞了?

如果在测试中发现普通 SELECT 也被阻塞,请检查以下三点:

  1. 隔离级别:是否执行了 SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  2. 表级锁:是否意外触发了表级排他锁(如使用了 LOCK TABLES 或非索引字段更新导致锁全表)?
  3. 元数据锁 (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;

解决方案与优化建议

Mysql Commands#解决死锁

避免死锁

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;

重试机制

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;
    }
}

减少锁等待时间

  1. 缩短事务生命周期:尽量将事务控制在业务逻辑的最小范围内,避免长时间持有锁
START TRANSACTION;
UPDATE lock_test SET `text` = 'kkkk' WHERE `id` = 1;
COMMIT; -- 立即提交
  1. 优化查询性能:确保 WHERE 条件字段(如 id)有 索引,减少锁扫描范围。
  2. 调整数据库配置
    • 降低锁超时时间:适当调小 SET innodb_lock_wait_timeout = 10; -- 10秒(默认 50 秒),减少长时间等待导致的超时错误。
    • 启用死锁检测:MySQL 默认会自动检测死锁并回滚最小事务,无需手动干预。

死锁保留机制

Mysql 默认会开启死锁检测机制

# 开启死锁检测
SET GLOBAL innodb_deadlock_detect = ON;
# 查看死锁检测状态
SHOW VARIABLES LIKE 'innodb_deadlock_detect';

当多个会话竞争同一资源并形成死锁时,数据库遵循以下生存法则:

  1. 牺牲权重最低者(The Weight Law) 数据库计算每个事务产生的 undo log(回滚日志)大小。谁改动的数据行数最少,谁就被优先回滚

本质: 为了以最低的 IO 成本解开死锁。

  1. 强制打破循环(The Loop-Breaking Law) 一旦检测到锁等待环路(如 A 等 B,B 等 A),数据库不会等待超时,而是主动干预。它会立即杀死其中一个事务,释放其持有的所有锁资源。

本质: 牺牲局部,保全大局的吞吐量,防止全线卡死。

  1. 幸存者获胜(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

总结

死锁保留机制就是 “定向清除最弱竞争者,腾出通道给幸存者”

死锁监控

默认状态:内存瞬时记录

# 查询锁信息
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';

运维排查路径

当收到死锁报错时,按以下步骤定位源头:

  1. 确定日志路径
SHOW VARIABLES LIKE 'log_error';
  1. 查看物理日志(以 Linux 为例):
tail -f /var/log/mysql/error.log
  1. 日志核心解析指标
    • HOLDS THE LOCK(S):当前事务已占有的锁。
    • WAITING FOR THIS LOCK TO BE GRANTED:正在申请并导致阻塞的锁。
    • WE ROLL BACK TRANSACTION:最终被系统判定牺牲(回滚)的事务。

死锁案例

案例1:间隙锁 (Gap Lock) 导致的插入意向锁冲突「IODKU」

这种场景最常见于并发插入表中不存在的记录。此时,InnoDB 会锁定记录之间的“间隙”。
前提:

步骤 事务 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 触发死锁检测并回滚。
核心逻辑总结
  1. S 锁是万恶之源:当 IODKU 遇到唯一键冲突时,会先申请 S Next-Key Lock。因为 S 锁与 S 锁兼容,多个事务可以同时持有,导致随后申请 X 锁进行更新时出现互相等待。
  2. 插入意向锁的定位:它是一种特殊的间隙锁。它不阻止其他事务的插入意向锁,只会被已存在的 Gap LockNext-Key Lock 阻塞。
  3. 冲突演变
    • 已有记录:S 锁冲突 共同持有 申请 X 锁 死锁
    • 无记录(并发插入):X 锁占位 其他事务转为 S 锁等待 最终演变为已有记录的情况。
如何避免 IODKU 死锁?

案例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 中,事务确实只申请插入意向锁。但当存在唯一性约束时,逻辑会发生变化:

2. 为什么 T4 会触发死锁?

当 A 执行 ROLLBACK 时,A 持有的 X 锁释放。此时:

  1. S 锁生效:由于 S 锁与 S 锁是相互兼容的,B 和 C 同时获得了 id=10 这个位置的 S 锁
  2. 转换 X 锁:B 和 C 发现 A 撤了,都想立刻完成自己的 INSERT。执行 INSERT 动作本身需要获取 X 锁(排他锁)
  3. 循环等待形成
    • B 想拿 X 锁,但必须等 C 释放 S 锁(S 与 X 互斥)。
    • C 想拿 X 锁,但必须等 B 释放 S 锁(S 与 X 互斥)。
3. 这与“插入意向锁”矛盾吗?

不矛盾。 请看下表的逻辑分层:

动作类型 正常插入 (无冲突) 唯一键冲突插入 (有占用)
初始申请 插入意向锁 (Insert Intention) 插入意向锁 (被挡住后立即转换)
等待状态 无需等待 S 锁 (共享锁)
执行插入瞬间 X 锁 (记录锁) 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

结论

  1. 允许其它事务也增加共享锁读取
  2. 不允许其它事务增加排他锁 (for update)
  3. 当事务同时增加共享锁时候,事务的更新必须等待先执行的事务 commit 后才行,如果同时并发太大可能很容易造成死锁

间隙锁

间隙锁(Gap Lock)是加在索引记录之间的间隙上的锁,或者加在第一条索引记录之前最后一条索引记录之后的间隙上的锁。

间隙锁的三大位置

以数据 [1, 3, 9] 为例

关键特性与行为对照

特性分类 核心逻辑 业务影响
锁定目标 锁定 “不存在” 的空间,而非具体的行记录。 阻止 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)立即执行。

深入理解左侧无限间隙

  1. 判定区间:当查询 id = -5 时,MySQL 发现它小于索引树中的最小记录 1
  2. 锁定下限:在 RR 级别下,为了防止幻读,InnoDB 会锁定第一条记录 1 及其左侧的所有空间
  3. 日志表现:在 SHOW ENGINE INNODB STATUS 中,这种锁通常显示为在第一条记录(heap no 2,通常是最小值)上加了 gap lock

核心知识点补充:为什么 INSERT 互不影响

案例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)立即恢复执行。

为什么这里会阻塞?

  1. 判定区间:当 MySQL 发现你查询的 id = 15 在索引树中找不到对应的行时,它会定位到当前索引的最右末端——即特殊的 Supremum 记录
  2. 锁定上限:由于是 RR 隔离级别,为了防止“幻读”(即防止你刚才搜 15 没有,过一会儿别人插了个 15 导致你搜到了),它会将最后一个记录 9 到正无穷大 +supremum 之间的所有空位全部锁死。
  3. 影响范围:此时,任何试图插入 id = 10id = 100 甚至 id = 9999 的操作都会被阻塞。

核心知识点

核心避坑指南

间隙锁的“副作用”

如何规避

锁冲突矩阵

请求锁 \ 已持锁 间隙锁 (Gap) 记录锁 (Record) 邻键锁 (Next-Key Lock) 插入意向锁 (I-I)
间隙锁 (Gap) 兼容 兼容 兼容 冲突
记录锁 (Record) 兼容 冲突 冲突 兼容
邻键锁 (Next-Key Lock) 兼容 冲突 冲突 冲突
插入意向锁 (I-I) 冲突 兼容 冲突 兼容

案例

环境准备:

场景 已持锁 (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=3Record 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=4I-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=9Record 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”?

在数据库锁逻辑中,锁是加在事务上的,而不是加在语句上的

关键结论

间隙锁是“损人利己”的锁:它保护了当前事务不会读到“幻影数据”,但代价是牺牲了其他事务的插入并发度。

RC隔离级别

RC(Read Committed)与 RR(Repeatable Read)在不同实战场景下的锁行为对比。

案例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 层判断该行是否符合 UPDATEWHERE 条件。

步骤 Session A (事务 A) Session B (Session B) RR 级别表现 RC 级别表现
1 BEGIN; BEGIN; - -
2 UPDATE lock_test SET status = 1 WHERE id = 3; - 锁定 id=3Record Lock 锁定 id=3Record 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 锁定非唯一索引记录 id=n 时(假设其前后相邻存量记录分别为 prevnext),其最终锁定的物理空间公式为:

=(prev,n]+(n,next)=(prev,next)

公式拆解与定义

为了实现防幻读,InnoDB 将两种锁算法进行了组合:

锁范围公式:唯一索引

RR 隔离级别下,锁定唯一索引 id=n 时,公式会根据记录是否存在发生退化

记录存在(等值命中)

=[n]

记录不存在(等值未命中)

=(prev,next)

锁范围公式:范围查询

当执行如 WHERE id > 3WHERE id >= 3 这种范围锁定读/写时,InnoDB 会沿着索引树向后扫描,直到触碰到索引的尽头。

唯一索引、非唯一索引范围写

假设记录为 1, 3, 9

案例1:非唯一索引等值查询(记录存在)

假设数据库已有记录:1, 3, 9id 字段建有普通索引

步骤 事务 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; - 持有 (1,3] + (3,9) 包含记录 3,以及 1~33~9 之间的空隙。
3 - INSERT INTO lock_test(id) VALUES(0); 成功 佐证左开:锁从 1 之后开始,0 不在范围内。
4 - INSERT INTO lock_test(id) VALUES(1); 阻塞 落在左侧间隙 (1,3)
5 - INSERT INTO lock_test(id) VALUES(2); 阻塞 落在左侧间隙 (1,3)
6 - INSERT INTO lock_test(id) VALUES(4); 阻塞 落在右侧扩展间隙 (3,9)
7 - INSERT INTO lock_test(id) VALUES(9); 成功 佐证右侧间隙全开:间隙锁 (3,9) 不含端点 9
8 COMMIT; - 释放所有锁 事务 A 提交,Session B 的阻塞语句会立即执行。

事务 B 为什么在 id = 1 处阻塞?

这涉及到索引记录的物理排列顺序。当你执行 INSERT INTO lock_test(id) VALUES(1); 时:

  1. 索引排序:在 idx 索引树中,已经存在一个 id = 1 的节点。
  2. 插入位置:新插入的 id = 1被排在现有的 id = 1记录之后
  3. 触碰间隙:现有的 id = 1 之后、id = 3 之前的空间,正是事务 A 持有的 (1,3] 间隙锁覆盖的范围
  4. 冲突:事务 B 尝试获取该间隙的 插入意向锁(Insert Intention Lock),但这与事务 A 已经持有的 间隙锁(Gap Lock) 互斥。

案例2:唯一索引等值查询(记录存在)

假设数据库已有记录:1, 3, 9id 字段为 唯一索引(如 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); 成功 间隙未锁:退化后不再持有 (1,3) 的间隙锁。
4 - INSERT INTO lock_test(id) VALUES(4); 成功 间隙未锁:退化后不再持有 (3,9) 的间隙锁。
5 - UPDATE lock_test SET name='x' WHERE id=3; 阻塞 记录冲突:事务 B 尝试修改被 A 锁定的唯一行。
6 COMMIT; - 释放所有锁 事务 A 提交,事务 B 的更新操作立即执行。

案例3:非唯一索引或唯一索引等值查询(记录不存在)

假设数据库已有记录:1, 3, 9id 字段为 非唯一索引或唯一索引。查询一个不存在的记录 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,9) 触发退化:记录不存在,邻键锁退化为间隙锁,防止幻读。
3
4 - INSERT INTO lock_test(id) VALUES(6); 阻塞 间隙命中6 落在 (3,9) 范围内,被间隙锁拦截。
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; - (1,3] + (3,+) 包含记录 31~33~+
3 - INSERT INTO lock_test(id) VALUES(2); 阻塞 落在左侧间隙 (1,3)
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) —— 准确定位,锁粒度变小

2. 非唯一索引 (Secondary Index) —— 为了防幻读,维持原状

💡总结

RR 级别,MySQL 8.0.34

核心锁定公式表

索引类型 查询/操作类型 记录是否存在 最终锁定物理范围 逻辑解析
非唯一索引 等值 (=) 无论是否存在 (prev,next) 记录本身及两侧间隙全锁(全开区间)。
唯一索引 等值 (=) 存在 [n] 唯一能退化为记录锁的场景,仅锁点,不锁间隙。
唯一索引 等值 (=) 不存在 (prev,next) 退化为间隙锁(全开区间)。
任何索引 范围 (>= n) 无论是否存在 (prev,+) 包含起始记录 n 及其左侧间隙,一直锁到末尾。
任何索引 范围 (> n) 无论是否存在 (n,+) 从记录 n 之后的第一个间隙开始,一路锁到天黑。

终极避坑与实战指南

插入意向锁

核心定义:什么是插入意向锁?

插入意向锁(Insert Intention Lock) 是一种特殊的间隙锁(Gap Lock)。当一个事务准备在某个索引间隙(Gap)中插入一条新记录时,它会先在这个间隙上加一个意向信号

工作原理:双阶段锁定

当你执行一个 INSERT 语句时,InnoDB 处理锁的逻辑如下:

第一阶段:间隙检查(Gap Check)

InnoDB 首先检查你要插入的位置,是否被其他事务所持有的 间隙锁(Gap Lock)Next-Key Lock 覆盖。

第二阶段:记录锁转化(Lock Conversion)

一旦间隙检查通过,插入意向锁的使命就完成了。在数据真正写入后,该锁会立即“升级”或“转化为”记录锁(Record Lock),锁住新插入的那一行,防止其他事务对这行进行修改。

锁定机制与兼容性

维度对比:插入意向锁 vs 排他锁 vs 间隙锁

维度 插入意向锁 (I-I) 记录排他锁 (X) 间隙锁 (Gap)
锁定对象 索引间隙(逻辑位置) 具体的行记录 索引记录之间的空间
主要目的 提高并发插入性能 防止其他事务修改同一行 防止幻读(禁止插入
并发行为 多个 I-I 锁可并存 绝对互斥 多个 Gap 锁可并存
持有场景 执行 INSERT 时自动触发 执行 UPDATE/DELETE 时触发 RR 级别下查询不存在的值

为什么不能用普通的排他锁代替它?

如果将插入操作设计为直接加“排他锁”或“间隙锁”,会引发性能灾难

总结:它到底是什么?

插入意向锁可以被形象地理解为 “排队入场前的信号”

  1. 服从于间隙锁:如果有人在做范围扫描(加了间隙锁),它会乖乖在门口等着。
  2. 包容同类:如果没有人扫描,它允许无数个同类在同一个大房间(间隙)里各自找空位坐下。

行锁升级表锁

核心底层原理

InnoDB 的行锁(Row Lock)本质上是索引记录锁

案例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 无法执行。
隐式转换失效口诀:“左函数,索引废;右转换,可走位”

案例3:范围查询导致全表扫描

场景:使用 !=NOT INOR 导致优化器放弃索引。
核心原理:如果 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. 核心风险预警

2. 性能避坑准则 (金句)

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 协作)

Mysql Basics#多版本并发控制(MVCC)

  1. 锁的默认行为
    • SELECT 语句默认不加锁,除非你明确使用 FOR SHARE(共享锁)或 FOR UPDATE(排他锁)。
    • INSERTUPDATEDELETE (CUD 操作) 默认会加排他锁
  2. 行锁的生效条件(关键):
    • 有索引:InnoDB 使用 行锁 (Locking Row),实现高并发。
    • 无索引:InnoDB 锁粒度退化为表锁,并发性急剧下降。
  3. 读操作的默认行为(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,GAPX (Next-key);而在 RC 级别,你会发现大部分时候只有简单的 X,REC_NOT_GAP(纯记录锁)。

强制释放锁

KILL [阻塞者的 进程 ID];

数据库优化建议

1. 索引与锁的精准控制

2. 规避间隙锁(Gap Lock)风险

3. 事务行为优化

4. 架构与策略调整

补充概念:页锁 (Page Lock)