☰
MySQL锁机制详解:从行锁到间隙锁,彻底掌握并发控制与死锁优化
2026/10/6 3:57:04 网站建设 项目流程

做后端开发和数据库维护的人,应该没少被并发问题折磨过。我早年在线上就碰到过一次典型的库存超卖:两条事务几乎同时读到某商品库存还剩1件,都执行了扣减,结果订单下了两单,库存变成了-1,对账的时候才发现问题。排查下来,根子不在业务代码,而在MySQL事务隔离和锁机制没有配合好。MySQL的锁,说白了就是一套让并发事务"排队"的制度,理解它、用好它,才能真正控制住并发下的数据一致性。这篇博文我会从锁的分类讲起,一直聊到隔离级别、死锁案例和优化手段,全程按实战视角来拆,希望能给正在入门或卡在锁问题上的朋友一点启发。

1. 并发问题的源头:没有锁的世界有多乱

1.1 经典超卖场景还原

先回到我遇到的那个超卖案例。假设商品表里有这么一行数据:

-- id=100 的商品,库存 stock=1 SELECT stock FROM product WHERE id = 100;

两个用户同时下单,每个下单逻辑都执行两步:先查库存,再把库存减一。如果没有锁机制,两个事务可以同时读到stock=1,然后都执行UPDATE product SET stock = stock - 1 WHERE id = 100,最终库存变成 0 而不是 -1 已经是万幸——更常见的结果是,两个订单都判定"库存足够",但实际只卖出去了1件,另一件是凭空多出来的。

这个问题的本质是:数据库要同时保证多个连接对同一行数据的修改不互相覆盖,又不能让所有请求都串行执行,否则性能就废了。MySQL 的 InnoDB 引擎就是靠着**锁 + MVCC(多版本并发控制)**这两套机制来解决矛盾的。锁负责在"写"的时候建立秩序,MVCC 负责在"读"的时候提供快照,两者搭配,才让业务既安全又高效。

1.2 锁的本质:将并行强制串行的代价与收益

很多人把锁理解成"数据库自己加的一道防线",这没错,但不够透彻。锁真正做的事情,是在冲突发生时,把一部分本该并行的操作强制变成串行。从数据库的角度看,事务 A 持有某一行数据的锁,事务 B 想操作同一行,就必须等 A 提交或回滚。这个"等待"就是并发的代价,也是数据一致性的保障。

理解锁的时候,我建议先明确一个核心判断题:**冲突发生在哪个粒度?**如果冲突只发生在同一行,那么行级锁就够;如果冲突发生在整个表级别,比如DDL(表结构变更),就必须用表级锁或元数据锁。粒度越小,能并行的请求就越多,但管理锁的成本也越高。InnoDB 选择的是"行级锁为主、表级锁为辅"的组合打法,这也是它比 MyISAM 更适合高并发业务的核心原因——MyISAM 只支持表锁,任何写操作都会把整张表锁住,一到高流量场景就排队排到崩溃。

1.3 锁与事务的纠缠关系

锁不是独立存在的,它总是跟着事务走。一个事务里执行的所有加锁操作,要等到事务提交或回滚时才会统一释放。这个特性直接引出了一个常见问题:**事务里执行时间越久,锁持有的时间就越长,其他事务等待的概率就越大。**我见过不少团队把多个接口的数据库操作塞进一个大事务里,表面上看是"保证原子性",实际上是把锁的占用时间拉长了好几倍,线上偶尔就会出现锁等待超时。所以后面聊优化策略的时候,第一刀一定是从"缩短事务时间"下手,原因就在这里。

2. 锁的分类全景:InnoDB的锁家族

2.1 全局锁、表锁与元数据锁

MySQL 的锁可以按作用范围分成几层,最顶层是全局锁。执行FLUSH TABLES WITH READ LOCK之后,整个实例变成只读,所有写操作被阻塞。这个操作通常只用于全库备份,现在用 mysqldump 配合--single-transaction做 InnoDB 备份时已经不需要它了,因为它会把所有业务全停掉,影响太大。

