
为什么需要锁
两个用户同时购买最后一件商品,两个事务同时执行「库存减 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 写锁。两者互斥。这会带来一个经典事故:
- 一个长事务正在查询这张表,持有 MDL 读锁;
- 此时执行
ALTER TABLE,它要等 MDL 写锁,进入等待; - 之后所有对这张表的查询,都排在这个
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)。它不锁任何已存在的记录,唯一的作用是阻止其他事务往这个空隙里插入新数据。
- 插入 id = 12 的事务会被阻塞;
- 间隙锁之间互不冲突:两个事务可以同时持有同一个间隙上的间隙锁,因为它们的目的都只是「不让别人插入」。

临键锁(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 的行锁,锁的是索引记录,而不是数据行本身。这带来一个非常重要的结论:
WHERE id = 3:走主键索引,只锁一条记录;WHERE name = '王五',而name上没有索引:只能全表扫描,扫描过的每一条记录都会被加锁(RR 下还包括间隙),效果几乎等于锁住整张表,其他事务对这张表的写入全部被阻塞。
所以:
- 更新和删除语句的条件,一定要能用上索引;
- 上线前用
EXPLAIN检查执行计划; - 通过二级索引更新时,二级索引的记录和对应的主键索引记录都会被加锁。
死锁

是怎么发生的
| 时间 | 事务 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 怎么处理
- 死锁检测(默认开启,
innodb_deadlock_detect):InnoDB 会主动检测等待关系中的环,一旦发现,就回滚其中一个事务,通常选择修改数据较少、回滚代价较小的那个,另一个事务得以继续。被回滚的事务会收到错误1213 Deadlock found when trying to get lock; - 锁等待超时:如果只是普通的锁等待(没有形成死锁),等待超过
innodb_lock_wait_timeout(默认 50 秒)后会报错1205 Lock wait timeout exceeded。
怎么避免
- 按固定顺序加锁:比如转账时总是先锁 id 较小的账户;
- 事务尽量短小:减少持有锁的时间,不要在事务里做远程调用;
- 条件走索引:减少加锁的范围;
- 降低隔离级别:如果业务允许,使用 RC 可以避免间隙锁带来的死锁;
- 应用层重试:死锁无法百分之百避免,捕获 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:如何避免死锁? 按固定顺序访问资源、缩短事务、让条件走索引、必要时降低隔离级别,并在应用层捕获死锁错误后重试。
总结

- 粒度:全局锁、表级锁、行级锁,InnoDB 的核心是行锁;
- 类型:共享锁和排他锁,只有「读 + 读」兼容;
- 形态:记录锁、间隙锁、临键锁,RR 下用临键锁防止幻读;
- 行锁加在索引上:更新语句的条件必须走索引;
- 死锁:InnoDB 会自动检测并回滚一个事务;按顺序加锁、短事务、应用重试;
- 排查:
SHOW ENGINE INNODB STATUS、performance_schema.data_locks。
参考资料
- 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 讲》.