人人都会AI编程

16.2 常见加锁场景分析

更新时间:2026-07-11

在实际开发中,死锁、锁等待、性能突降等问题,几乎都源于对“这条 SQL 到底加了什么锁”的模糊认知。下面通过几种最常见的 SQL 模式,分析在不同情况下 InnoDB 的加锁行为。理解这些场景,能帮助你在写业务代码时下意识地规避锁冲突。

以下所有讨论都基于 InnoDB 引擎,隔离级别为默认的可重复读(REPEATABLE READ),这是生产环境最常用的配置。示例表结构如下:

CREATE TABLE orders (
    id INT PRIMARY KEY,
    order_no VARCHAR(32) UNIQUE,
    user_id INT,
    amount DECIMAL(10,2),
    status TINYINT,
    KEY idx_user_id (user_id),
    KEY idx_status (status)
) ENGINE=InnoDB;

表中现有数据:

| id | order_no | user_id | amount | status |
|----|----------|---------|--------|--------|
| 10 | A001 | 100 | 99.00 | 1 |
| 20 | A002 | 200 | 150.00 | 1 |
| 30 | A003 | 100 | 200.00 | 2 |
| 40 | A004 | 300 | 50.00 | 2 |

场景一:主键或唯一索引等值查询

这是最精确、加锁范围最小的情形。由于主键或唯一索引能精确定位到一行,InnoDB 只需要锁定那一行记录。

命中记录——记录锁

SELECT * FROM orders WHERE id = 20 FOR UPDATE;

优化器会走主键索引,定位到 id=20 的记录,然后在该记录上加上行锁(记录锁)。其他事务依然可以读取该行(MVCC 无锁读),但如果另一个事务也执行 SELECT ... FOR UPDATEUPDATE orders SET amount=200 WHERE id=20,就会被阻塞,直到前一个事务提交。

未命中记录——间隙锁

SELECT * FROM orders WHERE id = 15 FOR UPDATE;

由于表中没有 id=15 的行,按理说没什么可锁的,但为了防止幻读(另一个事务插入 id=15 的行),InnoDB 会在 id=10 到 id=20 之间的间隙加上间隙锁。这意味着,在锁释放前,其他事务无法在这个间隙里插入任何 id 在 10 到 20 之间的记录(例如 id=15),也无法插入 id=10 或 id=20 本身(临键锁向右扩展)。这个间隙锁与传统意义上的“锁定不存在的行”不同,它锁住的是索引记录之间的空隙。

场景二:唯一索引等值查询与范围查询的锁差异

唯一索引的范围查询会使用临键锁(Next-Key Lock),即记录锁加间隙锁的组合,锁住一个左开右闭的区间。

SELECT * FROM orders WHERE id BETWEEN 15 AND 25 FOR UPDATE;

这条 SQL 会锁住哪些区间?包括:

  • (10, 20] 的临键锁(锁住 id=20 的记录及 10-20 的间隙)
  • (20, 30] 的临键锁(锁住 id=30 的记录及 20-30 的间隙)
  • 实际上覆盖了 (10, 30] 这个区间,20 和 30 都被记录锁锁定,间隙被间隙锁保护。

如果 BETWEEN 15 AND 25 中 15 不在现有记录之间,最左端的间隙从 10 开始。因此,在锁释放前,任何在 id=10 和 id=30 之间插入数据(比如 id=15、id=25)或者更新 id=20、id=30 的操作都会被阻塞。

而当唯一索引等值查询且记录存在时,只使用记录锁,没有间隙锁,这是唯一索引的优势所在。

场景三:非唯一索引等值查询

非唯一索引的等值查询会加临键锁,直到遇到第一个不满足条件的记录为止,并且还会对下一个间隙加锁。

SELECT * FROM orders WHERE user_id = 100 FOR UPDATE;

user_id 索引上的数据排列:(100 → id=10)(100 → id=30)(200 → id=20)(300 → id=40)

InnoDB 会扫描所有 user_id=100 的记录,对每个记录加临键锁:

  • 锁定 (最小值, 100→id=10] 的临键锁
  • 锁定 (100→id=10, 100→id=30] 的临键锁

