拾星 · 计算机与后端

MySQL 的锁:从表锁、行锁到间隙锁和死锁

锁的粒度与类型、共享锁和排他锁、记录锁/间隙锁/临键锁、为什么锁加在索引上、意向锁与元数据锁,以及死锁的成因、排查和避免

约 10 分钟读完 · 配套视频 1:20
同一行数据,两个事务同时改:后来的必须等
同一行数据,两个事务同时改:后来的必须等

为什么需要锁

两个用户同时购买最后一件商品,两个事务同时执行「库存减 1」。如果没有任何协调,可能出现:两个事务都读到库存是 1,各自减 1 后写回 0,结果卖出了两件,库存却只减了一次。

锁的作用,就是让并发修改同一份数据的事务排好队:先拿到锁的先改,其他的等它提交或回滚后再继续。

在 InnoDB 中,普通的 SELECT 通过 MVCC 读取快照,不加锁,所以读写可以并发(见本系列《MySQL 的 MVCC》)。本文讲的锁,主要作用于写操作和加锁读。

按范围分:锁住多大一块

全局锁、表级锁、行级锁
全局锁、表级锁、行级锁
粒度 作用 典型场景
全局锁 让整个数据库实例只读 全库逻辑备份(FLUSH TABLES WITH READ LOCK)
表级锁 锁住整张表 LOCK TABLES、元数据锁(MDL)、意向锁
行级锁 只锁住需要的行 InnoDB 的 UPDATE、DELETE、加锁读

锁的范围越小,同一时刻能并发执行的事务就越多,但管理的开销也越大。InnoDB 之所以适合高并发的业务,很重要的一点就是它支持行级锁。

对 InnoDB 表做全库备份时,通常使用 mysqldump --single-transaction,它借助一致性快照完成备份,不需要加全局锁。

两个容易忽略的表级锁

元数据锁(MDL):执行任何增删改查时,MySQL 会自动给表加 MDL 读锁;修改表结构(ALTER TABLE)时需要 MDL 写锁。两者互斥。这会带来一个经典事故:

  1. 一个长事务正在查询这张表,持有 MDL 读锁;
  2. 此时执行 ALTER TABLE,它要等 MDL 写锁,进入等待;
  3. 之后所有对这张表的查询,都排在这个 ALTER 后面,也被阻塞了。

结果是整张表突然「卡死」。所以修改表结构前,要先确认没有长事务,并为 DDL 设置等待超时。

意向锁(IS / IX):事务给某一行加 S 锁或 X 锁之前,会先在表上加一个意向共享锁或意向排他锁。它的作用是快速判断「这张表里有没有行被锁住」:当有人想给整张表加锁时,只需要检查表上的意向锁,而不必逐行检查。意向锁之间互相兼容,不会影响行锁的并发。

按类型分:共享锁与排他锁

共享锁与排他锁的兼容关系
共享锁与排他锁的兼容关系
锁 由谁加 含义
共享锁 S(读锁) SELECT ... FOR SHARE(旧写法 LOCK IN SHARE MODE) 可以多个事务一起持有,持有期间别人不能改
排他锁 X(写锁) UPDATE、DELETE、INSERT、SELECT ... FOR UPDATE 只能一个事务持有,别人加 S 锁或 X 锁都要等

兼容关系:

已持有 → / 想要加 ↓ S X
S 兼容 等待
X 等待 等待

只有「读锁 + 读锁」可以同时存在。锁会一直持有到事务结束(提交或回滚)时才释放,所以事务越长,持有锁的时间越长。

一个典型用法:防止超卖

BEGIN;
SELECT stock FROM product WHERE id = 1001 FOR UPDATE;   -- 加 X 锁,其他事务要等
-- 应用中判断 stock > 0
UPDATE product SET stock = stock - 1 WHERE id = 1001;
COMMIT;                                                 -- 释放锁

更简洁的写法是把判断放进更新语句中,一条语句原子完成:

UPDATE product SET stock = stock - 1 WHERE id = 1001 AND stock > 0;
-- 根据影响行数判断是否扣减成功

行锁的三种形态

InnoDB 的行锁,按锁住的范围,可以分为三种。下面的例子假设某个索引上已有的值是 5、10、15、20,隔离级别是默认的可重复读(RR)。

记录锁:只锁住一条记录
记录锁:只锁住一条记录

记录锁(Record Lock)

锁住索引上的一条记录。

UPDATE t SET c = 1 WHERE id = 10;   -- id 是主键,且记录存在

用唯一索引做等值查询、并且记录存在时,只需要锁住这一条记录。

间隙锁:锁住记录之间的空隙
间隙锁:锁住记录之间的空隙

间隙锁(Gap Lock)

锁住两条记录之间的空隙,比如 (10, 15)。它不锁任何已存在的记录,唯一的作用是阻止其他事务往这个空隙里插入新数据。

临键锁:记录锁加前面的间隙
临键锁:记录锁加前面的间隙

临键锁(Next-Key Lock)

记录锁 + 这条记录前面的间隙,是一个左开右闭的区间,比如 (10, 15]。在 RR 隔离级别下,加锁读和更新语句默认以临键锁为基本单位,再根据具体情况优化:

范围查询会把扫描到的区间都锁住。例如:

SELECT * FROM t WHERE id > 10 AND id <= 15 FOR UPDATE;
-- 锁住 (10, 15],其他事务无法在这个范围内插入、修改