第二层是表级锁,分为两种。一种是显式的LOCK TABLES ... READ/WRITE,基本只用于 MyISAM 表;另一种是InnoDB 的MDL锁(元数据锁),它是自动加上的,事务访问一张表时,会先拿 MDL 锁防止表结构在事务执行期间被修改。MDL 锁最坑的场景是:一个长事务一直没结束,刚好有人执行了一条ALTER TABLE修改表结构,那么这条 DDL 会被阻塞住,并且后续所有读写这张表的请求都会被堵在 MDL 锁后面,然后整个业务雪崩。排查的时候,看到Waiting for table metadata lock基本就是这个原因。

2.2 行级锁:记录锁、间隙锁、临键锁与插入意向锁

行级锁是 InnoDB 的看家本领,再往下细分,有四种关键类型:

  • 记录锁(Record Lock):锁住具体的某一行索引记录,比如UPDATE ... WHERE id = 1会在 id=1 这条记录上加锁。
  • 间隙锁(Gap Lock):锁住一个区间,但不锁具体记录,比如 id 在 5 和 10 之间的空隙。它的作用是阻止其他事务在这个空隙里插入新数据,主要为了解决"幻读"问题。
  • 临键锁(Next-Key Lock):记录锁和间隙锁的组合,锁住一个左开右闭的区间,例如(5, 10]。在可重复读(REPEATABLE READ)隔离级别下,普通查询加锁时默认用的就是临键锁。
  • 插入意向锁(Insert Intention Lock):是一种特殊的间隙锁,事务打算往某个间隙插入行时,必须先获得插入意向锁。它和间隙锁的区别是,多个事务的插入意向锁之间是兼容的,可以一起等待,而间隙锁之间的是冲突的。

看到这里你可能会问:为什么加锁动不动就锁一个区间,直接锁行不好吗?因为 InnoDB 要防的不只是两个事务改同一行,还要防止另一个事务"插进来一行"导致同一个查询在事务内两次结果不一致——这就是幻读。只有把区间也锁住,才能让幻读无路可走。

2.3 意向锁为什么是"隐形"的

意向锁(Intention Locks)是 InnoDB 里一个容易被忽略但非常重要的机制,分**意向共享锁(IS)和意向排他锁(IX)**两种。它解决的是一个效率问题:如果一张表上很多行已经被事务加了行锁,另外来了一个事务想要对整张表加表锁,怎么快速判断能不能加?总不能逐行扫描检查吧。意向锁就是用来"提前登记"的:事务打算给某行加锁时,会先在这张表上加一个意向锁,表示"我准备动这表里的某些行"。别人想加表锁,一看有意向锁存在,立刻就知道不能加,连扫描都省了。

意向锁的兼容规则,我整理了一张表方便记忆:

锁类型ISIXSX
IS兼容兼容兼容冲突
IX兼容兼容冲突冲突
S(表级共享锁)兼容冲突兼容冲突
X(表级排他锁)冲突冲突冲突冲突

注意,IX 和 IX 之间是兼容的,所以多个事务可以同时给不同行加排他锁,互不干扰。意向锁只是"登记意图",并不会阻塞别的意向锁。

2.4 自增锁与隐式锁

除了上面几类,还有两个边角但实际会踩到的锁。一个是自增锁(AUTO-INC Lock),用来保证自增主键的连续性。MySQL 8.0 之前的默认策略是每次插入都申请一个表级锁来控制自增值分配,并发插入性能很差;8.0 以后调整成了更轻量的互斥量机制,只在分配自增值时短暂占用,性能提升非常明显。

