资讯动态

MySQL锁机制详解:从全局锁到行级锁,锁等待与死锁排查全攻略

发布时间:2026/10/20 15:05:46 来源:尧图企业网站定制
做数据库的都知道锁机制是MySQL并发控制的基石。不管你是写业务SQL的开发还是管实例的DBA只要系统一上并发锁的问题就绕不开要么是没走索引导致行锁变成“全表锁”把整条链路拖垮要么是两个事务互相等锁最后死锁被回滚。这篇内容我就围绕MySQL的锁机制从最容易被忽略的全局锁、表级锁一路拆到InnoDB行级锁的实现细节把日常最容易碰到的排队场景、故障现场和处理手段完整梳理一遍。内容适合需要做业务SQL优化的开发同学也适合需要排查锁等待和死锁的DBA参考。1. 锁到底在解决什么问题1.1 并发访问下的三类冲突先想清楚一个最简单的问题为什么需要锁因为数据库是共享资源多个客户端同时读写同一份数据如果没有约束就会看到不一致的中间状态。写写冲突两个事务同时修改同一行后提交的会把先提交的覆盖掉这是典型的丢失更新。读写冲突一个事务在读数据另一个事务在改数据读方可能读到修改到一半的数据也就是脏读或者在一次事务内读到前后不一样的数据也就是不可重复读。读读冲突两个事务同时读同一份数据大家看到的都是已提交的稳定版本互相不影响不需要锁。所以锁的核心使命就是让“不可并发的操作”排队执行把可能产生脏数据、幻读、不可重复读的交叉访问隔开。理解这一点很重要因为你后续看到间隙锁、临键锁、意向锁这些概念时都会发现它们全部服务于同一条原则在不该并行的时候必须有人排队。我习惯拿洗手间打比方。读操作就像照镜子多个人同时照镜子没问题但有人在里面洗澡时外面的人就不能进去。行锁就好比给每个隔间装了门锁而后面要讲的全局锁就像整层楼只留一个卫生间任何人洗澡时其他人只能在外面等着。1.2 锁粒度与并发能力的取舍锁粒度的选择是个典型的工程权衡。粒度越粗加锁成本越低但并发能力越差粒度越细并发能力越强但锁管理和冲突检测的开销越大。MySQL早期默认引擎MyISAM只有表级锁写操作会把整张表锁住读多写少的场景还行一旦写入密集就是灾难。后来InnoDB引入行级锁才让MySQL在互联网高并发场景下有了一席之地。这个技术路线上的转变本质上就是从“粗粒度低成本”向“细粒度高并发”的演进。现代MySQL里锁粒度大致是这样一个层级锁粒度锁的是什么并发能力主要来源全局锁整个实例最差FTWRL备份表级锁整张表差MDL、LOCK TABLES行级锁索引记录及间隙最好InnoDB你会发现一个现象锁粒度越细排查难度也越高。全局锁卡住时进程列表一眼就能看出来行锁死锁则要靠专门的命令和视图一层层翻这也是我写这篇文章的原因把每一层锁的特征和排障方法都放在一起遇到问题才不会慌。1.3 锁的分类框架为了后面不混乱先给一个全貌框架。MySQL锁可以从三个维度去理解按粒度全局锁、表级锁、行级锁。按模式共享锁S锁和排他锁X锁。S锁之间兼容S锁与X锁互斥X锁与任何锁都互斥。按实现细节记录锁、间隙锁、临键锁、插入意向锁、自增锁、MDL锁等。模式是“能不能并行”的规则粒度是“锁住多大范围”的边界实现细节是“底层到底怎么锁”的手段。三者合起来才构成你看到的锁等待和死锁现场。接下来的章节就按这个框架从最粗的全局锁一路拆到最细的行级锁。2. 全局锁与表级锁被低估的开销与阻塞源头2.1 全局锁一条命令让全库只读全局锁最典型的使用方式是执行FLUSH TABLES WITH READ LOCK;执行完这条命令后整个实例处于只读状态所有DML增删改、DDL建表、加字段等全部阻塞只有SELECT能继续跑。这个操作主要用于全库逻辑备份因为备份需要得到一份一致性快照如果备份期间还在写入备份出来的数据就是乱的。但这里有个很现实的坑FTWRL的阻塞范围是全局的只要有一个事务持有写锁没释放或者有一处长查询FLUSH TABLES WITH READ LOCK就会卡在那里后面的业务全部雪崩。我见过有人把备份任务放到业务高峰期跑结果一个慢查询拖住整库十几分钟线上告警响成一片。解决办法是尽量不让备份走这条路。如果备份对象是InnoDB表用mysqldump时加上--single-transaction参数走MVCC一致性快照全程不加全局锁。只有MyISAM表或者必须做一致性物理备份时才需要FTWRL配合而且现在多数场景已经有专门的备份组件可以做到不阻塞业务。提示FLUSH TABLES WITH READ LOCK拿到锁之后一定要记得UNLOCK TABLES释放。很多新手在备份脚本里没有写释放语句导致全库持续只读这一条值得写进你的操作清单。2.2 表级锁显式表锁与元数据锁表级锁分两类显式表锁和元数据锁MDL。显式表锁就是我们手动执行LOCK TABLES t READ/WRITE把整张表锁住。这类锁在业务代码里现在已经很少见因为它会粗暴阻断所有并发路径而MySQL官方文档也明确说InnoDB场景下不要用LOCK TABLES来保证一致性应该用事务。只有在做某些特殊的数据修复、归档操作时显式表锁还有点用处。更值得关注的是MDL锁。MDL锁在MySQL 5.5时引入解决的是表结构变更时数据不一致的问题。DML语句执行时需要拿到表的MDL读锁DDL语句需要MDL写锁。读锁之间兼容所以并发查询、更新完全不受影响但写锁是排他的一旦有事务持有MDL读锁不释放DDL就得排队等。实战里最典型的故障场景是一个长事务迟迟不提交持有MDL读锁然后有人执行ALTER TABLE加字段ALTER一直拿不到写锁后续所有对该表的查询和更新也会因为等MDL被堵住——因为ALTER没能拿到写锁后新的DML虽然在拿读锁也要排在写锁后面。最后表现为整个业务卡死。排查方法看一眼进程列表SHOW PROCESSLIST;如果看到大量“Waiting for table metadata lock”第一件事就是找到那个未提交的长事务把它处理掉。很多DBA在加字段之前先检查一遍information_schema.innodb_trx确认没有长时间未提交的事务再执行DDL就是为了避免这个坑。2.3 自增锁特殊表级锁的隐性影响自增锁是我在很多文章里被一笔带过但实际经常踩坑的点。AUTO-INC Lock是InnoDB为保证自增主键连续不重复而设的一种特殊表级锁它在INSERT语句执行时获取但注意它不在事务提交时释放而是在INSERT语句结束后立即释放。这里牵扯出一个重要参数innodb_autoinc_lock_mode。模式值行为适用场景0传统表级自增锁语句结束释放兼容旧版本1简单插入用轻量互斥量批量插入用表级锁MySQL 5.7及以前默认2全部使用互斥量不锁表MySQL 8.0默认为什么MySQL 8.0敢把默认值和主从复制配合起来因为8.0要求binlog格式为ROW批量插入时自增值可以不连续但行级binlog能保证主从数据一致。如果还在用MySQL 5.7且binlog是STATEMENT格式把自增锁开到模式2批量插入产生的自增ID缺口会被同步到从库极端情况下主从表数据对不上。自增锁还有一个容易产生误解的地方事务回滚不会回收已经分配的自增值所以自增主键出现空洞是正常现象。很多人看到主键不连续就觉得数据库“出问题”了其实这是设计上的取舍因为加锁分配ID再去判断“这个ID回没回滚”的成本远超空洞带来的影响。3. 行级锁InnoDB并发控制的核心3.1 三种行锁类型记录锁、间隙锁、临键锁InnoDB行锁真正的实现不是“锁一行记录”这么简单它实际包含三种形态。记录锁Record Lock只锁索引记录本身在唯一索引等值查询命中记录时生效。间隙锁Gap Lock锁住两条索引记录之间的间隙让别的事务无法在这个间隙插入新记录。临键锁Next-Key Lock记录锁加间隙锁的组合默认可重复读隔离级别下InnoDB加锁的基本单位就是临键锁。为什么需要间隙锁根本原因是解决幻读。可重复读隔离级别要求事务内多次查询结果一致如果只锁住已有记录另一个事务在间隙里插入一条新记录当前事务再查就会多出一行这就是幻读。间隙锁就是不让别人插入这个间隙。举个例子表里有id为1、5、10的三条记录事务A执行SELECT * FROM t WHERE id 3 AND id 9 FOR UPDATE;在可重复读隔离级别下InnoDB不仅会锁住id5这一行的记录还会锁住(1,5]和(5,10]这些范围内的间隙和记录防止其他事务插入id4、id6这样的值。于是你会看到明明只更新了两条记录锁范围却比预想大得多。这个行为既是保护一致性的盾牌也是并发性能的杀手。间隙锁之间是兼容的但间隙锁和插入意向锁冲突导致大量并发插入被串行化。所以我在设计阶段就会评估业务真的需要可重复读吗如果只需要读已提交切换到RC隔离级别间隙锁的行为会大幅收敛并发插入性能明显提升。3.2 两阶段锁协议与死锁的真实成因InnoDB遵循两阶段锁协议事务执行过程中按需加锁但所有锁都等到事务提交或回滚时才统一释放。这句话是理解死锁的钥匙。死锁的经典场景是两个事务按相反顺序更新两张表-- 事务A UPDATE t1 SET namea WHERE id1; UPDATE t2 SET nameb WHERE id2; -- 事务B UPDATE t2 SET namec WHERE id2; UPDATE t1 SET named WHERE id1;事务A先拿t1的锁事务B先拿t2的锁然后互相等对方释放手里的锁于是死锁。InnoDB默认开启死锁检测会主动选择回滚undo log较少的事务让另一个事务继续执行。所以应用拿到的报错往往是Deadlock found when trying to get lock。处理死锁的常规套路是固定访问顺序所有业务对多张表的更新统一按相同顺序执行就不会出现交叉等待。另一个常规手段是缩小事务把加锁时间压缩到最短。我见过很多死锁不是SQL本身设计错了而是事务里塞了大量无关查询、远程调用把锁的持有时间拉得太长冲突概率自然暴涨。3.3 行锁与索引的绑定关系行级锁名字叫“行锁”但它真正锁的是索引记录不是无索引的堆表记录。这一点决定了锁范围和索引设计强相关。如果UPDATE或DELETE的WHERE条件能命中唯一索引InnoDB只需要锁那一条索引记录。如果是范围条件命中了普通索引除了锁二级索引记录还要回表锁对应的主键索引记录。最麻烦的是WHERE条件没走索引InnoDB只能对聚簇索引做全表扫描然后把扫描过程中碰到的所有记录全加锁。从现象上看就是“行锁升级成了表锁”其实并不是真升级而是你让它扫了全表它只能给每一行都加锁。这里有一个真实的高频事故某个表用字符串字段做查询条件却忘记加索引只是隐约记得“数据量不大”。线上并发稍微一高所有更新都在抢同一批锁锁等待堆积数据库连接数飙满。排查时用EXPLAIN看执行计划typeALL一眼就知道问题在哪。所以行锁优化的底层逻辑十分朴素让WHERE条件尽量走到索引锁的自然范围就小。索引不只是加速查询更是锁数量的“减震器”。4. 实操锁问题排查与死锁处理4.1 常用排查命令与视图遇到锁相关问题我有一套固定的查证顺序从进程列表开始一层层缩小范围。第一板斧是看进程列表SHOW PROCESSLIST;重点观察State列如果出现“Waiting for table metadata lock”多半是DDL被长事务挡住如果出现“Waiting for lock wait timeout”或者大量“Updating”就要往行锁方向查。第二板斧是看事务和锁等待视图。MySQL 8.0里数据锁信息可以从performance_schema直接查SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits;MySQL 5.7及更早版本则用information_schema下的innodb_trx、innodb_lock_waits、innodb_locks三张表配合。其中innodb_trx可以看到当前所有未提交事务、运行时间和等待状态是定位长事务的头号工具。第三板斧是分析最近一次死锁现场SHOW ENGINE INNODB STATUS;把输出定位到LATEST DETECTED DEADLOCK段落里面会明确列出两个事务各自执行的最后一条SQL、持有的锁和等待的锁以及被回滚的事务。这段日志是调试死锁最重要的现场还原材料。4.2 一个完整的锁等待排查实例我在模拟项目X里处理过一个典型的行锁死锁场景是订单状态更新和库存扣减的表都做了拆分但不同服务写入顺序不一致。服务A的操作顺序是先更新订单状态再扣库存。 服务B的操作顺序是先扣库存再更新订单状态。当服务A的订单事务和服务B的库存事务同时跑到一半就出现互相锁等待。看SHOW PROCESSLIST只能看到两个会话都处于“Updating”看不到根因。真正定位靠information_schema.innodb_trxSELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;两条未提交事务同时存在启动时间已经几十秒trx_state都是RUNNING。再查data_lock_waits能看到哪个事务在等待哪个事务的锁。锁等待关系一旦理清死锁循环就清楚了。当时我的处理是先让其中一个事务回滚恢复业务然后在代码层统一所有服务的更新顺序强制“先扣库存再更新订单”同时在事务里去掉无关的查询操作。上线后再没出现过同类死锁。注意不要一看到锁等待就kill事务。虽然kill是快速止血手段但被回滚的事务如果是一次大批量更新回滚本身也会消耗大量资源。正确思路是先定位顺序问题再决定是kill还是让它执行完。4.3 锁等待超时参数与兜底策略InnoDB有个参数控制行锁等待的最长秒数innodb_lock_wait_timeout默认是50秒。业务等锁超过这个时间会直接报Lock wait timeout exceeded错误。有些团队为了快速止损把超时时间调低到3秒或5秒。这样锁冲突快速报错后应用重试整体体验反而比傻等50秒好。但调低也要结合业务如果事务本身执行时间就要2秒设成3秒会让正常排队的事务频繁失败导致大量重试反而放大压力。兜底策略还要想清楚死锁检测开关。innodb_deadlock_detect默认开启InnoDB会在每次加锁时检测是否有死锁循环。高并发热点行更新场景下死锁检测本身会成为性能瓶颈因为检测成本与等待锁的线程数呈正相关。某些团队对热点账户扣减这类业务会关闭死锁检测同时把锁等待超时调到一个较短值让超时回滚代替死锁检测。这个方案能用但务必要先评估业务是否接受超时回滚这种结果不建议在通用场景照搬。5. 设计层面几个值得记住的优化思路5.1 事务做小锁天然就少两阶段锁协议决定了锁释放的唯一时机是事务结束。所以事务代码里每多执行一条无关SQL锁的累计持有时间就多一秒其他事务排队等待的概率就大一分。我经常跟业务同学强调一件事远程调用、外部HTTP请求、消息队列发送绝对不要放到数据库事务里。在一次线上故障里服务端在事务里调用了第三方支付接口网络波动导致接口卡了20秒数据库侧的锁就跟着握了20秒后面所有相关更新全部排队。把远程调用挪到事务外后锁冲突立刻降了一个量级。5.2 让查询走索引是行锁的命脉行锁锁的是索引记录这意味着索引既是加速查询的工具也是限定锁范围的边界。一个没有索引的更新条件会让InnoDB扫描聚簇索引并在每一行上加锁行为上等同于一次全表写锁。所以设计阶段的索引评审不能只看SELECT查询还要看UPDATE、DELETE的WHERE条件。我一般在模型评审时拿一条核心更新SQL直接EXPLAIN看type范围低于range级别就要求必须加索引。这不只是为了查询速度更是为了锁的精准度。5.3 隔离级别的选择直接影响锁范围默认的可重复读隔离级别下InnoDB用临键锁防止幻读这也意味着范围查询会锁住一大片间隙。如果业务可以接受读已提交切换到RC级别后间隙锁基本退出舞台锁冲突大幅减少。但切换隔离级别不是无脑优化。RC级别在binlog为STATEMENT格式时无法保证主从一致的语义所以MySQL 8.0强制要求ROW格式才能使用RC。对于新项目我建议直接采用RC加上行级binlog配合应用层幂等设计多数情况下比RR更容易预测。当然如果有明确的幻读防护需求还是得留在RR下并接受它的锁代价。5.4 热点行更新的特殊处理最后说一个所有高并发系统都会撞上的墙热点行更新。典型场景是秒杀、计数器、库存扣减大量并发都指向同一行记录。即使行锁粒度已经最细同一行也只能有一个写者排队是必然的。面对热点行调整锁参数只是杯水车薪核心思路是分流。常见手段有把库存拆到多个子记录上先随机选一个子账户扣减或者把更新操作改成对一张独立流水表的追加写入异步汇总最终数据。我曾在模拟项目X里处理过一个商品库存热点问题当时把所有请求压到同一行库存字段上高峰期锁等待日志刷屏。后来把库存拆成几十个子槽位每次扣减随机选槽位锁冲突直接消失最终一致性通过定时任务汇总余额。这种设计不改变锁机制但能从源头上绕开“同一把锁”。从架构层面看这往往比任何数据库参数调整都更有效。6. 排障习惯与一点个人心得处理锁问题这几年我逐渐养成一个习惯先看事务再看SQL最后才看参数。事务列表告诉你是谁一直在占着锁执行计划告诉你的SQL是不是扫了全表参数调整只在这两者都确认没问题时才碰。顺序反了很容易出现一种尴尬局面——参数调了好几个锁冲突却毫无改善因为根因根本不在参数上。另一个体会是锁问题的“最佳解法”往往不在数据库层。绝大多数死锁和锁等待拆到根子上就是事务太大、更新顺序不统一、SQL没走索引这三类。把这些在设计评审阶段解决掉比事后在线上分析锁等待要划算得多。最后分享一个看起来不起眼但很实用的小技巧上线核心SQL前先在测试环境用两个并发会话模拟一下锁行为或者直接开启general log观察实际加锁语句。用SHOW ENGINE INNODB STATUS看一眼加锁范围能提前发现很多“看起来没问题、一并发就出事”的隐患。锁这个东西晚发现不如早发现早发现不如根本不让它发生。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