事务和锁,这两个词在数据库领域算得上老面孔了。但说实话,看了很多文章、也面试过不少人,我发现大家对“数据库事务与InnoDB锁有什么关联”这个问题的理解,大多停留在一个很模糊的状态:知道事务要隔离、锁能解决冲突,可真要问“锁是什么时候加的、什么时候释放的、隔离级别变了锁会怎么变、死锁到底是怎么死的”,能说清楚的人并不多。
我最早也被这个问题绕晕过。那时候排查一个线上卡顿,业务说“我只是执行了一条UPDATE,怎么后面所有写操作都堵住了”,我盯着SHOW ENGINE INNODB STATUS里的锁信息看了半天,才慢慢把事务和锁这两件事在脑子里连成一条线。今天想把这条线彻底捋清楚,从原理到实操一次讲透,希望能帮到正在被锁困扰的兄弟们。
1. 事务的四大特性里,锁到底扛了多少活
1.1 别把MVCC和锁当成同一个东西
先说一个最常见的误解。
很多人以为MySQL的InnoDB用了MVCC(多版本并发控制),所以读操作不阻塞写操作、写操作也不阻塞读操作,那“锁”是不是就没那么重要了?
这是错的。MVCC解决的是“读”和“写”之间的并发问题,它是通过undo log里的旧版本数据,让一个事务在读取时,哪怕别的行正在被修改,也能看到一个一致性快照,从而避免了加锁。但注意,这仅限于快照读——也就是普通的SELECT语句。
真正的写操作,比如UPDATE、DELETE,以及显式加锁的读操作(SELECT ... FOR UPDATE / SELECT ... FOR SHARE),走的都是当前读。当前读必须读到记录的最新版本,不能去读旧快照,否则就会出现“两个事务同时改同一行,后提交的覆盖先提交的”这种丢失更新问题。
这时候,锁就出场了。
打个比方:MVCC像图书馆里同时允许很多人翻看同一本书的不同版本复印件,大家谁都不妨碍谁;而锁就像这本书只有一本真迹,一旦有人在上面做批注,其他人就得等着。MVCC负责让“读读”和“读写”尽量并行,锁负责让“写写”真正串行。
1.2 ACID里谁依赖锁,谁不依赖锁
把事务的四个特性拆开看:
- 原子性(Atomicity):主要靠undo log。事务执行过程中如果出错,可以用undo log回滚到事务开始前的状态。锁在这里参与不多。
- 一致性(Consistency):这是一条贯穿性约束,不是某一个机制单独扛的。锁保证并发写入不互相覆盖,约束条件不因并发被破坏,间接服务于一致性。
- 隔离性(Isolation):这是锁的主场。隔离级别定义了两个事务之间的可见程度,InnoDB就是通过锁 + MVCC的组合来实现不同隔离级别的。
- 持久性(Durability):主要靠redo log。服务崩溃后能重放日志恢复数据,和锁的关系不大。
所以,如果有人问你“事务和锁有什么关联”,最直接的回答是:事务要保证隔离性,而隔离性在写写冲突这个维度上,必须靠锁来实现。
明白这一点之后,后面所有细节就都能挂上钩了。
2. 从一把行锁开始,扒开InnoDB的锁类型和加锁时机
2.1 最核心的S锁和X锁
InnoDB的锁从兼容性上分两大类:
- 共享锁(S锁):也叫读锁。一个事务加了S锁之后,其他事务还能继续加S锁,但不能加X锁。S锁允许多个事务同时读取同一条记录。
- 排他锁(X锁):也叫写锁。一个事务加了X锁之后,其他事务无论是S锁还是X锁都加不上。X锁意味着“这条记录我准备写了,谁也别碰”。
它们的关系就是一张兼容矩阵:
| 锁类型 | S锁 | X锁 |
|---|---|---|
| S锁 | 兼容 | 冲突 |
| X锁 | 冲突 | 冲突 |
总之,锁的排斥性可以概括成一句话:写写互斥、读写互斥、读读不互斥。这也是为什么同一行的并发更新性能上不去的原因——写这条路的并行度天生就低。
除了S锁和X锁这种行级锁,InnoDB还有两种表级意向锁:意向共享锁(IS)和意向排他锁(IX)。意向锁的意思是“我这个事务打算对某些行加S锁/X锁,先在表级别上做个标记”。它存在的意义是,当一个事务想对整张表加锁(比如ALTER TABLE),就可以快速判断出是否有其他事务正在锁表里的某些行,而不需要逐行扫描锁信息。
2.2 记录锁之外,还有间隙锁和临键锁
行级锁还能继续细分,按锁定的范围来看:
- 记录锁(Record Lock):锁住索引上的一条记录。注意InnoDB是索引组织表,即使你UPDATE时WHERE条件用的是普通字段,最终也是通过扫描索引,找到对应的聚集索引记录后再加锁。所以没有索引的话,加锁范围会非常难看。
- 间隙锁(Gap Lock):锁住索引记录之间的区间,或者第一条记录之前的间隙、最后一条记录之后的间隙。间隙锁是个左开右开的区间,目的是防止其他事务在这个间隙里插入新记录,从而避免幻读。间隙锁之间不冲突,它只和“插入”冲突。
- 临键锁(Next-Key Lock):可以理解为“记录锁 + 间隙锁”的组合锁,锁住的是一个左开右闭的区间。比如索引上有记录5和10,临键锁可能锁的是(5, 10],表示5到10之间的间隙、以及10这条记录本身都被锁住了。
为什么会有这么多种锁?因为可重复读(RR)隔离级别要求事务在多次查询时看到同样的行集合,不只要锁住已有的记录,还得防止别人插入新记录。这个“防插入”的需求,就是间隙锁存在的根本理由。
2.3 实操中你会遇到哪些加锁场景
我用一个非常常见的例子说明:电商库存扣减。
假如有张表sku_stock,里面有一行记录sku_id = 'A100',当前库存是5,两个用户同时下单:
-- 事务1(用户A) BEGIN; UPDATE sku_stock SET stock = stock - 1 WHERE sku_id = 'A100'; -- 事务2(用户B) BEGIN; UPDATE sku_stock SET stock = stock - 1 WHERE sku_id = 'A100';事务1先执行UPDATE时,会对sku_id = 'A100'这条记录加X锁。事务2执行同样的UPDATE时,发现这条记录的X锁已被持有,只能进入等待状态。直到事务1执行COMMIT或ROLLBACK,锁被释放,事务2的UPDATE才能继续执行。
这个过程中,事务2的UPDATE语句会一直阻塞,阻塞时间取决于事务1什么时候提交、以及事务1在加锁之后到底做了多少别的事情。如果事务1锁完这行之后又去调用外部接口、等了500毫秒才提交,那事务2就得跟着等500毫秒。
这就是事务和锁的第一个直观关联:事务的存活时间,直接决定锁的持有时间;锁的持有时间,又直接决定其他人的阻塞时间。
再看一个带范围查询的例子。假设sku_stock表在stock字段上有二级索引,现在执行:
UPDATE sku_stock SET stock = stock - 1 WHERE stock BETWEEN 3 AND 8;在可重复读隔离级别下,InnoDB不仅要给满足条件的索引记录加记录锁,还会给这些记录之间的间隙加间隙锁,防止其他事务插入stock值为4、6这些落在(3,8)区间里的新记录。这俩锁合起来,就是临键锁。
这个逻辑很多人会忽略:你以为自己只是改了3到8之间的几条记录,实际上你把整个3到8的区间都给堵死了,别的插入操作全都会排队。
3. 不同隔离级别下,事务和锁的联动规则完全不同
3.1 隔离级别的本质是对锁的使用策略
InnoDB的四种隔离级别(读未提交、读已提交、可重复读、串行化),区别不仅仅在于“能不能读到别人未提交的数据”,更关键是它们对锁的加锁范围、锁的释放时机、是否需要间隙锁,有着完全不同的规定。
拿读已提交(RC)和可重复读(RR)这两个最常用的隔离级别对比:
| 维度 | 读已提交(RC) | 可重复读(RR) |
|---|---|---|
| 普通SELECT是否加锁 | 不加锁,走快照读 | 不加锁,走快照读 |
| UPDATE/DELETE加锁的范围 | 只锁满足条件的记录(记录锁) | 记录 + 间隙(临键锁) |
| 锁释放时机 | 语句执行完就释放(配合半一致性读) | 事务提交/回滚时才释放 |
| 是否防幻读 | 不防,只能看到提交后的最新版本 | 通过间隙锁防幻读 |
| 死锁概率 | 相对低 | 相对高 |
简单说,RR隔离级别为了让同一个事务内的多次查询保持结果一致,采用了激进的加锁策略——不仅锁记录,还锁间隙,而且锁一直持有到事务结束。RC隔离级别则更宽松,它允许一个事务在两次SELECT之间看到其他事务已提交的新数据,因此不需要间隙锁,锁也能在语句执行完就释放。
3.2 RC下的“半一致性读”到底是怎么回事
这里有个很隐蔽的细节值得单独说说。
在RC隔离级别下,UPDATE语句并不是简单地对所有匹配行加锁。InnoDB的半一致性读机制会在执行UPDATE时,先去尝试读取已经提交的旧版本数据,判断这行到底满不满足WHERE条件。如果某一行已经被别的事务加了X锁,InnoDB会读取它最近一个已提交版本,看看这行是否真的需要更新。
如果这一行不满足更新条件,就不等了,直接跳过;只有确认需要更新的行,才会真正进入锁定等待状态。
举个例子,执行:
UPDATE orders SET status = 'PAID' WHERE user_id = 100;如果user_id = 100的记录里有一行恰好被另一个事务锁住了,而这一行的status本来就是'PAID',不需要更新,RC下InnoDB可以先看一眼已提交版本,发现没必要更新,就会绕开这把锁继续往下走。
这个机制带来的直接效果就是:RC下UPDATE的阻塞面比RR小得多。所以在高并发互联网场景里,很多团队宁可选RC来降低锁等待和死锁概率,代价是允许极少数的重复统计误差。
3.3 RR下为什么容易产生间隙锁死锁
我们来看一个比较典型的问题场景。
RR隔离级别下,事务A执行。
BEGIN; SELECT * FROM orders WHERE amount > 100 FOR UPDATE;假设这条查询覆盖了一些索引记录和间隙,事务A拿到了对应范围的临键锁。这时候事务B尝试往这个范围里插入一条amount = 200的新订单:
INSERT INTO orders (amount, status) VALUES (200, 'INIT');INSERT操作发现无法插入,因为目标间隙被事务A的间隙锁挡住了,于是事务B开始等待。
现在的关键是:如果事务A接下来的操作,偏偏又要去更新一条事务B已经锁定(或者事务B的插入即将涉及)的记录,就可能形成循环等待。比如事务B虽然插入被阻塞,但它可能已经在之前的语句里锁了另外一行,而事务A恰好在等这一行。两边各持一把锁、互相等,死锁就产生了。
这就是事务和锁关联中最典型的负面产物:死锁。想要把死锁问题解决好,不能只盯着某个SQL看,要把它放在事务的完整生命周期里去理解。
4. 一次真实死锁的完整排查链路
4.1 从报错到现场还原
某天我们线上报了这个错:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction业务方第一反应是“咋回事,我明明只改了一行”。我打开错误日志,发现这条UPDATE后面跟的是账户余额变更,再结合代码一分析,很快就定位到了两个事务的加锁顺序。
当时是大致这样的逻辑:
-- 事务1:从账户A转钱到账户B UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; -- 事务2:从账户B转钱到账户A UPDATE account SET balance = balance - 50 WHERE id = 2; UPDATE account SET balance = balance + 50 WHERE id = 1;两个事务都走的是双账户更新:先减后加。事务1先锁了id=1,再去锁id=2;事务2恰好先锁了id=2,再去等id=1。两边都只拿到了自己需要的第一把锁,然后都在等对方的第二把锁——典型的循环等待,死锁条件凑齐了。
4.2 怎么看懂INNODB STATUS里的死锁信息
遇到死锁,第一件事不是猜,而是去数据库里拉现场信息:
SHOW ENGINE INNODB STATUS\G输出里找到LATEST DETECTED DEADLOCK段落,重点看两个TRANSACTION块:
- 每个事务持有哪些锁(
holding或LOCK WAIT信息) - 哪个事务在等哪条记录的哪类锁
- 事务整体执行了什么SQL(
SQL Statement会显示出来)
比如输出里可能会出现:
*** (1) TRANSACTION: ... LOCK WAIT 3 lock struct(s) ... *** (1) HOLDS THE LOCK(S): ... *** (1) WAITING FOR THIS LOCK TO BE GRANTED: ...HOLDS THE LOCK表示这个事务手里握着哪些锁,WAITING FOR说明它在等哪把锁。结合两边事务的HOLDS和WAITING,基本就能把锁的等待环画出来了。
信息拉出来后,回看应用代码,确认加锁顺序。这个案例里就是两个事务对id=1和id=2的加锁顺序恰好相反。死锁的核心成因,不在于你用了多少锁,而在于多个事务请求锁的顺序不一致,形成了一个闭合环。
4.3 修复方案和进一步思考
修复方式其实很简单:让所有事务都按同一个顺序去加锁,比如永远先锁id小的账户、再锁id大的账户。这样两个事务都会先去锁id=1,第二个事务会等第一个事务释放id=1的锁,不会出现交叉等待。
另一个常见修复思路是缩小事务体量——让事务里只保留必要的写操作,外部调用、耗时的计算全部移到事务外。事务持续越短,锁持有时间越短,死锁概率自然就下来了。
还有个更隐蔽的情况值得一提:就算两个事务加锁顺序一致,如果其中一个事务的WHERE条件没走索引,实际加锁的记录范围和预期完全不一样,也容易产生意外死锁。所以在排查死锁时,除了看加锁顺序,还要顺手检查一下执行计划,确认SQL真的用对了索引。
5. 索引和执行计划对锁范围的影响,比想象中大得多
5.1 不加索引的行锁,实际是全表范围的锁
这是InnoDB很容易被人误解的一点。
很多人说InnoDB支持行锁,就以为“我UPDATE一条记录,只锁这一条”。但InnoDB的行锁是建立在索引基础上的——它锁的是索引记录,不是直接锁“物理行”。如果UPDATE的WHERE条件没有用到任何索引,InnoDB只能从聚集索引的第一条记录开始全表扫描,把所有扫描过的记录都加上X锁,直到确认不满足条件的才放掉。
听起来是“扫描过程中会锁所有行”,实际效果几乎等同于锁表。大量写请求都会在这里排队。
来看一个典型场景:
UPDATE user_order SET status = 'CLOSED' WHERE user_name = '张三';如果user_name字段上没有索引,这条SQL执行时,InnoDB会逐条扫描所有用户订单记录,先加X锁,再判断user_name是否为张三。不是张三的记录虽然最终会解锁,但扫描期间依然让很多人卡在了锁等待上。
同样是这条SQL,如果在user_name上建了普通索引,InnoDB就能利用二级索引快速定位到张三的订单,加锁范围大幅缩小。
所以一句经验之谈:写操作的WHERE条件必须走索引,这不是性能优化建议,而是并发正确性问题。没有索引的UPDATE,等于给整张表按下了串行化开关。
5.2 联合索引的“最左前缀”也在影响加锁
索引设计对加锁范围的影响不止在于“有没有索引”,还在于“SQL用到了索引的哪一部分”。
举个例子,假设order表上有联合索引(user_id, status)。
UPDATE order SET amount = amount * 0.9 WHERE status = 'PAID';这条SQL只有status条件,不满足联合索引的最左前缀原则,优化器很可能不走这个索引,而是选择全表扫描或扫另一个可用索引。加锁范围同样不可控。
但如果WHERE条件带上user_id:
UPDATE order SET amount = amount * 0.9 WHERE user_id = 100 AND status = 'PAID';联合索引就能从左到右匹配,InnoDB会精准定位到符合条件的那几条索引记录,加锁范围大大缩小。
这个细节提醒我们:写过WHERE条件时,不仅要看“有没有索引”,还要看“优化器到底选没选上”。最直接的办法是写完SQL之后,顺手看一下执行计划:
EXPLAIN UPDATE order SET amount = amount * 0.9 WHERE status = 'PAID';观察key列有没有用到预期索引、rows列大概扫描了多少行。如果rows的数字远大于实际匹配行数,那加锁范围大概率也会比预期大很多。
5.3 插入语句表面上是新记录,其实也会碰锁
很多人觉得INSERT是插入新记录,跟锁没什么关系。实际不是。
刚才提到过,INSERT在RR隔离级别下,如果插入位置落在某个事务持有的间隙锁范围内,会被阻塞。此外,InnoDB里还有一种插入意向锁(Insert Intention Lock)。它其实是间隙锁的一种,强调的是“我打算在这个间隙里插一条记录,先占个位”。多个事务同时往同一个间隙插入不同值的记录时,插入意向锁之间是兼容的,可以并发插入;但如果这个间隙已经有一个事务加了间隙锁,那所有插入意向锁都得等。
自增列还有一个自增锁(AUTO-INC Lock),在批量插入时持有。如果业务里频繁执行大批量INSERT,自增锁同样可能成为写并发瓶颈。
在这个问题上,最容易犯的错误是:只分析SELECT或UPDATE的锁,忽略了INSERT语句本身对锁的依赖,结果死锁现场分析半天也找不到头绪。
6. 实际调优中最值得收藏的几个锁经验
6.1 锁信息应该怎么看
遇到锁问题,常用的查询手段不只是SHOW ENGINE INNODB STATUS。MySQL 8.0以后,performance_schema里有一组专门看锁的表:
-- 查看当前持有锁的事务 SELECT * FROM performance_schema.data_locks; -- 查看锁等待关系 SELECT * FROM performance_schema.data_lock_waits;data_locks能看到锁定对象、锁类型、锁模式以及哪个事务持有;data_lock_waits能看到哪个事务在等哪个事务。
还有一个更基础的办法,在information_schema里看正在运行的事务:
SELECT trx_id, trx_state, trx_started, trx_query FROM information_schema.innodb_trx;通过trx_started字段可以判断某个事务到底跑了多久。如果一个事务持续了很长时间还没提交,那它的锁也一直没释放,后面排队的请求会越攒越多。
遇到生产环境卡顿,我的习惯是:先看innodb_trx找出长时间未提交的事务,再看data_lock_waits确认谁在堵谁。这两步做完,大部分锁问题的方向就清晰了。
6.2 隔离级别要按场景去选,而不是跟风
很多默认配置把MySQL的隔离级别设成RR,这是有历史原因的——它解决了binlog在复制场景下的主从一致性问题。但到了高并发互联网应用,RC往往对吞吐更友好,因为它减少了间隙锁、锁持有时间也短,写并发能力明显更强。
所以我的建议是:如果你的业务允许出现很小的重复读偏差(比如报表统计、状态更新),优先考虑RC;如果业务严格不允许幻读(比如金额计算、审批流程判断),才选RR。
不过要注意一点:RR下的间隙锁并不是完全没救。如果只是个别SQL需要防幻读,可以保持整体RR,把这些SQL单独用临键锁机制去处理;如果大多数SQL都允许RC,那直接全局改RC更省心。
6.3 常见误区对应的调整方向
把几个高频误区和对应解法整理成一张表:
| 常见误区 | 实际情况 | 调整方向 |
|---|---|---|
| 事务提交后锁才释放 | 只有RR下的当前读锁才放到事务结束;RC下语句执行完就释放 | 别盲目延长事务,锁的释放时机由隔离级别决定 |
| 间隙锁只有范围查询才有 | 等值查询查不到记录时也会加间隙锁 | 注意UPDATE/SELECT FOR UPDATE查不到记录时的锁行为 |
| 小事务一定不死锁 | 死锁只要加锁顺序相反就可能出现,和事务大小无关 | 固定所有事务的加锁顺序 |
| 行锁永远只锁一行 | 没有索引时实际会锁大面积记录 | 写SQL前查执行计划,确保走索引 |
| 锁只在写写冲突时出现 | INSERT也可能因为间隙锁被阻塞 | 分析插入场景时也要考虑锁等待 |
6.4 最后分享一个实际的小技巧
有一次我们做一个订单状态流转的批量更新,SQL模板长这样:
UPDATE order SET status = 'PAYING' WHERE biz_type = 'ONLINE' AND status = 'INIT';这个UPDATE要扫不少行,RR隔离级别下,扫描范围内的所有相关记录都会加临键锁,导致用户端下单操作偶发卡顿。
排查完锁等待现场之后,我们做了三件事:
- 把隔离级别从RR切成RC,消除了间隙锁;
- 在
(biz_type, status)上建了联合索引,让UPDATE扫描的范围缩小; - 把批量更新拆小,拆成一次只更新几百条,减少单事务持有锁的时间。
改完之后,锁等待的告警基本绝迹了。这三条经验后来也成了我做数据库锁优化的标配动作:先看隔离级别、再看索引、最后控制事务体量。优先级从高到低,任何一个环节掉链子,锁都可能成为业务并发路上那根最细的瓶颈。
现在再回头想“数据库事务与InnoDB锁有什么关联”这个问题,我的理解是:事务给数据库提供了一致性和隔离性的承诺,而锁就是兑现这个承诺的底层执行者。没有锁,事务之间的并发写入就会互相踩踏;没有事务,锁的存在就失去了意义。搞懂了它们之间这层关系,再去看锁等待、死锁、性能调优,思路会清晰很多。