另一个是隐式锁,它不算一种真正的锁,而是一种"延迟加锁"机制。一个事务刚插入了一行数据,这行数据上其实是没有显式锁的,靠的是事务 ID 来标记归属。如果此时另一个事务想修改这行,InnoDB 会发现"这行属于一个未提交的事务",然后给这个事务生成真正的锁记录。这种机制省去了每次插入都要加锁的开销,我也经常把它理解成"先上车后补票"。

3. 隔离级别如何决定锁的范围与行为

3.1 READ COMMITTED:只锁记录,间隙锁靠什么兜底

事务隔离级别直接决定了 InnoDB 用哪些锁。在READ COMMITTED(读已提交)级别下,InnoDB 只会使用记录锁,不会使用间隙锁,所以加锁范围会小很多,并发能力更强。但它也放弃了通过锁来防止幻读的能力,靠的是 MVCC 的快照读——普通的SELECT会读到事务开始前已提交的数据版本,所以即便其他事务插入了新行,读出来的结果也是一致的。

这个级别最大的坑在于 binlog。如果 binlog 格式是默认的 STATEMENT,靠记录日志来复制的备库或下游消费者,自己执行同样的 SQL 时并不知道主库当初加过的锁,有可能会产生主从数据不一致。所以生产中如果选择 RC 级别,binlog 格式必须设置为 ROW,这也是很多云数据库默认配置的做法。

3.2 REPEATABLE READ:临键锁与幻读的源头

REPEATABLE READ(可重复读)是 MySQL InnoDB 的默认隔离级别,也是锁问题最集中的地方。在这个级别下,事务执行当前读(SELECT ... FOR UPDATE、UPDATE、DELETE)时,会用临键锁锁住命中的记录和它前后的区间。比如:

-- 假设表中有 id=5, id=10 两条记录 SELECT * FROM product WHERE id BETWEEN 5 AND 10 FOR UPDATE;

这段 SQL 不仅锁住 id=5 和 id=10 这两行,还会锁住(5, 10]这个区间,另外表里 id 小于5的最大值一侧也会加上间隙锁。结果就是:其他事务想在 id=6 到 id=9 的范围内插入任何一行,都会被阻塞。

这是 RR 级别下保证"当前读"不产生幻读的手段。很多人误以为 RR 是通过 MVCC 消灭了幻读,其实 MVCC 只对快照读有效,当前读被防住靠的全是锁。如果你看到业务里明明没有并发更新,但插入总是时不时被卡住,大概率就是被间隙锁拦了。

3.3 RR下的当前读与快照读差异

理解当前读和快照读的区别,是排查锁问题的基础。快照读是普通SELECT,走 MVCC,不加锁;当前读是SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE,读的是最新已提交版本,并且加锁。

在 RR 下,这两种读的行为差异应用层面经常出现:一个事务先执行普通SELECT拿到了快照,然后另一个事务提交了新数据,此时第一条事务再次普通SELECT,看到的还是自己的旧快照,这是符合 RR 语义的;但如果第一条事务改用了SELECT ... FOR UPDATE,它会立刻读到新数据并加锁。很多"RR 下为什么读到不一致数据"的问题,根源都在于读写模式混用,建议在排查时先确认语句是走快照读还是当前读。

3.4 间隙锁引发的"锁升级"事故

间隙锁还有一种放大效应,我把它称为"锁升级"事故。举个例子:某个业务表在普通查询条件下,本来只想更新一条记录,但因为WHERE条件没有走唯一索引,而是走了一个普通二级索引,或者干脆走了全表扫描,间隙锁的范围就从"一条记录"膨胀到"一个区间甚至整个索引区间"。

更极端的情况是,更新条件没有命中任何索引。InnoDB 只能全表扫描去找目标行,扫描过程中会对每一行加锁,最后表现就跟表锁一样,所有写操作全部阻塞。这也是为什么业界常说"索引不牢,锁会升级"。排查时如果发现明明是小范围更新,SHOW ENGINE INNODB STATUS里却出现大段锁记录,首选检查执行计划是否走了合适的索引。

