1. 从一次诡异的“超卖”事故说起:为什么我们需要理解锁
那天下午,运维的告警电话直接打到了我手机上,说库存系统出现了“超卖”——明明数据库里显示某个热门商品的库存只剩10件,但后台却成功生成了15个待发货订单。整个团队瞬间进入战斗状态。我们首先怀疑是缓存和数据库的双写不一致,但检查了Redis的库存扣减逻辑,没发现问题。接着怀疑是应用层的并发逻辑有漏洞,但代码Review了几遍,扣减前校验、扣减后更新的逻辑看起来天衣无缝。
直到我们打开了MySQL的通用日志,并抓取了那个时间点附近的所有SQL,真相才浮出水面。我们发现,在库存从10扣减到9的那个瞬间,几乎同时有多个UPDATE inventory SET stock = stock - 1 WHERE product_id = 100 AND stock > 0的语句在执行。在默认的、我们以为“够用”的隔离级别下,这些语句竟然全部判断stock > 0为真,然后成功地将库存减到了负数。
这次事故的学费是惨痛的,但它也像一记重锤,敲醒了我对数据库并发控制的认知。我们常常把MySQL当作一个黑盒,写写SELECT和UPDATE,默认的“可重复读”似乎就能解决一切。但真正遇到高并发下的数据竞争时,你不深入到“锁”这一层,就永远不知道系统在背着你做什么。UPDATE、DELETE、SELECT ... FOR UPDATE这些语句,在MySQL内部到底是如何给数据上锁,来防止你我同时修改同一份数据的?这就是“临键锁”、“间隙锁”和“记录锁”登场的舞台。它们不是面试八股文,而是确保你线上数据一致性的最后一道、也是最关键的一道防线。理解它们,就是理解你的数据库在高并发下如何保持“清醒”。
2. 基石:记录锁、间隙锁与临键锁的核心定义与区别
在深入“三部曲”之前,我们必须先搭建一个统一的理解框架。InnoDB的锁机制是施加在“索引”之上的,这是理解所有锁类型的第一前提。即使你没有显式创建索引,InnoDB也会为你生成一个隐藏的聚簇索引。锁,本质上就是在这个索引的数据结构上做标记。
2.1 记录锁:最朴素的独占
记录锁,顾名思义,就是锁住索引上的一条具体记录。它是排他性的,就像一个停车位上的地锁,你占用了,别人就不能停。
它是如何工作的?假设我们有一张用户表users,在id字段上有主键索引,目前有id为 1, 5, 10 的三条记录。 当你执行SELECT * FROM users WHERE id = 5 FOR UPDATE;时,InnoDB就会在id=5这条记录的索引项上,设置一个记录锁。
关键点与误区:
- 锁的是索引记录,不是数据行。虽然效果上我们感觉锁住了整行数据,但锁的粒度在索引键上。如果表上还有另一个唯一索引(比如
email),那么通过email条件加锁,锁住的就是email索引上的对应记录,同时会“顺便”在主键索引上对应的记录也加上锁(防止通过主键修改)。 - 记录锁只锁已存在的记录。如果你执行
SELECT * FROM users WHERE id = 7 FOR UPDATE;,而id=7的记录不存在,在“读已提交”隔离级别下,可能不会加任何锁(或者很快释放);但在“可重复读”下,故事就变了,这引出了我们的下一位主角。
2.2 间隙锁:守护“不存在”的领域
间隙锁锁住的是索引记录之间的“间隙”,是一个左开右开的区间。它的存在,主要是为了解决“幻读”问题。
它是如何工作的?继续用users表,现有记录id: 1, 5, 10。那么索引上就形成了几个间隙:(-∞, 1), (1, 5), (5, 10), (10, +∞)。 当你执行SELECT * FROM users WHERE id BETWEEN 5 AND 10 FOR UPDATE;在“可重复读”隔离级别下,InnoDB不仅会给id=5和id=10的记录加上记录锁,还会给它们之间的间隙(5, 10)加上间隙锁。
这意味着什么?在间隙锁生效期间,任何试图向这个间隙中插入新记录的操作都会被阻塞。例如,执行INSERT INTO users (id, name) VALUES (7, ‘new’);这个事务会被挂起,直到持有间隙锁的事务提交。间隙锁锁定的是一种“可能性”,防止了其他事务在这个范围内插入新的记录,从而保证了当前事务两次执行相同范围查询时,看到的结果集是一致的(不会多出新的“幻影行”)。
重要特性:
- 间隙锁之间不互斥。多个事务可以同时持有同一个间隙的间隙锁。因为间隙锁的目的是防止插入,而两个事务都允许防止插入,它们之间没有冲突。
- 间隙锁可能带来严重的锁竞争。如果你在一个频繁插入的区间(比如自增ID的尾部区间)加上了一个大的范围间隙锁,可能会阻塞大量的插入操作,导致性能骤降。
2.3 临键锁:记录锁与间隙锁的“二合一”
临键锁是InnoDB在“可重复读”隔离级别下默认使用的行锁算法。你可以把它理解为记录锁 + 该记录之前的间隙锁的组合体,是一个左开右闭的区间。
它是如何工作的?还是那个users表。当你执行SELECT * FROM users WHERE id > 5 FOR UPDATE;时,InnoDB会怎么加锁?
- 找到
id>5的第一条记录,是id=10。 - 给
id=10这条记录加上记录锁。 - 给
id=10记录之前的间隙(5, 10)加上间隙锁。 - 但查询条件是
id>5,范围一直到正无穷。所以,InnoDB还会给(10, +∞)这个“间隙”加上一个“下一代”的临键锁。在具体实现上,对于 supremum(上确界,即比索引中最大值还大的一个伪记录),加的锁也是一种特殊的间隙锁。
所以,最终锁定的范围是:(5, 10]+(10, +∞)。这有效地防止了任何id>5的新记录的插入(幻读),也锁住了已存在的id=10的记录。
为什么叫“临键”?因为它锁定了“当前记录”和“前一个索引键之间的间隙”。这个命名形象地描述了它的锁定范围。
| 锁类型 | 锁定对象 | 锁范围(示例) | 主要目的 | 互斥性 |
|---|---|---|---|---|
| 记录锁 | 单个已存在的索引记录 | id = 5 | 防止记录被修改或删除 | 排他锁互斥 |
| 间隙锁 | 两个索引记录之间的间隙 | (5, 10) | 防止间隙内插入新记录,解决幻读 | 共享,不互斥 |
| 临键锁 | 记录 + 前一个间隙 | (5, 10] | 默认行锁算法,同时解决当前读和幻读 | 排他锁互斥 |
3. 实战推演:在不同SQL场景下的锁表现
理论总是抽象的,我们直接进入数据库,通过一系列实验来观察锁的行为。这是理解锁最有效的方式。你需要打开两个MySQL会话(我们称为事务A和事务B),将隔离级别设置为REPEATABLE-READ。
实验准备:
CREATE TABLE `t_lock_test` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_c` (`c`) ) ENGINE=InnoDB; INSERT INTO `t_lock_test` VALUES (5, 5, 5), (10, 10, 10), (15, 15, 15);3.1 场景一:等值查询与“消失”的间隙锁
操作:事务A:SELECT * FROM t_lock_test WHERE id = 7 FOR UPDATE;(id=7的记录不存在) 事务B:尝试INSERT INTO t_lock_test VALUES (6, 6, 6);和INSERT ... VALUES (8, 8, 8);
会发生什么?在“可重复读”下,你可能会惊讶地发现,事务B的插入操作被阻塞了。事务A的查询条件id=7,由于记录不存在,InnoDB会怎么加锁?它会使用“临键锁”算法,锁定id=7这个值所在的间隙。由于表中有(5, 10)这个间隙,id=7正好落在这个区间。因此,事务A实际上锁定了(5, 10)这个间隙。所以,事务B试图插入id=6或id=8(都在5和10之间)都会被阻塞。
注意:这里有一个至关重要的优化点。如果
id字段上有唯一索引(主键必然是唯一索引),且查询条件是等值查询(=),当记录不存在时,InnoDB为了提升并发度,只会施加间隙锁,而不会将间隙锁升级为临键锁。但在我们的例子中,id是主键,等值查询不存在的记录,加的确实是(5,10)这个间隙锁。如果id=7存在,那么加的就是id=7上的记录锁。
3.2 场景二:范围查询与临键锁的威力
操作:事务A:SELECT * FROM t_lock_test WHERE id >= 10 AND id < 11 FOR UPDATE;事务B:尝试UPDATE t_lock_test SET d=d+1 WHERE id = 10;和INSERT INTO t_lock_test VALUES (9, 9, 9);
分析与结果:
UPDATE ... WHERE id = 10:这条语句会被阻塞。因为事务A的查询条件id>=10,首先会找到id=10这条记录,并对其加上临键锁,锁住(5, 10]。事务B想更新id=10,需要获取该记录的排他锁,与事务A持有的锁冲突。INSERT ... VALUES (9,9,9):这条语句也会被阻塞!这是理解临键锁的关键。事务A锁定的(5, 10]区间,包含了id=9这个间隙。虽然事务A并没有查询id=9,但临键锁的机制阻止了在这个区间内的任何插入,完美地防止了幻读。
你可以尝试将事务A的查询改为id > 10,再观察事务B插入id=12和id=18的行为,会发现id=12的插入被阻塞(因为落在(10, 15]区间),而id=18的插入可能成功(如果锁范围没有延伸到(15, +∞)的话,具体取决于查询优化器的执行计划)。
3.3 场景三:非唯一索引上的等值查询
这是死锁的高发区,需要格外小心。
操作:事务A:SELECT * FROM t_lock_test WHERE c = 10 FOR UPDATE;(c上有普通索引idx_c) 在事务A提交前,观察事务B的以下操作:
INSERT INTO t_lock_test VALUES (12, 10, 12);(插入一个c=10但id不同记录)UPDATE t_lock_test SET d=100 WHERE c = 10;
分析与结果:
- 对于
c=10这个查询,由于idx_c是普通索引,c=10的记录可能不止一条(虽然我们目前只有一条)。InnoDB会如何处理?- 首先,在
idx_c索引上,找到所有c=10的记录(这里是(c=10, id=10)),并给它们加上临键锁。这意味着不仅锁定了这条索引记录,还锁定了它前面的间隙。假设idx_c上相邻的值是5和15,那么锁定的就是(5, 10]这个区间在idx_c索引上的部分。 - 然后,由于是
FOR UPDATE,它还会回到主键索引上,对id=10这条记录加上记录锁。
- 首先,在
- 事务B的插入
(12,10,12):这条语句试图在idx_c索引上插入一个(c=10, id=12)的条目。这个c=10的值,是否落在事务A在idx_c上锁定的(5, 10]区间内?注意,c=10是等于区间右边界10的。对于临键锁,右边界是闭区间,意味着c=10本身是被锁住的。因此,这个插入操作会被阻塞,它在等待获取idx_c索引上c=10位置的插入意向锁。 - 事务B的更新
UPDATE ... WHERE c=10:这条语句也需要扫描idx_c索引上c=10的记录。它需要获取这些索引记录上的锁(可能是临键锁)。这与事务A已经持有的锁冲突,因此也会被阻塞。
这个场景清晰地展示了,锁是加在索引上的。通过非唯一索引查询,会在非唯一索引和主键索引上都加锁,锁的范围可能比你想象的要大,极易导致复杂的锁等待和死锁。
4. 避坑指南:由锁引发的典型性能与死锁问题
理解了锁的原理,我们就能诊断和避免那些令人头疼的线上问题。
4.1 坑一:全表扫描的锁升级
现象:一个本应很快的UPDATE语句,突然长时间运行,并阻塞了大量其他查询。根因:你的UPDATE或SELECT ... FOR UPDATE语句没有使用到索引。例如:UPDATE users SET status=1 WHERE name LIKE ‘%test%’;而name字段上没有索引。发生了什么:InnoDB在无法使用索引快速定位记录时,会退而求其次进行全表扫描。但在扫描过程中,为了满足“可重复读”的隔离级别,它需要防止其他事务插入新记录(幻读)。怎么办?它会对扫描过的所有记录及其之间的所有间隙都加上锁。最终,这可能导致锁住整个表的所有记录和所有间隙,效果上接近于一个表级锁,并发性能急剧下降。解决方案:
- 为查询条件添加合适的索引。这是根本解决之道。
- 如果业务允许,评估使用读已提交隔离级别。在该级别下,InnoDB不会使用间隙锁,可以避免很多因范围扫描导致的锁问题,但你需要承担幻读的风险,并在应用层处理。
- 优化查询语句,避免无法使用索引的写法(如
LIKE ‘%xxx’,对字段进行函数操作等)。
4.2 坑二:非唯一索引上的“死锁三角”
这是最经典的死锁场景之一,涉及两个事务和一条非唯一索引记录。
复现步骤:
- 表结构同上,
idx_c是普通索引,有记录(c=5, id=5)和(c=10, id=10)。 - 事务A:执行
SELECT * FROM t_lock_test WHERE c = 10 FOR UPDATE;它锁定了idx_c上(5, 10]的临键锁和主键id=10的记录锁。 - 事务B:执行
SELECT * FROM t_lock_test WHERE c = 10 FOR UPDATE;同样,它也需要获取idx_c上c=10的锁。此时,它会进入锁等待状态,等待事务A释放锁。 - 事务A:接着执行
INSERT INTO t_lock_test VALUES (12, 5, 12);它想插入一条c=5的记录。插入需要获取c=5位置的插入意向锁。插入意向锁是一种特殊的间隙锁,表示想往某个间隙插入。但c=5这个位置,是否被其他事务锁定了呢? - 死锁发生:事务B正在等待事务A释放
c=10的锁。而事务A的插入操作,需要获取c=5的插入意向锁。如果此时事务B已经持有了c=5相关的间隙锁(比如事务B之前也执行过其他范围查询,锁定了包含c=5的间隙),那么事务A的插入就会被事务B阻塞。这就形成了“事务A等事务B,事务B等事务A”的循环等待,死锁检测器会立即介入,回滚其中一个事务。
如何避免:
- 使用主键或唯一索引进行条件锁定。等值查询唯一索引且记录存在时,只加记录锁,不加间隙锁,能极大减少锁冲突。
- 保持事务短小精悍,尽快提交,减少锁的持有时间。
- 以固定的顺序访问资源。如果所有事务都约定先操作
id小的记录,再操作id大的记录,可以避免循环等待。 - 在应用层使用更细粒度的锁或乐观锁。
4.3 坑三:INSERT ... ON DUPLICATE KEY UPDATE的锁争夺
这个语法非常方便,但它的加锁行为比单纯的INSERT或UPDATE更复杂。
行为分析:
- 当唯一键冲突发生时,InnoDB会执行
UPDATE操作。此时,它会对冲突的那条已存在的记录加上一个排他锁。注意,这个锁的粒度可能是临键锁。 - 如果有多个事务同时执行同一条记录的
ON DUPLICATE KEY UPDATE,它们会竞争这个排他锁。虽然INSERT部分可能涉及间隙锁,但死锁往往发生在UPDATE阶段的锁竞争上。 - 在高并发下,这可能导致大量的锁等待和超时。
建议:对于极高并发的“写入或更新”场景,ON DUPLICATE KEY UPDATE可能不是最佳选择。可以考虑使用:
- 先尝试
UPDATE,如果影响行数为0,再INSERT。但这需要两次网络交互。 - 使用应用层的队列,将请求串行化。
- 如果业务能接受,使用
READ COMMITTED隔离级别,可以减少间隙锁带来的影响。
5. 高级视角:锁与隔离级别的共舞
锁机制不是孤立存在的,它与事务的隔离级别紧密耦合。我们通常讨论的临键锁、间隙锁行为,默认都是以“可重复读”隔离级别为前提的。
- 读未提交:不加锁(对于读),或者只加极短的锁,数据一致性最差。
- 读已提交:这是很多Oracle等数据库的默认级别。在RC级别下,InnoDB不会使用间隙锁(除了外键约束和唯一性检查等特殊情况)。这意味着,
SELECT ... FOR UPDATE或UPDATE语句通常只锁住那些实际存在的记录。这大大提高了并发度,减少了死锁,但代价是允许“幻读”发生。在RC下,我们开篇提到的“超卖”问题,如果只是简单地使用UPDATE ... WHERE stock > 0,依然可能发生,因为两个事务可能同时读到同一个stock > 0的值。 - 可重复读:MySQL InnoDB的默认级别。在这个级别下,临键锁(记录锁+间隙锁)作为默认的行锁算法被广泛使用,旨在解决幻读问题。但这也带来了更复杂的锁行为和更高的死锁概率。
- 可串行化:最强的隔离级别,通过强制事务串行执行来实现。在InnoDB中,它甚至会将普通的
SELECT语句也转换为SELECT ... FOR SHARE这样的加锁读,并发性能最低。
选择建议:
- 大多数互联网应用对“幻读”并不敏感(比如更新用户余额,你关心的是当前这条记录,而不是两次查询之间是否多出了一条新用户记录)。因此,将隔离级别从默认的RR降低到RC,是很多高性能MySQL架构的常见优化手段,可以显著提升并发能力,减少锁竞争和死锁。
- 但降级前必须仔细评估:你的业务逻辑是否真的能容忍幻读?例如,需要根据一个范围查询的结果来做统计或决策的场景,幻读就可能带来问题。此时,你可能需要在应用层通过更精细的控制(如使用唯一约束、乐观锁等)来弥补。
6. 诊断利器:如何观察和分析锁状态
当出现锁等待或死锁时,盲猜是没有用的,必须学会使用MySQL提供的工具。
6.1 核心命令:SHOW ENGINE INNODB STATUS
这是最强大的InnoDB状态诊断命令。执行后,在输出结果中找到LATEST DETECTED DEADLOCK部分,这里记录了最近一次死锁的详细信息,包括:
- 参与死锁的各个事务的ID。
- 每个事务正在执行的SQL语句(可能被截断)。
- 每个事务持有和等待的锁信息,包括锁的类型(
RECORD,GAP,X/S)、锁定的索引和具体值。 这是分析死锁原因的首要依据。
6.2 系统表:information_schema库
MySQL提供了几个信息表来查看当前的锁和事务信息:
SELECT * FROM information_schema.INNODB_TRX;查看当前所有运行的事务。SELECT * FROM information_schema.INNODB_LOCKS;查看当前出现的锁(MySQL 8.0中已被performance_schema.data_locks取代)。SELECT * FROM information_schema.INNODB_LOCK_WAITS;查看锁等待关系(MySQL 8.0中已被performance_schema.data_lock_waits取代)。
在MySQL 8.0+中,更推荐使用performance_schema:
SELECT * FROM performance_schema.data_locks;查看当前持有的锁。SELECT * FROM performance_schema.data_lock_waits;查看当前的锁等待。
你可以通过关联这些表,找到是哪个事务(trx_id)阻塞了哪个事务,它们分别在执行什么SQL,锁定了什么资源。
6.3 性能模式与慢查询日志
- 开启
performance_schema:确保performance_schema=ON,它提供了极其丰富的内部性能数据,包括锁事件。 - 分析慢查询日志:长时间被阻塞的查询最终可能会以“执行时间很长”的形式出现在慢查询日志中。结合时间点,去
SHOW ENGINE INNODB STATUS或锁信息表中反查,能找到阻塞源。
6.4 一个简单的诊断流程
- 发现性能问题:应用超时、接口响应慢。
- 检查数据库状态:使用
SHOW PROCESSLIST;查看是否有大量State为Waiting for ... lock的线程。 - 定位阻塞链:查询
information_schema.INNODB_LOCK_WAITS和INNODB_LOCKS,找到谁在等谁。 - 分析事务:根据锁等待找到的事务ID,去
INNODB_TRX中查看该事务运行了多久,执行了什么SQL(trx_query字段可能为空,需要结合SHOW ENGINE STATUS的死锁信息或应用日志)。 - 解读死锁信息:如果发生了死锁,直接分析
SHOW ENGINE INNODB STATUS中的死锁报告,这是最直接的证据。 - 优化:根据分析结果,优化索引、修改SQL、调整事务逻辑或隔离级别。
锁的世界充满了细节和边界情况,但万变不离其宗:锁是加在索引上的,目的是为了保证隔离性。临键锁是RR级别的守护者,间隙锁是幻读的防火墙,记录锁是数据安全的基石。从一次事故出发,到深入原理,再到实战和避坑,我希望这篇长文能帮你构建起关于MySQL锁的清晰图景。下次当你写下FOR UPDATE时,或许能更清晰地感知到,数据库引擎正在为你构建一个怎样的并发安全边界。理解这些,不是为了炫技,而是为了在设计和编码时,能做出更明智的选择,写出既安全又高效的代码。毕竟,在分布式和高并发的今天,对数据一致性的任何一点轻视,都可能在未来某个深夜,让你付出成倍的代价来偿还。