SELECT ... FOR UPDATE 默认行锁,但必须WHERE条件命中索引(主键/唯一/普通索引),否则因全表扫描退化为表锁;必须显式开启事务,否则锁立即释放;RR隔离级别下还自动加间隙锁防幻读。

MySQL 的 SELECT ... FOR UPDATE 能实现行级锁定,但前提是必须走索引(主键或唯一/普通索引),否则会退化为表锁——这是绝大多数人踩坑的根源。
为什么 SELECT ... FOR UPDATE 有时锁整张表?
根本原因不是语句本身有问题,而是查询条件没命中索引。InnoDB 的行锁本质是“锁索引”,不是“锁数据行”。
- WHERE 条件用
id = ?(主键)→ 锁主键索引上的对应索引项 → 行锁生效 - WHERE 条件用
name = ?(有idx_name索引)→ 锁idx_name索引项 → 行锁生效 - WHERE 条件用
balance = ?(balance字段无索引)→ 无法定位索引,只能扫描全表 → 升级为表锁 - 即使加了
LIMIT 1,只要没索引,照样锁表
SELECT ... FOR UPDATE 必须在事务中执行
脱离事务的 SELECT ... FOR UPDATE 会立即释放锁,等于没锁。MySQL 不允许“单条语句事务”自动隐式开启。
- 必须显式写
BEGIN或START TRANSACTION - 锁持续到
COMMIT或ROLLBACK,中间任何时刻其他事务对被锁行做UPDATE/DELETE/SELECT ... FOR UPDATE都会阻塞 - 普通
SELECT不受影响(MVCC 多版本读) - 如果忘了
COMMIT,锁会一直挂着,可能拖垮整个表的并发更新能力
高并发下容易触发死锁的典型场景
死锁不是偶然,而是多个事务以不同顺序访问索引导致的资源循环等待。比如两个事务分别先锁 A 行再锁 B 行,和先锁 B 行再锁 A 行。
- 事务 1 执行:
SELECT * FROM account WHERE id = 100 FOR UPDATE→ 锁住主键索引项 100 - 事务 2 同时执行:
SELECT * FROM account WHERE name = 'alice' FOR UPDATE→ 锁住idx_name索引项 'alice'(假设该记录id = 200) - 事务 1 接着执行:
SELECT * FROM account WHERE name = 'alice' FOR UPDATE→ 等待事务 2 释放idx_name锁 - 事务 2 接着执行:
SELECT * FROM account WHERE id = 100 FOR UPDATE→ 等待事务 1 释放主键锁 - 此时 InnoDB 检测到环形等待,主动回滚其中一个事务(报错
Deadlock found when trying to get lock)
实际使用中最容易被忽略的细节
很多人只关注“能不能锁住”,却忽略了锁的覆盖范围和行为边界。
-
SELECT ... FOR UPDATE在REPEATABLE READ隔离级别下默认带间隙锁(Gap Lock),会锁住索引区间,不只是命中的那几行 —— 这是为了防止幻读,但也意味着INSERT可能被意外阻塞 - 如果想跳过已锁定的行(类似 Oracle 的
SKIP LOCKED),MySQL 8.0+ 支持SELECT ... FOR UPDATE SKIP LOCKED,但低版本只能靠重试或业务层控制 - 锁的是“查询结果集”,不是“WHERE 条件本身”:例如
SELECT id FROM t WHERE balance > 100 FOR UPDATE,哪怕最终只查出 1 行,也可能因间隙锁锁住多个索引区间 - 没有
NOWAIT选项(MySQL 原生不支持),超时时间由innodb_lock_wait_timeout控制,默认 50 秒,线上建议调低到 5~10 秒并捕获超时异常











