资讯动态

MySQL锁机制全解析:从加锁规则到死锁排查与并发优化

发布时间:2026/10/20 22:22:13 来源:尧图企业网站定制
MySQL的锁机制向来是并发控制里最难啃的一块骨头。不少人在项目里遇到过锁等待超时、死锁甚至核心表流量一上来就卡死的情况但真要定位根因时往往一脸茫然。这篇内容就从我实际排查和调优的经验出发把MySQL主要是InnoDB引擎的锁机制、隔离级别、加锁规则、死锁预防和并发优化策略串一遍帮大家建立一套清晰、可落地的判断框架。无论你是刚接触MySQL的后端开发还是已经在维护线上库的DBA这篇文章都值得你花二十分钟读完。1. 并发控制这件事为什么锁会成为MySQL的核心命题1.1 没有锁的世界会乱成什么样先设想一个最简单的场景一张账户表用户A给用户B转账。整个业务逻辑是先扣A的余额再加B的余额。如果两个请求同时对同一行数据做操作一个在扣钱另一个在查余额谁先谁后都会导致结果对不上。更极端一点两个事务同时修改同一行最后写入的值取决于谁后提交。这种丢失更新、脏读、不可重复读的问题本质上都源于并发操作之间缺乏秩序。锁就是MySQL用来建立秩序的手段。它保证同一时刻只有一个事务能修改某条记录保证事务之间的操作是串行化的、可预期的。理解了这一点你就能理解为什么InnoDB要设计那么多锁类型共享锁S锁、排他锁X锁、意向锁、记录锁、间隙锁、临键锁、插入意向锁——每一种锁的诞生都是在解决特定并发场景下的特定冲突。1.2 事务和锁的配合ACID里的隔离性靠什么撑起来MySQL的事务有四大特性原子性、一致性、隔离性、持久性。其中隔离性是最难实现的因为数据库要同时在隔离和性能之间找平衡。隔离得太彻底所有操作都串行性能就没了隔离得不够数据就乱套。InnoDB的方案是MVCC加锁机制的组合拳。MVCC多版本并发控制负责处理读和写不冲突的情况让普通的SELECT查询走快照读不加锁。而真正需要加锁的场景是当前读——比如SELECT ... FOR UPDATE、UPDATE、DELETE。这些操作必须读到最新已提交的数据并且要防止别人同时改所以必须加锁。理解MVCC和锁的分工是理解整个MySQL并发控制的前提。1.3 为什么理解锁对你的实际工作这么重要很多开发者在本地开发环境基本遇不到锁问题因为并发量太低。但一到生产环境流量上来问题就全暴露了。最常见的几类锁等待超时Lock wait timeout exceeded死锁Deadlock found when trying to get lock大量事务堆积导致性能雪崩某个慢SQL把整个表的更新都堵住这些问题的根因几乎都能归结到对锁机制理解不到位。要么是事务过大导致持锁时间过长要么是SQL写法导致锁范围超出预期要么是多个事务的加锁顺序不一致导致死锁。懂得锁机制不是为了面试背题而是为了在线上出问题时能快速定位、快速止血。2. 从隔离级别反推锁设计不同场景下的锁组合逻辑2.1 四种隔离级别四种不同的并发取舍MySQL提供了四个事务隔离级别从宽松到严格分别是读未提交READ UNCOMMITTED、读已提交READ COMMITTED、可重复读REPEATABLE READ、串行化SERIALIZABLE。默认情况下MySQL使用可重复读REPEATABLE READ这也是InnoDB在多数场景下最平衡的选择。但这四个级别对锁的使用方式截然不同理解它们之间的差别很多加锁规则就能反推出来。我用一个简单的表来对照它们的特点隔离级别脏读不可重复读幻读加锁特点READ UNCOMMITTED可能可能可能读不加锁写加行锁READ COMMITTED不会可能可能快照读不加锁当前读只加记录锁无间隙锁REPEATABLE READ不会不会可能但InnoDB解决当前读加记录锁间隙锁临键锁SERIALIZABLE不会不会不会所有读都加锁2.2 REPEATABLE READ下的临键锁其实是为了干掉幻读提到幻读很多人只停留在概念层面同一个事务里两次查询第二次多出来了一些行。在REPEATABLE READ隔离级别下普通的SELECT走快照读MVCC通过版本链保证两次查询结果一致幻读问题天然被规避。但问题是当前读UPDATE、DELETE、SELECT FOR UPDATE不能走快照必须读最新数据。如果两个事务同时在范围内做当前读就可能出现幻读。InnoDB的解法是引入临键锁Next-Key Lock本质上是记录锁加间隙锁的组合它锁定一个左开右闭的区间既锁住已存在的记录也锁住这个区间内的间隙防止其他事务在这个区间插入新数据。这样一来当前读的范围也被锁死幻读就没有了立足之地。2.3 READ COMMITTED的退化为什么它不加间隙锁READ COMMITTED隔离级别下当前读只对命中的记录加记录锁不加间隙锁。它的代价是可能出现幻读但换来的是更小的锁冲突概率和更高的并发度。在那些依赖最终一致性、不要求严格防止幻读的业务里很多团队会刻意把隔离级别调整到READ COMMITTED换来更好的吞吐量。我自己在排查过程中见到过不少案例同样的SQL在REPEATABLE READ下面锁范围远大于预期切换到READ COMMITTED后锁冲突明显下降。但这里必须提醒一点切换隔离级别不是万能药如果你依赖MySQL在REPEATABLE READ下的间隙锁来防幻读改成READ COMMITTED后业务逻辑必须自己做兜底否则会出数据问题。2.4 锁的类型也会叠加从行锁到意向锁InnoDB的锁的类型其实是在行级锁和表级锁两个层面上协同工作的。行级锁包括记录锁、间隙锁、临键锁而表级锁方面InnoDB引入的是意向锁Intention Lock。意向锁是一种表级锁用来标记这个表里有没有行被锁住。事务准备给某一行加锁之前必须先给表加意向锁。这么做是为了快速判断表层面是否存在锁冲突避免每次加行锁都要遍历所有行去检查。常见的意向锁组合有意向共享锁IS事务打算在表的某些行加共享锁意向排他锁IX事务打算在表的某些行加排他锁意向锁与行锁之间的兼容性是InnoDB锁调度效率的关键。比如一个事务在更新某些行时加了IX锁另一个事务想对整个表加X锁典型场景是ALTER TABLE它会发现表上有IX锁与自己的表级X锁冲突于是立即阻塞等待而不用去遍历所有行。3. 加锁规则实战拆解从等值查询到范围查询再到索引失效3.1 主键等值查询锁定最小范围加锁规则的第一步是搞清楚SQL通过什么索引访问数据。如果走主键索引且等值命中加锁范围通常就是那一行记录。比如-- 假设id是主键 UPDATE t_user SET status 1 WHERE id 100;这条SQL只会对id100的这条记录加排他记录锁。其他任何事务对id100的更新都会被阻塞但插入新记录完全不受影响更新id101的记录也不受影响。这是最小的锁范围也是我们在优化时最希望达到的理想状态。但要注意一点如果这条UPDATE语句在事务里没有提交或回滚持有锁的时间会一直持续到事务结束。所以哪怕锁范围很小如果事务迟迟不提交依然会卡住其他事务对同一行的操作。3.2 辅助索引等值查询行锁之外的间隙锁如果WHERE条件走的是非唯一辅助索引情况就比主键等值复杂得多。举例-- 假设user_no有非唯一索引 UPDATE t_user SET status 1 WHERE user_no U10086;InnoDB会先锁住辅助索引中所有满足条件的记录然后回表锁住对应的主键记录。更关键的是由于user_no是非唯一索引InnoDB不仅要锁住已存在的U10086记录还要锁住这个记录前后之间的间隙防止其他事务插入新的User_noU10086的记录。这就是间隙锁Gap Lock。这里有个容易踩坑的点很多人以为间隙锁只影响写操作其实不然。间隙锁本质上锁的是间隙内不允许其他事务插入但它有一个特点——间隙锁之间是兼容的多个事务可以同时对同一个间隙加锁只要它们不是要插入。换句话说间隙锁主要卡的是插入操作这也是为什么你会看到明明两个事务锁的不是同一条记录却还是发生了锁等待的现象。3.3 范围查询临键锁把边界卡得很死范围查询是锁范围失控的高发区。看下面这个例子SELECT * FROM t_order WHERE order_no 20240101 ORDER BY order_no LIMIT 100 FOR UPDATE;在REPEATABLE READ下InnoDB会对满足条件的每一行加记录锁同时对每个相邻区间加间隙锁组合成临键锁。如果你查询的范围本身就覆盖了表的大部分数据那么其他事务在这个范围内的任何写或插入都会被阻塞。我在实际优化中看过一个案例某团队用SELECT ... FOR UPDATE做任务领取每次扫一批未处理的任务范围条件覆盖了大量数据。结果并发量稍微一涨整个任务表几乎被锁死。根因就是FOR UPDATE配合范围查询把锁的范围扩大到几乎整张表。这种情况的优化思路通常不是死磕SQL而是改变取任务的方案比如按主键ID点查或者用乐观锁结合版本号来替代当前读。3.4 索引失效退化成全表扫描意味着全表加锁这是比较严重的一种情况。如果WHERE条件里的索引列做了运算、函数处理或者使用了隐式类型转换导致索引失效MySQL就会全表扫描。在全表扫描过程中InnoDB的行锁实际上会逐行扫描、逐行加锁表现上接近于表锁。最典型的现象是一个普通UPDATESQL本身逻辑很简单却把整张表的其他更新都堵住了。下面这个SQL就是一个容易让索引失效的例子-- 索引设在了create_time上但这里用了DATE()函数 UPDATE t_order SET status 2 WHERE DATE(create_time) 2024-06-01;优化方式很简单改用范围条件让索引生效UPDATE t_order SET status 2 WHERE create_time 2024-06-01 AND create_time 2024-06-02;锁范围会从全表退化成命中区间的行效果立竿见影。这也是为什么我一直强调写SQL的时候一定要留意执行计划EXPLAIN看到typeALL或rows特别大时就要警惕锁范围失控的风险。3.5 加锁顺序复现一个经典死锁现场死锁的本质是多个事务互相持有对方需要的锁形成循环等待。这里用一个很经典的例子来说明加锁顺序的重要性。假设有两张表账户表account和订单表order。事务A先更新account表id1的记录再更新order表id100的记录事务B先更新order表id100的记录再更新account表id1的记录。如果两个事务几乎同时执行就可能出现A持有account锁、等待order锁而B持有order锁、等待account锁的僵局。这两个事务的加锁顺序相反是导致死锁的根本原因。解决思路也比较直接让所有事务的加锁顺序保持一致约定先account后order两个事务都按这个顺序执行循环等待就不会出现。4. 死锁场景复盘根因分析、监测手段与工程防御4.1 死锁是怎么一步步形成的继续上面的例子把时序拉细一点事务A执行 UPDATE account SET balance balance - 100 WHERE id 1; —— 拿到account id1的X锁事务B执行 UPDATE order SET status 1 WHERE id 100; —— 拿到order id100的X锁事务A继续执行 UPDATE order SET status 1 WHERE id 100; —— 发现order id100的X锁被事务B持有A进入等待事务B继续执行 UPDATE account SET balance balance 100 WHERE id 1; —— 发现account id1的X锁被事务A持有B进入等待InnoDB检测到循环等待回滚其中一个事务通常是undo日志量较小的一方让另一个事务继续很多死锁并不是在第一步就暴露的而是要等到两个事务互等的那一刻才对碰。所以死锁排查的关键是要看两个事务都在等什么锁、各持有什么锁。InnoDB的死锁检测默认是开启的它会在检测到死锁时让其中一个事务回滚并打印相关信息到错误日志。4.2 死锁检测的开关与参数死锁检测机制由innodb_deadlock_detect参数控制默认是ON。这个参数带来的一个副作用是当锁等待非常多时死锁检测本身会消耗CPU资源。所以在某些超高并发场景下有人会关闭死锁检测完全依赖innodb_lock_wait_timeout超时机制来兜底。我的建议是除非你有足够的把握和监控手段否则不要轻易关闭死锁检测。死锁检测关闭后事务会一直等到超时而超时时间默认是50秒。50秒的阻塞对线上业务来说基本等于一场灾难。实际需要调整时更要关注的是innodb_lock_wait_timeout这个参数合理缩短锁等待时间比如10秒以内一旦出现锁问题能更快暴露和止损。4.3 用SHOW ENGINE INNODB STATUS抓取死锁现场死锁发生后最直接的信息来源是错误日志和下面这条命令SHOW ENGINE INNODB STATUS;输出内容中重点看LATEST DETECTED DEADLOCK这一段里面会完整列出两个死锁事务的SQL、持锁和等锁的信息。我在排查死锁时第一步永远是先看这段输出定位两个事务分别持有什么锁、在等什么锁然后反推业务逻辑里的加锁顺序差异。这里分享一个我在实践中总结的排查流程拿到死锁日志找到两个事务的SQL和锁信息。把两个事务涉及的表、索引、WHERE条件逐一标注出来。分析两个事务的加锁顺序确认是否存在顺序相反。检查SQL的索引使用是否合理是不是因为索引失效导致锁范围扩大。从业务代码层面看是否能统一加锁顺序或者缩小事务边界。复现验证模拟并发执行两个事务确认修复后不再出现死锁。4.4 工程层面的防御手段除了改SQL和调整事务顺序工程层面还有几个防御手段非常有效第一缩短事务长度。事务里只放最少必要的SQL把无关查询移到事务外面。事务持续时间越短持锁时间越短死锁和锁等待的概率都会明显下降。第二用乐观锁替代悲观锁。很多死锁场景其实可以用版本号字段version来规避UPDATE时带上WHERE version ?如果版本不匹配则重试。这种方法在读多写少、冲突不激烈的场景下特别适合。第三统一资源顺序。如果多个业务模块都需要更新多张表约定一个固定的表更新顺序比如先account后order从源头消除循环等待。5. 并发性能优化锁粒度、索引设计与事务边界协同调整5.1 索引设计直接影响锁的粒度和数量很多人在设计索引时只考虑了查询性能完全忽略了锁的影响。实际上索引设计对锁的影响比很多人想象中更大。原因很简单锁是加在索引记录上的索引扫描越精确加的锁就越少。举个例子一个订单表里有个查询条件是状态字段status你想通过status来筛选待处理订单。如果给status建了普通索引而status的区分度很低比如只有0、1、2三个值优化器往往会放弃这个索引走全表扫描导致锁范围失控。这种情况下单列索引不如联合索引有意义比如(status, create_time)联合索引既能命中查询又能让锁范围限定在更精确的记录集内。另一个典型案例是唯一索引和普通索引的选择。前面说过非唯一索引等值查询会加间隙锁而唯一索引等值查询只加记录锁。如果业务上确实能保证字段的唯一性果断用唯一索引这不仅省去了间隙锁的开销还能减少锁冲突。5.2 事务边界的控制大事务是锁问题的第一根源大事务是并发优化的头号敌人。一个事务里包含几千条UPDATE或者包含长时间的业务计算、外部接口调用都会让锁持有时间过长。其他事务如果恰好要操作同样的记录或范围只能等待。我见过一个极端的线上事故某定时任务对一张近千万行的大表执行批量更新单事务更新了百万级数据。结果所有在线业务的UPDATE全部被阻塞数据库连接数飙升最终服务不可用。这个案例的优化方向是把大更新拆成小批次每批1000条提交一次既减少了单次持锁量也让其他操作有机会穿插执行。控制事务边界还有几个具体做法事务里的查询尽量用快照读不要动不动就FOR UPDATE第三方接口调用挪到事务外面事务只做本地DB操作避免在事务里做耗时的计算和循环批量操作分批提交单批控制在1000到5000条5.3 针对读写竞争的调优案例从锁等待到零阻塞我参与优化过一个模拟订单系统的案例业务场景是对同一条订单记录做状态流转和金额变更读写并发很高。最初线上频繁报Lock wait timeout监控里能看到大量线程处于Waiting for lock状态。排查下来有三个问题叠加事务A先读订单再跑外部风控接口最后才更新订单状态。外部接口平均耗时500毫秒导致持有订单行锁的时间被拉长到一秒以上。事务B的UPDATE语句虽然目标是同一条订单但WHERE条件里带了一个非索引字段导致优化器走了全表扫描锁范围扩大到整张表。部分业务逻辑在一个事务里循环更新多条订单锁数量多、持锁时间长。针对这三个问题分别做了调整把外部调用移出事务UPDATE的WHERE条件改成主键或唯一键点查循环更新改成批量小事务处理。调整后锁等待的告警基本消失整体吞吐量提升了3倍左右。5.4 并发参数层面的优化空间除了业务和SQL层面的调整MySQL的并发相关参数也值得关注参数建议值优化思路innodb_lock_wait_timeout5-10秒避免锁等待拖死连接让系统快速失败并重试innodb_deadlock_detectON保持开启让死锁快速暴露并及时回滚innodb_buffer_pool_size物理内存的60%-70%减少磁盘IO提高整体吞吐间接减少持锁时间transaction-isolationREAD COMMITTED或REPEATABLE READ根据业务对幻读的容忍度选择这里要特别提一句参数调整只是辅助手段它不能解决业务SQL本身的问题。如果锁等待的根源是SQL写得太差、事务太长调参数只能治标不治本。6. 锁问题的观测与定位值得长期坚持的排查习惯6.1 监控锁等待和死锁的几个核心视图排查锁问题最方便的入口是information_schema下的三张表INNODB_TRX当前正在执行的事务能看到事务状态、开始时间、等待的锁INNODB_LOCKS当前出现的锁信息MySQL较新版本中已部分迁移到performance_schemaINNODB_LOCK_WAITS锁等待关系能看到谁在等谁日常排查时一条SQL就能找到谁堵住了谁SELECT waiting_trx_id, waiting_task, blocking_trx_id, blocking_task FROM sys.innodb_lock_waits;这个视图会直接告诉我们等待者事务ID、阻塞者事务ID以及各自的状态。用它先定位阻塞源头再对应的去查INNODB_TRX里的SQL和历史状态基本能快速判断出是哪个连接持有锁太久不释放。6.2 通过连接线程状态快速发现锁堆积MySQL的连接线程状态也能反映锁问题。当大量会话都处于Waiting for lock状态时基本可以断定锁冲突已经非常严重。用performance_schema的events_statements_current或者系统自带的SHOW FULL PROCESSLIST都能看到这类信息。我的实操习惯是先看SHOW FULL PROCESSLIST找到处于Waiting for lock状态的会话。使用sys.innodb_lock_waits定位阻塞关系。找到阻塞源事务评估是否可以主动杀掉KILL。杀连接只是止血真正的修复还是要从业务SQL和事务设计入手。6.3 经验数据什么样的锁指标算正常什么样要警惕锁相关的指标没有绝对的标准值更多是要观察趋势和突刺。根据我接触过的线上系统可以给几个大致参考锁等待次数一天内偶发几次属于正常如果持续每分钟都在发生就需要排查锁等待平均时长超过1秒就值得警惕超过5秒基本会拉垮业务死锁次数每周出现个位数可能还能容忍但如果每天都有必须当天处理事务平均持锁时间以毫秒级为准超过几百毫秒就要检查事务里是否混入了非必要操作这里有一点需要提醒很多团队只看CPU、内存这些基础指标锁问题往往要到业务报告超时才发现这是很被动的。更主动的做法是把锁等待相关指标接入监控告警体系一旦出现持续锁等待或者死锁第一时间捞日志和SQL。这样处理问题的速度会快上一个量级。6.4 一次模拟故障的完整排查链路为了让大家有更直观的感受我描述一个模拟故障的排查过程。某业务中午高峰时段突然出现大量更新超时监控显示数据库连接数在短时间内打满。我登录到数据库后执行了SHOW FULL PROCESSLIST发现大概30个会话处于Waiting for lock状态全部在等同一行数据上的锁。随后查询sys.innodb_lock_waits定位到阻塞者是某个批量导入任务的事务它正在对同一张表执行大范围的UPDATE。再查看该事务的开启时间和已处理行数确认是长时间大事务持有锁不释放。处理方案分两步先KILL掉阻塞事务恢复线上服务然后对大批量更新任务增加批次切分每批次只更新1000行就提交避开业务高峰执行。整个过程从发现问题到止损控制在5分钟左右。如果没有锁的监控和查询手段直接去翻代码找SQL少说也要花半个小时。6.5 日常开发中的锁意识把优化做在前头最后分享一点日常开发中的心得体会。锁机制的内容其实并不复杂难的是把锁意识融入到每一步SQL设计里。我在写代码之前会先在脑子里过一遍这五个问题这条SQL走的是不是最优索引事务里有没有可以移出去的外部调用这个操作的锁范围是几行还是整张表多个并发事务的加锁顺序是否一致如果出现锁等待业务重试机制是否已经准备好养成这个习惯后线上遇到的锁问题会明显减少就算出现了定位也会快得多。锁机制不是靠背概念就能掌握的而是要在一次次真实的加锁、等待、死锁和排查过程中建立感觉。希望这篇内容能帮你少走一些弯路也欢迎结合实际场景多试一试时间长了你会发现它其实没有想象中那么神秘。

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

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

免费获取报价 →
↑