然后,还会额外锁住 (100→id=30, 200→id=20) 这个间隙,因为扫描到 user_id=200 时发现不满足条件,但为了防止幻读,必须锁住这个间隙。所以,最终被锁定的范围是:所有 user_id=100 的记录,以及它们之间的间隙和下一个间隙。这意味着,你无法插入 user_id=100 的任何新记录(比如 id=25, user_id=100),也无法插入 user_id 在 (100, 200) 之间的记录。

注意:虽然只查了 user_id=100,但 user_id=200 附近的间隙也被锁了,这可能会导致意外的锁等待。这也是为什么在非唯一索引上更新或删除时,锁范围比想象的大。

场景四:无索引的查询——退化为表锁

这是一个极易踩坑的场景。

SELECT * FROM orders WHERE amount = 150 FOR UPDATE;

amount 列没有索引,优化器只能全表扫描。在扫描过程中,每一行被检查的记录都会被加临键锁,相当于对聚簇索引的所有记录及间隙都加锁。效果等同于锁住了整张表,其他事务的任何插入、更新、删除操作都会被阻塞。

开发规范:永远避免在 WHERE 条件中使用无索引的列进行锁定读(FOR UPDATE 或 LOCK IN SHARE MODE)。即使只是查询,若后续可能进行更新,也必须在相关列上建立索引。一次全表扫描的锁定,可能瞬间拖垮整个系统的并发能力。

场景五:插入操作与插入意向锁

INSERT 语句一般不加传统的行锁,而是加插入意向锁(Insert Intention Lock),它是一种特殊的间隙锁,表示“我想在这个间隙插入这条记录”。插入意向锁之间不冲突,只要插入的主键值不同,多个事务可以在同一个间隙内并发插入。

但插入意向锁与间隙锁或临键锁是冲突的。如果一个事务持有了某个间隙的间隙锁(比如上面的 WHERE id = 15 未命中),那么其他事务要在这个间隙内插入数据,就会被阻塞,直到间隙锁释放。

这也是为什么一个事务执行 SELECT ... FOR UPDATE 对一个不存在的行加间隙锁后,可能影响大批插入操作,导致业务大面积等待。

场景六:UPDATE 与 DELETE 的加锁行为

UPDATE 和 DELETE 本质上与 SELECT ... FOR UPDATE 加锁机制一致,因为它们也需要先定位到行,然后加锁修改或删除。

  • 如果 WHERE 条件使用主键或唯一索引命中,加记录锁。
  • 如果使用非唯一索引,加临键锁和间隙锁,范围可能超出预期。
  • 如果无索引,退化为全表加锁。

一个需要特别注意的细节是:当 UPDATE 修改了索引列的值(尤其是唯一索引列),加锁顺序是先加旧记录的锁,然后在新位置加插入意向锁。这个过程可能产生死锁。例如,事务 A 将 id=10 的 user_id 从 100 改为 200,事务 B 将 id=20 的 user_id 从 200 改为 100,这两个事务分别持有各自的旧记录锁,同时试图在对方占用的间隙上加插入意向锁,形成互相等待,导致死锁。

场景七:LOCK IN SHARE MODE(共享锁)与 FOR UPDATE 的区别

  • SELECT ... LOCK IN SHARE MODE:加共享锁(S 锁)。其他事务可以继续加共享锁读取,但不能加排他锁修改。适合确认数据存在后不立即修改,但需要防止其他事务删除或更新的场景。
  • SELECT ... FOR UPDATE:加排他锁(X 锁)。其他事务既不能加共享锁,也不能加排他锁,只能通过 MVCC 无锁读。适合读取后紧接着更新的场景。

两者在加锁范围上基本一致,只是锁的强度不同。

小结:日常开发最重要的三个加锁原则

  1. 尽量使用主键或唯一索引进行锁定读,避免非必要的大范围间隙锁。
  2. 绝不在没有索引的列上执行 FOR UPDATE 或 UPDATE/DELETE,否则全表扫描加锁,瞬间瘫痪并发。
  3. 留意非唯一索引等值查询会锁住下一个间隙,在做批量更新或删除时要评估影响范围,必要时分批提交,减少锁定时间。

加锁场景远不止这些,但掌握这七类基本情况的规律,足以应对绝大多数业务中遇到的锁等待和死锁问题。当线上出现锁相关告警时,你也能更快地通过 SHOW ENGINE INNODB STATUSinformation_schema.innodb_locks 等工具定位到是哪条 SQL 在持锁、哪条在等锁。