MySQL 的行级锁到底是怎么加的?
InnoDB锁机制的核心原理
在剖析具体加锁行为前,必须厘清两个底层逻辑:数据库锁定的目标是索引,而加锁的基本单元是next-key lock。这个单元由记录锁(锁定索引项本身)和间隙锁(锁定索引项之间的区间)组合而成。两者在区间定义上有本质区别:next-key lock采用左开右闭区间,而间隙锁采用左开右开区间。
但锁的形态并非固定。当仅凭记录锁或间隙锁就足以隔离事务、防止幻读时,next-key lock会“退化”为更精确的锁类型。掌握这些退化条件,是精准控制InnoDB并发行为的关键。
为便于后续推演,我们定义一张示例表:id列为主键(唯一索引),age列为普通二级索引(非唯一),name列为无索引列。初始数据状态如下:
一、唯一索引等值查询的锁行为
基于唯一索引进行等值查询时,加锁策略会根据目标记录是否存在而动态调整。
核心规则可概括为:若目标记录存在,引擎在索引树精确定位后,会将next-key lock退化为仅锁定该行的记录锁。若目标记录不存在,引擎会定位到大于查询值的第一条记录,并将其上的next-key lock退化为间隙锁。
1. 记录存在时的锁退化
假设事务A执行以下查询,且表中存在id=1的记录。
select * from user where id = 1 for update;
此时,事务A将在id=1的主键索引记录上施加X型(排他)记录锁。
任何其他事务对id=1记录的更新或删除操作都将被阻塞。退化的逻辑在于:在此场景下,记录锁已足够保证隔离性。由于唯一索引约束,其他事务无法插入新的id=1记录,也无法修改现有记录,从而确保了查询结果集的一致性。
2. 记录不存在时的锁退化
若事务A查询一条不存在的记录:
select * from user where id = 2 for update;
事务A会在id=5的主键索引记录上施加一个间隙锁,锁定区间(1, 5)。
这将阻塞任何插入id值为2、3、4的新事务。选择间隙锁而非next-key lock是出于性能考量:若锁定(1,5]区间,id=5的记录将被阻塞,影响其更新。但无论id=5是否变更,事务A的查询结果始终为空。因此,仅防止插入的间隙锁是实现隔离的最优解。
二、唯一索引范围查询的锁策略
范围查询的加锁逻辑更为精细。基本原则是:扫描过程中遇到的每一条索引记录都会先附加next-key lock,随后根据特定条件判断是否退化。
具体规则可归纳为:
- 对于大于等于查询,由于包含等值条件,若等值记录存在,则该记录上的next-key lock退化为记录锁。
- 对于小于或小于等于查询,退化行为取决于条件值对应记录的存在性:
- 若该记录不存在,扫描终止时,终止记录上的next-key lock退化为间隙锁。
- 若该记录存在,对于“小于”查询,终止记录的next-key lock退化为间隙锁;对于“小于等于”查询,则保持next-key lock。
1. 大于(等于)范围查询分析
(1) 大于查询的锁范围
假设事务A执行:
select * from user where id > 15 for update;
事务A将在主键索引上施加两个next-key lock:
- 在id=20记录上,施加(15, 20]的next-key lock。该锁防止id=20被修改,并阻止id在16至19区间的新记录插入。
- 在虚拟的“上确界”记录上,施加(20, +∞]的next-key lock,防止插入任何id大于20的记录。
此处无锁退化。要保证“id>15”结果集的稳定性,必须同时防止范围内记录被修改和新记录插入,next-key lock恰好满足此双重需求。
(2) 大于等于查询的锁退化
假设事务A执行:
select * from user where id >= 15 for update;
事务A将施加三把锁:
- 在id=15记录上,因等值查询命中,next-key lock退化为记录锁,仅锁定该行。
- 在id=20记录上,施加(15, 20]的next-key lock。
- 在“上确界”记录上,施加(20, +∞]的next-key lock。
可见,id=15上的锁发生了退化,其逻辑与前述“唯一索引等值查询”完全一致。
2. 小于(等于)范围查询分析
(1) 条件值记录不存在时的锁退化
查询id < 6,且id=6记录不存在。
select * from user where id < 6 for update;
加锁情况如下:
- 在id=1上加(-∞, 1]的next-key lock。
- 在id=5上加(1, 5]的next-key lock。
- 在id=10上加(5, 10)的间隙锁。注意,扫描到第一条不满足条件的记录(id=10)时,next-key lock退化了。
这印证了前述规则:当条件值记录不存在时,终止扫描的记录其next-key lock退化为间隙锁。
(2) 小于等于查询且记录存在时的锁保持
查询id <= 5,且id=5存在。
select * from user where id <= 5 for update;
加锁情况如下:
- 在id=1上加(-∞, 1]的next-key lock。
- 在id=5上加(1, 5]的next-key lock。
此处id=5上的锁未退化。原因在于:对于“id<=5”查询,id=5本身属于结果集。若退化为间隙锁,其他事务可删除id=5,导致前后查询结果集不一致,引发幻读。因此必须用next-key lock锁定。
(3) 小于查询且记录存在时的锁退化
查询id < 10,且id=10存在(但不满足条件)。
select * from user where id < 10 for update;
加锁情况与第(1)种情况一致:
- 在id=1上加(-∞, 1]的next-key lock。
- 在id=5上加(1, 5]的next-key lock。
- 在id=10上加(5, 10)的间隙锁。
由于id=10是第一条不满足条件的记录,且查询为“小于”条件,其next-key lock退化为间隙锁。
三、非唯一索引等值查询的锁机制
非唯一索引等值查询涉及主键与二级索引的双重锁定,逻辑更为复杂。加锁时会对两者同时操作,但对主键索引仅锁定满足条件的记录。
核心规则同样分为两种情况:
- 记录存在:由于索引非唯一,查询过程实为扫描,直至找到第一条不满足条件的记录。扫描过程中,对所有命中的二级索引记录加next-key lock,并将第一条不满足条件记录的next-key lock退化为间隙锁。同时,为所有满足条件记录的主键索引加记录锁。
- 记录不存在:扫描到第一条不满足条件的记录后,将其next-key lock退化为间隙锁。由于无命中记录,故不对任何主键索引加锁。
1. 记录不存在的间隙锁
假设事务A查询age = 25(表中无此记录)。
select * from user where age = 25 for update;
引擎将扫描至第一条age > 25的记录,即age=39。随后,在该记录的二级索引上,next-key lock退化为间隙锁,锁定区间(22, 39)。
这将阻塞插入age值在23至38之间的新记录。该间隙锁的核心目的是防止幻读——避免在事务期间插入age=25的记录。
需注意一个关键细节:插入age=22或age=39的记录是否被阻塞,取决于插入记录的主键id值。因为二级索引的定位依据是“索引值+主键值”。插入操作能否成功,取决于其目标位置的下一条记录是否被间隙锁锁定。
- 插入age=22:若插入(id=3, age=22),其下一条记录为(id=10, age=22),该记录未被锁,可插入。若插入(id=12, age=22),其下一条记录为(id=20, age=39),该记录被间隙锁覆盖,插入被阻塞。
- 插入age=39:若插入(id=3, age=39),其下一条记录为(id=20, age=39),被锁,插入阻塞。若插入(id=21, age=39),其下一条记录不存在,无锁,可插入。
因此,间隙锁锁定的是一个逻辑区间,但具体插入操作是否被阻塞,取决于其试图插入的精确“位置”。
2. 记录存在的扫描加锁
假设事务A查询age = 22(表中存在多条记录)。
select * from user where age = 22 for update;
加锁过程为顺序扫描:
- 定位第一条age=22的记录(id=10),对其二级索引加(21, 22]的next-key lock,并对主键索引id=10加记录锁。
- 继续扫描,找到下一条age=39的记录(首个不满足条件记录),将其二级索引上的next-key lock退化为间隙锁(22, 39)。
- 停止扫描。
最终加锁状态如下:
- 主键索引:仅对id=10施加记录锁。
- 二级索引:
- 在age=22记录上,施加(21, 22]的next-key lock。
- 在age=39记录上,施加(22, 39)的间隙锁。
这些锁协同工作,有效防止了幻读:记录锁保护了已存在的age=22记录不被修改;二级索引锁则阻止了在查询区间内插入新的age=22记录。
同样,插入age=21或22的记录是否成功,也取决于其主键id值,原理同上。
四、非唯一索引范围查询的锁策略
非唯一索引的范围查询规则反而简化:next-key lock不发生退化。即对扫描到的每一条二级索引记录,均施加next-key lock。
以以下查询为例:
select * from user where age >= 22 for update;
加锁情况如下:
- 主键索引:对id=10和id=20这两条满足条件的记录施加记录锁。
- 二级索引:
- age=22: 施加(21, 22]的next-key lock。
- age=39: 施加(22, 39]的next-key lock。
- 上确界记录: 施加(39, +∞]的next-key lock。
这里存在一个关键差异:为何age=22这个等值命中的记录,其next-key lock没有像唯一索引那样退化为记录锁?根本原因在于age字段的非唯一性。若仅加记录锁,只能防止该特定记录被修改,无法阻止其他事务插入全新的age=22记录。这将导致前后两次范围查询的结果集可能不同,从而产生幻读。因此,必须使用next-key lock来同时封锁插入操作。
五、无索引查询的锁风险
最后讨论最需警惕的场景。若锁定读查询(如SELECT ... FOR UPDATE)未使用索引列作为条件,或未走索引扫描而引发全表扫描,后果极为严重:每一条记录的索引上都会被附加next-key lock。这实质上锁定了整个表。
需特别注意,不仅是SELECT ... FOR UPDATE,UPDATE和DELETE语句若条件无法利用索引,同样会触发全表扫描及全表加锁。
因此,在生产环境中执行任何带锁性质的语句前,必须审查其执行计划,确保查询利用了合适的索引。全表扫描引发的锁表问题,对数据库并发性能的冲击是灾难性的。即便语句包含索引,优化器也可能选择全表扫描,但若无索引,则全表扫描必然发生,这一点必须铭记于心。










