首页 / 资讯中心 / 文章详情

MySQL锁机制详解:从行锁到间隙锁,彻底掌握并发控制与死锁优化

MySQL锁机制详解:从行锁到间隙锁,彻底掌握并发控制与死锁优化 ★ FEATURED ARTICLE
做后端开发和数据库维护的人应该没少被并发问题折磨过。我早年在线上就碰到过一次典型的库存超卖两条事务几乎同时读到某商品库存还剩1件都执行了扣减结果订单下了两单库存变成了-1对账的时候才发现问题。排查下来根子不在业务代码而在MySQL事务隔离和锁机制没有配合好。MySQL的锁说白了就是一套让并发事务排队的制度理解它、用好它才能真正控制住并发下的数据一致性。这篇博文我会从锁的分类讲起一直聊到隔离级别、死锁案例和优化手段全程按实战视角来拆希望能给正在入门或卡在锁问题上的朋友一点启发。1. 并发问题的源头没有锁的世界有多乱1.1 经典超卖场景还原先回到我遇到的那个超卖案例。假设商品表里有这么一行数据-- id100 的商品库存 stock1 SELECT stock FROM product WHERE id 100;两个用户同时下单每个下单逻辑都执行两步先查库存再把库存减一。如果没有锁机制两个事务可以同时读到stock1然后都执行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会在 id1 这条记录上加锁。间隙锁Gap Lock锁住一个区间但不锁具体记录比如 id 在 5 和 10 之间的空隙。它的作用是阻止其他事务在这个空隙里插入新数据主要为了解决幻读问题。临键锁Next-Key Lock记录锁和间隙锁的组合锁住一个左开右闭的区间例如(5, 10]。在可重复读REPEATABLE READ隔离级别下普通查询加锁时默认用的就是临键锁。插入意向锁Insert Intention Lock是一种特殊的间隙锁事务打算往某个间隙插入行时必须先获得插入意向锁。它和间隙锁的区别是多个事务的插入意向锁之间是兼容的可以一起等待而间隙锁之间的是冲突的。看到这里你可能会问为什么加锁动不动就锁一个区间直接锁行不好吗因为 InnoDB 要防的不只是两个事务改同一行还要防止另一个事务插进来一行导致同一个查询在事务内两次结果不一致——这就是幻读。只有把区间也锁住才能让幻读无路可走。2.3 意向锁为什么是隐形的意向锁Intention Locks是 InnoDB 里一个容易被忽略但非常重要的机制分**意向共享锁IS和意向排他锁IX**两种。它解决的是一个效率问题如果一张表上很多行已经被事务加了行锁另外来了一个事务想要对整张表加表锁怎么快速判断能不能加总不能逐行扫描检查吧。意向锁就是用来提前登记的事务打算给某行加锁时会先在这张表上加一个意向锁表示我准备动这表里的某些行。别人想加表锁一看有意向锁存在立刻就知道不能加连扫描都省了。意向锁的兼容规则我整理了一张表方便记忆锁类型ISIXSXIS兼容兼容兼容冲突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时会用临键锁锁住命中的记录和它前后的区间。比如-- 假设表中有 id5, id10 两条记录 SELECT * FROM product WHERE id BETWEEN 5 AND 10 FOR UPDATE;这段 SQL 不仅锁住 id5 和 id10 这两行还会锁住(5, 10]这个区间另外表里 id 小于5的最大值一侧也会加上间隙锁。结果就是其他事务想在 id6 到 id9 的范围内插入任何一行都会被阻塞。这是 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 语句互相等。我把时间线和现场状态还原了一遍过程大致是事务 A 执行UPDATE order SET status 1 WHERE id 123拿到了 id123 这一行的排他锁但事务迟迟不提交。事务 B 执行同样的更新尝试给同一行加排他锁进入等待状态。默认的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 先锁了 id1B 先锁了 id2然后 A 想锁 id2 发现被 B 持有B 想锁 id1 发现被 A 持有两个事务就僵住了。InnoDB 有个后台死锁检测线程会定期检查等待关系图一旦发现有环就选择回滚代价较小的那个事务让它释放锁另一个事务继续执行。你会在日志里看到类似Deadlock found when trying to get lock的报错。避免这类死锁最有效的办法是全局固定访问顺序所有事务都先更新 id1 再更新 id2就不会出现交叉等待。这个约束看着简单在多人协同开发时很容易被忽略。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_deadlocksON可以把所有死锁打到 error log 里方便事后分析。默认只记录最近一次的死锁出问题时日志很容易被覆盖开着这个参数会让你省很多事。5. 优化策略从锁层面做文章5.1 让锁的粒度越小越好——索引与锁范围优化锁竞争第一原则是锁的范围越小越好。InnoDB 的行锁是基于索引实现的锁的是一行一行的索引记录所以 SQL 能不能命中索引直接决定锁是小还是大。我见过一个高频次问题——UPDATE user SET name xxx WHERE status 1status 字段没有索引结果就是全表扫描加锁所有会话的更新全部互相阻塞。解决方案不是简单加个索引就行而是要评估 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本次更新失败需要重新读取最新值再试一次。这套 CASCompare And Swap思路在冲突率低的时候非常高效不用等待锁释放比SELECT ... FOR UPDATE快得多。我在一些配置表、账户状态表的更新里就很喜欢用乐观锁因为这类场景写频率低、冲突概率小避免了每次更新都拿事务锁的开销。注意这种方案只能保证单行更新的一致性如果牵涉到多行联合更新还是得回到数据库事务来保证原子性。6.2 乐观锁的并发放大问题乐观锁不是没有代价。当冲突率升高时比如秒杀瞬间几百个请求同时抢同一个商品的库存大部分人更新都会失败然后发起重试。每次重试都要重新查询、重新比较、重新尝试数据库压力反而成倍放大锁是少了查询却多了。遇到这种情况我会更推荐限流队列的思路先在应用层把请求削峰让真正进入数据库的写请求数量降下来然后在数据库层用悲观锁或小米式的库存拆分来兜底。单纯依赖乐观锁重试系统会处于一种看似并发不高、实际上数据库满载的亚健康状态。6.3 设计层面的最后一个建议聊了这么多最后说一点方法论上的体会。锁机制不是孤立的数据库知识点它是和事务隔离级别、索引设计、SQL执行计划、业务并发模型绑在一起的整体。我每次排查线上锁问题都是同时看事务、看索引、看SQL、看隔离级别从四个角度共同推断很少只盯着锁定语法本身。给新手一个建议锁的规则确实不少但你不必背住每种锁在所有场景下的兼容矩阵关键是遇到问题时能建立一套从现象到根因的推导路径——先判断是锁等待还是死锁再看谁持有锁、说过哪些SQL然后分析SQL是否走索引、事务是否超长、隔离级别是否合适最后用优化手段去消除恶性竞争。这套路径你完整走过两三次再回头看数据库锁就不会觉得它玄学了。
阅读完成 · 觉得有帮助?
咨询建站