欢迎光临
我们一直在努力

MySQL 锁机制完全解析:从行锁到死锁,一篇彻底搞懂

无论是高并发场景下的数据一致性,还是面试中必问的 MVCC 与锁的关系,MySQL 的锁机制都是硬核中的硬核。本文将从锁的分类出发,深入 InnoDB 的行锁算法、间隙锁、临键锁、意向锁、插入意向锁,并剖析死锁的成因与解决方案,带你彻底吃透 MySQL 的锁世界。

一、为什么需要锁?

当多个事务同时对同一份数据进行读写操作时,如果没有合理的隔离机制,就会出现脏读、不可重复读、幻读等问题。锁就是 MySQL 用来控制并发访问、保证数据一致性的重要手段。

不同的存储引擎支持不同的锁策略:

  • MyISAM:只支持表级锁,并发性能差

  • InnoDB:支持行级锁、表级锁,并提供 MVCC 多版本并发控制,是当前 MySQL 的主流引擎

本篇文章以 InnoDB 为核心展开。

二、锁的宏观分类

2.1 按锁的粒度分类

锁粒度说明优点缺点适用场景
行锁 锁定一行或多行记录 并发度最高,冲突概率低 锁开销大,容易死锁 高并发 OLTP 系统
表锁 锁定整张表 开销小,加锁快,不会死锁 并发度极低 全表更新、DDL 操作
页锁 锁定一个数据页(InnoDB 早期曾支持,现已废弃)

InnoDB 的行锁是通过对索引项加锁实现的,而不是直接对记录行加锁。这意味着:如果 SQL 没有用到索引,行锁可能会升级为表锁(实际上是锁住所有行,效果等同于表锁)。

2.2 按锁的模式分类(S/X/IS/IX)

锁模式全称兼容性说明
共享锁(S) Shared Lock 与 S 兼容,与 X 冲突 读锁,允许其他事务加 S 锁,但不能加 X 锁
排他锁(X) Exclusive Lock 与任何锁都冲突 写锁,独占资源
意向共享锁(IS) Intention Shared 与 IS/S 兼容,与 IX/X 冲突 事务想要在表的某些行上加 S 锁时,先在表级别加 IS
意向排他锁(IX) Intention Exclusive 与 IS 兼容,与 S/IX/X 冲突 事务想要在表的某些行上加 X 锁时,先在表级别加 IX

意向锁的作用:避免表级锁与行级锁之间的全表扫描判断。当事务需要加表锁(如 LOCK TABLES … WRITE)时,只需检查表上是否有 IX/IS 锁,就知道是否有行锁存在,无需遍历所有行。

2.3 按锁的算法分类(InnoDB 行锁的三种实现)

InnoDB 的行锁算法决定了它在不同的隔离级别和查询条件下会锁哪些记录:

  • 记录锁(Record Lock):锁定单个索引记录

  • 间隙锁(Gap Lock):锁定一个范围(不包含记录本身),防止幻读

  • 临键锁(Next-Key Lock) = 记录锁 + 间隙锁,锁定一个左开右闭的区间

这三种锁只在 可重复读(REPEATABLE READ,RR) 隔离级别下才会完全使用,且间隙锁只在 RR 级别下存在。读已提交(READ COMMITTED,RC)级别只有记录锁,没有间隙锁。

三、深入 InnoDB 行锁算法

3.1 记录锁(Record Lock)

sql

— 事务 A
BEGIN;
SELECT * FROM user WHERE id = 10 FOR UPDATE; — 对 id=10 的记录加 X 锁

— 事务 B(阻塞,直到 A 提交或回滚)
UPDATE user SET name = 'test' WHERE id = 10;

记录锁锁定的是索引记录。如果表没有索引,InnoDB 会自动使用隐藏的聚簇索引(ROW_ID)来加锁。

3.2 间隙锁(Gap Lock)与幻读防护

间隙锁锁定的是索引记录之间的“间隙”,不包含记录本身。它的目的是防止幻读——其他事务在间隙中插入新记录。

