行级锁
行级锁每次操作锁住对应的行数据:粒度最小、冲突概率最低、并发度最高,仅 InnoDB 支持。分行锁(Record Lock)、间隙锁(Gap Lock)、**临键锁(Next-Key Lock)**三类。
一、介绍
- 行级锁,每次操作锁住对应的行数据。锁定粒度最小,发生锁冲突的概率最低,并发度最高。应用在 InnoDB 存储引擎中。
- InnoDB 的数据是基于索引组织的,行锁是通过对索引上的索引项加锁来实现的,而不是对记录加的锁。
| # | 类型 | 说明 | 隔离级别 |
|---|---|---|---|
| 1 | 行锁(Record Lock) | 锁定单个行记录,防止其他事务对此行进行 UPDATE 和 DELETE | RC、RR 都支持 |
| 2 | 间隙锁(Gap Lock) | 锁定索引记录间隙(不含该记录),确保间隙不变,防止其他事务在该间隙 INSERT 产生幻读 | RR 支持 |
| 3 | 临键锁(Next-Key Lock) | 行锁 + 间隙锁组合,同时锁住数据及数据前面的间隙 Gap | RR 支持 |
演示用表:
CREATE TABLE stu(
id INT PRIMARY KEY,
name VARCHAR(20),
age INT
);
INSERT INTO stu VALUES (1,'tom',1), (3,'PHP',3), (8,'rose',8),
(11,'jetty',11), (19,'lily',19), (25,'luci',25);
二、行锁
InnoDB 实现了以下两种类型的行锁:
- 共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排它锁。
- 排他锁(X):允许获取排他锁的事务更新数据,阻止其他事务获得相同数据集的共享锁和排他锁。
兼容性矩阵
| 当前锁 \ 请求锁 | S(共享锁) | X(排他锁) |
|---|---|---|
| S(共享锁) | 兼容 | 冲突 |
| X(排他锁) | 冲突 | 冲突 |
SQL 与行锁类型
| SQL | 行锁类型 | 说明 |
|---|---|---|
INSERT ... | 排他锁 | 自动加锁 |
UPDATE ... | 排他锁 | 自动加锁 |
DELETE ... | 排他锁 | 自动加锁 |
SELECT(正常) | 不加任何锁 | |
SELECT ... LOCK IN SHARE MODE | 共享锁 | 需要手动在 SELECT 之后加 |
SELECT ... FOR UPDATE | 排他锁 | 需要手动在 SELECT 之后加 |
MySQL 8.0 起
LOCK IN SHARE MODE已由FOR SHARE取代,旧写法仍可识别但会告警。
演示要点:默认情况下,InnoDB 在 REPEATABLE READ 事务隔离级别运行,使用 next-key 锁进行搜索和索引扫描,以防止幻读。
- 针对唯一索引检索时,对已存在的记录进行等值匹配,会自动优化为行锁;
- InnoDB 的行锁是针对索引加的锁,不通过索引条件检索数据时,InnoDB 将对表中的所有记录加锁,此时就会升级为表锁。
2.1 实测:普通 SELECT 不加锁
-- 会话 A
BEGIN;
SELECT * FROM stu WHERE id = 1; -- 锁粒度:不加锁
SELECT ... FROM performance_schema.data_locks;
Empty set (0.00 sec)
2.2 实测:共享行锁
-- 会话 A
BEGIN;
SELECT * FROM stu WHERE id = 1 LOCK IN SHARE MODE; -- 锁粒度:行锁(主键等值命中)
+---------------+-------------+------------+-----------+---------------+-----------+
| object_schema | object_name | index_name | lock_type | lock_mode | lock_data |
+---------------+-------------+------------+-----------+---------------+-----------+
| itcast | stu | NULL | TABLE | IS | NULL |
| itcast | stu | PRIMARY | RECORD | S,REC_NOT_GAP | 1 |
+---------------+-------------+------------+-----------+---------------+-----------+
两个会话同时执行该语句时,data_locks 会同时出现两组 IS + S,REC_NOT_GAP —— 共享锁之间兼容。
2.3 实测:排他行锁
-- 会话 A
BEGIN;
UPDATE stu SET name = 'Java' WHERE id = 1; -- Query OK, 1 row affected
-- 会话 B:更新同一行 → 阻塞
BEGIN;
UPDATE stu SET name = 'Java' WHERE id = 1; -- 等会话 A 提交后才执行
WHERE id = 1 走主键等值匹配 → 只锁这一行(X,REC_NOT_GAP),其他行不受影响。
2.4 实测:无索引条件 → 升级为表锁
-- 会话 A:name 列上还没有索引
BEGIN;
UPDATE stu SET name = 'Lei' WHERE name = 'lily'; -- Query OK, 1 row affected
-- 会话 B:按主键更新另一行,却同样被阻塞
BEGIN;
UPDATE stu SET name = 'PHP' WHERE id = 3;
-- Query OK, 1 row affected (26.82 sec) —— 一直等到会话 A 提交
-- 给 name 建索引后重做同样操作
CREATE INDEX idx_stu_name ON stu(name);
-- 会话 A:BEGIN; UPDATE stu SET name = 'Lily' WHERE name = 'Lei';
-- 会话 B:BEGIN; UPDATE stu SET name = 'PHP' WHERE id = 3; -- 立即成功,不再阻塞
name上没有索引时,InnoDB 无法用索引定位目标行,只能全表扫描并对扫描到的每条记录加锁 —— 效果等同锁住整张表。这就是「UPDATE的WHERE不走索引 ⇒ 行锁升级为表锁」的原因。
三、间隙锁与临键锁
演示要点(均针对 RR 隔离级别):
| # | 场景 | 加锁结果 |
|---|---|---|
| 1 | 索引上的等值查询(唯一索引),给不存在的记录加锁 | 优化为间隙锁 |
| 2 | 索引上的等值查询(普通索引),向右遍历时最后一个值不满足查询需求 | next-key lock 退化为间隙锁 |
| 3 | 索引上的范围查询(唯一索引) | 会访问到不满足条件的第一个值为止 |
间隙锁唯一目的是防止其他事务插入间隙。间隙锁可以共存,一个事务采用的间隙锁不会阻止另一个事务在同一间隙上采用间隙锁。
3.1 唯一索引等值查询不存在 → 间隙锁
表内主键:1, 3, 8, 11, 19, 25
-- 会话 A
BEGIN;
UPDATE stu SET age = 10 WHERE id = 5; -- Query OK, 0 rows affected(id = 5 不存在)
+------------+-----------+-----------+-----------+
| index_name | lock_type | lock_mode | lock_data |
+------------+-----------+-----------+-----------+
| NULL | TABLE | IX | NULL |
| PRIMARY | RECORD | X,GAP | 8 |
+------------+-----------+-----------+-----------+
没有命中记录,于是锁住
3 ~ 8之间的间隙(lock_data指向间隙右侧的记录 8),防止别的事务插入id = 5这类记录。
3.2 唯一索引范围查询 → 访问到不满足条件的第一个值
-- 会话 A
BEGIN;
SELECT * FROM stu WHERE id >= 19 LOCK IN SHARE MODE;
+------------+-----------+---------------+------------------------+
| index_name | lock_type | lock_mode | lock_data |
+------------+-----------+---------------+------------------------+
| NULL | TABLE | IS | NULL |
| PRIMARY | RECORD | S,REC_NOT_GAP | 19 |
| PRIMARY | RECORD | S | supremum pseudo-record |
| PRIMARY | RECORD | S | 25 |
+------------+-----------+---------------+------------------------+
>= 19会一直向右扫描到最后一个记录之后的伪记录supremum pseudo-record,因此25之后的间隙也被临键锁锁住。
3.3 普通索引等值查询 → next-key lock 退化为间隙锁
CREATE INDEX idx_stu_age ON stu(age);
-- 会话 A
BEGIN;
SELECT * FROM stu WHERE age = 3 LOCK IN SHARE MODE;
+-------------+-----------+---------------+-----------+
| index_name | lock_type | lock_mode | lock_data |
+-------------+-----------+---------------+-----------+
| NULL | TABLE | IS | NULL |
| idx_stu_age | RECORD | S | 3, 3 |
| PRIMARY | RECORD | S,REC_NOT_GAP | 3 |
| idx_stu_age | RECORD | S,GAP | 7, 7 |
+-------------+-----------+---------------+-----------+
对应三种锁:
idx_stu_age上的S(无后缀)是临键锁:锁住age = 3这条记录及其前面的间隙;PRIMARY上的S,REC_NOT_GAP是行锁:回表取出整行时给主键行加锁;idx_stu_age上的S,GAP是间隙锁:因为age是普通索引,值可能重复,必须继续向右遍历;遍历到7发现不满足条件,next-key lock 退化为间隙锁,锁住3 ~ 7的间隙。
速查
-- 普通 SELECT → 不加任何锁
BEGIN;
SELECT * FROM stu WHERE id = 1;
-- 共享行锁(S)+ 意向共享锁(IS)→ 读且不允许别人改
BEGIN;
SELECT * FROM stu WHERE id = 1 LOCK IN SHARE MODE;
-- 排他行锁(X)+ 意向排他锁(IX)→ 更新自动加
BEGIN;
UPDATE stu SET name = 'Java' WHERE id = 1;
-- 行锁升级为表锁 → where 条件列上没有索引
BEGIN;
UPDATE stu SET name = 'Lei' WHERE name = 'lily';
-- 查看加锁情况 → REC_NOT_GAP = 行锁,GAP = 间隙锁,无后缀 = 临键锁
SELECT object_schema, object_name, index_name, lock_type, lock_mode, lock_data
FROM performance_schema.data_locks;
| 场景 | 关键点 |
|---|---|
| 只想读不想别人改 | SELECT ... LOCK IN SHARE MODE(共享锁之间兼容,与排他锁冲突) |
| 想改且不让别人读 | SELECT ... FOR UPDATE / UPDATE / DELETE / INSERT(自动加排他锁) |
WHERE 走唯一索引等值命中 | 优化为行锁,只锁这一行 |
WHERE 列无索引 | 全表扫描加锁 ⇒ 退化为表锁,并发急剧下降 |
| 等值查询没命中(唯一索引) | 给间隙加间隙锁,防止别的事务插入该值 |
| 普通索引等值查询 | 临键锁;向右扫描到不满足条件的值后退化为间隙锁 |
| 间隙锁之间 | 不互斥,只用于阻止 INSERT |
相关
- 表级锁:表锁、元数据锁(MDL)、意向锁
- 锁概述与全局锁:锁的分类与全局锁
- 索引概述 / 索引结构:行锁加在索引项上,理解间隙锁先理解 B+ 树
- 并发事务问题与隔离级别:幻读与 RR 隔离级别
- SQL 优化 - limit / count / update:
UPDATE不走索引导致行锁升级为表锁