news 2026/10/15 19:49:58

数据库锁机制详解:从MVCC到行锁、间隙锁与死锁排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
数据库锁机制详解:从MVCC到行锁、间隙锁与死锁排查

1. 先说清楚:为什么数据库会有锁这回事

接手过并发业务的开发者基本都体会过这种场景——线上某个接口偶尔报错,日志里一行"Deadlock found when trying to get lock",或者某个更新操作莫名其妙卡了很久。这时候十有八九就是锁在搞事情。数据库里有个经典说法:不加锁是灾难,乱加锁也是灾难。MVCC、表级锁、行级锁、共享/排他锁、意向锁这些概念本质上都在回答一个问题:多个事务同时操作同一份数据时,怎么保证不错乱、不丢数据,同时还能跑得快。

我刚开始接触这块时也觉得很绕,五个名词放在一起像绕口令。后来工作里实际排查了几次死锁、优化过几个慢事务之后,才逐渐理清它们的职责分工:MVCC负责让"读"不被"写"挡住,共享/排他锁负责划清读写权限,表级锁和行级锁控制的是锁的范围大小,意向锁则是数据库用来协调"表锁"和"行锁"之间关系的一种内部约定。它们不是一个东西的五个名字,而是一套组合拳。

这篇文章不打算从教科书定义开始念,我想按一条实际业务线走一遍:从一条简单的UPDATE语句出发,看数据库究竟在后台做了哪些加锁动作,每个锁解决什么问题,又会在什么情况下变成麻烦。最后再聊几个我实际踩过、帮别人排查过的坑,附上定位方法。

2. MVCC:让"读"和"写"尽量互不打扰

2.1 快照读和当前读是理解MVCC的前提

先说一个最日常的现象。你在一个事务里执行了好几条SELECT,执行期间另一个事务正在更新同一张表,你这边大多数情况是感觉不到的,查询照常返回,数据也一致。这就是MVCC在起作用,它让你拿到了一份"过去某个时间点"的稳定快照,读操作不需要去等那个写事务提交,更不需要去阻塞它。

MVCC全称是多版本并发控制,核心思路简单说就是:数据不只有一个版本,每行记录在更新时会留下历史版本,读操作可以挑一个符合隔离级别的旧版本来读。生活里可以类比成图书馆的预约借阅——书被某个人借走(写锁),其他人想读时不是干等着,而是管理员给一份影印本(快照)先看着。

但要澄清一个容易混淆的点:MVCC只对"快照读"有效。事务里那种普通的SELECT语句,不加FOR UPDATE也不加LOCK IN SHARE MODE,走的是快照读。可如果你执行SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT,这些是当前读,读的是已提交的最新版本,必须加锁,没法靠历史版本躲过去。也就是说,MVCC解决的是"读读不阻塞、读写不阻塞"里的读写部分,真正写和写之间还得靠锁来约束。

2.2 MVCC背后的三样核心物件

MVCC在各大数据库主流实现里都有对应逻辑,以常用的InnoDB为例,它靠三个东西配合:

  • 隐藏字段:每一行除了业务字段,还带着两个关键列——DB_TRX_ID(最近一次修改这行的事务ID)和DB_ROLL_PTR(指向undo log里旧版本的指针)。新建或更新一行时,事务ID会被写进去,旧版本数据则串到回滚日志链上。
  • undo log版本链:一行数据被更新N次,就会在undo log里留下N个历史版本,用回滚指针连成链表。最新版本在聚簇索引里,历史版本在链表上依次排开。
  • ReadView(一致性视图):事务做快照读的瞬间,数据库会生成一个视图,记录当前哪些事务是活跃的(未提交)。判断一条记录对当前事务可见不可见,就是拿这行的事务ID和视图里的信息做对比。如果这行的事务ID比视图里最小活跃ID还小,说明这个修改早就提交了,可见;如果这个ID落在活跃事务列表里,说明改这行的事务还没提交,不可见;如果ID比最大ID还大,说明它是之后才启动的事务,同样不可见。

判断逻辑听着复杂,但实际效果是:在你开启快照的那一刻,已经提交的数据你能看到,尚未提交的一律看不到。后面的修改无论提交不提交,都影响不了你手里这份视图。这就是为什么一个长事务里多次SELECT结果能保持一致。我在实际排查中见过不少人把"同一事务里每次SELECT结果都一样"当成理所当然,却说不清原理,面试时被追问两句就露馅,这里建议亲手建表做一次"事务A开启视图、事务B更新提交"的验证实验,感受会深很多。

2.3 不同隔离级别下MVCC的差异