sql

— 假设 user 表有 id 列,现有 id = 5, 10, 15

— 事务 A
BEGIN;
SELECT * FROM user WHERE id BETWEEN 8 AND 12 FOR UPDATE;
— 此时会锁定间隙: (5,10) 和 (10,15) 两个间隙,以及记录 10 本身(Next-Key Lock)

— 事务 B(阻塞,因为插入 12 属于 (10,15) 间隙)
INSERT INTO user (id, name) VALUES (12, 'new');

间隙锁的特性:

  • 只存在于 RR 隔离级别

  • 间隙锁之间不互斥(不同事务可以同时加同一间隙的 Gap Lock,因为它们的目的是阻止插入,不是彼此排斥)

  • 间隙锁在唯一索引等值查询且记录存在时,会退化为记录锁(不产生间隙锁)

  • 间隙锁在唯一索引等值查询且记录不存在时,会在该记录的前后间隙加 Gap Lock

3.3 临键锁(Next-Key Lock)

临键锁 = 记录锁 + 间隙锁,是 InnoDB RR 级别下默认的行锁算法。它锁定的范围是 (上一个索引值, 当前索引值](左开右闭)。

sql

— 假设现有 id: 5, 10, 15
— 查询条件是 id > 10 FOR UPDATE
— 临键锁会锁定:间隙 (10,15] + (15, +∞) 的间隙锁

在 RR 级别下,InnoDB 使用临键锁解决了幻读问题:它既锁住了已存在的记录(记录锁),又锁住了记录之间的间隙(间隙锁),使其他事务无法插入符合条件的新记录。

3.4 插入意向锁(Insert Intention Lock)

插入意向锁是一种特殊的间隙锁,它在 INSERT 操作前,在待插入的间隙上获取。多个事务可以同时对同一个间隙的不同位置插入数据,而不会互相阻塞,只要它们插入的位置不同。

sql

— 间隙 (10,15)
— 事务 A 准备插入 id=12,先获取插入意向锁
— 事务 B 准备插入 id=13,也可以获取插入意向锁,不会阻塞
— 但如果有事务已经持有了 (10,15) 的间隙锁(例如通过 SELECT … FOR UPDATE),则插入意向锁必须等待

插入意向锁的设计目的是提高插入并发度,同时又能与间隙锁互斥,防止幻读。

四、锁的兼容性矩阵

InnoDB 中不同锁模式的兼容关系如下(表级别):

锁模式SXISIX
S ✅ 兼容 ❌ 冲突 ✅ 兼容 ❌ 冲突
X ❌ 冲突 ❌ 冲突 ❌ 冲突 ❌ 冲突
IS ✅ 兼容 ❌ 冲突 ✅ 兼容 ✅ 兼容
IX ❌ 冲突 ❌ 冲突 ✅ 兼容 ✅ 兼容
  • IS 与 IX 相互兼容:因为它们只是“意向”,表示后续要加行锁,但还没真正加。

  • X 与任何锁都冲突:排他锁的独占性。

  • S 与 S/IS 兼容:读读并发。

行级别的锁兼容性更简单:S 与 S 兼容,其他组合都冲突。

五、不同 SQL 语句的加锁行为

SQL 语句加锁行为(RR 级别)
SELECT … FROM … WHERE …(快照读) 不加锁,利用 MVCC 读取历史版本
SELECT … FOR UPDATE 对扫描到的索引记录加 X 锁(Next-Key Lock)
SELECT … LOCK IN SHARE MODE 对扫描到的索引记录加 S 锁(Next-Key Lock)
UPDATE … WHERE … 对扫描到的记录加 X 锁(Next-Key Lock)
DELETE … WHERE … 对扫描到的记录加 X 锁(Next-Key Lock)
INSERT … 在插入的行上加 X 锁(Record Lock),在插入的间隙上加插入意向锁
INSERT … ON DUPLICATE KEY UPDATE 额外对唯一索引冲突的行加 X 锁
ALTER TABLE 等 DDL 元数据锁(MDL),非本文重点

