
做后端开发和数据库运维的同学几乎都遇到过这种场景凌晨两点被监控电话吵醒工单上写着“Deadlock found when trying to get lock; try restarting transaction”或者某个接口的P99延迟从50毫秒飙到5秒数据库连接池被占满业务页面直接卡死。十次里有八次根因都指向同一个地方——MySQL的锁机制。锁真不是面试题里“共享锁和排他锁有什么区别”那种死记硬背的东西它是InnoDB并发控制体系的命脉。理解锁本质上是在回答一个问题当多个事务同时抢同一份数据时系统凭什么保证不出错又凭什么让尽量多的人能“同时干活”。这个平衡点就是并发控制的核心矛盾。这篇文章从一次真实的线上事故讲起把MySQL锁机制的骨架拆开讲清楚锁的分类、加锁规则、死锁排查、锁竞争优化最后落到几套生产环境真正能用的并发优化策略。不管你是刚入门MySQL的新手还是被线上死锁折磨过的开发都能从这里拿走可以直接用的东西。1. 并发控制的核心矛盾隔离与效率怎么兼得1.1 一次线上事故引发的思考去年我接手过一个订单系统的性能优化现象非常典型每天晚高峰的时候订单表的insert和update偶尔会出现大面积阻塞一个原本几十毫秒的扣库存操作时不时飙到两三秒紧接着就是死锁报错。监控面板上能看到Innodb_row_lock_current_waits和Innodb_row_lock_time这两个指标在飙升。当时第一反应是“是不是某条SQL写得有问题”但翻了一遍慢查询日志发现所有SQL执行计划都走了索引单条语句怎么看都不至于拖垮库。后来把问题固化到一个具体场景里才看清楚A事务先更新商品库存再把订单状态从未支付改成已支付B事务先查订单状态再更新库存两条事务的加锁顺序恰好相反在并发量上来之后双方各自持有一把锁等待对方释放死锁就是这么产生的。这个案例特别典型它说明了一个关键点锁问题的根源往往不是某一单条SQL而是多个事务之间的资源访问顺序和锁持有时间。MySQL底层是一个很“老实”的引擎你让它怎么加锁它就怎么加锁它不会分辨你的业务逻辑是不是合理。数据库本身没错问题出在业务代码对并发场景的失控。1.2 锁、MVCC与隔离级别三者的分工要把锁机制讲透得先摆清楚InnoDB并发控制的完整拼图。MySQL的并发控制不是靠锁单打独斗而是“锁 MVCC多版本并发控制 事务隔离级别”这三者协作的结果。事务隔离级别决定了事务能看到什么数据它由transaction_isolation参数控制默认是REPEATABLE READ可重复读简称RR。查当前的隔离级别很简单SHOW VARIABLES LIKE transaction_isolation; -- 或者 SELECT transaction_isolation;MVCC做的事情是用undo log生成数据的历史版本让普通的SELECT查询快照读不加锁就能读到一致性快照这样读操作永远不会阻塞写操作写操作也不会阻塞读操作。而锁负责的是“当前读”——也就是SELECT ... FOR UPDATE、UPDATE、DELETE这些语句它们必须读取数据的最新版本并且要对目标记录加锁防止其他事务同时修改。两者之间的关系可以打个比方MVCC是一套“历史档案系统”每个人都可以翻阅档案而不影响当下正在发生的修改锁是一套“实时登记簿”谁要动当前的数据谁就得先在登记簿上签字占位。机制负责内容是否加锁典型场景MVCC快照读的一致性视图不加锁普通SELECT锁机制当前读的互斥控制加锁UPDATE、DELETE、FOR UPDATE隔离级别定义事务间的可见性与锁的启用策略视级别而定全局事务配置所以定位并发问题的时候先要分清到底是“读到了不该读的数据”还是“写的时候互相阻塞”。前者多半和MVCC、隔离级别有关后者基本就是锁的问题。这篇文章后面的内容全部围绕锁展开。2. InnoDB锁体系全景拆解2.1 锁的粒度表锁、行锁、页面锁锁的粒度说直白点就是“一次锁住的范围有多大”。MySQL的存储引擎各自实现不同MyISAM只有表锁InnoDB则同时支持表锁和行锁这也是InnoDB在高并发场景下能碾压MyISAM的根本原因。NDB引擎还有页面锁但日常开发基本碰不到知道即可。表锁Table Lock锁住整张表粒度大、开销小、并发度低。InnoDB里显式的表锁用得不多LOCK TABLES ... WRITE这种语句一般在做表结构维护或者数据迁移时才会见到。真正在日常运行中常见的表锁是元数据锁和DDL锁这部分我后面单独讲。行锁Row Lock锁住的是索引记录粒度小、并发度高但加锁开销也大。InnoDB的行锁不是直接锁“行数据”而是锁“索引记录”——这是理解InnoDB锁的一个关键点。如果一张表没有主键InnoDB会隐式生成一个6字节的row id作为聚簇索引如果连索引都没有行锁也就无从谈起会退化成表锁。行锁的实现依赖索引这一特性导致了一个非常容易踩的坑如果更新语句没有走到索引InnoDB会对全表记录逐条加锁从效果上看等于表锁。这也是“明明有行锁为什么并发一高还是堵死”的最常见原因。2.2 锁的模式共享锁、排他锁、意向锁按模式分InnoDB锁有两类基础模式共享锁Shared LockS锁读锁加了S锁的记录其他事务还能加S锁来读但不能加X锁来写。排他锁Exclusive LockX锁写锁加了X锁的记录其他事务无论S锁还是X锁都不能再加。这两种锁的兼容性可以用一张小表来表示锁模式是否兼容S锁是否兼容X锁S锁兼容不兼容X锁不兼容不兼容也就是说读读不互斥读写互斥写写互斥。这套规则是整个并发互斥的基础。日常SQL里普通SELECT不加锁SELECT ... LOCK IN SHARE MODE8.0里也可以用SELECT ... FOR SHARE加的是S锁SELECT ... FOR UPDATE、UPDATE、DELETE加的是X锁。除了S锁和X锁InnoDB还有一种很特殊的意向锁Intention Lock。意向锁是表级别的锁它不锁任何具体记录只是标记“这个事务准备在表里的某些行上加锁”。它的作用是在表级判断“有没有行锁冲突”时省去逐行检查的麻烦。举个例子事务A给表里的第100行加了X锁此时事务B想执行LOCK TABLES ... WRITE锁住整张表如果不去检查行锁凭什么知道这张表“已经有行被锁住了”有了意向锁A在加行锁之前会先给表加一个意向排他锁IXB一看表上已经有IX锁立刻就知道“这张表有事务在改行”不用扫描全表行锁。意向锁之间是互相兼容的它只用来和显式的表锁做冲突判断。2.3 行锁的三种算法记录锁、间隙锁、临键锁行锁虽然叫“行锁”实际在InnoDB里细分下来有三种算法这也是“MySQL锁的分类”里最容易被混淆的部分记录锁Record Lock锁住具体的某一条索引记录。比如WHERE id 10且id是主键就直接锁住id10这一条。间隙锁Gap Lock锁住两个索引记录之间的区间防止其他事务在这个区间插入新记录。它锁的是一个“范围”而不是具体记录。临键锁Next-Key Lock记录锁间隙锁的组合。锁住一个左开右闭的区间比如(5, 10]既锁住记录10也锁住5到10之间的空隙。临键锁是InnoDB在REPEATABLE READ隔离级别下的默认行锁算法。它存在的目的是为了解决幻读问题——也就是事务A两次查询同一范围的数据第一次查到5条第二次却变成了6条多出来的那条就是“幻影记录”。临键锁通过锁住范围内的所有间隙和记录让别的事务无法插入新记录从而保证范围查询的一致性。很多人在理解间隙锁的时候卡住我提供一个简单的记忆方式记录锁锁“点”间隙锁锁“缝”临键锁锁“点加缝”。间隙锁是范围锁的一大代价——它允许两个事务同时锁住同一个间隙因为间隙锁之间互相兼容但它们都会阻塞往这个间隙里插入新记录的新事务。所以一旦业务里频繁出现范围更新间隙锁很容易成为“隐形的并发杀手”。2.4 容易被忽视的几种锁MDL锁、自增锁、插入意向锁除了上面这些耳朵都听出茧子的锁生产环境里更要命的往往是那些平时不太被提到的锁。**元数据锁Metadata LockMDL**就是其中一个。MySQL从5.5开始引入MDL目的是保护表结构定义。任何事务执行SQL时都会先拿MDL读锁ALTER TABLE改表结构时需要MDL写锁。写锁和读锁互斥所以一个长事务把持着MDL读锁不放后面的DDL就一直在“Waiting for table metadata lock”把整张表的读写全部堵住。这种事故非常常见症状是数据库看起来“卡死”SHOW PROCESSLIST里一堆Waiting for table metadata lock但innodb_row_lock指标却正常。**自增锁AUTO-INC Lock**则是在插入包含自增列的数据时使用的特殊表级锁。它跟普通锁不一样每次插入都会短暂持有但长度可以配置。innodb_autoinc_lock_mode0是传统模式1是批量插入时锁表2是性能最优但binlog不能混用格式的模式。8.0默认是模式2配合binlog_formatROW用没问题。**插入意向锁Insert Intention Lock**是间隙锁的“温柔版本”。当多个事务想往同一个间隙插入记录时它们会先在间隙上申请插入意向锁互相不阻塞只是在插入前需要等待已有的间隙锁释放。所以“高并发下同一张表插入卡顿”往往不是插入意向锁互斥而是有事务拿间隙锁堵住了插入区间——比如前面有事务执行了大范围查询且隔离级别是RR。2.5 锁信息去哪里查理论讲完上实操。定位锁问题核心就两张表performance_schema.data_locks当前持有的锁详情8.0用这个视图替代了老版本的information_schema.INNODB_TRX和INNODB_LOCKS。information_schema.INNODB_TRX当前运行中的事务列表。一条常用查询是SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query, trx_rows_locked, trx_rows_modified FROM information_schema.INNODB_TRX;再看看锁等待的明细SELECT engine_transaction_id, object_name, index_name, lock_type, lock_mode, lock_status, lock_data FROM performance_schema.data_locks;这两条SQL配合使用能迅速定位到“谁在等谁”。更直观的办法是用SHOW ENGINE INNODB STATUS\G里的LATEST DETECTED DEADLOCK和TRANSACTIONS段落能看到最近一次死锁的事务和锁信息。这个命令非常有用下面第4部分会专门教怎么读它的输出。3. 加锁规则与并发控制实现机制3.1 加锁的基本规则两阶段锁与当前读InnoDB的加锁遵循两阶段锁协议事务中加锁操作分为扩展阶段和收缩阶段锁只能增加不能提前释放所有锁在事务提交或回滚时统一释放。这意味着锁的持有时间等于事务从加锁到提交之间的全部时间。很多锁等待问题根子不在数据库而在事务里“加锁之后还磨磨蹭蹭做了很多别的事”把锁持有时间拉长了。举个极端例子一个事务里执行了两次更新中间夹了一次RPC调用外部接口RPC超时3秒这3秒里事务握着所有行锁不释放后面的更新全部排队。这类问题靠调数据库参数是治不好的必须从应用层改代码——把耗时的外部调用挪到事务外面。“当前读”需要加锁“快照读”不加锁这个区别前面已经提过。实际开发中常见的误用是用SELECT查出来的数据直接参与后续更新中间没有加锁保护导致结果出现偏差。这时候应该用SELECT ... FOR UPDATE加上排他锁把判断和修改做成一个原子的临界区。3.2 不同隔离级别下的加锁差异隔离级别会影响锁的“激进程度”这是并发性能优化的一个隐性杠杆。拿REPEATABLE READ和READ COMMITTED读已提交RC对比RR级别下普通查询走快照读但UPDATE、DELETE、FOR UPDATE这类当前读会默认使用临键锁锁住扫描范围的所有记录和间隙。间隙锁的存在让“范围更新”变成一场灾难。RC级别下当前读只使用记录锁不启用间隙锁因此并发度更高。这也是为什么很多互联网公司把隔离级别从RR调成RC来提升性能。代价是RC的“可重复读”能力被削弱同一事务里两次读可能看到不同的数据。不过在大部分业务里快照读保证一致性已经够用RR带来的间隙锁阻塞反而得不偿失。我见过的生产库绝大多数是RR和RC并存默认保持RR对某个特定高并发业务库单独设置RC。修改隔离级别要千万注意它不是Session级别的玩具而是会影响所有并发事务的全局决策。改之前要评估业务里有没有依赖可重复读语义的代码否则数据一致性会出问题。3.3 从一条SQL看加锁范围加锁范围的分析是理解锁机制的“高等数学”也是面试里经常被问到的“一条update会锁几行”。这里看两个案例。第一等值查询只有唯一索引或主键命中时加的是记录锁。比如UPDATE t SET status 1 WHERE id 500;如果id是主键这条语句只会锁住id500那一个索引叶子节点其他行的插入更新都不受影响。第二范围查询或索引筛选不够精确时加的是临键锁UPDATE t SET status 1 WHERE create_time 2024-06-01 00:00:00;如果create_time上有二级索引InnoDB不仅会锁住所有满足条件的记录还会锁住第一个不满足条件记录之前的间隙防止插入新的满足条件的记录。如果这条SQL扫描了10万行那10万行索引记录加间隙全部会被锁住其他事务想往这个范围插数据统统阻塞。这类查询通常在“批量更新”场景出现全表扫描的批量更新是生产环境锁风暴的源头。优化方向是拆分批次每批只更新少量数据并确保WHERE条件能走索引。4. 实测模拟锁等待与死锁并读懂现场4.1 环境准备与造数理论再复杂不如手动复现一次。我准备一张简单的订单表来说明CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, sku_id INT NOT NULL, user_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, INDEX idx_sku (sku_id), INDEX idx_status (status) ) ENGINEInnoDB; INSERT INTO t_order (order_no, sku_id, user_id, status) VALUES (O1001, 2088, 1, 0), (O1002, 2088, 2, 1), (O1003, 3088, 3, 0), (O1004, 4088, 4, 2), (O1005, 2088, 5, 0);4.2 复现锁等待开两个会话session A执行BEGIN; SELECT * FROM t_order WHERE sku_id 2088 FOR UPDATE;此时session A在idx_sku索引上锁住了sku_id2088对应的多条记录。session B再执行BEGIN; UPDATE t_order SET status 1 WHERE sku_id 2088;正常来讲B会一直卡住直到A提交或超时。查看B的状态SELECT trx_id, trx_state, trx_wait_started, trx_mysql_thread_id FROM information_schema.INNODB_TRX;B的事务状态会显示LOCK WAIT等待超时时间由innodb_lock_wait_timeout控制默认50秒。等不住的时候会直接报Lock wait timeout exceeded; try restarting transaction。这个复现过程说明了一个很重要的优化思路如果你能准确知道事务改了哪些行就可以考虑缩短锁范围或者让不同事务操作不同sku的数据避免把多个用户的操作压在同一条数据上。4.3 复现死锁再来模拟经典死锁AB-BA场景。接上面两张表我们开两个事务都同时操作相同的两行数据但顺序相反。session ABEGIN; UPDATE t_order SET status 2 WHERE id 1; -- 稍等一下不要提交session BBEGIN; UPDATE t_order SET status 2 WHERE id 2; -- 稍等一下不要提交然后session A继续执行UPDATE t_order SET status 2 WHERE id 2; -- 会发生锁等待session B继续执行UPDATE t_order SET status 2 WHERE id 1; -- 发生死锁MySQL选择回滚其中一个事务此时MySQL会立即检测到死锁让其中一方回滚报错字符串是Deadlock found when trying to get lock; try restarting transaction。这就是开头工单里那一行的由来。死锁检测的原理是InnoDB维护了一个等待图Wait-for Graph当事务之间的锁等待形成环时立刻选中代价最小的事务作为牺牲者回滚。所以InnoDB的死锁不是靠超时“等”出来的而是秒级检测出来的。4.4 死锁日志怎么读实战里我们碰到的死锁绝大多数是从日志反推原因的。死锁后执行SHOW ENGINE INNODB STATUS\G找到LATEST DETECTED DEADLOCK段落逐行解读几个关键字段TRANSACTION后的十六进制数事务ID可用来反查应用日志里的连接来源。MySQL thread id是哪个连接会话可以顺着查到具体是哪个应用实例。WAITING FOR THIS LOCK TO BE GRANTED等待的锁。LOCK HELD BY THIS持有的锁。lock_mode X locks rec but not gap排他锁且是记录锁不是间隙锁。index PRIMARY of table xxx.t_order锁在哪个表的哪条索引上。一个典型的死锁日志会交替出现两个事务一个“持有”一个“等待”结尾出现WE ROLL BACK TRANSACTION字样。生产上分析死锁的正确姿势是把日志里的thread id和应用日志对起来还原两条SQL的执行顺序然后看是不是加锁顺序相反——是的话调整业务代码的加锁顺序即可。5. 优化策略把并发性能压出来5.1 从SQL层面减少锁范围见过太多慢和堵的问题根本原因就是SQL扫描范围太大。优化的第一板斧永远是让WHERE条件走索引且走窄索引。比如上面那张表如果经常要用user_id和status做条件就应该建联合索引(user_id, status)而不是单独两个单列索引。联合索引能显著缩小加锁的记录数。反之如果UPDATE语句的WHERE里带了一个OR条件或者对字段做了函数运算比如WHERE DATE(create_time) 2024-06-01索引就会失效扫描范围瞬间扩大锁的范围也跟着变大。此外尽量避免SELECT * FOR UPDATE这种全列加锁的写法只要能确认主键直接WHERE id IN (...)分小批操作。5.2 从索引层面细化锁粒度行锁的本质是索引记录锁。索引设计直接决定“一行更新会锁住多少行”。最典型的问题是二级索引回表——更新语句走了二级索引InnoDB会同时在二级索引记录和对应的聚簇索引记录上加锁。如果二级索引的区分度很低比如只有几个值一个条件下会命中大量重复索引记录锁的范围会被放大器放大。还有个容易忽略的细节是覆盖索引下锁会不会更小。答案是如果SQL只需要二级索引上的列就能完成判断不需要回表InnoDB有时可以只锁二级索引而不锁聚簇索引但一旦需要回表读其他列两条索引的锁都要加。因此适当把高频更新涉及的小字段放进二级索引里既提升查询性能又能减少锁冲突。5.3 从事务层面压缩锁持有时间SQL优化做到位之后锁竞争的最大剩余来源就是事务太长。压缩锁持有时间的几个实操手段事务里只放必要的SQL尽量把读操作放到事务外先查出结果。大事务拆小事务比如批量更新1万行拆成每批500行批间sleep或立即提交。不要在一个事务里调用外部接口或等待用户输入这类IO操作是锁超时的头号元凶。减少交互式事务——很多API框架里默认开启了事务但事务范围包含了接口的网络传输时间特别危险。5.4 参数调优的关键细节MySQL的锁相关参数里最该关注的有三个innodb_lock_wait_timeout 50 -- 锁等待超时时间单位秒可适当调小避免SQL长时间霸占 innodb_deadlock_detect ON -- 死锁检测开关默认开启 innodb_autoinc_lock_mode 2 -- 自增锁模式建议保持默认一个值得讨论的取舍是innodb_lock_wait_timeout。默认50秒太长线上接口等50秒早就超时了调太短又可能误伤正常的短暂排队。经验值一般是5到10秒配合应用层的重试机制。另外一个冷门但有用的点高并发秒杀场景下可以把innodb_deadlock_detect关掉用innodb_lock_wait_timeout兜底。因为死锁检测在高并发下会消耗大量的CPU去遍历等待图关掉后依靠超时回滚反而能提高吞吐。这个操作比较激进适合压测验证过的特定场景普通业务别乱关。5.5 业务层降级方案数据库锁优化的天花板是单库单表的并发能力。到这一步还顶不住就该在业务层分流了热点行拆分把同一个商品SKU的库存拆成多行比如50行库存明细每行存一部分更新时随机选一行。这是秒杀系统的经典做法。异步化削峰更新操作投递到消息队列后端串行消费消除峰值并发。分库分表把锁竞争分散到不同实例和不同表上。这是最彻底的方案但对业务改造影响最大。6. 常见问题速查表与经验技巧6.1 高频问题排查速查表现象可能原因排查手段快速处理UPDATE一直卡住直到超时行锁被其他长事务持有SHOW PROCESSLIST看Waiting for lock查INNODB_TRX找到持锁事务kill或等提交表结构修改卡死MDL锁被长SELECT阻塞SHOW PROCESSLIST看Waiting for table metadata lock找到长查询kill或等它结束插入很慢间隙锁阻塞RR隔离级别的范围锁查data_locks看是否有Gap锁换成RC级别或缩小范围死锁频繁事务加锁顺序不一致SHOW ENGINE INNODB STATUS看死锁日志统一加锁顺序缩短事务高并发下CPU飙高死锁检测遍历等待图压测确认后关掉innodb_deadlock_detect压测验证后调参6.2 独家避坑经验我踩过几次坑之后整理了几条经验不一定写在官方文档里但实战价值很高。第一SELECT ... FOR UPDATE不要轻易用于“存在性判断”。很多同学喜欢先查一下“这条数据存不存在”存在就update不存在就insert。这个操作在并发下会出现诡异的现象两个事务同时发现数据不存在同时插入然后其中一个报主键冲突或死锁。正确姿势是直接对唯一索引执行INSERT ... ON DUPLICATE KEY UPDATE让数据库自己处理冲突。第二批量更新一定要留“后门”。线上秒级大批量更新时如果事务没提交完想停都停不下来只能眼睁睁看着锁把业务拖死。我的习惯是给批量任务加一个“开关表”任务每处理一批就检查开关状态发现要停就立刻提交并退出。这比硬kill进程优雅得多。第三锁竞争问题的排查顺序要固定。先看SHOW PROCESSLIST确认卡在哪里再看INNODB_TRX找出长事务最后看data_locks确认具体锁。顺序反过来的话很容易被海量锁信息淹没几十个事务几百行锁记录看半天也看不出谁堵谁。6.3 面试高频问题速答把“MySQL锁的分类”这种常见面试题串一下拿去直接背按粒度表锁、行锁、页面锁。按模式共享锁S、排他锁X、意向锁IX/IS。按算法记录锁、间隙锁、临键锁。按用途元数据锁MDL、自增锁、插入意向锁。面试里加一道“一条UPDATE语句会加哪些锁”的进阶题回答框架是先看隔离级别RR下默认加临键锁再看条件是否走索引走主键就是记录锁走二级索引还要锁二级索引记录和对应聚簇索引范围查询会加间隙锁最后别忘了表级的意向锁。我个人在排查了这么多锁问题之后的体会是锁不是设计出来的是被业务场景逼出来的。真正的高手不是背熟锁分类而是在写SQL和设计事务时就能预判“这段代码在高并发下会不会成为锁的风暴中心”。理解了锁的底层逻辑再回来看那些慢查询、死锁、连接池打满的告警会发现每条告警背后都有一个清晰的故事。最后再分享一个小技巧维护一个“锁问题复盘文档”每次线上死锁或锁等待都把SHOW ENGINE INNODB STATUS的输出和当时的SQL存下来标注根因和解决方式。攒上几个月你会发现自己对并发控制的理解上一个台阶因为数据库的锁行为是最诚实的并发教科书。