MVCC不是在所有隔离级别下都同样工作。可重复读(RR)和读已提交(RC)都用快照读,但关键差异是ReadView的生成时机:

  • 可重复读(RR):事务内第一次快照读时创建ReadView,之后一直复用。所以整个事务阶段看到的都是同一份数据,天然避免"不可重复读"问题。
  • 读已提交(RC):每次SELECT都重新生成一个ReadView,所以能看到其他事务新提交的数据。如果你的应用需要看到最新已提交值,就选RC;需要事务内数据一致,选RR更合适。大多数在线业务默认RR,但某些报表类查询改成RC反而能减少不必要的间隙锁开销,这点后面还会提到。

要特别提醒:MVCC并不能解决"幻读"的全部问题。RR级别下,快照读层面基本挡住了幻读,但如果你事务中先做了一次当前读(比如SELECT ... FOR UPDATE),再插入数据,仍可能跟并发事务插入的新数据产生冲突。真正的防护靠的是后面要讲的间隙锁配合,MVCC只是其中一环。

3. 共享锁、排他锁与意向锁:把读写权限和协作关系理清楚

3.1 共享锁和排他锁就是一把锁的两个挡位

如果只有一把锁,没有挡位区分,那就只能做到"同一时间只有一个人操作",并发度非常低。所以主流数据库会把锁分成两种状态:

  • 共享锁(S锁,Shared Lock):也叫读锁,多个事务可以同时给同一份数据加S锁。加S锁期间,其他事务可以继续加S锁来读,但不能修改。适合"大家都能看,但谁也别动"的场景。
  • 排他锁(X锁,Exclusive Lock):也叫写锁,加锁后只有自己能用,其他事务想再加S锁或X锁都不行。UPDATE、DELETE、INSERT自动加的就是X锁,SELECT ... FOR UPDATE也是显式请求X锁。

S锁和X锁的兼容关系可以做个小表,遇到死锁分析时经常用:

锁类型共享锁S排他锁X
共享锁S兼容不兼容
排他锁X不兼容不兼容

多次强调"不兼容"在实际中意味着什么?意味着一旦有事务持有X锁,其他想读这行的普通快照读不受影响(走MVCC),但想走当前读或写操作的事务就必须排队等锁。等锁等太久,应用端表现就是超时、卡顿。你可以在一个会话里执行START TRANSACTION; UPDATE t SET ... WHERE id=1;不提交,另开一个会话执行SELECT * FROM t WHERE id=1 FOR UPDATE;,几乎必然卡住。这就是锁等待最直接的演示。

3.2 意向锁:表锁和行锁之间的协调信号

意向锁是很多人初学时的盲区,因为它不是业务方手动加的,是数据库自动加的。它的意义在于解决一个老大难问题:表级锁和行级锁如何快速判断能不能叠加。

假设事务A给表中某一行加了X锁,事务B想把整张表加X锁做DDL或全表更新。如果不引入意向锁,数据库判断"表能不能锁"时,得去遍历这张表的所有行,看是否有行锁存在,数据量大时开销难以接受。意向锁就是一个快速标记:事务对某行加S锁前,先给表加一个意向共享锁(IS锁);对某行加X锁前,先给表加意向排他锁(IX锁)。这样后续事务想锁整张表时,看一眼表上的意向锁就能确定有没有行锁冲突,根本不用逐行扫描。

意向锁有两个特点值得记住:

  1. 意向锁之间互相兼容。多个事务可以同时持有IX锁,因为各自锁的是不同行,互相不妨碍。
  2. 意向锁和表级S/X锁不兼容。如果表上已有IX锁,再想整表加X锁必须等待;反过来,表级S锁存在时,新事务也没法对行加X锁(因为行级X锁前要先加IX,而IX和表级S锁冲突)。

我用一个类比来记:酒店门口的前台登记。每个客人进房间(对行加锁)之前,都要在前台登记一下(加意向锁)。客房部想整层楼封锁检查(表锁)时,看一眼前台登记簿就知道还有没有客人在房内,不需要挨个房间敲门。想象一下没有登记簿的场景,效率差距一目了然。

3.3 显式加锁语法与适用场景

日常开发中你可能很少手写加锁语句,但ORM框架或某些复杂查询里还是会遇到。这里整理一下常见的显式用法:

  • SELECT ... FOR UPDATE:对读取的行加X锁,常用于"先查后改"的业务,比如下单时锁定库存行,防止并发超卖。注意要放在事务里,且必须走索引命中,否则可能升级成锁很多行甚至全表扫描。
  • SELECT ... LOCK IN SHARE MODE(MySQL 8.0 已更名SELECT ... FOR SHARE):加S锁,用于"读但不允许改"的场景,比如读取配置时防止别人同时改这份配置。
  • 普通SELECT:默认不加锁,走MVCC快照读,这是绝大多数读请求的常态。

