☰
数据库事务与InnoDB锁的关联:从加锁时机到死锁排查全解析
2026/10/13 4:31:47 网站建设 项目流程

事务和锁,这两个词在数据库领域算得上老面孔了。但说实话,看了很多文章、也面试过不少人,我发现大家对“数据库事务与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隔离级别下,扫描范围内的所有相关记录都会加临键锁,导致用户端下单操作偶发卡顿。

排查完锁等待现场之后,我们做了三件事:

  1. 把隔离级别从RR切成RC,消除了间隙锁;
  2. 在(biz_type, status)上建了联合索引,让UPDATE扫描的范围缩小;
  3. 把批量更新拆小,拆成一次只更新几百条,减少单事务持有锁的时间。

改完之后,锁等待的告警基本绝迹了。这三条经验后来也成了我做数据库锁优化的标配动作:先看隔离级别、再看索引、最后控制事务体量。优先级从高到低,任何一个环节掉链子,锁都可能成为业务并发路上那根最细的瓶颈。

现在再回头想“数据库事务与InnoDB锁有什么关联”这个问题,我的理解是:事务给数据库提供了一致性和隔离性的承诺,而锁就是兑现这个承诺的底层执行者。没有锁,事务之间的并发写入就会互相踩踏;没有事务,锁的存在就失去了意义。搞懂了它们之间这层关系,再去看锁等待、死锁、性能调优,思路会清晰很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询