
开篇先说一个我反复遇到的场景线上某个订单接口突然变慢慢日志里一堆UPDATE语句卡在Waiting for lockShow processlist里看不到死锁就是单纯地互相等待。大部分人第一反应是代码写错了但查了半天发现业务逻辑没毛病最后定位到是锁表或者锁范围过大。这种事遇得多了我就把 MySQL 锁相关的知识系统地整理了一遍从全局锁到行锁从原理到排查手段包括一个非常隐蔽的二级索引回表锁定交叉窗口问题都总结在这篇文章里。这个话题适合所有跟数据库打交道的人刚入门的新手需要知道锁的分类和基本概念后端开发要懂得如何避免锁等待和死锁DBA 和架构师则需要一套完整的排查和优化方法论。这篇文章没有那些花哨的东西全是实际工作中能直接用上的经验和判断方法。1. 锁解决的是并发下的账本错乱问题1.1 没有锁会发生什么很多初学者觉得锁是个性能杀手一提到锁就头疼甚至想尽办法避免它。这种心态其实不对锁本质上是在保护数据的一致性它解决的问题非常实在。想象一个最简单的场景商品库存表一行记录stock 100。两个用户同时下单各扣 1 件库存。如果没有锁两个事务同时读到100各自算出99最后写回去的也是99实际卖出 2 件库存只减了 1 件这就叫丢失更新。在真实的并发系统里这种数据错乱是灾难性的。所以 InnoDB 引入锁的核心理由就一句话在并发读写时保证数据的正确性。它的工作方式是当一个事务要修改某条记录时先在这条记录上盖个章其他事务看到这个章就得等直到盖章的事务提交或回滚。1.2 事务隔离级别和锁的关系锁不是孤立存在的它和事务隔离级别是配合工作的。MySQL 支持四种隔离级别读未提交READ UNCOMMITTED、读已提交READ COMMITTED、可重复读REPEATABLE READ、串行化SERIALIZABLE。不同隔离级别下锁的类型、加锁时机、释放时机都不一样。在 InnoDB 里默认隔离级别是可重复读RR。很多文章讲MVCC 实现了可重复读听起来好像和锁完全没关系但实际并不是这样。MVCC 解决的是普通SELECT的快照读问题让读操作不阻塞写操作但对于UPDATE、DELETE、SELECT ... FOR UPDATE这种当前读操作必须走锁机制来保证每次都读到最新版本。举个例子在 RR 隔离级别下执行SELECT * FROM orders WHERE order_id 100 FOR UPDATE;这个语句会真的给order_id 100这行记录加一个排他锁其他事务想改这行就必须等。而在读已提交RC级别下同样的语句加锁期间如果事务还没结束其他事务也能对这条记录进行修改这就可能产生不可重复读的问题。所以锁和隔离级别是一体两面的关系隔离级别定义了事务之间的隔离程度锁则是实现这种隔离程度的具体手段。1.3 InnoDB 锁的粒度选择逻辑锁可以加在不同粒度上整张表、某个页、某条记录。粒度越大加锁开销小但并发能力低粒度越小并发能力高但锁管理和检查的成本也高。MyISAM 只支持表锁所以同一时间只能有一个事务写某张表并发写入性能极差。InnoDB 之所以成为主流核心就是支持行级锁让不同事务可以同时修改同一张表里的不同记录并发能力大幅提升。但 InnoDB 也没有完全抛弃表锁它用了一种叫意向锁Intention Lock的机制来协调表锁和行锁的关系事务想锁某个行之前必须先给表加一个意向锁。意向锁分意向共享锁IS和意向排他锁IX它们之间是兼容的但和真正的表级排他锁X冲突。这样做的好处是当一个事务想锁整张表时可以快速判断出有没有其他事务在锁其中的行不用逐行检查。2. 全局锁、表锁与元数据锁被低估的隐性阻塞源2.1 全局锁全库只读的大动作全局锁是整个 MySQL 实例级别的锁最典型的操作是FLUSH TABLES WITH READ LOCK;这条命令会把所有表都锁定为只读状态任何写入操作都会被阻塞直到执行UNLOCK TABLES释放。全局锁最大的用途是保证备份的一致性。以前用mysqldump做个物理备份或者逻辑备份时如果不加全局只读锁备份过程中数据可能被修改导出的数据就不一致了。不过实际生产环境里我很少直接用FLUSH TABLES WITH READ LOCK因为影响面太大了——一条命令下去整个库的写入全部卡住。现在的主流做法是使用 InnoDB 的mysqldump --single-transaction它利用 MVCC 在单个事务里做快照备份不需要锁全库备份过程中业务照常写入。这个命令已经能覆盖绝大多数备份场景全局锁基本退化为某些特定运维操作比如一些 MyISAM 表备份、数据迁移等才会使用。先说结论能不用全局锁就不用它的并存场景极其受限代价却是整个数据库的写入停顿。2.2 表级锁LOCK TABLES 与显式锁的坑表锁是最古老的锁类型在 MyISAM 时代是唯一选择。InnoDB 也支持表锁但日常业务基本不会主动用。LOCK TABLES orders WRITE; -- 做一些需要独占表的操作 UNLOCK TABLES;LOCK TABLES ... WRITE执行后其他事务无法读写这张表。我自己在实际使用中最常遇到的问题是InnoDB 下LOCK TABLES的范围比你想象的大它除了锁表以外还会把当前会话里所有已经打开的表都锁住容易引发不可预知的阻塞。所以现在运营同学或开发同学偶尔提出我要手动锁一下某张表做数据修复我的建议都是尽量改用ALTER TABLE在线操作或其他更细的方案。在 MyISAM 引擎里表锁还有一个隐藏问题写锁优先级高于读锁。这意味着如果一张 MyISAM 表的写入很频繁读请求会被大量积压。这也是为什么新业务我都直接劝用 InnoDB省得后面还要处理这种锁优先级导致的性能问题。2.3 MDL 元数据锁最容易忽略的隐形势力MDLMetadata Lock是 MySQL 5.5 开始引入的锁类型它锁的不是数据而是表结构。任何 DDL 操作比如ALTER TABLE、DROP TABLE都需要获取 MDL 排他锁而任何 DML 操作增删改查都需要 MDL 共享锁。它们之间的兼容性是共享锁之间兼容排他锁和共享锁互斥。这里有个非常经典的线上事故场景某天下午业务侧发了一个ALTER TABLE orders ADD INDEX idx_status(status)打算给大表加个索引。由于这张表数据量大ALTER TABLE执行时间较长一直持有 MDL 排他锁。此时如果有几个慢查询先持有了 MDL 共享锁DDL 就会排队等待。更麻烦的是DDL 一旦开始排队后续所有新的SELECT、UPDATE也会因为需要 MDL 共享锁而全部排队。这个叫MDL 排队传染。我见过一张只有 2GB 的表因为一个ALTER TABLE卡住导致整张表的所有查询和更新全部阻塞业务方报过来的时候已经堆了几百个连接。排查方法很简单show processlist里看到大量Waiting for table metadata lock基本就是 MDL 的问题。处理方式一般是找到阻塞源那条 DDL 或持锁的长事务并 kill 掉让后续请求恢复。所以关于 MDL我自己的经验是两条DDL 尽量安排在低峰期执行先用SHOW PROCESSLIST确认没有长事务在跑再执行ALTER TABLE最好配合pt-osc这类在线变更工具降低 MDL 锁占用的时间窗口。3. 行锁的精细世界记录锁、间隙锁与临键锁3.1 记录锁Record Lock记录锁是最基础的行锁它就是锁住索引上的一条具体记录。注意关键词索引。InnoDB 的行锁是加在索引上的不是直接加在表的行记录上的。如果一张表没有显式定义主键InnoDB 也会隐式创建一个聚簇索引行锁最终都落在某个索引项上。举个实际例子假设表结构如下CREATE TABLE orders ( id INT PRIMARY KEY, order_no VARCHAR(32) UNIQUE, status TINYINT, amount DECIMAL(10,2) );事务 A 执行UPDATE orders SET amount 100 WHERE id 10;此时 InnoDB 会在主键索引id 10这个索引项上加上排他记录锁。其他事务想更新id 10这行就会被阻塞但更新id 11完全不受影响。这是 MySQL 并发性能的重要保障不同行的操作互不干扰。3.2 间隙锁Gap Lock与幻读再往深一层RR 隔离级别下只锁一条记录是不够的。假设有一个事务要执行SELECT * FROM orders WHERE status 1 FOR UPDATE;事务先查到了一批status 1的记录并锁住了它们。但如果另一个事务在这个事务提交之前插入了一条新记录恰好status也是 1会发生什么第一个事务再次执行同样的查询时会看到一条之前不存在的记录——这就是幻读。为了在 RR 级别下阻止幻读InnoDB 引入了间隙锁。间隙锁锁的不是某条记录而是索引结构中两个值之间的空档。比如表中id现有 10、20、30 三条记录间隙锁可以锁住(10, 20)这个区间。其他事务往这个区间插入数据就会阻塞。间隙锁是 RR 隔离级别防幻读的核心机制但它也是很多锁等待问题的来源。一个常见的踩坑案例是UPDATE orders SET amount 0 WHERE amount 1000 AND amount 5000;如果amount上有索引这条语句不仅会锁住满足条件的行还会锁住这些行之间的所有间隙。如果业务频繁往这个区间插入新订单插入操作全都会卡住直到这个事务提交。加锁范围比开发者预想的大得多这是新手最容易忽略的点。3.3 临键锁Next-Key Lock临键锁可以理解为记录锁 间隙锁的组合锁定的范围是左开右闭的区间。比如索引值有 10、20、30临键锁可能锁住的是(10, 20]意味着锁住了 20 这条记录本身也锁住了 10 到 20 之间的间隙。在 RR 隔离级别下InnoDB 默认对普通索引的等值和范围查询都使用临键锁。这也是为什么很多锁等待事故不是发生在更新同一条记录上而是发生在更新了相邻范围的记录这个场景。这里要注意RC读已提交隔离级别下InnoDB 只使用记录锁不使用间隙锁和临键锁。这也是为什么不少高并发团队会把隔离级别从 RR 改成 RC核心目的之一就是减少间隙锁带来的锁冲突。代价是需要自己在业务层面处理幻读问题或者接受一定程度的一致性妥协。具体怎么取舍后面第 6 节再展开。3.4 插入意向锁Insert Intention Lock插入意向锁是一种特殊的间隙锁它表示一个事务想在某个间隙里插入记录但还没真正插入。多个插入意向锁之间是互相兼容的这意味着不同事务可以同时想往同一个间隙里插入数据并不互相阻塞但它们与间隙锁是互斥的——一个事务持有间隙锁时其他事务的插入操作只能等待。举一个我踩过坑的场景某个订单表经常有批量插入操作业务方升级代码后突然频繁报Lock wait timeout exceeded。排查后发现是一个定时任务用SELECT ... FOR UPDATE扫了一段范围的记录加了间隙锁而主业务在这个范围里做批量插入全部被间隙锁挡住。解决办法是把定时任务改成只锁具体的记录比如加个LIMIT或者改成主键等值查询插入操作很快就顺畅了。这四种行锁类型的关系和差异我用一张表总结锁类型锁对象典型场景兼容性记录锁Record Lock具体的一条索引记录主键或唯一键等值更新不同记录互不影响间隙锁Gap Lock索引值之间的空档RR 下范围查询防幻读与其他间隙锁兼容与插入意向锁冲突临键锁Next-Key Lock记录锁 间隙锁RR 下范围更新、普通索引扫描左开右闭区间锁冲突高发区插入意向锁Insert Intention Lock待插入的间隙向某间隙插入新记录多个插入意向锁互相兼容与间隙锁冲突4. 二级索引回表锁定的交叉窗口一个非常隐蔽的死锁源头4.1 正常加锁路径是怎样的前面提到 InnoDB 的行锁加在索引上但真实场景中很多更新语句并不是直接命中主键索引的而是通过二级索引去查找数据再回表到主键索引。举个例子表结构还是上面那个orders表假设order_no上有唯一索引事务执行UPDATE orders SET amount 100 WHERE order_no 20250101001;实际的加锁路径是先通过二级索引order_no 20250101001定位到索引项在二级索引上加锁然后回表在主键索引上找到对应行在主键索引记录上加锁。这两个操作之间存在着一个时间窗口而正是这个窗口藏着一个容易形成交叉死锁的隐患。4.2 怎么构造一个交叉窗口的案例我构造一个真实复现过的场景。假设表里有两行数据id 1, order_no A, status 1 id 2, order_no B, status 1事务 1 执行UPDATE orders SET status 2 WHERE order_no A;事务 2 执行UPDATE orders SET status 2 WHERE order_no B;按直觉来看这两个事务操作的是不同的行应该互不相干。但实际上它们可能死锁原因是这样的事务 1 先给二级索引order_no A加锁回表给主键id 1加锁事务 2 先给二级索引order_no B加锁回表给主键id 2加锁如果加锁顺序完全一致先二级索引后主键不会出问题但 InnoDB 内部加锁并不是严格按照所有事务同一顺序执行的。在某些条件下事务 1 在二级索引上加完锁、还没回表锁主键时事务 2 可能已经锁住了主键id 2而事务 1 接下来要锁主键id 2时发现被事务 2 持有事务 2 回表时可能又需要访问某些二级索引项而该索引项已被事务 1 持有。于是形成一个闭环事务 1 等事务 2 释放主键锁事务 2 等事务 1 释放二级索引锁。这就是搜索热词里提到的通过二级索引更新时先锁二级索引项再回表锁主键这个时间窗口容易形成交叉。这类死锁非常隐蔽因为业务逻辑看起来并发量很低甚至只是更新不同的行但它就是会偶发出现。4.3 规避思路针对这个交叉窗口问题我总结了几条可落地的规避策略第一等值更新优先走主键。如果业务上能拿到主键 ID直接用主键作为 WHERE 条件只加主键索引上的记录锁完全不经历二级索引加锁 回表加锁这个两阶段过程交叉窗口就不存在了。第二确保二级索引是唯一的且查询能命中。等值查询走唯一索引时加锁范围通常较小交叉概率低。如果是普通二级索引范围会放大潜在冲突面也变大。第三缩短回表窗口时间。回表窗口本质上就是事务持有二级索引锁到主键锁之间的间隔如果能把这个间隔压到最短交叉的概率自然下降。具体手段包括避免在更新语句中做耗时操作、保持字段简单、减少不必要的连接查询。第四必要时使用覆盖索引。如果更新语句的字段都在二级索引里InnoDB 在某些情况下可以减少回表加锁的需求但这一点依赖版本和执行计划不要盲目依赖。这里补充一句死锁出现后 InnoDB 会自动回滚其中一个事务数据库本身不会挂掉但业务方会收到死锁异常如果不重试用户体验就是这次操作失败了。所以除了尽量减少死锁发生应用层对死锁异常做一定的重试机制也是必要的兜底。5. 死锁与锁等待从 show engine innodb status 到锁监控5.1 Lock wait timeout exceeded 是哪个参数MySQL 的锁等待超时时间由参数innodb_lock_wait_timeout控制默认值是 50 秒。也就是说一个事务等待另一个事务释放锁超过 50 秒InnoDB 会主动放弃并抛错。50 秒这个默认值在生产环境里其实偏长。对用户来说一个接口超过 5 秒没返回就已经很难受了根本等不到 50 秒。对 DBA 来说被锁住的事务长时间悬挂还会拖住一堆后续请求形成雪崩。我通常建议把innodb_lock_wait_timeout调小到 5~10 秒让事务快速失败应用层收到错误后可以快速重试或降级而不是傻等。查看当前值SHOW VARIABLES LIKE innodb_lock_wait_timeout;修改以 5 秒为例重启后失效需要配合配置文件持久化SET GLOBAL innodb_lock_wait_timeout 5;5.2 死锁日志怎么看出现死锁时InnoDB 会把死锁信息记录到错误日志里。查看最近一次死锁详情的命令SHOW ENGINE INNODB STATUS\G重点看输出中的LATEST DETECTED DEADLOCK部分它会清晰地列出两个事务分别持有什么锁、在等待什么锁。我摘一段典型的输出格式内容做了简化方便说明------------------------ LATEST DETECTED DEADLOCK ------------------------ *** (1) TRANSACTION: TRANSACTION 1001, ACTIVE 2 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136 WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 3 n bits 72 index PRIMARY of table test.orders *** (2) TRANSACTION: TRANSACTION 1002, ACTIVE 1 sec HOLDING THE LOCK: RECORD LOCKS space id 18 page no 3 n bits 72 index PRIMARY of table test.orders WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 3 n bits 72 index idx_order_no of table test.orders日志虽然看起来有点绕但核心信息就两点事务 1001 正在等待一个主键索引上的锁事务 1002 持有这个主键锁自己在等一个二级索引锁而这个二级索引锁恰好被事务 1001 持有。看到这个结构基本就可以确认是第 4 节说的二级索引回表交叉窗口问题。如果两个事务都在等主键锁那就更像是在抢同一行。5.3 用 performance_schema 定位锁等待源头死锁日志只能看到过去的信息如果想实时判断当前谁阻塞了谁比如抓到一堆Waiting for lock的会话就需要查 performance_schema。MySQL 5.7 以后提供了比较完整的数据字典常用查询如下SELECT r.trx_id AS waiting_trx_id, r.trx_mysql_thread_id AS waiting_thread, r.trx_query AS waiting_query, b.trx_id AS blocking_trx_id, b.trx_mysql_thread_id AS blocking_thread, b.trx_query AS blocking_query FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON w.REQUEST_ENGINE_LOCK_ID l.ENGINE_LOCK_ID JOIN information_schema.innodb_trx r ON r.trx_id w.REQUESTING_TRX_ID JOIN information_schema.innodb_trx b ON b.trx_id w.BLOCKING_TRX_ID;这条 SQL 可以直接告诉我们哪个事务在等等的是哪个事务的锁阻塞线程是哪个。拿到阻塞线程的trx_mysql_thread_id之后如果确认是异常长事务可以手动 kill 掉那个线程快速恢复业务。在 5.7 版本还可以直接用 sys 库的视图SELECT * FROM sys.innodb_lock_waits\G这个视图已经把连接 ID、SQL 文本、锁等待时间都格式化好了排查效率非常高。我在线上问题处置时基本就是一条sys.innodb_lock_waits查出来直接定位阻塞源再决定 kill 还是等待。常用的排查命令对应关系命令 / 视图作用产出SHOW ENGINE INNODB STATUS查看最近一次死锁详情事务持有锁、等待锁的完整链路SHOW PROCESSLIST查看当前所有会话状态是否有大量Waiting for lock会话sys.innodb_lock_waits实时展示锁等待关系谁阻塞了谁、等待多久performance_schema.data_locks查看当前所有锁记录每个事务持有哪些锁6. 降低锁竞争的实操经验与方法论6.1 事务设计层面的锁缩减锁竞争最根本的解法是让事务持有的锁时间短、范围小。锁持有时长和事务时长强相关一个事务从开始到提交之间所有加的锁都会一直占用。所以第一事务体量要小。很多业务喜欢在一个事务里做很多次查询和更新比如先查用户信息、再扣余额、再写订单、再更新优惠券状态中途还夹杂几个远程调用。这种事务一旦并发上来锁竞争指数级增加。我的经验是将远程调用、复杂计算全部挪到事务外面事务里只保留必要的数据库读写。第二批量操作用小批次。比如一次更新 1 万条记录换成每批 500 条提交虽然总时间变长了但每条记录被锁住的时间大幅缩短其他事务的等待概率就下降了。这个对全表更新、批量修复数据尤其重要。第三避免大范围的无谓更新。给所有行设置同一个值明明用一条 SQL 就能完成但这条 SQL 可能锁住整张表的所有相关间隙。如果业务允许可以按主键范围拆成多条语句或者放到低峰期执行。6.2 索引设计与 SQL 优化锁竞争和索引的关系极其密切。同样一条UPDATE语句走没走对索引锁的范围可能相差几十倍。经验法则高频更新语句的 WHERE 条件必须命中索引而且最好是唯一索引或主键。如果 WHERE 条件上没有索引InnoDB 会退化成全表扫描把所有扫描过的记录都锁住等同于锁表。使用等值条件而不是范围条件能缩小加锁范围。举个实际例子UPDATE orders SET status 2 WHERE create_time 2025-01-01这种语句即便create_time有索引锁的范围也覆盖了大量记录如果业务上可以改成WHERE id IN (...)的等值条件组合锁只落在指定 ID 上。用EXPLAIN检查执行计划看是不是走了预期索引。我见过太多以为走了索引实际是 full scan的情况尤其在 OR 条件、函数包裹字段、隐式类型转换等场景下索引会静默失效锁范围瞬间扩大。6.3 业务层的降锁策略数据库层的优化是有极限的真正高并发场景还要配合业务层的策略。一种方式是乐观锁代替悲观锁。表里加一个版本号字段version更新时带上版本条件UPDATE orders SET amount 100, version version 1 WHERE id 10 AND version 1;如果受影响行数为 0说明数据已被别人改过业务层做重试或报错。这种方式完全不依赖数据库行锁并发能力高很多。它适合冲突率低的场景比如更新自己的订单状态不适合多人抢同一资源的热点场景。另一种是异步化、队列化。热点账户的余额变更、秒杀库存扣减这类操作如果全部并发更新同一行不管怎么调数据库都会大量锁等待。现实的做法是把请求串行化发到 MQ由消费者单线程处理同一个 key 的更新数据库层几乎不产生锁等待。本质上是用顺序换取并发。还有一种是热点行拆分。对所有写压力集中的单行比如爆款商品的库存字段拆成多行子账户存储通过哈希或随机因子分散不同请求的更新目标最后汇总余额。这个方案改造量大一般只在出现极端热点时才值得做。6.4 监控与应急处理锁问题很难提前 100% 发现所以监控和应急手段是最后一道防线。线上我比较依赖的检查逻辑是开启慢查询日志把执行超过 1 秒的语句打出来定期分析有没有异常的锁等待语句对show processlist中State字段包含Locked或Waiting for table metadata lock的会话做告警及时处理长事务定期巡检死锁日志把所有死锁记录收集到日志系统按事务 SQL 聚合看哪些 SQL 组合频繁死锁。应急处理的经验顺序是先定位是谁阻塞的建议用sys.innodb_lock_waits如果是偶发的长事务kill 掉恢复业务即可如果是 SQL 本身锁范围太大kill 之后还要分析执行计划、调整 SQL 或索引否则很快会再次爆发。最后再分享一个我自己的习惯所有核心业务的更新语句我都会刻意检查它走的是主键还是二级索引以及锁的范围会不会覆盖到其他并发路径。MySQL 的锁不像很多人的直觉那样只锁一行它锁的是索引区间一个不小心的范围条件就能把整张表的关键路径卡死。对锁的理解越深你就越知道怎么写出真正高并发的应用。