我自己在实践中的建议是:能用普通查询解决就别加FOR UPDATE,因为FOR UPDATE本身是当前读,会直接和并发写冲突。只有涉及"读-判断-写"这样完整事务链,且判断结果直接影响写动作时,才值得牺牲一点并发度换取强一致。

4. 表级锁与行级锁:从范围和代价两个维度看加锁策略

4.1 表级锁的直白与局限

表级锁是粒度最粗的锁,锁住的是整张表。事务对表执行写操作时,其他事务不能再读写这张表(部分隔离级别读可能走快照绕开)。它的好处是简单、开销低、不会死锁,适合批量操作、DDL、统计类任务。早期版本的数据库或在某些特殊引擎上,表锁是主流方案。

但表锁的瓶颈也很明显:并发能力极差。一个事务锁表三秒,期间所有涉及这张表的读写都排队。日常OLTP系统里任何核心业务表都不会想用这种锁。那么,既然行级锁并发能力强那么多,为什么还会有人遇到底层锁表现"锁表"?常见情况是:写法没走索引,数据库只能扫全表,为了安全只能把所有扫描到的行都锁住,严重时实际效果等同于锁表。

4.2 行级锁的精细与控制

行级锁只锁住被操作的那一行,其他行仍然可以并发读写,这是InnoDB的默认路子。它的优势是并发度高,适合高QPS的在线交易场景;劣势是锁管理更复杂,占内存更多,有死锁风险,因为多个事务各自锁了几行,又互相等对方持有的行锁时就会形成循环等待。

InnoDB的行级锁在实现上有个特点,是基于索引来加锁的。也就是说,如果查询条件没走索引,数据库需要扫描聚簇索引的每一条记录,每条都被加锁,从表现上看就是"行锁升级成了表锁"。这是个极其常见的性能杀手。有一次我帮一个团队排查线上更新卡顿,原因是某张表的UPDATE条件列虽然建了索引,但函数调用WHERE DATE(create_time) = CURDATE()导致索引失效,结果一次更新锁住了整张表几万行,差点引发线上故障。

4.3 记录锁、间隙锁与临键锁:行锁的三副面孔

InnoDB行锁里还有更精细的分类,这几类同时也是死锁和锁等待的高发地带:

  • 记录锁(Record Lock):最简单的行锁,只锁某条索引记录本身。适合等值查询命中唯一索引的场景。
  • 间隙锁(Gap Lock):锁住的是某个索引区间范围内的"空隙",也就是不存在的记录位置。设计目的是防止其他事务在间隙中插入新记录,从而解决幻读。注意间隙锁只作用于可重复读(RR)级别。一个典型场景是WHERE id BETWEEN 10 AND 20没有匹配到记录时,数据库仍可能把这个区间锁住,禁止其他事务插入id=15的新行。
  • 临键锁(Next-Key Lock):记录锁+前面间隙锁的合体,锁住左开右闭的区间。InnoDB在RR隔离级别下默认用的就是这种锁,扫描到的每一行都把它和它前面的一段间隙锁起来。这是防幻读的真正主力。

这三者用起来有个麻烦点:间隙锁的影响范围往往比你以为的大。等值查询没命中记录会锁住一个区间,范围查询更是锁住好几个区间。所以很多DBA建议在允许的情况下把隔离级别调到RC,并且用基于唯一索引的等值条件做更新,来减少间隙锁带来的并发瓶颈。当然,改隔离级别需要业务确认"不可重复读"是否可以接受。

5. MVCC和锁是怎么配合跑完一条UPDATE的

5.1 一条UPDATE语句的加锁路径拆解

先看一条实际场景的语句:

-- 事务1 START TRANSACTION; SELECT stock FROM product WHERE id = 7 FOR UPDATE; -- 检查库存足够... UPDATE product SET stock = stock - 1 WHERE id = 7; COMMIT;

这条语句背后数据库做了哪些步骤?我尝试拆一下:

  1. 执行SELECT ... FOR UPDATE时,走主键索引定位到id=7的记录,这是一个当前读。
  2. InnoDB先给这条索引记录加记录锁(X锁)。同时,由于默认是RR隔离级别,InnoDB会给这条记录之前的区间加临键锁,防止其他事务在附近插入新记录。
  3. 加行锁前,先给表加IX意向锁,这样其他想锁整个表的操作可以快速感知冲突。
  4. 事务提交时统一释放所有锁。

