做数据库的都知道,锁机制是MySQL并发控制的基石。不管你是写业务SQL的开发还是管实例的DBA,只要系统一上并发,锁的问题就绕不开:要么是没走索引导致行锁变成“全表锁”,把整条链路拖垮;要么是两个事务互相等锁,最后死锁被回滚。这篇内容我就围绕MySQL的锁机制,从最容易被忽略的全局锁、表级锁,一路拆到InnoDB行级锁的实现细节,把日常最容易碰到的排队场景、故障现场和处理手段完整梳理一遍。内容适合需要做业务SQL优化的开发同学,也适合需要排查锁等待和死锁的DBA参考。1. 锁到底在解决什么问题1.1 并发访问下的三类冲突先想清楚一个最简单的问题:为什么需要锁?因为数据库是共享资源,多个客户端同时读写同一份数据,如果没有约束,就会看到不一致的中间状态。写写冲突:两个事务同时修改同一行,后提交的会把先提交的覆盖掉,这是典型的丢失更新。读写冲突:一个事务在读数据,另一个事务在改数据,读方可能读到修改到一半的数据,也就是脏读,或者在一次事务内读到前后不一样的数据,也就是不可重复读。读读冲突:两个事务同时读同一份数据,大家看到的都是已提交的稳定版本,互相不影响,不需要锁。所以锁的核心使命就是让“不可并发的操作”排队执行,把可能产生脏数据、幻读、不可重复读的交叉访问隔开。理解这一点很重要,因为你后续看到间隙锁、临键锁、意向锁这些概念时,都会发现它们全部服务于同一条原则:在不该并行的时候,必须有人排队。我习惯拿洗手间打比方。读操作就像照镜子,多个人同时照镜子没问题,但有人在里面洗澡时,外面的人就不能进去。行锁就好比给每个隔间装了门锁,而后面要讲的全局锁就像整层楼只留一个卫生间,任何人洗澡时其他人只能在外面等着。1.2 锁粒度与并发能力的取舍锁粒度的选择是个典型的工程权衡。粒度越粗,加锁成本越低,但并发能力越差;粒度越细,并发能力越强,但锁管理和冲突检测的开销越大。MySQL早期默认引擎MyISAM只有表级锁,写操作会把整张表锁住,读多写少的场景还行,一旦写入密集就是灾难。后来InnoDB引入行级锁,才让MySQL在互联网高并发场景下有了一席之地。这个技术路线上的转变,本质上就是从“粗粒度低成本”向“细粒度高并发”的演进。现代MySQL里,锁粒度大致是这样一个层级:锁粒度锁的是什么并发能力主要来源全局锁整个实例最差FTWRL备份表级锁整张表差MDL、LOCK TABLES行级锁索引记录及间隙最好InnoDB你会发现一个现象:锁粒度越细,排查难度也越高。全局锁卡住时进程列表一眼就能看出来;行锁死锁则要靠专门的命令和视图一层层翻,这也是我写这篇文章的原因:把每一层锁的特征和排障方法都放在一起,遇到问题才不会慌。1.3 锁的分类框架为了后面不混乱,先给一个全貌框架。MySQL锁可以从三个维度去理解:按粒度:全局锁、表级锁、行级锁。按模式:共享锁(S锁)和排他锁(X锁)。S锁之间兼容,S锁与X锁互斥,X锁与任何锁都互斥。按实现细节:记录锁、间隙锁、临键锁、插入意向锁、自增锁、MDL锁等。模式是“能不能并行”的规则,粒度是“锁住多大范围”的边界,实现细节是“底层到底怎么锁”的手段。三者合起来,才构成你看到的锁等待和死锁现场。接下来的章节就按这个框架,从最粗的全局锁一路拆到最细的行级锁。2. 全局锁与表级锁:被低估的开销与阻塞源头2.1 全局锁:一条命令让全库只读全局锁最典型的使用方式是执行:FLUSH TABLES WITH READ LOCK;执行完这条命令后,整个实例处于只读状态,所有DML(增删改)、DDL(建表、加字段等)全部阻塞,只有SELECT能继续跑。这个操作主要用于全库逻辑备份,因为备份需要得到一份一致性快照,如果备份期间还在写入,备份出来的数据就是乱的。但这里有个很现实的坑:FTWRL的阻塞范围是全局的,只要有一个事务持有写锁没释放,或者有一处长查询,FLUSH TABLES WITH READ LOCK就会卡在那里,后面的业务全部雪崩。我见过有人把备份任务放到业务高峰期跑,结果一个慢查询拖住整库十几分钟,线上告警响成一片。解决办法是尽量不让备份走这条路。如果备份对象是InnoDB表,用mysqldump时加上--single-transaction参数,走MVCC一致性快照,全程不加全局锁。只有MyISAM表或者必须做一致性物理备份时才需要FTWRL配合,而且现在多数场景已经有专门的备份组件可以做到不阻塞业务。提示:FLUSH TABLES WITH READ LOCK拿到锁之后,一定要记得UNLOCK TABLES释放。很多新手在备份脚本里没有写释放语句,导致全库持续只读,这一条值得写进你的操作清单。2.2 表级锁:显式表锁与元数据锁表级锁分两类:显式表锁和元数据锁(MDL)。显式表锁就是我们手动执行LOCK TABLES t READ/WRITE,把整张表锁住。这类锁在业务代码里现在已经很少见,因为它会粗暴阻断所有并发路径,而MySQL官方文档也明确说,InnoDB场景下不要用LOCK TABLES来保证一致性,应该用事务。只有在做某些特殊的数据修复、归档操作时,显式表锁还有点用处。更值得关注的是MDL锁。MDL锁在MySQL 5.5时引入,解决的是表结构变更时数据不一致的问题。DML语句执行时需要拿到表的MDL读锁,DDL语句需要MDL写锁。读锁之间兼容,所以并发查询、更新完全不受影响;但写锁是排他的,一旦有事务持有MDL读锁不释放,DDL就得排队等。实战里最典型的故障场景是:一个长事务迟迟不提交,持有MDL读锁,然后有人执行ALTER TABLE加字段,ALTER一直拿不到写锁,后续所有对该表的查询和更新也会因为等MDL被堵住——因为ALTER没能拿到写锁后,新的DML虽然在拿读锁,也要排在写锁后面。最后表现为整个业务卡死。排查方法看一眼进程列表:SHOW PROCESSLIST;如果看到大量“Waiting for table metadata lock”,第一件事就是找到那个未提交的长事务,把它处理掉。很多DBA在加字段之前先检查一遍information_schema.innodb_trx,确认没有长时间未提交的事务,再执行DDL,就是为了避免这个坑。2.3 自增锁:特殊表级锁的隐性影响自增锁是我在很多文章里被一笔带过但实际经常踩坑的点。AUTO-INC Lock是InnoDB为保证自增主键连续不重复而设的一种特殊表级锁,它在INSERT语句执行时获取,但注意,它不在事务提交时释放,而是在INSERT语句结束后立即释放。这里牵扯出一个重要参数:innodb_autoinc_lock_mode。模式值行为适用场景0传统表级自增锁,语句结束释放兼容旧版本1简单插入用轻量互斥量,批量插入用表级锁MySQL 5.7及以前默认2全部使用互斥量,不锁表MySQL 8.0默认为什么MySQL 8.0敢把默认值和主从复制配合起来?因为8.0要求binlog格式为ROW,批量插入时自增值可以不连续,但行级binlog能保证主从数据一致。如果还在用MySQL 5.7且binlog是STATEMENT格式,把自增锁开到模式2,批量插入产生的自增ID缺口会被同步到从库,极端情况下主从表数据对不上。自增锁还有一个容易产生误解的地方:事务回滚不会回收已经分配的自增值,所以自增主键出现空洞是正常现象。很多人看到主键不连续就觉得数据库“出问题”了,其实这是设计上的取舍,因为加锁分配ID再去判断“这个ID回没回滚”的成本远超空洞带来的影响。3. 行级锁:InnoDB并发控制的核心3.1 三种行锁类型:记录锁、间隙锁、临键锁InnoDB行锁真正的实现不是“锁一行记录”这么简单,它实际包含三种形态。记录锁(Record Lock):只锁索引记录本身,在唯一索引等值查询命中记录时生效。间隙锁(Gap Lock):锁住两条索引记录之间的间隙,让别的事务无法在这个间隙插入新记录。临键锁(Next-Key Lock):记录锁加间隙锁的组合,默认可重复读隔离级别下,InnoDB加锁的基本单位就是临键锁。为什么需要间隙锁?根本原因是解决幻读。可重复读隔离级别要求事务内多次查询结果一致,如果只锁住已有记录,另一个事务在间隙里插入一条新记录,当前事务再查就会多出一行,这就是幻读。间隙锁就是不让别人插入这个间隙。举个例子,表里有id为1、5、10的三条记录,事务A执行:SELECT * FROM t WHERE id > 3 AND id < 9 FOR UPDATE;在可重复读隔离级别下,InnoDB不仅会锁住id=5这一行的记录,还会锁住(1,5]和(5,10]这些范围内的间隙和记录,防止其他事务插入id=4、id=6这样的值。于是你会看到明明只更新了两条记录,锁范围却比预想大得多。这个行为既是保护一致性的盾牌,也是并发性能的杀手。间隙锁之间是兼容的,但间隙锁和插入意向锁冲突,导致大量并发插入被串行化。所以我在设计阶段就会评估:业务真的需要可重复读吗?如果只需要读已提交,切换到RC隔离级别,间隙锁的行为会大幅收敛,并发插入性能明显提升。3.2 两阶段锁协议与死锁的真实成因InnoDB遵循两阶段锁协议:事务执行过程中按需加锁,但所有锁都等到事务提交或回滚时才统一释放。这句话是理解死锁的钥匙。死锁的经典场景是两个事务按相反顺序更新两张表:-- 事务A UPDATE t1 SET name='a' WHERE id=1; UPDATE t2 SET name='b' WHERE id=2; -- 事务B UPDATE t2 SET name='c' WHERE id=2; UPDATE t1 SET name='d' WHERE id=1;事务A先拿t1的锁,事务B先拿t2的锁,然后互相等对方释放手里的锁,于是死锁。InnoDB默认开启死锁检测,会主动选择回滚undo log较少的事务,让另一个事务继续执行。所以应用拿到的报错往往是Deadlock found when trying to get lock。处理死锁的常规套路是固定访问顺序:所有业务对多张表的更新统一按相同顺序执行,就不会出现交叉等待。另一个常规手段是缩小事务,把加锁时间压缩到最短。我见过很多死锁不是SQL本身设计错了,而是事务里塞了大量无关查询、远程调用,把锁的持有时间拉得太长,冲突概率自然暴涨。3.3 行锁与索引的绑定关系行级锁名字叫“行锁”,但它真正锁的是索引记录,不是无索引的堆表记录。这一点决定了锁范围和索引设计强相关。如果UPDATE或DELETE的WHERE条件能命中唯一索引,InnoDB只需要锁那一条索引记录。如果是范围条件命中了普通索引,除了锁二级索引记录,还要回表锁对应的主键索引记录。最麻烦的是WHERE条件没走索引,InnoDB只能对聚簇索引做全表扫描,然后把扫描过程中碰到的所有记录全加锁。从现象上看就是“行锁升级成了表锁”,其实并不是真升级,而是你让它扫了全表,它只能给每一行都加锁。这里有一个真实的高频事故:某个表用字符串字段做查询条件,却忘记加索引,只是隐约记得“数据量不大”。线上并发稍微一高,所有更新都在抢同一批锁,锁等待堆积,数据库连接数飙满。排查时用EXPLAIN看执行计划,type=ALL,一眼就知道问题在哪。所以行锁优化的底层逻辑十分朴素:让WHERE条件尽量走到索引,锁的自然范围就小。索引不只是加速查询,更是锁数量的“减震器”。4. 实操:锁问题排查与死锁处理4.1 常用排查命令与视图遇到锁相关问题,我有一套固定的查证顺序,从进程列表开始,一层层缩小范围。第一板斧是看进程列表:SHOW PROCESSLIST;重点观察State列,如果出现“Waiting for table metadata lock”,多半是DDL被长事务挡住;如果出现“Waiting for lock wait timeout”或者大量“Updating”,就要往行锁方向查。第二板斧是看事务和锁等待视图。MySQL 8.0里,数据锁信息可以从performance_schema直接查:SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits;MySQL 5.7及更早版本则用information_schema下的innodb_trx、innodb_lock_waits、innodb_locks三张表配合。其中innodb_trx可以看到当前所有未提交事务、运行时间和等待状态,是定位长事务的头号工具。第三板斧是分析最近一次死锁现场:SHOW ENGINE INNODB STATUS;把输出定位到LATEST DETECTED DEADLOCK段落,里面会明确列出两个事务各自执行的最后一条SQL、持有的锁和等待的锁,以及被回滚的事务。这段日志是调试死锁最重要的现场还原材料。4.2 一个完整的锁等待排查实例我在模拟项目X里处理过一个典型的行锁死锁,场景是订单状态更新和库存扣减的表都做了拆分,但不同服务写入顺序不一致。服务A的操作顺序是:先更新订单状态,再扣库存。 服务B的操作顺序是:先扣库存,再更新订单状态。当服务A的订单事务和服务B的库存事务同时跑到一半,就出现互相锁等待。看SHOW PROCESSLIST只能看到两个会话都处于“Updating”,看不到根因。真正定位靠information_schema.innodb_trx:SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;两条未提交事务同时存在,启动时间已经几十秒,trx_state都是RUNNING。再查data_lock_waits,能看到哪个事务在等待哪个事务的锁。锁等待关系一旦理清,死锁循环就清楚了。当时我的处理是:先让其中一个事务回滚,恢复业务;然后在代码层统一所有服务的更新顺序,强制“先扣库存再更新订单”,同时在事务里去掉无关的查询操作。上线后再没出现过同类死锁。注意:不要一看到锁等待就kill事务。虽然kill是快速止血手段,但被回滚的事务如果是一次大批量更新,回滚本身也会消耗大量资源。正确思路是先定位顺序问题,再决定是kill还是让它执行完。4.3 锁等待超时参数与兜底策略InnoDB有个参数控制行锁等待的最长秒数:innodb_lock_wait_timeout默认是50秒。业务等锁超过这个时间,会直接报Lock wait timeout exceeded错误。有些团队为了快速止损,把超时时间调低到3秒或5秒。这样锁冲突快速报错后应用重试,整体体验反而比傻等50秒好。但调低也要结合业务,如果事务本身执行时间就要2秒,设成3秒会让正常排队的事务频繁失败,导致大量重试,反而放大压力。兜底策略还要想清楚死锁检测开关。innodb_deadlock_detect默认开启,InnoDB会在每次加锁时检测是否有死锁循环。高并发热点行更新场景下,死锁检测本身会成为性能瓶颈,因为检测成本与等待锁的线程数呈正相关。某些团队对热点账户扣减这类业务会关闭死锁检测,同时把锁等待超时调到一个较短值,让超时回滚代替死锁检测。这个方案能用,但务必要先评估业务是否接受超时回滚这种结果,不建议在通用场景照搬。5. 设计层面几个值得记住的优化思路5.1 事务做小,锁天然就少两阶段锁协议决定了锁释放的唯一时机是事务结束。所以事务代码里每多执行一条无关SQL,锁的累计持有时间就多一秒,其他事务排队等待的概率就大一分。我经常跟业务同学强调一件事:远程调用、外部HTTP请求、消息队列发送,绝对不要放到数据库事务里。在一次线上故障里,服务端在事务里调用了第三方支付接口,网络波动导致接口卡了20秒,数据库侧的锁就跟着握了20秒,后面所有相关更新全部排队。把远程调用挪到事务外后,锁冲突立刻降了一个量级。5.2 让查询走索引是行锁的命脉行锁锁的是索引记录,这意味着索引既是加速查询的工具,也是限定锁范围的边界。一个没有索引的更新条件,会让InnoDB扫描聚簇索引并在每一行上加锁,行为上等同于一次全表写锁。所以设计阶段的索引评审不能只看SELECT查询,还要看UPDATE、DELETE的WHERE条件。我一般在模型评审时拿一条核心更新SQL,直接EXPLAIN看type范围,低于range级别就要求必须加索引。这不只是为了查询速度,更是为了锁的精准度。5.3 隔离级别的选择直接影响锁范围默认的可重复读隔离级别下,InnoDB用临键锁防止幻读,这也意味着范围查询会锁住一大片间隙。如果业务可以接受读已提交,切换到RC级别后,间隙锁基本退出舞台,锁冲突大幅减少。但切换隔离级别不是无脑优化。RC级别在binlog为STATEMENT格式时无法保证主从一致的语义,所以MySQL 8.0强制要求ROW格式才能使用RC。对于新项目,我建议直接采用RC加上行级binlog,配合应用层幂等设计,多数情况下比RR更容易预测。当然,如果有明确的幻读防护需求,还是得留在RR下并接受它的锁代价。5.4 热点行更新的特殊处理最后说一个所有高并发系统都会撞上的墙:热点行更新。典型场景是秒杀、计数器、库存扣减,大量并发都指向同一行记录。即使行锁粒度已经最细,同一行也只能有一个写者,排队是必然的。面对热点行,调整锁参数只是杯水车薪,核心思路是分流。常见手段有:把库存拆到多个子记录上,先随机选一个子账户扣减;或者把更新操作改成对一张独立流水表的追加写入,异步汇总最终数据。我曾在模拟项目X里处理过一个商品库存热点问题,当时把所有请求压到同一行库存字段上,高峰期锁等待日志刷屏。后来把库存拆成几十个子槽位,每次扣减随机选槽位,锁冲突直接消失,最终一致性通过定时任务汇总余额。这种设计不改变锁机制,但能从源头上绕开“同一把锁”。从架构层面看,这往往比任何数据库参数调整都更有效。6. 排障习惯与一点个人心得处理锁问题这几年,我逐渐养成一个习惯:先看事务再看SQL,最后才看参数。事务列表告诉你是谁一直在占着锁,执行计划告诉你的SQL是不是扫了全表,参数调整只在这两者都确认没问题时才碰。顺序反了,很容易出现一种尴尬局面——参数调了好几个,锁冲突却毫无改善,因为根因根本不在参数上。另一个体会是,锁问题的“最佳解法”往往不在数据库层。绝大多数死锁和锁等待,拆到根子上就是事务太大、更新顺序不统一、SQL没走索引这三类。把这些在设计评审阶段解决掉,比事后在线上分析锁等待要划算得多。最后分享一个看起来不起眼但很实用的小技巧:上线核心SQL前,先在测试环境用两个并发会话模拟一下锁行为,或者直接开启general log观察实际加锁语句。用SHOW ENGINE INNODB STATUS看一眼加锁范围,能提前发现很多“看起来没问题、一并发就出事”的隐患。锁这个东西,晚发现不如早发现,早发现不如根本不让它发生。
做数据库的都知道,锁机制是MySQL并发控制的基石。不管你是写业务SQL的开发还是管实例的DBA,只要系统一上并发,锁的问题就绕不开:要么是没走索引导致行锁变成“全表锁”,把整条链路拖垮;要么是两个事务互相等锁,最后死锁被回滚。这篇内容我就围绕MySQL的锁机制,从最容易被忽略的全局锁、表级锁,一路拆到InnoDB行级锁的实现细节,把日常最容易碰到的排队场景、故障现场和处理手段完整梳理一遍。内容适合需要做业务SQL优化的开发同学,也适合需要排查锁等待和死锁的DBA参考。1. 锁到底在解决什么问题1.1 并发访问下的三类冲突先想清楚一个最简单的问题:为什么需要锁?因为数据库是共享资源,多个客户端同时读写同一份数据,如果没有约束,就会看到不一致的中间状态。写写冲突:两个事务同时修改同一行,后提交的会把先提交的覆盖掉,这是典型的丢失更新。读写冲突:一个事务在读数据,另一个事务在改数据,读方可能读到修改到一半的数据,也就是脏读,或者在一次事务内读到前后不一样的数据,也就是不可重复读。读读冲突:两个事务同时读同一份数据,大家看到的都是已提交的稳定版本,互相不影响,不需要锁。所以锁的核心使命就是让“不可并发的操作”排队执行,把可能产生脏数据、幻读、不可重复读的交叉访问隔开。理解这一点很重要,因为你后续看到间隙锁、临键锁、意向锁这些概念时,都会发现它们全部服务于同一条原则:在不该并行的时候,必须有人排队。我习惯拿洗手间打比方。读操作就像照镜子,多个人同时照镜子没问题,但有人在里面洗澡时,外面的人就不能进去。行锁就好比给每个隔间装了门锁,而后面要讲的全局锁就像整层楼只留一个卫生间,任何人洗澡时其他人只能在外面等着。1.2 锁粒度与并发能力的取舍锁粒度的选择是个典型的工程权衡。粒度越粗,加锁成本越低,但并发能力越差;粒度越细,并发能力越强,但锁管理和冲突检测的开销越大。MySQL早期默认引擎MyISAM只有表级锁,写操作会把整张表锁住,读多写少的场景还行,一旦写入密集就是灾难。后来InnoDB引入行级锁,才让MySQL在互联网高并发场景下有了一席之地。这个技术路线上的转变,本质上就是从“粗粒度低成本”向“细粒度高并发”的演进。现代MySQL里,锁粒度大致是这样一个层级:锁粒度锁的是什么并发能力主要来源全局锁整个实例最差FTWRL备份表级锁整张表差MDL、LOCK TABLES行级锁索引记录及间隙最好InnoDB你会发现一个现象:锁粒度越细,排查难度也越高。全局锁卡住时进程列表一眼就能看出来;行锁死锁则要靠专门的命令和视图一层层翻,这也是我写这篇文章的原因:把每一层锁的特征和排障方法都放在一起,遇到问题才不会慌。1.3 锁的分类框架为了后面不混乱,先给一个全貌框架。MySQL锁可以从三个维度去理解:按粒度:全局锁、表级锁、行级锁。按模式:共享锁(S锁)和排他锁(X锁)。S锁之间兼容,S锁与X锁互斥,X锁与任何锁都互斥。按实现细节:记录锁、间隙锁、临键锁、插入意向锁、自增锁、MDL锁等。模式是“能不能并行”的规则,粒度是“锁住多大范围”的边界,实现细节是“底层到底怎么锁”的手段。三者合起来,才构成你看到的锁等待和死锁现场。接下来的章节就按这个框架,从最粗的全局锁一路拆到最细的行级锁。2. 全局锁与表级锁:被低估的开销与阻塞源头2.1 全局锁:一条命令让全库只读全局锁最典型的使用方式是执行:FLUSH TABLES WITH READ LOCK;执行完这条命令后,整个实例处于只读状态,所有DML(增删改)、DDL(建表、加字段等)全部阻塞,只有SELECT能继续跑。这个操作主要用于全库逻辑备份,因为备份需要得到一份一致性快照,如果备份期间还在写入,备份出来的数据就是乱的。但这里有个很现实的坑:FTWRL的阻塞范围是全局的,只要有一个事务持有写锁没释放,或者有一处长查询,FLUSH TABLES WITH READ LOCK就会卡在那里,后面的业务全部雪崩。我见过有人把备份任务放到业务高峰期跑,结果一个慢查询拖住整库十几分钟,线上告警响成一片。解决办法是尽量不让备份走这条路。如果备份对象是InnoDB表,用mysqldump时加上--single-transaction参数,走MVCC一致性快照,全程不加全局锁。只有MyISAM表或者必须做一致性物理备份时才需要FTWRL配合,而且现在多数场景已经有专门的备份组件可以做到不阻塞业务。提示:FLUSH TABLES WITH READ LOCK拿到锁之后,一定要记得UNLOCK TABLES释放。很多新手在备份脚本里没有写释放语句,导致全库持续只读,这一条值得写进你的操作清单。2.2 表级锁:显式表锁与元数据锁表级锁分两类:显式表锁和元数据锁(MDL)。显式表锁就是我们手动执行LOCK TABLES t READ/WRITE,把整张表锁住。这类锁在业务代码里现在已经很少见,因为它会粗暴阻断所有并发路径,而MySQL官方文档也明确说,InnoDB场景下不要用LOCK TABLES来保证一致性,应该用事务。只有在做某些特殊的数据修复、归档操作时,显式表锁还有点用处。更值得关注的是MDL锁。MDL锁在MySQL 5.5时引入,解决的是表结构变更时数据不一致的问题。DML语句执行时需要拿到表的MDL读锁,DDL语句需要MDL写锁。读锁之间兼容,所以并发查询、更新完全不受影响;但写锁是排他的,一旦有事务持有MDL读锁不释放,DDL就得排队等。实战里最典型的故障场景是:一个长事务迟迟不提交,持有MDL读锁,然后有人执行ALTER TABLE加字段,ALTER一直拿不到写锁,后续所有对该表的查询和更新也会因为等MDL被堵住——因为ALTER没能拿到写锁后,新的DML虽然在拿读锁,也要排在写锁后面。最后表现为整个业务卡死。排查方法看一眼进程列表:SHOW PROCESSLIST;如果看到大量“Waiting for table metadata lock”,第一件事就是找到那个未提交的长事务,把它处理掉。很多DBA在加字段之前先检查一遍information_schema.innodb_trx,确认没有长时间未提交的事务,再执行DDL,就是为了避免这个坑。2.3 自增锁:特殊表级锁的隐性影响自增锁是我在很多文章里被一笔带过但实际经常踩坑的点。AUTO-INC Lock是InnoDB为保证自增主键连续不重复而设的一种特殊表级锁,它在INSERT语句执行时获取,但注意,它不在事务提交时释放,而是在INSERT语句结束后立即释放。这里牵扯出一个重要参数:innodb_autoinc_lock_mode。模式值行为适用场景0传统表级自增锁,语句结束释放兼容旧版本1简单插入用轻量互斥量,批量插入用表级锁MySQL 5.7及以前默认2全部使用互斥量,不锁表MySQL 8.0默认为什么MySQL 8.0敢把默认值和主从复制配合起来?因为8.0要求binlog格式为ROW,批量插入时自增值可以不连续,但行级binlog能保证主从数据一致。如果还在用MySQL 5.7且binlog是STATEMENT格式,把自增锁开到模式2,批量插入产生的自增ID缺口会被同步到从库,极端情况下主从表数据对不上。自增锁还有一个容易产生误解的地方:事务回滚不会回收已经分配的自增值,所以自增主键出现空洞是正常现象。很多人看到主键不连续就觉得数据库“出问题”了,其实这是设计上的取舍,因为加锁分配ID再去判断“这个ID回没回滚”的成本远超空洞带来的影响。3. 行级锁:InnoDB并发控制的核心3.1 三种行锁类型:记录锁、间隙锁、临键锁InnoDB行锁真正的实现不是“锁一行记录”这么简单,它实际包含三种形态。记录锁(Record Lock):只锁索引记录本身,在唯一索引等值查询命中记录时生效。间隙锁(Gap Lock):锁住两条索引记录之间的间隙,让别的事务无法在这个间隙插入新记录。临键锁(Next-Key Lock):记录锁加间隙锁的组合,默认可重复读隔离级别下,InnoDB加锁的基本单位就是临键锁。为什么需要间隙锁?根本原因是解决幻读。可重复读隔离级别要求事务内多次查询结果一致,如果只锁住已有记录,另一个事务在间隙里插入一条新记录,当前事务再查就会多出一行,这就是幻读。间隙锁就是不让别人插入这个间隙。举个例子,表里有id为1、5、10的三条记录,事务A执行:SELECT * FROM t WHERE id > 3 AND id < 9 FOR UPDATE;在可重复读隔离级别下,InnoDB不仅会锁住id=5这一行的记录,还会锁住(1,5]和(5,10]这些范围内的间隙和记录,防止其他事务插入id=4、id=6这样的值。于是你会看到明明只更新了两条记录,锁范围却比预想大得多。这个行为既是保护一致性的盾牌,也是并发性能的杀手。间隙锁之间是兼容的,但间隙锁和插入意向锁冲突,导致大量并发插入被串行化。所以我在设计阶段就会评估:业务真的需要可重复读吗?如果只需要读已提交,切换到RC隔离级别,间隙锁的行为会大幅收敛,并发插入性能明显提升。3.2 两阶段锁协议与死锁的真实成因InnoDB遵循两阶段锁协议:事务执行过程中按需加锁,但所有锁都等到事务提交或回滚时才统一释放。这句话是理解死锁的钥匙。死锁的经典场景是两个事务按相反顺序更新两张表:-- 事务A UPDATE t1 SET name='a' WHERE id=1; UPDATE t2 SET name='b' WHERE id=2; -- 事务B UPDATE t2 SET name='c' WHERE id=2; UPDATE t1 SET name='d' WHERE id=1;事务A先拿t1的锁,事务B先拿t2的锁,然后互相等对方释放手里的锁,于是死锁。InnoDB默认开启死锁检测,会主动选择回滚undo log较少的事务,让另一个事务继续执行。所以应用拿到的报错往往是Deadlock found when trying to get lock。处理死锁的常规套路是固定访问顺序:所有业务对多张表的更新统一按相同顺序执行,就不会出现交叉等待。另一个常规手段是缩小事务,把加锁时间压缩到最短。我见过很多死锁不是SQL本身设计错了,而是事务里塞了大量无关查询、远程调用,把锁的持有时间拉得太长,冲突概率自然暴涨。3.3 行锁与索引的绑定关系行级锁名字叫“行锁”,但它真正锁的是索引记录,不是无索引的堆表记录。这一点决定了锁范围和索引设计强相关。如果UPDATE或DELETE的WHERE条件能命中唯一索引,InnoDB只需要锁那一条索引记录。如果是范围条件命中了普通索引,除了锁二级索引记录,还要回表锁对应的主键索引记录。最麻烦的是WHERE条件没走索引,InnoDB只能对聚簇索引做全表扫描,然后把扫描过程中碰到的所有记录全加锁。从现象上看就是“行锁升级成了表锁”,其实并不是真升级,而是你让它扫了全表,它只能给每一行都加锁。这里有一个真实的高频事故:某个表用字符串字段做查询条件,却忘记加索引,只是隐约记得“数据量不大”。线上并发稍微一高,所有更新都在抢同一批锁,锁等待堆积,数据库连接数飙满。排查时用EXPLAIN看执行计划,type=ALL,一眼就知道问题在哪。所以行锁优化的底层逻辑十分朴素:让WHERE条件尽量走到索引,锁的自然范围就小。索引不只是加速查询,更是锁数量的“减震器”。4. 实操:锁问题排查与死锁处理4.1 常用排查命令与视图遇到锁相关问题,我有一套固定的查证顺序,从进程列表开始,一层层缩小范围。第一板斧是看进程列表:SHOW PROCESSLIST;重点观察State列,如果出现“Waiting for table metadata lock”,多半是DDL被长事务挡住;如果出现“Waiting for lock wait timeout”或者大量“Updating”,就要往行锁方向查。第二板斧是看事务和锁等待视图。MySQL 8.0里,数据锁信息可以从performance_schema直接查:SELECT * FROM performance_schema.data_locks; SELECT * FROM performance_schema.data_lock_waits;MySQL 5.7及更早版本则用information_schema下的innodb_trx、innodb_lock_waits、innodb_locks三张表配合。其中innodb_trx可以看到当前所有未提交事务、运行时间和等待状态,是定位长事务的头号工具。第三板斧是分析最近一次死锁现场:SHOW ENGINE INNODB STATUS;把输出定位到LATEST DETECTED DEADLOCK段落,里面会明确列出两个事务各自执行的最后一条SQL、持有的锁和等待的锁,以及被回滚的事务。这段日志是调试死锁最重要的现场还原材料。4.2 一个完整的锁等待排查实例我在模拟项目X里处理过一个典型的行锁死锁,场景是订单状态更新和库存扣减的表都做了拆分,但不同服务写入顺序不一致。服务A的操作顺序是:先更新订单状态,再扣库存。 服务B的操作顺序是:先扣库存,再更新订单状态。当服务A的订单事务和服务B的库存事务同时跑到一半,就出现互相锁等待。看SHOW PROCESSLIST只能看到两个会话都处于“Updating”,看不到根因。真正定位靠information_schema.innodb_trx:SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;两条未提交事务同时存在,启动时间已经几十秒,trx_state都是RUNNING。再查data_lock_waits,能看到哪个事务在等待哪个事务的锁。锁等待关系一旦理清,死锁循环就清楚了。当时我的处理是:先让其中一个事务回滚,恢复业务;然后在代码层统一所有服务的更新顺序,强制“先扣库存再更新订单”,同时在事务里去掉无关的查询操作。上线后再没出现过同类死锁。注意:不要一看到锁等待就kill事务。虽然kill是快速止血手段,但被回滚的事务如果是一次大批量更新,回滚本身也会消耗大量资源。正确思路是先定位顺序问题,再决定是kill还是让它执行完。4.3 锁等待超时参数与兜底策略InnoDB有个参数控制行锁等待的最长秒数:innodb_lock_wait_timeout默认是50秒。业务等锁超过这个时间,会直接报Lock wait timeout exceeded错误。有些团队为了快速止损,把超时时间调低到3秒或5秒。这样锁冲突快速报错后应用重试,整体体验反而比傻等50秒好。但调低也要结合业务,如果事务本身执行时间就要2秒,设成3秒会让正常排队的事务频繁失败,导致大量重试,反而放大压力。兜底策略还要想清楚死锁检测开关。innodb_deadlock_detect默认开启,InnoDB会在每次加锁时检测是否有死锁循环。高并发热点行更新场景下,死锁检测本身会成为性能瓶颈,因为检测成本与等待锁的线程数呈正相关。某些团队对热点账户扣减这类业务会关闭死锁检测,同时把锁等待超时调到一个较短值,让超时回滚代替死锁检测。这个方案能用,但务必要先评估业务是否接受超时回滚这种结果,不建议在通用场景照搬。5. 设计层面几个值得记住的优化思路5.1 事务做小,锁天然就少两阶段锁协议决定了锁释放的唯一时机是事务结束。所以事务代码里每多执行一条无关SQL,锁的累计持有时间就多一秒,其他事务排队等待的概率就大一分。我经常跟业务同学强调一件事:远程调用、外部HTTP请求、消息队列发送,绝对不要放到数据库事务里。在一次线上故障里,服务端在事务里调用了第三方支付接口,网络波动导致接口卡了20秒,数据库侧的锁就跟着握了20秒,后面所有相关更新全部排队。把远程调用挪到事务外后,锁冲突立刻降了一个量级。5.2 让查询走索引是行锁的命脉行锁锁的是索引记录,这意味着索引既是加速查询的工具,也是限定锁范围的边界。一个没有索引的更新条件,会让InnoDB扫描聚簇索引并在每一行上加锁,行为上等同于一次全表写锁。所以设计阶段的索引评审不能只看SELECT查询,还要看UPDATE、DELETE的WHERE条件。我一般在模型评审时拿一条核心更新SQL,直接EXPLAIN看type范围,低于range级别就要求必须加索引。这不只是为了查询速度,更是为了锁的精准度。5.3 隔离级别的选择直接影响锁范围默认的可重复读隔离级别下,InnoDB用临键锁防止幻读,这也意味着范围查询会锁住一大片间隙。如果业务可以接受读已提交,切换到RC级别后,间隙锁基本退出舞台,锁冲突大幅减少。但切换隔离级别不是无脑优化。RC级别在binlog为STATEMENT格式时无法保证主从一致的语义,所以MySQL 8.0强制要求ROW格式才能使用RC。对于新项目,我建议直接采用RC加上行级binlog,配合应用层幂等设计,多数情况下比RR更容易预测。当然,如果有明确的幻读防护需求,还是得留在RR下并接受它的锁代价。5.4 热点行更新的特殊处理最后说一个所有高并发系统都会撞上的墙:热点行更新。典型场景是秒杀、计数器、库存扣减,大量并发都指向同一行记录。即使行锁粒度已经最细,同一行也只能有一个写者,排队是必然的。面对热点行,调整锁参数只是杯水车薪,核心思路是分流。常见手段有:把库存拆到多个子记录上,先随机选一个子账户扣减;或者把更新操作改成对一张独立流水表的追加写入,异步汇总最终数据。我曾在模拟项目X里处理过一个商品库存热点问题,当时把所有请求压到同一行库存字段上,高峰期锁等待日志刷屏。后来把库存拆成几十个子槽位,每次扣减随机选槽位,锁冲突直接消失,最终一致性通过定时任务汇总余额。这种设计不改变锁机制,但能从源头上绕开“同一把锁”。从架构层面看,这往往比任何数据库参数调整都更有效。6. 排障习惯与一点个人心得处理锁问题这几年,我逐渐养成一个习惯:先看事务再看SQL,最后才看参数。事务列表告诉你是谁一直在占着锁,执行计划告诉你的SQL是不是扫了全表,参数调整只在这两者都确认没问题时才碰。顺序反了,很容易出现一种尴尬局面——参数调了好几个,锁冲突却毫无改善,因为根因根本不在参数上。另一个体会是,锁问题的“最佳解法”往往不在数据库层。绝大多数死锁和锁等待,拆到根子上就是事务太大、更新顺序不统一、SQL没走索引这三类。把这些在设计评审阶段解决掉,比事后在线上分析锁等待要划算得多。最后分享一个看起来不起眼但很实用的小技巧:上线核心SQL前,先在测试环境用两个并发会话模拟一下锁行为,或者直接开启general log观察实际加锁语句。用SHOW ENGINE INNODB STATUS看一眼加锁范围,能提前发现很多“看起来没问题、一并发就出事”的隐患。锁这个东西,晚发现不如早发现,早发现不如根本不让它发生。