间隙锁介绍
间隙锁
间隙锁是InnoDB行级锁的一种。与锁定一条实际记录的记录锁不同,间隙锁锁定的是一个索引记录之间的“间隙”,它是一个左开右开的区间。
举个例子,假设一个表products的id列上有索引,并且有id为3和8的两条记录。那么,间隙锁可以锁定(3, 8)这个区间。当这个间隙被锁住时,任何其他事务都不能在这个间隙内INSERT新的记录,比如不能插入id为4, 5, 6, 7的记录。
它的核心目的就是防止其他事务在某个范围内插入新的数据,从而避免幻读现象的发生。
间隙锁锁住的时候还可以读吗?
-
快照读 (Snapshot Read):这是指不加锁的SELECT语句。在可重复读(Repeatable Read)隔离级别下,当事务开始时,InnoDB会创建一个数据快照。后续的快照读将读取这个快照中的数据,而不会受到其他事务提交的更新或插入的影响。因此,即使某个范围被间隙锁锁定,快照读仍然可以读取该范围内的已有数据,因为它读取的是事务开始时的版本。
-
当前读 (Current Read):这是指会加锁的读取操作,主要包括:
-
SELECT ... FOR UPDATE (排他锁)
-
SELECT ... LOCK IN SHARE MODE (共享锁)
-
INSERT, UPDATE, DELETE 操作
-
这些“当前读”操作会读取数据库中最新的数据,并且会对读取的记录或范围加锁。如果这些操作涉及的范围被另一个事务的间隙锁覆盖,那么它们就会被阻塞,直到持有间隙锁的事务提交或回滚。
间隙锁可能会造成死锁吗?
是的,间隙锁是造成死锁的一个常见原因。
一个经典的死锁场景如下:
假设id列有索引,但表中没有id=5的记录。
-
事务A:
UPDATE products SET name = 'A' WHERE id = 5;这个UPDATE操作找不到id=5的记录,但为了防止幻读,它会在id=5所在的那个间隙上加上一个间隙锁。 -
事务B:
UPDATE products SET name = 'B' WHERE id = 5;事务B也想执行同样的操作,它也需要在同一个间隙上加间隙锁。 由于间隙锁之间是互相兼容的,事务B可以成功地在同一个间隙上加上自己的间隙锁。此时,事务A和事务B都持有了同一个间隙的锁。 -
死锁发生: 现在,事务A想插入一条
id=5的记录:INSERT INTO products (id, name) VALUES (5, 'A');这个插入操作需要获取一个“插入意向锁”,但是这个插入动作被事务B持有的间隙锁阻塞了。所以事务A开始等待事务B。 同时,事务B也想插入一条id=5的记录:INSERT INTO products (id, name) VALUES (5, 'B');这个插入动作同样被事务A持有的间隙锁阻塞了。所以事务B开始等待事务A。
于是,事务A等待事务B,事务B等待事务A,死锁形成。
如果锁住很大范围的间隙,怎么解决性能上的问题?
当一个查询条件没有命中索引,或者索引选择性很差时,可能会导致扫描大量记录并锁定一个非常大的间隙,这会严重影响并发性能。
解决方法主要有以下几种:
-
优化SQL,使用精准索引:这是最根本的解决方法。确保你的查询条件能够精确地命中一个高选择性的索引。比如,如果
WHERE条件是范围查询,尽量缩小范围;如果能用唯一索引定位,就不要用普通索引。 -
按需调整业务逻辑:如果业务上无法避免大范围的锁定,可以考虑将大的事务拆分成多个小的事务,尽快提交,以缩短锁的持有时间。
怎么解决间隙锁之间的锁冲突?
-
保持一致的加锁顺序:确保所有并发的事务都以相同的顺序来获取锁。比如,总是先更新
id小的记录,再更新id大的记录。这可以从业务逻辑层面规避死锁。 -
减少锁的范围和持有时间: 尽量不要在事务中执行耗时的非DB操作。 将
UPDATE、DELETE等加锁操作尽可能地放在事务的末尾。 -
开启死锁检测和设置锁等待超时: InnoDB默认开启了死锁检测(
innodb_deadlock_detect),当检测到死锁时,它会自动回滚其中一个代价较小的事务,让另一个事务继续执行。 设置一个合理的锁等待超时时间(innodb_lock_wait_timeout)。如果一个事务等待锁的时间超过这个阈值,它会自动放弃并回滚。这是一种兜底机制,可以防止线程被无限期地挂起。