这里有个细节:虽然这条语句只操作一行,但在RR下它连附近的插入空隙也锁住了。如果id=7是表中最后一条记录,它甚至可能锁住id>7的半开区间,导致其他事务不能往表里插入比7更大的记录。这是很多"明明只更新一行,却插入不了新数据"迷惑案例的根源。

5.2 多事务并发时谁等谁

设想一个更复杂的并发场景:

  • 事务A:UPDATE account SET balance = balance - 100 WHERE user_id = 1001;
  • 事务B:UPDATE account SET balance = balance + 100 WHERE user_id = 1001;

两条语句试图改同一行,B会等待A释放行锁。如果A长时间不提交,B就长时间卡住,直到innodb_lock_wait_timeout(默认50秒)超时。生产环境里见过因为一个事务里遗忘COMMIT,或者业务代码里事务没正常关闭,导致下游一堆写请求全部等待超时的案例。排查这种问题有个快速手段:查information_schema.innodb_trx表,看长时间未提交的事务,再通过sys.schema_table_lock_waits或performance_schema去看锁等待关系。

5.3 锁和MVCC如何避免互相拖后腿

回到本文开头那句话:锁保证"写"和"写"之间的安全,MVCC减少"读"和"写"之间的阻塞。这两者在实际数据库里不是各自为政,而是精细配合。快照读完全无锁,所以读请求不会堵住写事务;当前读和写操作则靠行级锁串行化。对于并发读多写少的典型应用,MVCC能扛住绝大多数压力;对于写冲突密集的场景,锁就成了核心约束。

由此可以得出一个优化思路:降低写冲突概率,就是降低锁等待。比如更新时尽量用更细粒度的条件,避免同一行被多个事务反复更新;把长事务切短,缩短持锁时间;通过合理索引把锁的范围从表缩小到行。这几招在我在多个项目的实际调优中都立竿见影。

6. 实操中与锁有关的典型问题和排查方案

6.1 间隙锁引发的"插入卡顿"和"死锁"

这类问题在RR隔离级别下特别常见。举个例子:一张订单表,统计用的报表事务对order_time BETWEEN '2024-01-01' AND '2024-01-31'做了范围查询并加锁,即使你插入的新订单时间落在2024-02,只要索引区间覆盖到附近,也可能被堵。如果两个事务各自对不同区间持有间隙锁,又都想插入对方区间内的数据,就会死锁,其中一个事务会被数据库选为牺牲者,回滚释放锁,报错Deadlock found。

排查死锁的通用做法是开死锁日志,执行:

SHOW ENGINE INNODB STATUS;

在输出里找LATEST DETECTED DEADLOCK段,里面会标明两个事务各持有什么锁、等什么锁、执行的SQL分别是哪条。拿到这些信息后,绝大多数死锁都能定位到具体语句和加锁范围。记住一句话排查原则:死锁不一定是SQL写错,更多是多条SQL的加锁顺序不一致。比如事务A先更新订单再更新用户,事务B先更新用户再更新订单,两个事务各自锁了一行又等另一行,必死无疑。解决办法是把多表更新的顺序在业务层统一。

6.2 锁等待超时和慢接口怎么快速定位

锁等待超时的问题比死锁更常见。如果接口偶发超时,但日志没有死锁错误,只有Lock wait timeout exceeded,多半是某个事务持锁太久。这时候按下面步骤排查:

  1. 找不提交的长事务:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx WHERE trx_state = 'RUNNING' AND TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 10;
  1. 找锁等待关系:performance_schema.data_lock_waits能看到谁在等谁的锁,加上sys.innodb_lock_waits更直观。重点看waiting_pid和blocking_pid,拿到阻塞源头。

  2. 干掉肇事事务或让业务提交:确认是正常事务就等它完成;如果是程序bug导致事务忘了提交,可以KILL对应线程,但先确认没有人在跑关键批量任务,不然误杀更麻烦。

经验之谈:我遇到过好几次所谓"数据库死锁",最后查出来都是代码里事务方法内嵌套了一个远程调用或消息推送,持锁时间被拉长到几秒甚至十几秒。所以事务代码里做远程调用、发消息、耗时计算都是大忌,事务只应该承载和数据库相关的短线操作。

6.3 什么时候该改表锁、什么时候不用锁