4. 锁等待与死锁:从现象到根因的完整排查链路

4.1 一次锁等待超时的时间线还原

有一次客户报障,说业务里经常出现Lock wait timeout exceeded,两条 UPDATE 语句互相等。我把时间线和现场状态还原了一遍,过程大致是:

  1. 事务 A 执行UPDATE order SET status = 1 WHERE id = 123,拿到了 id=123 这一行的排他锁,但事务迟迟不提交。
  2. 事务 B 执行同样的更新,尝试给同一行加排他锁,进入等待状态。
  3. 默认的innodb_lock_wait_timeout是 50 秒,B 等了 50 秒后超时,报错回滚。

看起来很简单,但真正要查的是:**事务 A 为什么迟迟不提交?**连上去看information_schema.innodb_trx,发现 A 的trx_started已经是 20 分钟之前,它是一个从应用侧发起的分布式事务,因为某个下游 RPC 调用失败,一直没收到提交指令。根因根本不在这条 SQL,而在事务生命周期管理。

这类问题的排查链路,我有固定的三步:

# 第一步:看正在运行的事务 SELECT trx_id, trx_state, trx_started, trx_query FROM information_schema.innodb_trx; # 第二步:看锁等待关系 SELECT * FROM performance_schema.data_lock_waits; # 第三步:看 InnoDB 引擎状态 SHOW ENGINE INNODB STATUS;

performance_schema.data_lock_waits的输出里,会明确标出谁在等谁,哪个事务持有锁,哪个事务在等待,这在 MySQL 8.0 里比老版本好用了不少。

4.2 死锁的经典双表更新案例

死锁和锁等待不一样。锁等待是"一个等另一个",死锁是"两个事务互相等对方持有的锁"。经典的案例是两个事务按不同顺序更新两张表:

-- 事务 A BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT; -- 事务 B BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 2; UPDATE account SET balance = balance + 100 WHERE id = 1; COMMIT;

如果 A 先锁了 id=1,B 先锁了 id=2,然后 A 想锁 id=2 发现被 B 持有,B 想锁 id=1 发现被 A 持有,两个事务就僵住了。InnoDB 有个后台死锁检测线程,会定期检查等待关系图,一旦发现有环,就选择回滚代价较小的那个事务,让它释放锁,另一个事务继续执行。你会在日志里看到类似Deadlock found when trying to get lock的报错。

避免这类死锁最有效的办法是全局固定访问顺序,所有事务都先更新 id=1 再更新 id=2,就不会出现交叉等待。这个约束看着简单,在多人协同开发时很容易被忽略。

4.3 information_schema与performance_schema定位

除了前面提到的锁等待场景,我还想再提一个 MDL 锁的排查技巧。表结构被ALTER TABLE阻塞后,所有读写都会卡住,但information_schema.innodb_trx不一定看得到它的完整信息,因为 MDL 锁的持有者可能是一个已经空闲但未提交的会话。此时要用:

SELECT * FROM performance_schema.metadata_locks;

这个表记录了所有会话当前持有的 MDL 锁,包括锁类型和状态。定位到持有者后,直接KILL掉对应连接,业务基本能立刻恢复。这个操作我建议慎用,务必先确认是空闲事务持有锁,而不是正在执行重要操作的事务,否则容易引发新的数据问题。

4.4 死锁日志的读法

SHOW ENGINE INNODB STATUS里,死锁信息集中在LATEST DETECTED DEADLOCK段落,里面会列出每个事务执行过的 SQL、持有的锁、等待的锁,还有等到的回滚事务是谁。读这段日志有一个小技巧:先看最底下标着WE ROLL BACK TRANSACTION的事务,它就是被牺牲掉的那个,然后沿着它的 SQL 往上找,确认它等待的是哪把锁、被谁持有,就能还原出死锁环。

