- 测试环境
- 一、表结构和测试数据
- 表结构
- 测试数据
- 二、基准加锁 SQL(会话1,全程不提交)
- 三、核心问题与结论
- Q1:Next-Key Lock 会不会退化成 Record Lock?
- Q2:锁的到底是"stock 字段的区间",还是别的?
- Q3:Gap 的左右边界谁大谁小,是不是按 id 排?
- Q4:`X` 后面的"记录锁"锁的是间隙里所有记录吗?
- Q5:二级索引和聚簇索引的锁是不是联动的?
- 四、实测数据排序(按四元组字典序)
- 五、加锁范围图解
- 六、performance_schema.data_locks 实测验证
- LOCK_MODE 后缀判断规则
- 二级索引 `idx_category_status_stock` 上的锁
- 主键 PRIMARY 索引上的锁
- performance_schema.data_locks 数据
- 七、验证过的具体场景
- 关键推论(第 4、5 条的原理)
- 八、验证过程
- 验证前操作(会话1: 加锁)
- 验证 No.1
- 验证 No.2
- 验证 No.3
- 验证 No.4
- 验证 No.5
- 验证 No.6
- 验证 No.7
- 九、最终结论汇总
测试环境
环境:MySQL 8.4.9 (InnoDB, REPEATABLE-READ)
测试表:product,联合索引idx_category_status_stock (category_id, status, stock)
一、表结构和测试数据
表结构
CREATETABLE`product`(`id`bigintunsignedNOTNULLAUTO_INCREMENTCOMMENT'商品ID',`category_id`bigintunsignedNOTNULLCOMMENT'所属分类ID',`name`varchar(128)NOTNULLCOMMENT'商品名称',`price`decimal(10,2)NOTNULLDEFAULT'0.00'COMMENT'商品单价(元)',`stock`intunsignedNOTNULLDEFAULT'0'COMMENT'库存数量',`description`varchar(500)DEFAULT''COMMENT'商品描述',`status`tinyintNOTNULLDEFAULT'1'COMMENT'状态:1-上架,0-下架',`create_time`datetimeNOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',`update_time`datetimeNOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'更新时间',PRIMARYKEY(`id`),KEY`idx_category_status_stock`(`category_id`,`status`,`stock`))ENGINE=InnoDBAUTO_INCREMENT=16DEFAULTCHARSET=utf8mb4COLLATE=utf8mb4_0900_ai_ciCOMMENT='商品表';关键点:idx_category_status_stock是非唯一二级索引。
测试数据
INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(100,4,'iPhone 15 Pro 256G',7999.00,120,'A17 Pro芯片,钛金属机身',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(200,4,'华为Mate 60 Pro',6999.00,85,'麒麟芯片,卫星通话',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(300,4,'小米14 Ultra',5999.00,200,'徕卡光学,骁龙8 Gen3',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(400,5,'MacBook Pro 14英寸',14999.00,45,'M3 Pro芯片,18GB内存',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(500,5,'联想拯救者Y9000P',8999.00,60,'i9-14900HX,RTX4070',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(600,5,'罗技MX Master 3S鼠标',799.00,300,'静音按键,8000DPI',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(700,6,'优衣库纯棉T恤',99.00,500,'100%纯棉,舒适透气',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(800,6,'李维斯501牛仔裤',599.00,150,'经典直筒,水洗做旧',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(900,6,'耐克Air Jordan 1',1299.00,80,'高帮复古篮球鞋',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(1000,7,'ZARA碎花连衣裙',399.00,220,'雪纺面料,收腰设计',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(1100,7,'优衣库羊毛大衣',899.00,90,'70%羊毛含量,中长款',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(1200,8,'烟台红富士苹果5斤',39.90,1000,'脆甜多汁,新鲜采摘',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(1300,8,'海南金煌芒果5斤',49.90,600,'果肉细腻,核小无丝',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(1400,9,'三只松鼠坚果大礼包',89.00,450,'7种坚果,每日坚果',1,'2026-08-24 14:37:17','2026-08-26 19:27:08');INSERTINTO`product`(`id`,`category_id`,`name`,`price`,`stock`,`description`,`status`,`create_time`,`update_time`)VALUES(1500,9,'可口可乐330ml*24罐',59.90,800,'经典口味,整箱装',0,'2026-08-24 14:37:17','2026-08-26 19:27:08');二、基准加锁 SQL(会话1,全程不提交)
SELECT*FROMproductWHEREcategory_id=4ANDstatus=1ANDstock>=120FORUPDATE;三、核心问题与结论
Q1:Next-Key Lock 会不会退化成 Record Lock?
结论:不会。
退化条件必须同时满足:
- 使用唯一索引(主键 / unique key)
- 等值查询且能唯一确定一行
本例中idx_category_status_stock是普通联合索引(非唯一),且stock >= 120是范围条件,两个条件都不满足,因此全程走 Next-Key Lock(Record Lock + Gap Lock)。
补充:即使把
stock >= 120换成stock = 120,因为索引本身非唯一(可能存在多行同值),仍然不会退化。
Q2:锁的到底是"stock 字段的区间",还是别的?
结论:锁的是联合索引(category_id, status, stock)在 B+ 树上按字典序排列的物理相邻区间,不是单独 stock 字段的取值区间。
对于非唯一二级索引,InnoDB 会在索引 key 末尾自动追加主键值做唯一标识和排序,真实排序键是四元组:
(category_id, status, stock, id)Q3:Gap 的左右边界谁大谁小,是不是按 id 排?
结论:不是。排序优先级严格按索引定义顺序,id只是最后的 tie-breaker(决胜属性)。
即:先比category_id,相同再比status,相同再比stock,只有前三者都相同时才比较id。id 数值大小本身不影响排序位置,除非前三个字段完全相同。
Q4:X后面的"记录锁"锁的是间隙里所有记录吗?
结论:不是。只锁LOCK_DATA标出的那一条具体记录;间隙本身不含任何行,Gap 锁锁的是这段空白,阻止插入。
Q5:二级索引和聚簇索引的锁是不是联动的?
结论:不是。只有真正满足 WHERE 全部条件、会被返回/修改的行,才会在聚簇索引(PRIMARY)上加锁;仅作为扫描边界经过的行,只在二级索引层加锁,聚簇索引上完全干净。
四、实测数据排序(按四元组字典序)
(4, 1, 85, 200) ← 华为 Mate 60 Pro (4, 1, 120, 100) ← iPhone 15 Pro ← stock>=120 命中起点 (4, 1, 200, 300) ← 小米 14 Ultra (5, 1, 45, 400) ← MacBook Pro 14英寸 ← 扫描终止边界 (5, 1, 60, 500) ← 联想拯救者 Y9000P ← 未被扫描/未锁五、加锁范围图解
(4,1,85,200) ──gapA── (4,1,120,100) ──gapB── (4,1,200,300) ──gapC── (5,1,45,400) ──未锁── (5,1,60,500) ↑ record+gapA ↑ record+gapB ↑ record+gapC规则:
- 扫描命中的每条记录都加Next-Key Lock(记录锁 + 记录前面的间隙锁)
- 遇到第一条不满足WHERE 条件的记录(
(5,1,45,400),category_id 已经变成 5)时,仍会锁住这条记录 + 它前面的 gap,然后扫描停止 - 该记录之后的 gap(
(5,1,45,400)到(5,1,60,500)之间)完全不会被锁
六、performance_schema.data_locks 实测验证
LOCK_MODE 后缀判断规则
| LOCK_MODE | 含义 |
|---|---|
| X(无后缀) | Next-Key Lock = 记录锁 + 记录前面的 Gap 锁 |
| X,REC_NOT_GAP | 纯记录锁,不含 gap,只锁这一行本身 |
| X,GAP | 纯 Gap 锁,不锁记录本身(本例中没出现) |
二级索引idx_category_status_stock上的锁
| LOCK_DATA | LOCK_MODE | 含义 |
|---|---|---|
4, 1, 120, 100 | X | Next-Key Lock:记录 + 前置 gap |
4, 1, 200, 300 | X | Next-Key Lock:记录 + 前置 gap |
5, 1, 45, 400 | X | Next-Key Lock:记录 + 前置 gap |
(4,1,85,200)未出现独立记录 —— 它的记录锁隐含在(4,1,120,100)的"前置 gap"里。(5,1,60,500)完全没出现 —— 印证扫描确实止步于(5,1,45,400)。
主键 PRIMARY 索引上的锁
| LOCK_DATA | LOCK_MODE | 含义 |
|---|---|---|
100 | X,REC_NOT_GAP | 纯记录锁,无 gap |
300 | X,REC_NOT_GAP | 纯记录锁,无 gap |
id=400(MacBook Pro)在 PRIMARY 上完全无锁。
performance_schema.data_locks 数据
结论:二级索引层——只要扫描到就加锁(用于界定扫描边界、防幻读);聚簇索引层——只有真正完全满足 WHERE 条件、会被返回/修改的行才会回表加锁。LOCK_MODE的REC_NOT_GAP后缀就是判断"纯记录锁 vs Next-Key Lock"的直接依据。
七、验证过的具体场景
| # | 场景 | 条件(category_id, status, stock, id) | 落入的 Gap | 是否阻塞 | 原因 |
|---|---|---|---|---|---|
| 1 | Insert | (4, 1, 100, 新id) | gapA/gapB 区间内 | ✅ 阻塞 | stock=100 落在 85~120 之间 |
| 2 | Insert | (4, 1, 999, 新id) | gapC(边界 gap) | ✅ 阻塞 | 落在(4,1,200,300)到(5,1,45,400)之间 |
| 3 | Insert | (6, 1, 100, 新id) | 无关区间 | ❌ 不阻塞 | category_id=6 完全不在锁定范围内 |
| 4 | Insert | (5, 1, 45, 新id),新 id > 400 | (5,1,45,400)之后的未锁 gap | ❌ 不阻塞 | 自增 id 必然更大,排在已锁记录之后,落入未扫描区间 |
| 5 | Insert | (4, 1, 85, 10000) | gapA 内 | ✅ 阻塞 | 前三段与(4,1,85,200)相同,id=10000>200 排其后,但仍在 gapA 与(4,1,120,100)之间 |
| 6 | SELECT FOR UPDATE | (,,,400) | PRIMARY 索引,未锁 | ❌ 不阻塞 | 该行只在二级索引加锁,聚簇索引干净 |
| 7 | SELECT FOR UPDATE | (5, 1, 45,) | 二级索引,命中记录锁 | ✅ 阻塞超时 | 精确命中(5,1,45,400)的 Record Lock 部分 |
关键推论(第 4、5 条的原理)
- 能否被阻塞,看的是新 key 落在哪个 gap,不是看 id 数值本身大小。
- 自增主键保证新插入行的 id 一定大于表中已有 id,因此在
(category_id,status,stock)相同的情况下,新行必然排在已有同值记录之后。 - 如果这个"之后"的位置恰好是已扫描并加锁的 gap 内部(如
(4,1,85,10000)),则阻塞; - 如果这个"之后"的位置落在扫描已经停止、未被触及的 gap(如
(5,1,45,新id)),则不阻塞。
八、验证过程
验证前操作(会话1: 加锁)
验证 No.1
会话2: Insert数据
performance_schema.data_locks 数据
验证 No.2
会话2: Insert数据
performance_schema.data_locks 数据
验证 No.3
会话2: Insert数据
performance_schema.data_locks 数据
验证 No.4
会话2:
performance_schema.data_locks 数据
验证 No.5
会话2:
performance_schema.data_locks 数据
验证 No.6
会话2:
performance_schema.data_locks 数据
补充:id=400 那行只在二级索引 idx_category_status_stock 上被锁(因为它是扫描边界,用于确定 Next-Key Lock 的范围),从未在 PRIMARY 索引上加锁(data_locks 里 PRIMARY 只有 100、300 两条)。窗口2 直接走 PRIMARY 查找,检测不到冲突,加锁成功。
验证 No.7
会话2:
performance_schema.data_locks 数据
补充:这是纯等值查询,在 idx_category_status_stock 上精确定位到的 key 正是 (5,1,45,400)——与窗口1 持有的那条 Next-Key Lock 完全相同,直接命中它的 Record Lock 部分,产生冲突,最终等待超时。
九、最终结论汇总
- 非唯一联合索引下,
FOR UPDATE走范围查询不会退化为 Record Lock,全程 Next-Key Lock。 - 非唯一二级索引的真实排序键是
(索引定义字段..., 主键id),id 只是最后的 tie-breaker,不是第一排序依据。 - Gap 锁的边界由索引字段的物理排序位置决定,不是 WHERE 条件里某字段的逻辑取值范围。
- 扫描到第一条不满足 WHERE 条件的记录后即停止,该记录之后的区域不会被锁——这是"看似应该锁却没锁"的常见原因。
- Next-Key Lock 里的"记录锁"只锁
LOCK_DATA标出的那一条具体记录,间隙锁只锁两条相邻记录之间的空白,不会波及间隙"里面"的其他行(因为间隙内本就没有行)。 - 二级索引和聚簇索引的锁是完全独立的两套锁空间:只有真正满足 WHERE 全部条件的行才会在聚簇索引上加锁;仅作为扫描边界经过的行,只在二级索引层留痕,可以被其他事务通过主键正常访问,不会阻塞。
- 判断某次操作是否会被阻塞,本质是判断它访问的索引树和索引 key是否与已有锁重叠(记录锁重叠 or 落入某个 gap),不能凭直觉推断。
performance_schema.data_locks是验证以上结论的直接手段:通过INDEX_NAME区分锁在哪棵树上,通过LOCK_MODE有无REC_NOT_GAP后缀判断是纯记录锁还是 Next-Key Lock。