根本原因在于是否命中索引及索引类型:唯一索引等值查询锁单行(record lock),非唯一索引可能触发next-key lock,无索引则全表扫描近似表锁;联合索引遵循最左前缀原则;需用EXPLAIN和INNODB_TRX等验证实际锁范围。

MySQL 中 SELECT ... FOR UPDATE 为什么有时锁整张表,有时只锁几行?
根本原因在于是否命中索引、以及索引类型(唯一 vs 非唯一)——InnoDB 的行锁是通过索引实现的,没索引就只能退化为表级锁(准确说是聚簇索引的全扫描锁)。
- 如果
WHERE条件中的字段有**唯一索引(含主键)且能精确匹配**,InnoDB 只加 **record lock(记录锁)**,锁定对应单行 - 如果字段有**普通二级索引(非唯一)**,会先在二级索引上加 record lock,再对聚簇索引上的对应记录加 record lock;但若查询无法确定范围(如
WHERE status = 'pending'),可能触发 **next-key lock(间隙+记录锁)**,锁住索引间隙,防止幻读 - 如果字段**完全无索引**,InnoDB 必须扫描全表,此时会对所有访问过的记录(甚至包括未命中但扫描过的间隙)加锁,实际效果接近表锁,且并发极差
联合索引下 FOR UPDATE 锁哪些行?最左前缀原则依然生效
联合索引的锁行为严格遵循最左前缀匹配规则。即使 SQL 写了多个条件,只要最左侧列未出现在 WHERE 中,后续列的索引能力就失效。
ALTER TABLE orders ADD INDEX idx_status_user (status, user_id);
-
SELECT * FROM orders WHERE status = 'paid' FOR UPDATE→ 走索引,锁所有status = 'paid'的记录(next-key lock,含间隙) -
SELECT * FROM orders WHERE user_id = 123 FOR UPDATE→ 不走 idx_status_user,全表扫描,高风险锁表 -
SELECT * FROM orders WHERE status = 'shipped' AND user_id = 456 FOR UPDATE→ 走索引,但锁的是status = 'shipped'对应的所有行(因为user_id是索引第二列,仅用于过滤,不改变锁范围)
用 EXPLAIN 和 INFORMATION_SCHEMA.INNODB_TRX 验证锁行为
光看 SQL 写法容易误判,必须结合执行计划和运行时锁信息交叉验证。
- 先跑
EXPLAIN SELECT ... FOR UPDATE,确认key列是否显示索引名,rows是否明显偏大(暗示扫描过多) - 在事务中执行
SELECT ... FOR UPDATE后,立刻查:SELECT trx_id, trx_state, trx_started, trx_query FROM INFORMATION_SCHEMA.INNODB_TRX;
找到对应事务 ID - 再查该事务持有的锁:
SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS;
(MySQL 5.7+)或更推荐用SELECT * FROM PERFORMANCE_SCHEMA.DATA_LOCKS;
(8.0+) - 特别注意
LOCK_MODE字段:值为X,REC_NOT_GAP是纯记录锁;X,GAP是间隙锁;X,NEXT-KEY是两者组合
唯一索引 + 等值查询 ≠ 绝对安全:隐式锁升级风险
看似最安全的场景——主键等值更新,也可能意外锁更多行,尤其在高并发下。
- 如果语句是
UPDATE users SET balance = balance - 100 WHERE id = 123,且id是主键,理论上只锁一行 - 但如果该行正在被另一个事务修改(比如另一条
UPDATE正在写入),当前事务会等待,而等待期间 InnoDB 可能将锁升级为更宽泛的模式以避免死锁检测开销 - 更隐蔽的是:如果该表有外键引用,且被引用表没有索引,InnoDB 会在父表操作时对子表全扫描并加锁——这时哪怕你只改主键行,也可能卡住整个子表
- 所以生产环境务必检查所有外键列是否建了索引,否则
FOR UPDATE的“行锁”会悄无声息地蔓延
锁的实际范围永远取决于执行路径,而不是 SQL 表面写的条件。一个没走索引的 WHERE,或一个缺失外键索引的关联,都可能让“行锁”变成“业务阻塞点”。