建议生产环境开启死锁日志落盘,MySQL 8.0 里的innodb_print_all_deadlocks=ON可以把所有死锁打到 error log 里,方便事后分析。默认只记录最近一次的死锁,出问题时日志很容易被覆盖,开着这个参数会让你省很多事。

5. 优化策略:从锁层面做文章

5.1 让锁的粒度越小越好——索引与锁范围

优化锁竞争,第一原则是锁的范围越小越好。InnoDB 的行锁是基于索引实现的,锁的是一行一行的索引记录,所以 SQL 能不能命中索引,直接决定锁是小还是大。

我见过一个高频次问题——UPDATE user SET name = 'xxx' WHERE status = 1,status 字段没有索引,结果就是全表扫描加锁,所有会话的更新全部互相阻塞。解决方案不是简单加个索引就行,而是要评估 status 列的区分度:如果 status 只有两个值(比如0和1),区分度太低,加索引也不一定能走,反而可能让优化器选择全表扫描。这种情况下,更好的做法是把大任务拆成小批次,每条 SQL 用主键精确锁定范围:

-- 分批更新,每批 1000 条,按主键范围 UPDATE user SET name = 'xxx' WHERE id BETWEEN 1 AND 1000 AND status = 1;

这样锁只落在 1000 行范围内,其他请求不会背锅。

5.2 事务排程:短事务优先与批量拆分

锁持有时间和事务存活时间强相关。优化策略里被我反复强调的一条是:尽可能把事务缩短。以下是我执行事务时的几个原则:

  • 事务里只放必要的写操作,将远程RPC调用、外部接口请求、消息通知全部挪到事务外。
  • 不在事务里做耗时查询、复杂计算或等待用户输入。
  • 大批量数据处理时,强制分批提交,比如一次 1000 行,处理完立即 COMMIT,再处理下一批。

有人会担心分批后数据一致性不好控制。确实如此,如果中途某批失败,之前的已经提交了,需要靠补偿机制或任务记录来做恢复。这是工程上的取舍:如果你能够接受"最终一致",分批几乎是最优解;如果必须严格原子性,那只能接受长事务和更重的锁开销。

5.3 隔离级别与并发度的权衡

隔离级别是另一把钥匙。很多团队从 Oracle 转到 MySQL,习惯用 READ COMMITTED;也有团队维持默认的 REPEATABLE READ。从锁的角度看,RC 因为不产生间隙锁,并发提升明显,尤其在大量范围更新的业务里,能避开很多隐形的锁竞争。

但 RC 不是银弹,它意味着业务查询在事务内可能读到其他事务刚提交的内容,如果你的逻辑依赖"事务内多次读结果一致",就得自己处理。我的建议是:优先评估业务对幻读的容忍度,如果业务逻辑中没有"先查再插"这种容易产生幻读的写法,RC 会更适合高并发生产环境;如果既有逻辑已经为 RR 做了很多设计,比如依赖SELECT ... FOR UPDATE来防幻读,就不要轻易降级,防止业务行为变化。

5.4 热点行的拆解与软硬结合

当锁竞争不是偶发而是集中在少数几行上时,比如秒杀场景下的库存表,就轮到"热点行拆分"登场了。思路很简单:把一行库存拆成多行子库存,比如 10 个子库存桶,每个桶走独立的行锁,并发度就能提升近 10 倍。

-- 库存桶结构 CREATE TABLE stock_bucket ( bucket_id INT PRIMARY KEY, product_id INT, stock_remaining INT, version INT ); -- 扣减时随机选一个桶 UPDATE stock_bucket SET stock_remaining = stock_remaining - 1, version = version + 1 WHERE bucket_id = 2 AND stock_remaining > 0;