注意:“扫描到的索引记录” 取决于执行计划。如果 WHERE 条件没有使用索引,InnoDB 会对聚簇索引的所有记录加锁(表锁的效果),因此务必让条件字段有索引。

六、MVCC 与锁的关系

很多初学者容易混淆 MVCC 和锁。实际上:

  • MVCC(多版本并发控制) 用于实现快照读(普通的 SELECT),通过 Undo Log 和 Read View 提供无锁的一致性读,读写不互斥。

  • 锁 用于实现当前读(FOR UPDATE、LOCK IN SHARE MODE、UPDATE、DELETE、INSERT),必须读取最新的数据,并阻止其他事务的并发修改。

简单理解:MVCC 让读不阻塞写,写不阻塞读。但当需要“强一致性”时(比如转账扣款),就必须使用当前读加锁。

七、死锁:成因、检测与解决方案

7.1 死锁的典型场景

死锁是指两个或两个以上的事务互相等待对方释放锁,导致所有事务都无法继续执行。

经典案例:

sql

— 事务 A
BEGIN;
UPDATE account SET balance = balance – 100 WHERE id = 1; — 锁住 id=1
UPDATE account SET balance = balance + 100 WHERE id = 2; — 等待 id=2 的锁

— 事务 B
BEGIN;
UPDATE account SET balance = balance – 50 WHERE id = 2; — 锁住 id=2
UPDATE account SET balance = balance + 50 WHERE id = 1; — 等待 id=1 的锁

— 死锁形成!

7.2 InnoDB 的死锁检测与回滚

InnoDB 会自动检测死锁(通过 等待图 算法),并选择回滚其中一个事务(通常是持有最少行锁的事务),另一个事务继续执行。

可以通过 innodb_deadlock_detect 参数开关死锁检测(默认 ON)。在高并发系统中,死锁检测可能会消耗大量 CPU,可以用 innodb_lock_wait_timeout 设置超时放弃等待,或者采用更低隔离级别。

