多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MySQL锁机制实战:从行锁分类到死锁排查全解析

MySQL锁机制实战:从行锁分类到死锁排查全解析 做后端开发这些年MySQL 锁相关的坑没少踩。平时写 SQL 感觉不到锁的存在一到并发量上来或者压测期间线上就会出现接口偶发抖动日志里不是Lock wait timeout exceeded就是Deadlock found。这时候回头查才知道锁不是背几个概念就能搞定的东西得从 MySQL 锁分类到加锁机制系统理解一遍。这篇文章沿着 MySQL 锁分类这条线讲起把全局锁、表锁、行锁、共享锁、排他锁、悲观锁、乐观锁这些概念放回真实并发场景里接着说 InnoDB 是怎么给一条 SQL 加锁的最后用实验还原锁等待和死锁的完整过程。内容偏实战适合刚接触 MySQL 不久的同学也适合已经写了几年业务代码、想搞清楚锁等待日志和死锁报告的后端开发。1. 锁分类速览先分清行锁、表锁与元数据锁1.1 全局锁和表锁用得少但坑不小全局锁是最容易理解的一种锁锁定的是整个数据库实例。执行FLUSH TABLES WITH READ LOCK之后所有库表都变成只读状态常规的增删改都会被阻塞。这个操作在早期常用来做备份或数据导出保证拿到一份一致性的快照。不过现在主流备份方式基本用 InnoDB 的mysqldump --single-transaction配合 MVCC 来做全局锁用得少了但遇到特殊情况比如要做只读维护操作时它仍然是最保险的手段。使用全局锁要特别注意它是会话级的一旦客户端断开连接锁会隐式释放。如果靠人工去UNLOCK TABLES要确保操作在同一会话里执行否则会出现“以为锁住了其实早就释放了”的尴尬情况。表锁就常见多了。LOCK TABLES ... READ/WRITE是我们主动加的表锁在 MyISAM 时代很常用因为存储引擎不支持行锁任何写操作都要整表加锁。到了 InnoDB 时代主动用表锁的场景很少了因为行锁已经把并发粒度降到了行级别再用表锁等于主动放弃并发能力。需要注意一种特殊的表锁MDLMetadata Lock元数据锁。它不需要手动加MySQL 自动维护。当一条 SQL 访问某张表时会获取表的 MDL 读锁当执行ALTER TABLE想修改表结构时需要获取 MDL 写锁。MDL 写锁和读锁互斥所以一个事务如果不结束一直占着表的 MDL 读锁后边的 DDL 就会一直卡住这是线上最常见的“表被锁住”案例实际上往往是长事务卡住了 DDL。表锁和行锁的差异我简单整理了一个对比方便大家快速回忆维度表锁行锁粒度整张表索引记录及其间隙并发度低读写互斥高不同行可并发开销小加锁快大需要定位索引记录死锁概率低相对高适用场景批量维护、MyISAM 表InnoDB 在线业务1.2 行锁InnoDB 并发控制的根基行锁是 InnoDB 区别于 MyISAM 的核心能力。InnoDB 的行锁不是锁在“数据行”这个抽象概念上而是锁在索引记录上。这里面有两个关键点第一只有通过索引定位记录时行锁才有意义第二如果一个查询无法走索引InnoDB 只能全表扫描这时候会扫描聚簇索引的所有记录给扫描到的记录逐条加锁效果上等同于锁表但底层仍然是行锁机制。这也是为什么我常跟团队说一条UPDATE语句若不命中索引线上就是事故。InnoDB 行锁按锁定位范围又分成三种Record Lock记录锁锁的是索引上的一条具体记录。Gap Lock间隙锁锁的是两个索引记录之间的开区间目的是防止其他事务在这个间隙插入新记录。Next-Key Lock临键锁是“记录锁 间隙锁”的组合锁住的是当前记录及其前面的间隙。在可重复读RR隔离级别下InnoDB 默认使用 Next-Key Lock这是它解决幻读问题的主要手段。等值查询时如果命中唯一索引Next-Key Lock 会退化成 Record Lock因为已经能唯一确定一条记录不需要锁间隙如果命中普通索引或者没有命中任何记录则保留 Gap Lock 或 Next-Key Lock。1.3 共享锁与排他锁读锁、写锁的兼容关系按锁的兼容性InnoDB 又把行锁分为共享锁S Lock和排他锁X Lock。共享锁也叫读锁多个事务可以同时持有同一行数据的共享锁排他锁也叫写锁一旦某个事务持有排他锁其他事务无论是读锁还是写锁都必须等待。加锁语句对应关系SELECT ... LOCK IN SHARE MODE或 MySQL 8.0 的SELECT ... FOR SHARE加 S 锁。SELECT ... FOR UPDATE加 X 锁。UPDATE、DELETE默认加 X 锁。INSERT情况稍特殊由隐式锁逻辑处理但可以理解为需要 X 锁权限。共享锁与共享锁之间是兼容的这好理解比如两个人同时读同一条记录互不影响。排他锁和任何锁都不兼容这保证了同一行数据同一时刻只有一个事务能写。用生活例子来看共享锁就是公交车座位可以坐很多人排他锁就像单人维修工位一个人进去检修其他人必须等在外面。这里必须提一下意向锁。意向锁是表级别的锁分意向共享锁IS和意向排他锁IX它本身不阻塞任何行级操作主要用来快速判断一个事务是否可以对整张表加锁。假设事务 A 已经锁住了 t 表的某一行事务 B 想给 t 表加表级排他锁如果没有意向锁机制数据库就必须扫描所有行锁来判断是否有冲突成本太高。有了意向锁B 只需要看 t 表上有没有 IX 锁有就说明存在行级排他锁直接等待。意向锁之间互相兼容但和表级 S/X 锁互斥。另外还有自增锁AUTO-INC Lock和插入意向锁它们本质上是特定场景下的锁。自增锁不细讲了后面在参数调整部分单独说插入意向锁属于间隙锁的一种特殊形式多个事务想往同一个间隙插入数据时只要插入位置不重叠可以互相不阻塞。2. 加锁机制的核心快照读和当前读2.1 两条路径决定了会不会加锁理解 InnoDB 加锁机制先得搞清楚一个前提并不是所有读操作都会加锁。InnoDB 把读操作分成两类。一类是快照读也就是普通的SELECT语句。它读的是该事务启动时生成的 Read View 可见版本走的是 MVCC 多版本控制链路不加锁也不会被别的写操作阻塞。这也是为什么在一个高并发业务里大多数SELECT性能很好因为压根不需要加锁排队。另一类是当前读也叫锁定读。SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE都属于当前读。这种读必须拿到最新已提交的数据所以不能走历史版本必须加锁防止其他事务同时修改。很多业务同学容易在这里出错以为SELECT查出来一条记录再基于这条记录去更新是安全的。实际上这中间存在时间差别的事务可能已经修改了数据。如果业务要求强一致就必须用当前读或者把两步操作合并成一条原子 SQL 来执行。从锁机制来看当前读才是加锁的主角快照读是 InnoDB 用来提升并发性能的关键设计。一读一锁两条路线分得很清楚。2.2 一条 UPDATE 在 InnoDB 里是怎么加锁的拿最常见的UPDATE语句举例。假设有下面这张表CREATE TABLE user_account ( id BIGINT PRIMARY KEY, user_name VARCHAR(50) NOT NULL UNIQUE, score INT NOT NULL, status TINYINT NOT NULL DEFAULT 0, KEY idx_score (score) ) ENGINEInnoDB;表里有几条数据id 分别是 1、2、3、5、8。现在执行以下语句UPDATE user_account SET score score 10 WHERE id 5;因为id是主键等值查询能唯一定位一条记录InnoDB 的加锁路径是这样通过主键索引直接定位到 id5 的记录。在聚簇索引上加 X 锁锁住这条索引记录。如果 update 涉及修改二级索引列比如把user_name改了还要处理二级索引的删除和插入步骤复杂一些这里只改score不涉及唯一索引变更所以只需要锁主键索引记录。整个过程看起来简单。但如果把条件换一下UPDATE user_account SET score score 10 WHERE score 100;假设idx_score上有多条 score100 的记录这条 SQL 会先通过普通索引定位到所有满足条件的二级索引记录给每一条二级索引记录加上 Next-Key Lock然后再回表锁住对应的聚簇索引记录。也就是说一次 UPDATE 可能锁住多行而且范围不局限于实际命中的记录还包括这些记录之间的间隙。更需要注意的是如果UPDATE的 WHERE 条件没有走任何索引比如WHERE status 1而 status 字段没有索引InnoDB 只能走聚簇索引全表扫描逐条给聚簇索引记录加锁。这种语句字面上只是更新几行实际效果等同锁了大半张表并发场景下基本就是事故。看锁范围我通常会把 SQL 拆成几个问题判断等值还是范围走唯一索引还是普通索引隔离级别是 RC 还是 RR这三个条件直接影响加锁范围。2.3 隔离级别如何影响锁范围MySQL 有四个事务隔离级别读未提交READ UNCOMMITTED、读已提交READ COMMITTED、可重复读REPEATABLE READ、串行化SERIALIZABLE。InnoDB 默认是 REPEATABLE READ也就是 RR。锁范围和隔离级别强相关影响最大的是 RC 和 RR。RC 隔离级别下InnoDB 只使用记录锁不使用间隙锁。这意味着当前读加锁范围小But 也会引入幻读事务中两次查询同样的条件因为其他事务插入了新行第二次结果集变多了。很多团队为了减少死锁会把隔离级别改成 RC这是可行的前提是业务能容忍幻读或者靠应用层逻辑去兜底。RR 隔离级别下当前读默认使用 Next-Key Lock既锁记录又锁间隙所以其他事务无法在锁定的区间内插入新记录从而解决幻读。代价是锁范围变大并发度下降死锁概率上升。无数死锁案例背后根源往往是 RR 下的间隙锁和记录锁之间发生了等待循环。READ COMMITTED 和 REPEATABLE READ 下加锁差异我列了一张简单对比场景RC 隔离级别RR 隔离级别等值命中唯一索引记录锁记录锁等值命中普通索引多条记录锁记录锁 间隙锁范围查询范围内记录锁Next-Key Lock查询未命中不加锁或记录锁Gap Lock幻读风险存在不存在SERIALIZABLE 级别下InnoDB 会把普通SELECT也变成当前读所有查询都要加锁并发性能极差基本没有业务会主动使用这里就不展开了。3. 实操亲手复现一次锁等待和死锁3.1 搭建实验环境与查看锁视图理解锁机制最好的办法是动手复现一次锁等待和死锁。我习惯用两个会话模拟两个并发事务。环境要求很简单一台本地 MySQL 就行版本推荐 8.0这样能直接用performance_schema.data_locks视图查看锁信息。先建一张测试表插入数据CREATE TABLE t_lock_demo ( id INT PRIMARY KEY, name VARCHAR(20), amount INT, KEY idx_amount (amount) ) ENGINEInnoDB; INSERT INTO t_lock_demo VALUES (1, A, 100), (2, B, 200), (3, C, 300);打开会话 A开启一个事务并更新一行-- 会话 A BEGIN; UPDATE t_lock_demo SET amount amount - 10 WHERE id 1;此时会话 A 持有 id1 这行的 X 锁。再打开会话 B执行同样的更新-- 会话 B BEGIN; UPDATE t_lock_demo SET amount amount - 10 WHERE id 1;可以发现会话 B 执行后一直卡住没有返回结果。这不是数据库坏了而是在等待会话 A 释放 id1 上的 X 锁。此时在第三个会话执行以下语句SELECT * FROM performance_schema.data_locks\Gdata_locks里会列出当前活跃的锁记录。关键列包括ENGINE锁事务对应的引擎。ENGINE_TRANSACTION_ID事务 ID。LOCK_TYPETABLE 还是 RECORD。LOCK_MODEX、S、IX、X,GAP、X,REC_NOT_GAP 等。LOCK_STATUSGRANTED已持有还是 WAITING等待中。LOCK_INDEX锁在哪个索引上PRIMARY 表示主键索引。看到 WAITING 状态就可以确认发生了锁等待。3.2 从两个会话看行锁阻塞过程把上面实验再做一步。让会话 A 回滚或者提交会话 B 的更新会立刻返回。这验证了行锁的基本逻辑同一行上排他锁互斥。接着试一个更典型的场景两个事务分别更新不同行不会互相阻塞。-- 会话 A BEGIN; UPDATE t_lock_demo SET amount amount - 10 WHERE id 1; -- 会话 B BEGIN; UPDATE t_lock_demo SET amount amount - 10 WHERE id 2;两个会话都能执行成功因为它们锁的是不同主键记录。InnoDB 行锁的这一特性正是它支撑高并发在线交易的关键。实际操作中如果锁等待时间超过innodb_lock_wait_timeout默认 50 秒MySQL 会主动回滚当前 SQL并报错ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction50 秒对在线业务来说非常长绝大多数业务都等不了这么久。我一般会把innodb_lock_wait_timeout调小一点比如 3 秒或 5 秒让失败的 SQL 快速返回而不是长时间占用连接池里的线程资源。查看是否有长事务占用了锁可以查performance_schema.data_locks配合sys.innodb_lock_waits视图。MySQL 8.0 里可以直接执行SELECT * FROM sys.innodb_lock_waits\G这条语句会输出阻塞者、等待者、等待时长等信息定位问题非常直观。3.3 死锁复现与日志分析锁等待只要等一段时间就结束了死锁则是事务之间互相持有对方需要的锁永远等下去。MySQL 的死锁检测线程发现死锁后会回滚其中一个事务通常回滚代价较小的一方并返回如下错误ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction复现死锁只需要两个事务交叉更新两行数据。表里已经有 id1 和 id2 两行按下面的顺序操作-- 会话 A BEGIN; UPDATE t_lock_demo SET amount amount - 10 WHERE id 1; -- 会话 B BEGIN; UPDATE t_lock_demo SET amount amount - 10 WHERE id 2; -- 会话 A 继续 UPDATE t_lock_demo SET amount amount - 10 WHERE id 2;此时会话 A 等待会话 B 释放 id2 的锁。然后会话 B 继续-- 会话 B 继续 UPDATE t_lock_demo SET amount amount - 10 WHERE id 1;会话 B 同样需要 id1 的锁而这个锁在会话 A 手里一瞬间就会触发死锁其中一方被回滚。真正排查死锁时光看报错没用要拿到死锁日志。执行SHOW ENGINE INNODB STATUS\G找到LATEST DETECTED DEADLOCK部分里面会列出两个事务分别执行的 SQL、持有的锁、等待的锁以及最后被回滚的事务。排查思路通常是这样先把涉及的两条 SQL 拎出来看它们之间是否存在循环等待再结合业务逻辑统一多个事务访问这些行数据时的加锁顺序比如都按 id 从大到小或者从小到大更新死锁概率就能大幅下降。4. 加锁机制背后的设计逻辑与业务避坑4.1 为什么行锁是锁在索引上的很多新手不理解为什么行锁不叫“数据行锁”而叫“索引记录锁”。这是因为 InnoDB 本身是索引组织表数据就是主键索引的叶子节点二级索引的叶子节点存储的是主键值。所谓定位一行数据本质上是定位一条聚簇索引记录。因此InnoDB 判断锁冲突时只要比较索引记录的物理位置即可不需要比较数据内容。这个设计带来的好处是锁信息可以精确关联到索引条目坏处就是没有索引的情况下无法精确定位单行只能扫描大量记录并加锁。这里没有“表级行锁”这种中间状态走全表扫描时就是逐行加锁效果上锁的范围接近整张表。明白了这一点业务上有一条非常实用的规则所有UPDATE、DELETE语句都必须考虑能否通过索引快速过滤出目标行。判断标准很直接执行EXPLAIN看type列是不是const、eq_ref、ref、range。如果出现ALL就说明没走到索引SQL 上线前就应该揪出来。4.2 事务、索引与锁范围的关系行锁并不是从 SQL 执行那一刻才存在而是从事务里第一条加锁语句开始一直保持到事务结束。事务是锁的边界COMMIT或ROLLBACK才会释放锁。这意味着一个事务无论多长只要中间有一条UPDATE锁住的行就会被一直占着其他事务只能等待。举一个我在某后台系统里遇到过的真实问题一个事务里先更新了订单表然后远程调用外部服务做了 3 秒的耗时操作再更新库存表。外部服务慢导致整个事务持续了几十秒期间订单表那行数据一直处于锁定状态用户重复提交订单时就会触发锁等待。后来改造很直接把外部调用移出事务让事务只保留必要的数据库更新操作锁等待问题立刻缓解。索引选择对锁范围的放大作用同样不可忽视。假设 WHERE 条件命中了普通索引idx_status但status字段只有两个值区分度很低那么一次更新可能锁住表中一半的记录。从执行计划看确实是用了索引可实际上锁范围和扫全表差不多。这种“索引失效等价于锁表”的案例我在线上排查时见过不止一次。4.3 高并发业务下的锁使用建议讲完了原理说说业务设计中最实用的几点。第一能用快照读就不要用当前读。统计报表、列表查询这类场景本来就允许一定时间内的数据延迟直接走普通SELECT就好没必要为了“更安全”去加锁白白牺牲并发。第二如果需要处理扣减库存、更新余额这类逻辑优先用原子 SQL而不是先查询再更新。比如扣减可用余额直接写UPDATE user_account SET balance balance - 100 WHERE uid ? AND balance 100;这样一条语句既完成了余额扣减又通过balance 100的条件在数据库层面做了余额校验完全不依赖提前SELECT出来的旧值也不需要显式加锁。在模拟秒杀系统的压测里这种做法单行更新的吞吐量比“先查询再更新”高出不少而且基本不会出现死锁。第三如果某些场景确实需要悲观锁事务一定要短、加锁顺序要一致。比如批量更新多张表时所有事务都按“订单表、库存表、账户表”的固定顺序执行交叉死锁的概率会大大降低。我自己带项目时会直接要求涉及多表更新的地方WHERE 条件里的主键必须按升序排列。第四大批量更新必须分批执行。一次UPDATE ... WHERE create_time 2024-01-01如果命中几十万行锁等待和回滚日志的压力都很大。可以把条件改成LIMIT 1000循环执行每批一个短事务把锁粒度控制住。5. 常见锁问题速查与参数调整5.1 锁等待超时如何定位线上遇到Lock wait timeout exceeded时不要只盯着报错语句看要回答三个问题谁占着锁占了多久锁在哪一行查询阻塞关系用sys.innodb_lock_waits视图最直观SELECT * FROM sys.innodb_lock_waits\G它会直接给出等待事务、阻塞事务、等待的锁类型、等待时间。然后再去查information_schema.innodb_trx看阻塞事务的trx_started字段判断这个事务是不是已经跑了好几分钟。如果发现一个事务长时间不结束基本可以确认它是锁等待的源头。处理方式一般是先评估能不能直接KILL掉阻塞事务。命令很简单KILL thread_id;但要注意KILL一个正在执行大事务的会话会导致事务回滚回滚本身也很耗时。所以这种情况更稳妥的做法是先通知排查代码里的长事务而不是急着 kill。5.2 死锁与元数据锁如何快速识别死锁和普通锁等待是不一样的。普通锁等待最终会超时报错死锁则会被 InnoDB 立即检测出来并且其中一个事务会被强制回滚。识别死锁是靠SHOW ENGINE INNODB STATUS\G里的LATEST DETECTED DEADLOCK段里面有两个事务的完整加锁历史。排查时重点看事务执行的最后两条 SQL再看加锁顺序是否一致。元数据锁MDL是另一种容易被误判的锁阻塞。场景通常是这样某张表上有一个长时间执行的查询或者未提交事务之后有人执行ALTER TABLE修改表结构DDL 卡住了再之后新进来的SELECT、UPDATE也要排队因为表上的 MDL 读锁也被 DDL 的写锁请求阻塞了。整个表的业务跟着停摆。查询 MDL 等待情况可以用SELECT * FROM performance_schema.metadata_locks\G找到持锁对象后轻则等待长事务提交重则需要 kill 掉长事务。经验是DDL 尽量安排在业务低峰期执行而且先检查information_schema.trx里有没有长事务在跑否则一条ALTER TABLE就能把整张表带崩。5.3 配置参数合理调整锁相关的参数工具有限但影响很大需要结合业务场景调整。innodb_lock_wait_timeout控制锁等待超时时间默认 50 秒在线交易系统建议调低到 3 至 5 秒。这样即使出现锁等待也不会长时间占着线程资源。innodb_deadlock_detect控制死锁检测是否开启默认开启。如果不是极端热点行更新的场景建议保持默认。关闭死锁检测可以减少一点 CPU 开销但会让死锁直接变成锁等待超时反而更难处理。关于自增锁innodb_autoinc_lock_mode参数需要单独评估。MySQL 8.0 默认是 2交错模式并发插入性能高但不保证批量插入的自增值连续如果业务强依赖自增主键的连续性或者用了老版本主从复制架构要谨慎调整。普通电商业务建议保持默认即可。集群内锁等待和死锁日志建议接入监控平台做好关键字告警。出现一次说明可能只是偶发反复出现就是对业务代码里加锁方式发出的警告。我自己做数据库巡检时重点看data_locks里LOCK_STATUS WAITING的记录数以及innodb_trx里事务数是否异常增多这两个指标往往能提前暴露问题。6. 最后分享几点实操体会做后端这些年我对锁的态度是敬畏但不恐惧。遇到锁等待先冷静下来判断是不是事务太长遇到死锁先看加锁顺序而不是急着调整数据库参数遇到锁表先去查 SQL 有没有走索引。一个很容易被忽略的细节连接池里的连接是复用的如果在代码里手动BEGIN之后忘记COMMIT事务会一直挂着数据库端看不出异常但锁和旧版本数据越积越多。排查这种问题我会查information_schema.innodb_trx重点看那些trx_started时间很早的空事务。处理过一次之后我在项目里一律要求用框架自带的事务注解不让人手写BEGIN/COMMIT。还有一个小技巧压测阶段故意调低innodb_lock_wait_timeout比如调到 1 秒把锁问题提前暴露出来。正常配置下 50 秒的等待可能被业务超时掩盖掉压测时看不出来反而把风险带到了线上。调低之后任何轻微锁等待都会立刻报错逼着开发人员把 SQL 和事务改干净。锁不是数据库在找麻烦它只是替并发出面说话。把这些规则理解透线上就能少很多“莫名其妙”的偶发超时。
返回列表