这类方案经常配合 Redis 预扣减一起用:先在缓存层扣减库存,异步再同步到数据库。数据库的锁压力大幅下降,数据一致性靠异步任务去对账兜底。我在压测里测过,热点行从一行拆成二十行后,极端情况下吞吐翻了近 8 倍。但这套方案会增加系统复杂度,数据校验和补偿机制要跟上,否则容易出现"缓存扣了、数据库没扣"的账实不符。

5.5 参数调优与监控

最后聊几个我实际调过的参数,都是和锁直接相关的:

参数名默认值作用与调优建议
innodb_lock_wait_timeout50锁等待超时。不要设得过小,比如 1 秒,否则正常的短暂等待也会被打断;也不要过大,否则事务堆积看不到错误。经验值 10~30 秒
innodb_deadlock_detectON死锁检测开关。高并发大量热点行时会消耗大量CPU在检测上,极端场景可考虑关闭,但必须用锁等待超时兜底,否则死锁永远不会被发现
innodb_autoinc_lock_mode2自增锁模式。MySQL 8.0 默认值基本合理,不建议改回兼容格式
transaction-isolationREPEATABLE-READ隔离级别。根据业务场景调整,精确评估后再改

监控方面,我一般盯三个指标:锁等待次数、锁等待超时次数、死锁次数。MySQL 8.0 的 performance_schema 提供了很多锁相关的统计表,可以按时间维度去做报表。把死锁和锁超时日志接入告警系统,要比等业务反馈再排查靠谱得多。

6. 当锁不再是被动防御:乐观锁与业务层的配合

6.1 版本号CAS为什么有时比悲观锁更好

行锁、表锁都属于悲观锁,逻辑是"先锁后查",天然安全,但遇到高冲突场景就会排队严重。另一种思路是乐观锁:默认认为冲突很少,不加锁去读数据,更新时用版本号做校验。

经典的实现是在表里维护一个version字段:

UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 100 AND version = 5;

如果影响行数为 0,说明在这个事务执行期间,别的会话已经改了 version,本次更新失败,需要重新读取最新值再试一次。这套 CAS(Compare And Swap)思路在冲突率低的时候非常高效,不用等待锁释放,比SELECT ... FOR UPDATE快得多。

我在一些配置表、账户状态表的更新里就很喜欢用乐观锁,因为这类场景写频率低、冲突概率小,避免了每次更新都拿事务锁的开销。注意,这种方案只能保证"单行更新"的一致性,如果牵涉到多行联合更新,还是得回到数据库事务来保证原子性。

6.2 乐观锁的并发放大问题

乐观锁不是没有代价。当冲突率升高时,比如秒杀瞬间几百个请求同时抢同一个商品的库存,大部分人更新都会失败,然后发起重试。每次重试都要重新查询、重新比较、重新尝试,数据库压力反而成倍放大,锁是少了,查询却多了。

遇到这种情况,我会更推荐限流+队列的思路:先在应用层把请求削峰,让真正进入数据库的写请求数量降下来,然后在数据库层用悲观锁或小米式的库存拆分来兜底。单纯依赖乐观锁重试,系统会处于一种"看似并发不高、实际上数据库满载"的亚健康状态。

6.3 设计层面的最后一个建议

聊了这么多,最后说一点方法论上的体会。锁机制不是孤立的数据库知识点,它是和事务隔离级别、索引设计、SQL执行计划、业务并发模型绑在一起的整体。我每次排查线上锁问题,都是同时看事务、看索引、看SQL、看隔离级别,从四个角度共同推断,很少只盯着锁定语法本身。

给新手一个建议:锁的规则确实不少,但你不必背住每种锁在所有场景下的兼容矩阵,关键是遇到问题时能建立一套从现象到根因的推导路径——先判断是锁等待还是死锁,再看谁持有锁、说过哪些SQL,然后分析SQL是否走索引、事务是否超长、隔离级别是否合适,最后用优化手段去消除恶性竞争。这套路径你完整走过两三次,再回头看数据库锁,就不会觉得它玄学了。

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

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

立即咨询