另外一个高频问题是:业务里有一张大表要做全量统计或批处理,直接跑的话会长时间持有锁,影响线上读写。这时可以考虑:

  • 分批提交:按主键范围或时间范围切片,每次只更新一小批,提交后停顿几毫秒再继续,避免单次持锁时间过长。
  • 低峰期执行:把批处理任务挪到凌晨流量低的时间窗口。
  • 读副本执行:统计查询走只读副本或备库,和主库写链路彻底隔离开。
  • 必要时用表锁:有些场景确实需要整表一致性,比如对账。那就直接在低峰期用LOCK TABLES t WRITE;保证没有并发写,操作完成再UNLOCK TABLES;。注意LOCK TABLES期间连接上不能访问其他表,用完必须释放,否则连接挂住影响连接池回收。

还有个别优化建议:如果业务对隔离级别要求没那么严格,把RR降到RC可以减少大量间隙锁问题。很多互联网业务模型完全接受RC下的语义,换来的是更低的锁冲突率和更少死锁。改之前先让DBA确认所有SQL在RC下的行为差异,尤其是报表类SQL。

7. 几个常见的理解误区

整理一下我这些年见到的典型误区,每一行都是真实工作里踩过或围观过的:

  • 误区一:只有UPDATE时才能加锁。实际上,普通的DELETE全表操作也可能锁很多行,SELECT ... FOR UPDATE也加锁。得把变量的场景都考虑进锁分析。
  • 误区二:MVCC意味着完全不用锁。MVCC只是减少了读写相互阻塞,写写之间、当前读之间依然靠锁。MVCC和锁是并存的。
  • 误区三:间隙锁只锁命中的记录。间隙锁往往锁的是一个区间,包括不存在的行,这也是幻读防护的本质。只盯着命中的那几条记录分析锁,方向就偏了。
  • 误区四:加了索引就一定能锁住单行。索引失效时锁的还是大范围。WHERE条件里对索引列做运算、隐式类型转换、前导模糊查询,都会让索引失效。我见过最隐蔽的一次是字符串列没加引号,数字传给字符串列导致类型转换,索引失效后更新缩表。
  • 误区五:死锁是数据库bug。绝大多数死锁是业务SQL设计问题,比如多表更新顺序不一致、批量操作和实时操作并发冲突。数据库会挑代价小的回滚,但根本解法还是在业务层消除循环等待。

附:一个快速自查表

场景可能涉及的锁机制常见处理思路
普通SELECT查询无锁,走MVCC快照读无需处理,注意长事务内存压力
先查后改(防超卖)SELECT FOR UPDATE + 行X锁/临键锁必须走唯一索引,保证锁单行
批量UPDATEX锁锁定大量行分批提交、索引优化
报表统计与在线写并发间隙锁/表锁/长事务读读副本、调低隔离级别
事务间互相等待死锁统一加锁顺序、缩短事务

我自己的深刻体会是:数据库锁机制并不难背,难的是在真实故障发生时能快速反推出"现在是什么锁在阻塞谁"。与其把五个名词单独背得滚瓜烂熟,不如实际做几次全链路演练——自己开两个终端,一个事务不提交,另一个执行查询或更新,看卡在哪里、日志报什么、怎么解除。亲自动手一遍,比看十篇文章都有用。

最后再说一个我在实践中养成的习惯:每次写完一条重要的更新语句,我都会习惯性看一眼它的执行计划(EXPLAIN),确认它确实命中了合适的索引、锁的影响范围可控。这个习惯帮我在上线前就挡掉了不少潜在的锁问题。数据库锁不是靠事后救火的,最好的方案就是在一开始写SQL时就对"这条语句会碰多少行、会影响多少区间"心里有数。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 19:49:28

蒜香空心菜,大火快炒保留脆嫩原生口感

蒜香空心菜是一道很常见的家常菜,南北都有,尤以南方更为普遍。空心菜本名蕹菜,喜欢温暖湿润的地方,夏秋两季长得最旺,是这段时间餐桌上的常客。它茎中空,叶细长,掐下来一节一节的,看…

作者头像 李华
网站建设 2026/10/15 19:48:02

STM32 Custom HID实战:免驱USB通信原理与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 19:47:59

Python 3.6包适配实战:四层验证与可持续维护体系

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 19:47:32

FPN特征金字塔详解:多尺度目标检测的核心架构

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/15 19:44:34

8086测速实验:软硬协同中断设计与实板调试指南

简介:本资源是一份面向高校物联网与微机原理课程学习者的完整实验报告,聚焦智慧交通场景下的车辆测速系统设计与实现,帮助学生将CPU、8253/8254定时计数器、8255并行接口等核心接口技术落地为实际硬件测控能力。报告涵盖实验目的、设备环境、…

作者头像 李华
网站建设 2026/10/15 19:44:19

强化学习入门:从MDP、贝尔曼方程到Q-Learning与深度RL实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华