select for update 实际锁索引项而非数据行:命中主键或唯一索引时锁对应聚簇索引记录;命中普通索引时锁该二级索引项及对应聚簇索引记录,rr级别下还加间隙锁;无索引则锁聚簇索引所有记录,等效表锁。

SELECT FOR UPDATE 实际锁住哪些索引项
MySQL 的 SELECT FOR UPDATE 不是“锁数据行”,而是“锁索引项”。InnoDB 没有直接锁数据的机制,所有锁都落在索引上。具体锁什么,完全取决于 WHERE 条件是否命中索引、命中哪种索引:
- 主键或唯一索引等值查询(如
WHERE id = 100)→ 只锁聚簇索引中对应的一条记录(行锁) - 普通索引等值查询(如
WHERE name = 'alice',且name有非唯一索引)→ 锁该普通索引上的匹配项 + 对应的聚簇索引记录,还可能触发 Next-Key Lock(行锁 + 间隙锁) - WHERE 条件没索引(如
WHERE balance > 500且balance无索引)→ 全表扫描 → 锁聚簇索引**所有记录**,等效于表级写锁 - 即使加了
LIMIT 1,只要没走索引,照样锁全表
为什么有时锁了不存在的值(空结果也阻塞 INSERT)
在 REPEATABLE-READ 隔离级别下(MySQL 8.0 默认),SELECT FOR UPDATE 默认使用 Next-Key Lock,即“行锁 + 间隙锁”。它不仅锁现有记录,还锁住索引中相邻记录之间的间隙。
例如:
SELECT * FROM accounts WHERE id = 100 FOR UPDATE;
若表中当前没有 id = 100 的记录,但存在 id = 99 和 id = 101,InnoDB 会锁住间隙 (99, 101)。此时另一个事务执行:
INSERT INTO accounts (id, balance) VALUES (100, 1000);
就会被阻塞——不是因为锁了某行,而是锁了“能插入 100 的那个位置”。
这个行为只在 RR 级别生效;RC 级别下仅加行锁,不加间隙锁。
不显式开启事务时,锁根本没用
MySQL 默认 autocommit = 1,此时单条 SELECT FOR UPDATE 会自动开启一个隐式事务,语句执行完立刻提交 → 锁瞬间释放。
常见错误写法:
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; UPDATE accounts SET balance = balance - 100 WHERE id = 1;
这两条语句不在同一事务里,第二条 UPDATE 执行时,第一行的锁早已释放,完全起不到保护作用。
正确做法必须显式控制事务边界:
- 用
BEGIN或START TRANSACTION开启 - 紧接着执行
SELECT ... FOR UPDATE - 再执行业务逻辑(如 UPDATE / 判断余额)
- 最后
COMMIT或ROLLBACK
漏掉 COMMIT 会导致长事务、锁堆积、甚至拖垮整张表的并发更新能力。
如何避免卡死或死锁
SELECT FOR UPDATE 卡住或报 Lock wait timeout exceeded,本质是等待其他事务释放锁。超时时间由 innodb_lock_wait_timeout 控制(默认 50 秒)。
更危险的是死锁:两个事务以不同顺序访问索引,形成环形等待。比如:
- 事务 A 先锁
id = 100(主键索引),再尝试锁name = 'alice'(二级索引) - 事务 B 先锁
name = 'alice',再尝试锁id = 100
InnoDB 会检测到并主动回滚其中一个,报错 Deadlock found when trying to get lock。
规避建议:
- 所有事务按相同顺序访问行(如统一按
id升序处理) - 高并发场景优先用
SELECT ... FOR UPDATE NOWAIT(MySQL 8.0.1+),失败立即抛异常,不等 - 应用层对数据库调用设置 query timeout,防止线程无限挂起
- 用
SELECT * FROM information_schema.INNODB_TRX查当前活跃事务,配合INNODB_LOCK_WAITS定位谁在堵你
最易被忽略的点:间隙锁不是可选项,它是 RR 隔离级别下的默认行为。你以为只锁了一行,其实已经悄悄锁住了一段索引区间——这既防幻读,也容易误伤正常 INSERT。