具体加了哪些锁,与索引类型、查询条件、MySQL 版本都有关系,排查时以 performance_schema.data_locks 的实际结果为准。

为什么要锁「空隙」

为了防止幻读:事务 A 先查询了 id > 10 AND id <= 15 的数据并加锁,如果事务 B 能往这个范围里插入一行并提交,A 再用加锁读查询时就会多出一行。锁住间隙,B 的插入就被挡住了。

在读已提交(RC)隔离级别下,一般只加记录锁,不加间隙锁(外键检查和唯一性检查等少数情况除外),所以 RC 的锁冲突和死锁通常更少,但也就不能用锁来防止幻读了。

插入意向锁

INSERT 在插入前,会在要插入的间隙上加一个插入意向锁。多个事务往同一个间隙的不同位置插入时,插入意向锁之间不冲突;但它和间隙锁冲突,这正是间隙锁能阻止插入的原因。

关键:行锁是加在索引上的

没走索引的更新,几乎等于锁整张表
没走索引的更新,几乎等于锁整张表

InnoDB 的行锁,锁的是索引记录,而不是数据行本身。这带来一个非常重要的结论:

所以:

  1. 更新和删除语句的条件,一定要能用上索引;
  2. 上线前用 EXPLAIN 检查执行计划;
  3. 通过二级索引更新时,二级索引的记录和对应的主键索引记录都会被加锁。

死锁

死锁:两个事务互相等待
死锁:两个事务互相等待

是怎么发生的

时间 事务 1 事务 2
① UPDATE account ... WHERE id = 1; 锁住 id = 1
② UPDATE account ... WHERE id = 2; 锁住 id = 2
③ UPDATE account ... WHERE id = 2; 等待事务 2
④ UPDATE account ... WHERE id = 1; 等待事务 1

两个事务互相等待对方持有的锁,谁也无法继续,这就是死锁。

比如两个人互相转账:A 转给 B 时先锁 A 再锁 B,B 转给 A 时先锁 B 再锁 A,就可能形成死锁。

间隙锁也很容易导致死锁:两个事务先后在同一个间隙上加了间隙锁(不冲突),然后都想往这个间隙里插入数据,插入意向锁和对方的间隙锁冲突,于是互相等待。

InnoDB 怎么处理

怎么避免

  1. 按固定顺序加锁:比如转账时总是先锁 id 较小的账户;
  2. 事务尽量短小:减少持有锁的时间,不要在事务里做远程调用;
  3. 条件走索引:减少加锁的范围;
  4. 降低隔离级别:如果业务允许,使用 RC 可以避免间隙锁带来的死锁;
  5. 应用层重试:死锁无法百分之百避免,捕获 1213 错误后重试整个事务。

排查锁问题

常用的排查手段
常用的排查手段
-- 最近一次死锁的详细信息(看 LATEST DETECTED DEADLOCK 部分)
SHOW ENGINE INNODB STATUS;

-- 当前持有和等待的锁(MySQL 8.0)
SELECT engine_transaction_id, object_name, index_name, lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks;

-- 谁在等谁
SELECT * FROM performance_schema.data_lock_waits;

-- 正在运行的事务,以及开始的时间
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx;

-- 每次死锁都记录到错误日志
SET GLOBAL innodb_print_all_deadlocks = ON;

MySQL 8.0 还提供了 sys.innodb_lock_waits 视图,一次性列出阻塞者和被阻塞者以及它们正在执行的语句,用起来很方便。

乐观锁与悲观锁

上面讲的数据库锁都属于悲观锁:假设冲突一定会发生,先加锁再操作。另一种思路是乐观锁:不加锁,更新时检查数据有没有被别人改过。

-- 读取时记下版本号
SELECT stock, version FROM product WHERE id = 1001;      -- stock = 10, version = 7
-- 更新时检查版本号
UPDATE product SET stock = 9, version = 8 WHERE id = 1001 AND version = 7;
-- 影响行数为 0,说明被别人改过了,需要重新读取再重试
悲观锁 乐观锁
思路 先加锁,再操作 不加锁,提交时检查冲突
适合 冲突频繁、重试代价高 冲突较少、读多写少
代价 加锁等待,可能死锁 冲突时需要重试

高频面试题速答

Q:InnoDB 的行锁有哪几种? 记录锁、间隙锁、临键锁。RR 下加锁的基本单位是临键锁,在唯一索引等值查询等情况下会退化为记录锁或间隙锁。

Q:间隙锁解决了什么问题? 阻止其他事务向间隙中插入数据,从而在加锁读的场景下防止幻读。

Q:为什么说「没走索引的更新会锁表」? 行锁加在索引记录上,没有可用的索引时会全表扫描,扫描过的记录都会被加锁。

Q:如何避免死锁? 按固定顺序访问资源、缩短事务、让条件走索引、必要时降低隔离级别,并在应用层捕获死锁错误后重试。

总结

S / X、三种行锁、锁在索引上、死锁
S / X、三种行锁、锁在索引上、死锁

参考资料

  • MySQL 8.0 Reference Manual: InnoDB Locking;Locks Set by Different SQL Statements in InnoDB;Deadlocks in InnoDB;Metadata Locking;The data_locks Table.
  • 小孩子 4919.《MySQL 是怎样运行的:从根儿上理解 MySQL》.
  • 林晓斌.《MySQL 实战 45 讲》.
← 拾星首页▶ 看配套视频
← 上一章:Redis 缓存目录下一章:消息队列 →