理解 MySQL 的加锁行为,是写出高并发下正确 SQL 的关键。很多死锁、锁等待、性能瓶颈,根源都在于开发时不清楚“这一行 SQL 到底会锁住什么”。而要准确判断加锁范围,必须同时考虑三个要素:当前隔离级别、索引命中情况、SQL 语句的具体条件。
本节以 InnoDB 引擎为例,重点分析可重复读(REPEATABLE READ,简称 RR)和读已提交(READ COMMITTED,简称 RC)这两种生产中最常用的隔离级别下的加锁规则。
6.6.1 两个基本前提
在讨论具体规则前,需要先明确两个基本前提:
1. 加锁单位是索引记录,而不是整行数据
InnoDB 的行锁是通过给索引记录加锁来实现的。如果一张表没有定义任何索引,InnoDB 会隐式创建一个聚簇索引来组织数据,此时所谓的“行锁”其实是锁定这个隐式索引上的记录。因此,一条 SQL 如果没有利用索引进行过滤,可能会导致扫描范围变大,锁定的记录数远超预期。
2. 语句类型决定锁模式
SELECT ... LOCK IN SHARE MODE(或 8.0 中的FOR SHARE)会加共享锁(S 锁)。SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT会加排他锁(X 锁)。- 普通的
SELECT在 RR 和 RC 级别下都是快照读,不加锁。
明白这两点之后,我们就可以深入不同隔离级别下的加锁行为了。
6.6.2 可重复读(RR)下的加锁规则
RR 是 InnoDB 的默认隔离级别,也是加锁行为最复杂的级别。为了防止幻读,RR 除了对命中记录加锁外,还会在合适的条件下加上间隙锁(Gap Lock)。
规则一:唯一索引等值查询,记录存在 → 锁住该记录
当你使用唯一索引进行等值查询,并且记录存在时,InnoDB 只会锁定那条索引记录本身,不加间隙锁。这是因为唯一索引保证值唯一,不会出现其他事务插入相同值的幻读情况。
示例:
-- 假设 id 是主键,表中有 id=10 的记录
SELECT * FROM t WHERE id = 10 FOR UPDATE;
加锁范围:只在 id=10 的那条主键索引记录上加 X 锁。其他事务可以插入 id=9 或 id=11 的记录,不受影响。
规则二:唯一索引等值查询,记录不存在 → 加间隙锁
如果等值查询的值不存在,InnoDB 会在该值应当所在的位置加上间隙锁,阻止其他事务在这个“空缺”中插入数据,从而避免幻读。
示例:
-- 表中 id 有 5 和 15,没有 id=10 的记录
SELECT * FROM t WHERE id = 10 FOR UPDATE;
加锁范围:在 id=5 和 id=15 之间的空隙加间隙锁,即 (5, 15) 区间。此时另一个事务如果尝试 INSERT INTO t (id) VALUES (10) 就会被阻塞。
规则三:唯一索引范围查询 → 锁住所有扫描到的记录及其间隙
范围查询会扫描多条记录,无论是否命中等值条件,被扫描到的记录都会被锁定,并且还会锁住相应的间隙。
示例:
-- 表中 id 有 5, 10, 15, 20
SELECT * FROM t WHERE id >= 10 AND id < 15 FOR UPDATE;
这次查询会扫描到 id=10 和 id=15 两条记录(因为范围条件包含下边界,扫描到上边界)。实际加锁范围是:id=10 的记录锁,id=15 的记录锁,(10, 15) 之间的间隙锁,以及 (5, 10) 和 (15, 20) 的间隙?等等,需要细化。InnoDB 在处理范围查询时,还会对第一个不满足条件的记录也加锁(即“行锁”会锁住下一个记录),并在边界前后加间隙锁。实际效果是整个 (5, 20) 区间几乎都被锁住,只有间隙 (5, 10) 可能有些微小差异,但通常理解成从范围起点之前的间隙到范围终点之后的间隙都会被锁定。简单来说,通过主键做范围查询时,锁的范围会比 SQL 写的条件更宽,开发时需要留意。
规则四:非唯一索引查询 → 在命中的索引记录上加临键锁
如果查询的列上只有普通索引(非唯一),那么等值查询可能匹配多行。InnoDB 会扫描命中记录,对它们加上临键锁(Next-Key Lock),即记录锁加上该记录之前的间隙锁。为了防止幻读,还会在最后一个匹配记录之后到下一个不匹配记录之间再加一个间隙锁。
示例:
-- age 列有普通索引,表中有 age=10 的多条记录
SELECT * FROM t WHERE age = 10 FOR UPDATE;
InnoDB 会锁住所有 age=10 的索引记录,以及在索引排序中,age=10 之前的间隙和 age=10 之后的间隙(直到 age 的下一个值,如 age=12 的记录之前)。如果该表还有另一个事务试图插入 age=10 的新记录,会被阻塞,因为间隙被锁住了。
RR 下加锁行为小结
在 RR 级别下,一条写语句的加锁范围通常由以下几个元素组成:
- 所有被扫描到的索引记录加记录锁。
- 在这些记录之间的间隙上加间隙锁。
- 在某些情况下,还会对第一个不满足查询条件的记录加锁(用来保护区间上限)。
这种“宁滥勿缺”的策略使得 RR 能够大幅避免幻读,但也带来了更高的锁竞争概率,容易因不合理的索引导致大面积锁等待。
6.6.3 读已提交(RC)下的加锁行为
RC 级别不需要解决不可重复读,因此很多“预防性”的间隙锁被移除了,加锁范围更精准,但代价是可能发生幻读(对大多数业务来说可接受)。
规则一:仅锁定查询返回的行,不加间隙锁
在 RC 下,UPDATE、DELETE、SELECT ... FOR UPDATE 只会对所返回(或修改)的行加上记录锁,不再加间隙锁。这意味着:
示例:
-- 表中 id 有 5, 10, 15
DELETE FROM t WHERE id < 10; -- 只删除 id=5,只会锁住 id=5 的记录
这个语句会删除 id=5,也只锁住 id=5 那一行。其他事务可以正常插入 id=6 的新记录,不会被阻塞。如果在 RR 下,它会锁住 (5, 10) 的间隙,阻止其他插入。
规则二:即使查询范围,也只锁命中的记录本身
对于范围查询,RC 只锁定实际满足条件的每一行,而不是锁整个扫描区间。但要注意,如果查询没有使用索引,导致全表扫描,那么 RC 依旧会对扫描过程中所有读取到的行加锁,容易造成大面积锁表。这与 RR 相似,区别是 RC 不加间隙锁,但全表扫描时依然会锁住所有被扫描到的行。
RC 下的“半一致性读”
在 RC 下,当使用 UPDATE 语句时,即使加了排他锁请求,如果某行已存在其他事务的锁,InnoDB 会先做一个“半一致性读”,即检查该行的最新提交版本是否满足 WHERE 条件。如果不满足,则跳过该行,不加锁也不等待。这能减少不必要的锁冲突。但如果是 SELECT ... FOR UPDATE 则不会跳过,会直接等待。
6.6.4 开发中的实用经验与避坑指南
理论知识最终要转化为写 SQL 时的肌肉记忆。以下是一些最常遇到的问题和应对策略:
1. 尽量让 SQL 走索引,避免全表扫描
无论是在 RR 还是 RC 下,如果查询没有合适的索引,InnoDB 必须扫描全表,从而对所有扫描到的记录加锁。这会导致锁范围急剧膨胀,并发能力瞬间下降。因此,对于写操作(UPDATE、DELETE)的 WHERE 条件,一定要确认其是否使用了索引(通过 EXPLAIN 检查)。
2. 善用 RC 隔离级别减少间隙锁
如果你的业务场景可以容忍幻读(比如靠唯一约束或业务逻辑兜底),强烈建议将事务隔离级别设置为 RC,并配合 binlog_format=ROW。RC 去掉了间隙锁,能显著减少因为间隙锁导致的死锁和锁等待,对高并发写入十分友好。很多互联网公司的主数据库默认就是 RC+ROW 格式。
3. 避免在长事务中持有锁
无论是 RR 还是 RC,锁都会随着事务的结束(COMMIT 或 ROLLBACK)才释放。如果一个事务打开后执行了 UPDATE,然后去调用外部接口、处理业务逻辑,再提交,那么这段时间内锁都一直持有,延长了其他事务的等待时间。永远把事务包裹的逻辑限定在与数据库直接交互的最小范围内。
4. 在 RR 下注意唯一索引与非唯一索引的加锁差异
当对非唯一索引进行等值查询,且结果集可能为空时,RR 会加上间隙锁。这个间隙锁可能会跟其他插入语句冲突,导致莫名其妙的锁等待。如果你发现某个功能总是有锁等待,检查一下是不是在普通索引上用了 FOR UPDATE 或者 DELETE 查询了一个不存在的值。
5. 使用 performance_schema 和 sys 库定位锁问题
当出现锁等待时,可以通过以下查询快速看到谁在等谁:
-- 查看正在进行的事务和锁等待
SELECT * FROM information_schema.innodb_trx WHERE trx_state = 'LOCK WAIT'\G
-- 查看当前锁信息
SELECT * FROM performance_schema.data_locks;
-- 使用 sys 库的快捷视图
SELECT * FROM sys.innodb_lock_waits;
掌握加锁规则,不是为了背出所有细节,而是为了在出现莫名其妙的小故障时,你能快速做出判断:是索引没走对,还是间隙锁在作怪。只要记住隔离级别决定了间隙锁的有无,索引决定了扫描范围的宽窄,这两条主线,就足以应对绝大多数开发实战中的锁问题。