ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL锁机制全解析:从全局锁到行锁,详解死锁排查与实战优化

MySQL锁机制全解析:从全局锁到行锁,详解死锁排查与实战优化 昨天半夜接到同事电话说线上一个核心接口的耗时突然从几十毫秒飙到十秒以上数据库连接池被打满一堆请求排队。登录到数据库一查SHOW PROCESSLIST里几十个线程都卡在Waiting for table metadata lock上源头是一个跑了好几分钟没结束的ALTER TABLE。这种场面凡是碰过 MySQL 生产环境的人应该都不陌生——表面上看是 DDL 惹的祸本质上全是锁的问题。MySQL 的锁机制是每个做后端开发、数据库运维的人迟早要正面刚的东西。你用SELECT ... FOR UPDATE扣库存用UPDATE改订单状态用INSERT ... ON DUPLICATE KEY UPDATE做幂等背后全部牵扯到锁的获取、持有和释放。这篇文章我打算把 MySQL 里最常见的几类锁彻底讲透从全局锁、表级锁到 InnoDB 的行锁再到分布式系统里大家都会聊到的乐观锁和悲观锁配合我自己踩过的一些坑直接当作实战笔记看就行。1. 锁的必要性并发写入时都发生了什么1.1 两个连接同时改一条记录的后果丢失更新与脏读先看一个最简单的场景。有一张商品库存表stock字段存剩余库存。两个用户同时下单各自执行了这样一条 SQLUPDATE product SET stock stock - 1 WHERE id 100;如果没有锁结果会是什么两个事务都读到stock 10各自在内存里减一然后都写回9。但正确的业务期望应该是8。最后商品明明卖出了两件数据库里却只少了 1 件——这就是经典的丢失更新问题。再延伸一点如果一个事务读到另一个事务尚未提交的中间数据然后基于这个脏数据做了一堆业务判断这就是脏读。丢失更新和脏读都是并发事务没有隔离导致的。MySQL 解决这个问题的核心手段就是锁配合事务的隔离级别保证多个会话在操作同一份数据时彼此之间是按规则排队的。1.2 悲观锁与乐观锁DB锁和版本号各自的主场聊 MySQL 锁之前必须先分清楚两个流派悲观锁和乐观锁。悲观锁的思路是“我改数据之前先把这条记录锁住防止别人动它”。数据库的行锁、表锁本质上都是悲观锁。在 MySQL 里最常见的就是SELECT ... FOR UPDATE执行这条语句时InnoDB 会对命中的行加排他锁直到当前事务提交或回滚才释放。其他事务想改这些行只能阻塞等待。乐观锁的思路是“我不提前锁更新的时候比对一下数据版本”。常见做法是在表里加一个version字段更新语句写成UPDATE product SET stock stock - 1, version version 1 WHERE id 100 AND version 8;执行后看影响行数如果为 0说明版本对不上有人抢先改了业务层再重试或提示失败。这两种流派没有绝对的好坏取舍点在于冲突概率和并发量。冲突不频繁、重试成本低的场景乐观锁很舒服库存扣减这类冲突极高、不允许失败重试的核心链路悲观锁更直接。我第一次做秒杀系统时图省事用乐观锁结果高并发下大量请求在版本比对处撞车重试逻辑又写得不够健壮最后被迫改回FOR UPDATE。后来我总结了一个经验不确定场景下先悲观锁保底再在热点路径上做拆分优化。2. 三种锁粒度全局锁、表级锁、行级锁各自管到什么范围2.1 全局锁备库一致性备份时为什么非用不可全局锁是 MySQL 里范围最大的一种锁由FLUSH TABLES WITH READ LOCK简称 FTWRL触发。执行后整个实例的所有表都变成只读状态任何写操作都会被阻塞直到执行UNLOCK TABLES手动释放。你可能想问MySQL 8.0 不是有mysqldump --single-transaction吗为什么还要用全局锁--single-transaction依赖 InnoDB 的 MVCC可以做到不加锁备份但前提是所有表都是 InnoDB 引擎。如果库里还有 MyISAM 表或者你的备份工具语义要求完全一致的快照点FTWRL 依然是兜底方案。全局锁实际使用频率不高但涉及主从一致性初始化、全库只读维护这类操作时它是无法绕开的概念。全局锁最需要注意的地方是它锁的是整个实例一旦在业务高峰期误执行所有写入瞬间全挂接口报错率直接拉满。我见过有人把FLUSH TABLES WITH READ LOCK和普通的LOCK TABLES混为一谈结果在生产库上执行后运维监控炸了一片。2.2 表级锁LOCK TABLES和MDL锁的恩怨表级锁分两种一种是你主动加的LOCK TABLES t READ/WRITE另一种是 MySQL 自动维护的元数据锁MDL 锁。主动加表锁这种方式在 InnoDB 时代已经被行锁替代得差不多了现在我自己基本只在 MyISAM 表或某些特殊维护场景才会用到。真正需要重视的是 MDL 锁。MDL 锁是 MySQL 5.6 以后引入的目的是保护表结构不被并发修改。任何一条 DML 语句增删改查执行前都需要先获取元数据锁DDL 语句ALTER TABLE、DROP TABLE则需要获取排他的 MDL 锁。文章开头提到的线上事故就是这种场景。一个长事务拿着ALTER TABLE在跑而这条 DDL 需要等待之前所有持有 MDL 读锁的事务结束。结果就是DDL 排在最前面后续所有新查询拿不到 MDL 读锁全部阻塞在Waiting for table metadata lock。这个经典的“DDL 排头兵阻塞全表”问题核心原因就是 MDL 锁的排队机制。排查 MDL 锁有个很实用的方法SELECT * FROM performance_schema.metadata_locks;这张表会列出所有会话当前持有的和等待中的 MDL 锁。找到阻塞源头的事务后评估是否能安全KILL如果可以就直接断开连接让 DDL 跑完。2.3 行级锁InnoDB把粒度细到极致的原因行级锁是 InnoDB 区别于 MyISAM 的核心特性之一。它的好处是并发度高两个事务只要修改的不是同一行互不干扰代价是锁的管理更复杂内存开销比表锁大。InnoDB 的行锁本质上不是直接锁“行记录”而是锁在索引项上。这句话值得反复琢磨因为它解释了很多实际问题为什么UPDATE语句条件列没走索引时行锁可能升级成表锁导致并发性能雪崩。后面我专门用一节来细讲这个坑。行级锁的加锁方式有两种LOCK IN SHARE MODE8.0 里等价写法是FOR SHARE加共享锁多个事务可以同时持有FOR UPDATE加排他锁只能一个事务持有。共享锁之间兼容排他锁和任何锁都不兼容这是理解锁冲突的基础。InnoDB 行级锁的兼容性关系用一张表就能说清楚锁类型共享锁S排他锁X共享锁S兼容冲突排他锁X冲突冲突3. InnoDB行锁的三种形态Record Lock、Gap Lock、Next-Key Lock3.1 Record Lock命中唯一索引时的精确锁定Record Lock 就是记录锁锁的是索引项本身。当WHERE条件命中的是唯一索引或主键时InnoDB 通常只需要加记录锁不需要锁间隙。举个例子SELECT * FROM orders WHERE order_id 1024 FOR UPDATE;order_id是主键那么这条语句只锁主键值等于 1024 的那一行。其他人的插入操作只要主键不是 1024完全不受影响。这种锁的粒度最细并发度最高也是我们写 SQL 时最希望达到的状态。要注意的是记录锁要求条件列必须是唯一索引并且查询能精确定位到单条记录。如果条件是WHERE status 1这样的普通字段即使结果只有一条InnoDB 也无法确定扫描范围内有没有其他可能插入的间隙加锁范围就会扩大。3.2 Gap Lock范围查询带来的间隙锁以及一次经典的insert阻塞Gap Lock 锁的是索引记录之间的“空隙”防止其他事务在这个间隙里插入新数据。它主要出现在可重复读隔离级别下范围查询或者条件列不是唯一索引时InnoDB 为了保证数据的一致性读和避免幻读会顺手把间隙也锁住。有一个非常经典的面试题两个事务同时插入同一张表第一个事务执行了SELECT * FROM students WHERE age BETWEEN 20 AND 30 FOR UPDATE;如果age上没有索引这个查询就会在扫描范围内加大量间隙锁。此时另一个事务尝试插入一条age 25的记录会发现阻塞在那里等第一个事务提交才继续。原因不是记录本身被锁而是 20 到 30 之间的空隙被 Gap Lock 堵住了。Gap Lock 在绝大多数业务场景下是“隐性副作用”它不直接报错但会降低并发插入的吞吐量。排查这类问题时SHOW ENGINE INNODB STATUS里经常能看到LOCK_MODE: X, INSERT_INTENTION等待的记录这就是另一个事务的插入意图锁在等 Gap Lock 释放。3.3 Next-Key Lock可重复读隔离级别下的真实加锁范围Next-Key Lock 可以理解为“记录锁 间隙锁”的组合锁的范围是“左开右闭”的区间。InnoDB 默认的可重复读隔离级别就是通过 Next-Key Lock 来实现幻读拦截的。举个具体例子一张表的主键是 1、5、10执行SELECT * FROM t WHERE id 3 AND id 9 FOR UPDATE;实际加锁的范围不只是id 5这条记录而是(1, 5]和(5, 10]两个区间。这意味着即使现在表里没有id 7这条记录其他事务试图插入id 7时也会被阻塞因为插入位置落在被锁的间隙里。这就是为什么 InnoDB 能在可重复读下防住幻读——不让任何“新记录”在查询范围内出现。这里顺便说一句很多人以为把隔离级别改成“读已提交”就能完全消除 Gap Lock这个说法不完全准确。在读已提交隔离级别下InnoDB 确实会禁用纯粹的 Gap Lock但在UPDATE和DELETE语句的执行过程中它仍可能因为需要变更或删除数据而加短暂的间隙锁。所以把隔离级别当万能药之前最好先想清楚自己要解决的问题是什么。锁类型锁的范围触发典型场景对插入操作的影响Record Lock单个索引记录主键/唯一索引等值查询只阻止修改同一行Gap Lock索引记录之间的间隙范围查询、普通索引等值查询阻止在间隙内插入Next-Key Lock记录 前向间隙可重复读下的范围查询记录和区间均受保护4. 死锁从一次线上卡死到一个锁等待节点的全程排查4.1 死锁的四个必要条件对照真实案例逐条打勾死锁是指两个或多个事务互相持有对方需要的锁谁都不肯放手导致彼此永远阻塞。形成死锁必须同时满足四个条件互斥至少有一个资源被排他锁占用占有且等待一个事务持有锁的同时还在等待另一个锁不可剥夺已获得的锁不会被强制抢走只能主动释放循环等待每个事务都在等另一个事务释放锁形成一个环。说一个我之前遇到过的真实案例。两张表orders和order_items业务逻辑是先更新订单主表再更新订单明细表。事务 A 的操作顺序是“更新订单 100 → 更新订单明细 200”事务 B 的操作顺序正好相反“更新订单明细 200 → 更新订单 100”。两个事务同时提交A 拿到了订单 100 的锁B 拿到了明细 200 的锁然后 A 去等明细 200B 去等订单 100循环等待形成死锁瞬间触发。InnoDB 会检测到死锁并选择回滚其中一个事务一般回滚 undo 日志量较小的那个或者成本较低的那个另一个事务继续执行。我在排查时对照真实日志逐条打勾确认发现四个条件在这条链路上全部成立。这个案例恰好也说明了一个非常实用的结论死锁很多时候不是锁本身的问题而是业务代码里操作多个对象的顺序不一致导致的。4.2 SHOW ENGINE INNODB STATUS里的死锁现场读日志的先后顺序死锁发生后MySQL 会往错误日志里写入一段“死锁现场信息”最直接的获取方式是SHOW ENGINE INNODB STATUS;输出内容特别长重点看末尾的LATEST DETECTED DEADLOCK段落。里面的关键信息按顺序读第一行会列出发生死锁的时间点然后是“事务 A”和“事务 B”各自执行的最后一条 SQL、持有锁的列表、等待锁的列表。举一个典型的输出片段模拟*** (1) TRANSACTION: TRANSACTION 381516, ACTIVE 3 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY lock_mode X locks rec but not gap waiting Record lock, heap no 3 PHYSICAL RECORD: ... *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY lock_mode X locks rec but not gap这段日志表达的意思很直白事务 1 正在等待一个lock_mode X的记录锁事务 2 持有这个记录锁而事务 2 自身又在等待别的锁。从日志里能直接看到双方等待的锁对象和对应的 SQL 语句足够还原死锁链路了。排查死锁我一般不看日志的全文而是先把两个事务的 SQL 单独拎出来放到实际数据上复现一次确认锁的获取顺序。MySQL 8.0 之后performance_schema.data_lock_waits这张表也能提供阻塞等待的实时视图适合死锁发生前进行预警和定位。4.3 破解死锁的三板斧顺序、粒度、时间死锁的破解思路不是“让死锁不发生”而是“降低死锁发生的概率以及让它快速暴露、快速恢复”。三年生产环境踩过来我总结了三板斧。第一板斧统一操作顺序。所有事务里涉及多张表、多行数据时都按固定的顺序去加锁。比如先处理订单主表再处理明细表全公司统一。这样循环等待的必要条件就被破坏了。上面那个真实的死锁案例就是用这招解决的约定所有事务先锁orders再锁order_items。第二板斧缩小锁粒度。把大事务拆小尽量让锁只在真正需要的那几行上持有。比如批量更新一万条数据就可以分段提交每 500 条一个事务。锁持有时间短了两个事务碰撞的窗口就小很多。第三板斧死锁后的重试。InnoDB 检测到死锁后会回滚其中一方的事务并向客户端返回错误码1213ER_LOCK_DEADLOCK。我在业务代码里会捕获这个错误码做有限次数的重试比如最多重试三次每次间隔随机退避。这些都有现成的客户端库可以配置但对新人来说知道“先判断错误码再重试”远比盲目重试安全。5. 锁与SQL设计这些年从生产环境摸出来的几条实操经验5.1 条件列必须走索引没索引的行锁会变成表锁这一点是行锁使用中最大的坑。InnoDB 的行锁锁在索引项上如果UPDATE的WHERE条件列没有索引InnoDB 就需要全表扫描才能找到要更新的行。扫描过程中它会对所有扫过的记录加锁效果上等同于锁了整张表。我处理过一起线上事故某张业务表一个status字段忘了建索引业务方在凌晨定时任务里执行UPDATE user_task SET status 2 WHERE status 1;结果整张表的所有行都被锁住白天的业务流量一进来更新全部阻塞数据库连接数直接打满。事后排查就是条件列没索引导致行锁升级为全表范围的锁。解决思路也很明确所有走UPDATE、DELETE的筛选条件必须确认索引可用用EXPLAIN看执行计划重点看possible_keys和key字段。另外大批量更新时提前评估影响行数不要一次性更新几十万行既拖垮日志又拖垮锁。5.2 事务短一点再短一点锁持有时间才是并发上限的瓶颈很多新手以为减少锁冲突要靠“少加锁”其实更关键的是“缩短锁持有时间”。锁从加上的那一刻起到事务提交或回滚才释放中间执行的所有 SQL、业务逻辑、甚至远程调用全都在持有锁。事务越长锁被占用的时间越长别的请求排队的时间就越久。具体优化方向有三个事务里只放必要的 SQL把无关的查询、计算移到事务外面避免在事务中做远程 RPC 调用、发消息、等待外部响应热点记录比如爆款商品的库存行的更新尽量简化事务逻辑甚至可以拆行、拆分库存减少单行锁的争用。我见过有人为了“保险”在一个事务里执行了十几条 SQL 外加一次 Redis 调用整个事务跑了一百多毫秒。扣库存这种高频操作事务一百毫秒意味着同一行同一秒最多支持大约十次事务并发稍稍一高就积压这还是不谈锁等待的情况。把事务压缩到只包含一次库存操作和必要的记录变更后吞吐直接翻了几倍。5.3 SELECT FOR UPDATE不是银弹先看清楚要保护什么SELECT ... FOR UPDATE是悲观锁的典型用法但它用不好很容易造成大面积锁等待。我见过不少人一遇到并发问题就随手加FOR UPDATE结果锁的范围比自己想象的大得多。一个典型的案例查询某用户未完成订单时用了FOR UPDATE。这个查询条件走的是user_id的非唯一索引InnoDB 不仅会给匹配到的记录加锁还会在索引区间上加 Gap Lock其他用户同一范围内的插入、更新都会受到影响。如果只是想防止重复支付更好的做法是锁唯一存在的“订单记录”而不是锁一个范围。另外要区分锁的目的。如果是防止超卖这种“库存更新”类场景直接UPDATE ... SET stock stock - 1 WHERE stock 0也能达到目的不需要先SELECT FOR UPDATE再更新。一条原子 UPDATE 自带排他锁代码更简洁锁的持有时间也更短。这个写法用好了能省掉相当一部分显式加锁的复杂度。5.4 查看锁等待的几个常用入口从SHOW PROCESSLIST到performance_schema遇到线上锁等待第一反应要能想到哪些命令能快速定位问题。下面是我常用的排查入口按使用频率排SHOW PROCESSLIST;最基础能看到所有连接当前状态。重点关注State字段里的Waiting for table metadata lock、Waiting for lock to be granted这类关键字以及Time字段的长耗时事务。-- 查看当前运行中的事务 SELECT * FROM information_schema.INNODB_TRX\GINNODB_TRX能直接看到未提交事务的执行时间、状态、是否有锁等待。结合SHOW PROCESSLIST里的trx_mysql_thread_id能精确对应到会话。-- MySQL 8.0 通过 data_lock_waits 查看锁等待链路 SELECT * FROM performance_schema.data_lock_waits\Gdata_lock_waits是 MySQL 8.0 之后比较推荐的表。它会列出每个等待锁的事务对应的BLOCKING_TRX_ID直接定位到阻塞源头。定位到源头事务后就可以评估是否要 KILL 掉那个长事务或者等它自然结束。innodb_lock_wait_timeout参数控制的是等待锁的超时时间默认 50 秒。生产环境我一般把它调低一些比如 5 秒或 10 秒宁可让请求快速失败进入重试也不让一堆请求在数据库里憋几十秒。这个参数不是解决锁冲突而是控制失控等待的时间成本。最后想说的是MySQL 锁学起来最容易出现的误区就是死记锁类型而不理解“为什么”。真到了现场你能拿到的只有一堆运行中的事务、等待状态和死锁日志能不能根据日志还原出加锁链路取决于你对索引结构、隔离级别和事务边界的理解程度。我处理过的每一起锁事故最后复盘时都发现不是 MySQL 的锁设计不够好而是 SQL 和事务边界没设计好。把 SQL 的执行计划检查清楚把事务控制在合理的范围内再复杂的锁机制也不会成为瓶颈。
返回列表