7.3 如何避免死锁

  • 固定访问顺序:所有事务都先操作 id=1,再操作 id=2,破坏循环等待条件。

  • 使用短事务:减少锁持有时间,降低死锁概率。

  • 为查询条件加上合适的索引:避免表锁导致锁范围过大。

  • 使用 READ COMMITTED 隔离级别(如果业务允许):RC 级别没有间隙锁,能减少死锁。

  • 在应用层捕获死锁异常并重试:MySQL 返回 ERROR 1213 (40001): Deadlock found,应用端可以重试事务。

  • 7.4 查看当前锁信息

    sql

    — 查看当前运行的事务(包括锁等待)
    SELECT * FROM information_schema.INNODB_TRX;

    — 查看当前锁(MySQL 8.0)
    SELECT * FROM performance_schema.data_locks;

    — 查看锁等待关系(MySQL 8.0)
    SELECT * FROM performance_schema.data_lock_waits;

    — 通过 SHOW ENGINE INNODB STATUS 查看详细锁信息
    SHOW ENGINE INNODB STATUS;

    八、不同隔离级别下的锁差异(实战对比)

    8.1 隔离级别回顾

    隔离级别脏读不可重复读幻读加锁行为
    READ UNCOMMITTED 可能 可能 可能 几乎不加锁
    READ COMMITTED (RC) 不可能 可能 可能 只有记录锁,无间隙锁
    REPEATABLE READ (RR) 不可能 不可能 不可能(InnoDB 通过 Gap Lock 阻止) 临键锁(记录锁+间隙锁)
    SERIALIZABLE 不可能 不可能 不可能 所有 SELECT 自动加 S 锁(读加锁)

    8.2 间隙锁在 RR 与 RC 下的差异

    sql

    — 现有 user 表有 id: 1, 3, 5, 7
    — 事务 A
    BEGIN;
    SELECT * FROM user WHERE id > 3 FOR UPDATE;

    — 事务 B(在 RR 下阻塞)
    INSERT INTO user (id) VALUES (4); — 间隙 (3,5) 被锁,阻塞

    — 事务 B(在 RC 下成功)
    INSERT INTO user (id) VALUES (4); — RC 无间隙锁,立即成功

    这就是为什么 RC 级别无法避免幻读——新的记录可以插入到扫描范围内,再次查询会出现“幻影行”。

    九、特殊场景:自增锁

    InnoDB 为了处理 AUTO_INCREMENT 列,有一个专门的 自增锁(AUTO-INC Lock)。它是一种特殊的表级锁,但只在插入数据时短暂持有。

    • 传统模式:每个 INSERT 都加表锁,插入完成释放,并发低

    • 连续模式:批量插入时预分配一批 ID,减少锁竞争

    • 交错模式(8.0 默认):使用互斥量,不加表锁,并发最高,但可能不保证连续、单调递增

    通过 innodb_autoinc_lock_mode 参数控制(0=传统,1=连续,2=交错)。大多数场景推荐 2。

    十、真实案例分析:死锁排查与解决

    场景:一个订单扣库存的接口,高并发下频繁出现死锁。

    表结构:

    sql

    CREATE TABLE `inventory` (
    `sku_id` bigint NOT NULL,
    `warehouse_id` int NOT NULL,
    `stock` int NOT NULL,
    PRIMARY KEY (`sku_id`, `warehouse_id`)
    ) ENGINE=InnoDB;

    SQL 逻辑:

    sql

    BEGIN;
    SELECT stock FROM inventory WHERE sku_id = 100 AND warehouse_id = 1 FOR UPDATE;
    — 计算新库存
    UPDATE inventory SET stock = new_stock WHERE sku_id = 100 AND warehouse_id = 1;
    COMMIT;

    死锁日志分析:发现两个事务同时在 SELECT FOR UPDATE 时,由于主键顺序是 (sku_id, warehouse_id),如果事务 A 先锁 (100,1),事务 B 先锁 (200,1),然后各自更新对方的锁,就会死锁。

    解决方案:

  • 强制按 (warehouse_id, sku_id) 顺序访问(修改应用逻辑)。

  • 改用 SELECT … FOR UPDATE 时始终按主键顺序加锁(在代码层面先对 sku_id 排序)。

  • 采用乐观锁:使用 UPDATE … WHERE stock >= need AND stock – need >= 0 代替 SELECT FOR UPDATE。

  • 最终采用乐观锁,性能提升且死锁消失。

    十一、总结与最佳实践

    最佳实践说明
    务必使用索引 无索引的行锁会升级为表锁,严重降低并发
    尽量使用 RC 隔离级别 如果不能接受间隙锁的性能损耗和死锁风险,且业务允许幻读,RC 是最好的平衡点
    短事务原则 事务中尽量不要有 RPC、文件操作等长耗时任务,减少锁持有时间
    固定加锁顺序 对多个资源加锁时,约定一致的顺序,破坏循环等待
    使用乐观锁处理热点行 对于库存、账户余额等高频更新字段,用版本号或 CAS 代替悲观锁
    善用死锁超时 极端高并发下可关闭死锁检测(innodb_deadlock_detect=0),依赖 innodb_lock_wait_timeout 超时回滚
    监控锁等待 定期查询 information_schema.INNODB_TRX 和 performance_schema.data_locks,及时发现长事务和锁冲突

    锁与事务的关系:锁是事务隔离性的实现手段之一,但优秀的应用设计应当尽量让事务使用 MVCC(快照读),仅在必要时加锁。

    掌握了 InnoDB 的锁机制,你就能写出更可靠的高并发代码,也能在遇到死锁问题时快速定位和解决。希望这篇文章能帮你彻底打通 MySQL 锁的任督二脉。

    赞(0)
    未经允许不得转载:171主机测评 » MySQL 锁机制完全解析:从行锁到死锁,一篇彻底搞懂
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址