凌晨两点,我被一条告警短信叫醒:核心订单库的更新成功率掉到了40%。登录备库一看,show processlist里一大片线程卡在Waiting for table metadata lock,后面还排着一大串正在执行的SELECT。经验告诉我,这一定是某个“顺手”执行的ALTER TABLE把整张表的读写全堵死了。MySQL锁机制这种东西,平时不见面,一见面就是事故。从全局锁到表级锁再到行级锁,每一层都有它的职责,也都有自己的坑。如果你是一个需要经常跟数据库打交道的开发或运维,理解这套机制不只是为了应付面试,更是为了在线上出问题时能快速定位、快速止血。这篇内容我打算把MySQL的锁体系从头到尾捋一遍,重点讲清楚每种锁到底锁的是什么、为什么这样设计、以及碰到锁等待和死锁时该怎么排查。1. 锁的本质:粒度越细,并发越高,但成本也越高1.1 一个UPDATE背后到底有几把锁很多刚开始接触MySQL的同学以为,执行一条UPDATE account SET balance = balance - 100 WHERE id = 123;就是简单地锁住这一行。实际上,这句SQL在InnoDB里会经历好几层加锁动作。首先,InnoDB会根据主键索引找到id=123那一条记录,在它上面加一把X型记录锁,也就是Record Lock。这个X锁的意思是排他锁,其他事务既不能修改这条记录,也不能对它加共享锁或排他锁。几乎同时,这个事务还会在表上获得一个IX锁,也就是意向排他锁。这个IX锁是表级别的,用来告诉其他事务“我在这张表的某些行上加了排他锁”,避免后续有人再从表级别粗暴地加一把X表锁。如果这条UPDATE的WHERE条件走的是二级索引,情况还会更复杂。InnoDB会在二级索引记录上加锁,同时还要在对应的聚簇索引记录上加锁,防止通过不同索引路径访问同一行时产生冲突。再极端一点,如果WHERE条件没有命中任何索引,InnoDB就不得不扫描聚簇索引的全部记录,那每扫到一条记录都可能会加锁,结果就是整张表的所有行都背上了锁。明明说的是行级锁,实战中却经常锁出“表锁”的效果。所以,别看一句话更新,背后其实是“行锁+间隙锁+意向锁”一整套组合拳。理解了这个,后面分析锁等待就顺理成章了。1.2 三种粒度如何各司其职MySQL的锁按粒度可以分为三层:全局锁、表级锁、行级锁。全局锁锁的是整个数据库实例,拿到它之后,所有数据库的写入操作都会被阻塞。表级锁锁的是某一张表,可以同时锁多张。行级锁锁的是InnoDB索引记录中的一行(或者一个间隙),是所有锁里粒度最细的。它们的关系可以类比成家里防盗门、卧室门和保险柜锁。全局锁相当于把整栋房子的大门反锁,所有人都别想进;表级锁相当于把卧室门锁了,你可以在客厅活动,但进不了卧室;行级锁相当于连保险柜的某一格都单独锁住了,其他格子的东西照常取用。显然,防盗门最重,保险柜锁最轻。但轻不代表没代价——锁越细,InnoDB需要管理的锁对象就越多,检测死锁的成本也越高,内存开销也会涨。MySQL之所以保留三层,不是因为设计者喜欢复杂,而是每个层级对应着不同场景下的不同诉求:全局锁服务于全库一致性备份、全实例只读切换这类极端操作;表级锁中的MDL锁保护的是表结构元数据,让DDL和DML之间保持秩序;行级锁则是为了支撑在线业务的高并发读写,把冲突面降到最小。1.3 MySQL为什么不能只用行锁可能有人会问:既然行锁粒度最细,是不是可以把所有锁都替换成行锁?答案是不行,因为有一些操作的本质决定了它必须要“锁全部”。比如修改表结构。ALTER TABLE在执行过程中需要保证表结构的元数据不被其他并发读写干扰。如果只锁某一行,怎么保证整个表的结构一致?数据库的表结构是全局的,任何一个连接访问这张表时都要读取元数据,所以必须在Server层加一把“元数据锁”(MDL锁),让所有访问这张表的请求都排队。再比如,做物理备份时,如果希望得到一个时间点完全一致的数据快照,在MyISAM这种不支持MVCC的存储引擎下,唯一简单的办法就是把整个库或整张表锁起来,禁止所有写入,然后复制文件。即便是InnoDB,它在很多操作中也依赖表级意向锁来与行锁配合。没有意向锁,判断“这张表能不能加表锁”就需要去遍历所有行的锁状态,效率极低。因此,行锁不是万能的,它是MySQL锁体系里最精细但绝非唯一的一环。2. 全局锁:一锁全库,那是早期备份时代的粗放手段2.1 FTWRL的完整流程与代价MySQL里执行全局锁的经典命令是:FLUSH TABLES WITH READ LOCK;俗称FTWRL。这条命令会做两件事:先把当前所有打开的表关闭并刷到磁盘,然后对整个实例加一把全局读锁。锁加上之后,其他连接只能执行SELECT操作,任何UPDATE、DELETE、INSERT、DDL都会被阻塞,连提交中的事务也会卡在后面。解锁命令是:UNLOCK TABLES;注意,FLUSH TABLES WITH READ LOCK只读锁对线上业务的影响就是灾难级的。假设此时有一个非常频繁的写流量,一个全局锁可能让所有写请求在几秒内全部堆积,连接数瞬时飙升,应用层超时,甚至引发雪崩。我在早年间参与过一些用MyISAM的表建的业务,备份时一执行FTWRL,立刻能感受到全站写暂停的“窒息感”。这个代价不是单纯“慢几秒”的问题。如果持有全局锁的会话因为客户端断开而异常退出,MySQL会自动释放锁;但如果持有锁的是一个长期活跃的事务,它可能一直不提交,后续所有写线程会一直等待。所以,使用全局锁必须非常谨慎,要确保持有时间极短,并且有超时机制兜底。2.2 用--single-transaction做无锁备份的底层逻辑现在做InnoDB逻辑备份,主流方案是mysqldump带上--single-transaction参数。这个参数的核心逻辑是:在备份开始时启动一个一致性快照事务,让备份进程通过MVCC(多版本并发控制)读取数据的历史版本,而不是直接锁住正在被修改的行。具体来说,mysqldump --single-transaction会先执行START TRANSACTION WITH CONSISTENT SNAPSHOT,在REPEATABLE READ隔离级别下,事务得到一个一致性视图。之后备份进程读取任何一条记录时,InnoDB都会根据视图版本去构建对应版本的数据,于是即使业务还在并发更新,备份也能看到一致的数据。整个过程不需要加全局读锁,业务写操作完全不受影响。但有两个前提必须记住:所有要备份的表必须都是InnoDB。如果有MyISAM表,--single-transaction对它们无效,备份时仍会退化为FTWRL,或者至少对MyISAM表加锁。备份过程中不能执行DDL。因为一致性快照是针对旧表结构的,中途ALTER TABLE会让备份进程读到不匹配的元数据。所以在决定用--single-transaction之前,我通常先确认一遍库里有没有MyISAM表,有的话先规划迁移到InnoDB,否则备份方案会偷偷“加锁”而不自知。2.3 什么时候你不得不手动动用全局锁尽管FTWRL很暴力,但不是完全没有用。一个典型场景是混合引擎备份:如果库里既有InnoDB又有MyISAM,且暂时无法迁移,--single-transaction就拍不了板。这时为了保证备份数据的一致性,只能牺牲一小段时间的写,用FTWRL锁住全库复制文件,并且在锁的窗口内完成文件复制。另一个场景是节点切换前的“只读保险”。比如主库要做存储迁移或版本升级,希望所有业务连接只能读不能写。虽然可以设置SET GLOBAL read_only = ON,但如果连接具备SUPER权限,read_only对它无效。在特殊情况下,DBA会用FTWRL强行把写入全部挡在外面,确保切换过程没有新写入发生。我的建议是:能用MVCC解决的不碰FTWRL,能用read_only解决的不碰FTWRL。全局锁是粗放年代留下的手段,现代架构里应该尽量避免。3. 表级锁中最容易翻车的MDL锁:坑过无数人的“在线DDL”3.1 MDL锁的加锁与排队模型MySQL从5.5版本开始引入了元数据锁(Metadata Lock,简称MDL)。它不在InnoDB引擎层,而是MySQL Server层统一管理的锁。只要一个连接访问某张表,无论是SELECT还是UPDATE,都会先为这张表获取MDL读锁(SHARED)。而执行ALTER TABLE、DROP TABLE这类DDL时,则需要获取MDL写锁(EXCLUSIVE)。MDL读写锁的兼容规则很简单:多个读锁可以共存,但读锁和写锁互斥,写锁和写锁也互斥。一个事务持有MDL读锁时,DDL就只能等待;而一旦DDL开始等待MDL写锁,MySQL会让后续所有对该表的请求(包括读请求)都排队等着,而不是让后来者插队继续读。这就产生了经典的“一个人排队,所有人陪堵”现象。一条普通的SELECT可能只需要几十毫秒就拿到MDL读锁并释放,但如果你在一个长事务里做了一次查询后又长时间不提交,这个事务就一直握着MDL读锁。此时另一个会话执行ALTER TABLE,它需要MDL写锁,只能等待。随后新进来的所有SELECT、UPDATE都在MDL队列里排着,数据库的这张表瞬间“假死”。3.2 DDL阻塞DML的经典场景与online DDL的真实限制上面说的假死场景我至少遇到过三次,最典型的一次是数据分析部门发了一条ALTER TABLE ADD INDEX,原本预算跑几分钟,结果因为前面有一个跑了十几分钟的分析查询,把整个业务表卡住,线上订单更新全部超时。可能有人会觉得,MySQL 5.6之后的online DDL不是可以并发DML吗?没错,InnoDB的ALGORITHM=INPLACE确实允许部分DDL在执行阶段不阻塞DML,但有两个限制:DDL的begin和end阶段仍然需要MDL写锁,只是持有时间大幅缩短;如果前面有长事务握着MDL读锁,DDL连进入执行阶段的“入场券”都拿不到,ALGORITHM=INPLACE也无济于事。online DDL解决的是“DDL过程中能不能做DML”,而不是“DDL怎么排队”。只要MDL读锁一直不被释放,任何DDL都会堵在后面,进而带崩整张表。3.3 如何安全执行DDL:三条实战建议第一,执行DDL之前先查长事务。用information_schema里的innodb_trx表找出运行时间超过阈值的事务,确认没有长事务之后再发DDL。SELECT trx_id, trx_mysql_thread_id, trx_started, trx_query FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 10;第二,查看MDL锁的持有情况。如果已经出现Waiting for table metadata lock,可以用performance_schema.metadata_locks看当前谁持有锁:SELECT OBJECT_SCHEMA, OBJECT_NAME, LOCK_TYPE, LOCK_STATUS, OWNER_THREAD_ID FROM performance_schema.metadata_locks WHERE OBJECT_SCHEMA = 'your_db' AND OBJECT_NAME = 'your_table';第三,给DDL设置超时,避免无限等待。MySQL可以通过lock_wait_timeout控制等MDL锁的最长时间,超时后主动报错,宁可让DDL失败,也不要让它挂在队列里传染所有访问。我自己在管理高并发表时,还会额外把DDL安排在业务低峰,并且先用从库测试一遍执行耗时,评估在线的风险。4. 行级锁:InnoDB的三板斧——Record、Gap、Next-Key4.1 三类行锁的定位与关系InnoDB的行级锁并不是“锁住每一行”这么简单,严格意义上有三种核心行锁类型:Record Lock(记录锁):直接锁住索引记录。它锁的是索引项,不是物理存储行。比如WHERE id=100,如果主键索引上有100这条记录,就锁住这个索引项。Gap Lock(间隙锁):锁住索引记录之间的间隙,防止其他事务在这个间隙中插入新记录。值得注意的是,间隙锁之间不互斥,它们甚至可以被多个事务同时持有。真正和间隙锁冲突的是“插入意向锁”。Next-Key Lock(临键锁):是Record Lock和Gap Lock的合体,同时锁住记录本身和它前面的一个间隙,形成一个左开右闭的区间。另外还有一个“插入意向锁”,它不是意向锁,而是一种特殊的间隙锁。当一个事务要插入记录时,它先检测插入位置是否被间隙锁覆盖,如果没有,就获得插入意向锁,允许和其他插入意向锁并存;如果被覆盖,则必须等待。用书架来类比:Record Lock就是锁住某一本具体的书;Gap Lock就是锁住两层书架挡板之间的空位,别人不能往这个空位塞新书;Next-Key Lock就是锁住从某一本书开始,到下一本书之间的所有位置。这样既能保护既有记录,也能防止幻读。4.2 加锁规则,以及为什么无索引更新会锁全表InnoDB在可重复读(REPEATABLE READ)隔离级别下,加锁规则有一套逻辑:等值查询命中唯一索引,只需要在目标记录上加Record Lock;等值查询命中普通索引,会对这条记录加Next-Key Lock,同时在对应的主键记录上加Record Lock;范围查询,会对扫描到的所有索引记录及其间隙加Next-Key Lock;等值查询没有命中任何记录,也会在目标区间加Gap Lock。这套规则最容易被忽略的触发点是条件列没有索引。比如:UPDATE user SET status = 1 WHERE name = '张三';如果name字段上没有索引,InnoDB会扫描聚簇索引全表,把每一条扫描到的记录都加上Next-Key Lock。虽然InnoDB对外说的是“行级锁”,但实际效果几乎等同于表锁:所有现有记录被锁住,所有间隙也被锁住,任何其他事务都无法插入或者修改数据。这也是为什么我反复强调“索引设计不只是走查询,更决定锁的粒度”。一个合理索引可能让锁影响范围从十万行缩小到一行,代价和收益完全不成比例。4.3 可重复读下Next-Key Lock对幻读的抑制机制“幻读”指的是同一个事务里,同样的查询语句在两次执行时返回了不同的行集合。InnoDB的MVCC快照读(普通SELECT)天然可以解决幻读,因为快照读看到的是事务开始时的版本。但对于SELECT ... FOR UPDATE、UPDATE、DELETE这类当前读,必须读取最新版本并加锁,这时预防幻读就得靠Next-Key Lock。举个例子:-- 事务A SELECT * FROM orders WHERE id BETWEEN 100 AND 200 FOR UPDATE;这条语句会对100到200之间所有已有记录加Record Lock,同时对id<100和id=100之间的间隙、100到200之间所有记录之间的间隙都加Gap Lock。此时事务B如果尝试插入id=150的新订单,插入时就会因为命中间隙锁而阻塞,直到事务A提交。这样事务A再次执行同样的FOR UPDATE查询时,扫描结果仍然是那一批记录,不会多出幻影行。需要提示的是,Gap Lock只在REPEATABLE READ隔离级别下生效。如果改成READ COMMITTED,InnoDB会关闭大部分间隙锁,仅保留外键约束和特殊校验所需的间隙锁,因此并发压力会更小,但也意味着业务需要接受可能出现的幻读。5. 从死锁日志到锁等待:实战排查链路5.1 show engine innodb status中锁信息的正确打开方式当遇到死锁时,InnoDB会自动检测并回滚其中一个事务,同时把死锁信息写到错误日志里。我们可以手动查看最近一次死锁的详细信息:SHOW ENGINE INNODB STATUS\G输出中有一个LATEST DETECTED DEADLOCK段,里面记录了参与死锁的两个事务编号、各自执行的SQL语句、隔离级别、已经持有/等待的锁,还有最后回滚的对象。因为输出很长,很多新手会被海量信息吓到,其实只需要关注几个关键点:WAITING FOR THIS LOCK TO BE GRANTED:当前事务正在等待哪一把锁;HOLDS THE LOCK(S):对面事务持有哪一把锁;WE ROLL BACK TRANSACTION:InnoDB选择回滚哪个事务,通常是代价较小的一方。SHOW ENGINE INNODB STATUS每5秒刷新一次全局状态,但LATEST DETECTED DEADLOCK是最近一次死锁的留存,不会每次刷新都覆盖,所以排查时间比较充裕。5.2 死锁日志到底该怎么读下面是我简化过的一种典型死锁日志特征:*** (1) TRANSACTION: TRANSACTION 3883, ACTIVE 12 sec starting index read MySQL thread id 721, OS thread handle 12345, query id 987665 UPDATE account SET balance = balance - 100 WHERE account_no = 'A001' *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 28 page no 4 n bits 72 index PRIMARY of table `test`.`account` *** (2) TRANSACTION: TRANSACTION 3884, ACTIVE 8 sec starting index read UPDATE account SET balance = balance - 100 WHERE account_no = 'A002' *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 28 page no 4 n bits 72 index PRIMARY of table `test`.`account` *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 28 page no 4 n bits 72 index PRIMARY of table `test`.`account`看到这种日志,我第一反应是去看两个事务分别锁了哪些账户。account_no如果走的是唯一索引,那么每个账户本来不应该互相冲突,除非账户A001和A002的account_no对应的主键都在同一个间隙上?不,那时因为account_no没有索引,导致全表锁。如果条件列有索引,这两个事务应该各锁各的行,很少会死锁。另一种常见死锁是相互交叉持有锁:事务1:先更新订单表,再更新用户表;事务2:先更新用户表,再更新订单表。两个事务在某一时刻各自持有对方下一步要更新的行的锁,于是形成循环等待,InnoDB只能牺牲其中一个事务。日志本身不会直接告诉你“因为代码顺序不对”,但通过回滚的事务SQL和锁等待方向,能帮你快速还原业务场景。5.3 用information_schema和performance_schema定位锁等待死锁之外的另一个高频问题是“锁等待”,也就是多个事务都在等同一把锁,但尚未构成死锁。出现LOCK WAIT超时时,可以用系统库视图快速定位阻塞源。MySQL 5.7及之后,有sys.innodb_lock_waits视图:SELECT * FROM sys.innodb_lock_waits\G这个视图会直接告诉你哪个线程被哪个线程阻塞、等待了多长时间的锁、两边分别执行的SQL是什么。MySQL 8.0中,更底层的信息在performance_schema.data_locks和performance_schema.data_lock_waits里。data_locks能看到当前所有事务持有的行锁和表锁,data_lock_waits则记录了等待关系。通过以下查询可以找出阻塞链:SELECT w.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx, w.BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx, b.THREAD_ID AS blocking_thread, b.OBJECT_NAME AS table_name FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks b ON w.BLOCKING_ENGINE_LOCK_ID = b.ENGINE_LOCK_ID;定位到阻塞线程后,如果确认是长事务导致,可以KILL对应线程:KILL <thread_id>;KILL之后,锁会随事务回滚释放。但这只能解决眼前问题,根治还要回到事务逻辑和索引设计上去。6. 六个降低锁冲突的实操习惯6.1 事务短小是万能药行锁的持有时间与事务生命周期完全相同。事务越长,锁被持有的时间越久,后面所有访问同一行的事务就越容易排队。秘诀很简单:把每个事务的活儿控制到最小。一个常见错误是在事务里写业务逻辑,先发一条消息队列通知,再调用外部接口,最后才提交数据库事务。外部调用如果耗时几百毫秒,这期间数据库锁就一直扛着,其他请求自然全堵住。正确做法是把外部调用放在事务外,数据库只做必要的增删改,一切非数据库IO都移出去。另外,很多开发环境默认autocommit=0,如果代码里忘记手动COMMIT,事务会无限期挂着,隐患极大。建议在连接池配置里强制autocommit=1,只有需要显式事务时才BEGIN。6.2 所有事务都按固定顺序访问资源死锁的本质是循环等待。既然循环才有死锁,那打破循环的一个有效办法就是让所有事务都按同一个顺序访问多张表或多行记录。比如现在有两个接口A和B,接口A更新订单表再更新用户表,接口B更新用户表再更新订单表,两个并发时非常容易死锁。约定俗成的做法是:团队内统一规定访问顺序,比如“先用户,后订单”。这样一来,即使并发更新同一批记录,也只是排队等待,不会互相卡住。对于更新同一张表的多个行,也建议先在应用层对主键排序,再按主键顺序执行UPDATE。这个技巧成本极低,但可以避免大量间隙锁方向相反的交叉等待。6.3 索引设计直接决定锁粒度索引对锁的影响我在前面已经说了很多。一个没有索引的UPDATE会退化成全表加锁,这是锁冲突最严重的源头。所以给高频查询和更新条件建立合适的索引,不只是为了读得快,更是为了让写锁范围尽可能小。每次对线上慢查询或锁等待复盘时,都应该先问一句:EXPLAIN看这个WHERE条件走了什么索引?扫描行数有多少?如果扫描行数从十万缩到5行,锁范围自然大幅缩小。还要小心函数或隐式转换导致索引失效。比如字符串字段存了数字,查询时没加引号,MySQL可能选择全表扫描,那锁范围同样失控。6.4 隔离级别选择:能RC就不RR在可重复读(RR)隔离级别下,InnoDB为了阻止幻读会使用大量间隙锁。但在很多业务场景中,我们并不真正需要RR,只需要行锁保证同一行数据不丢失更新。读提交(READ COMMITTED,简称RC)下,InnoDB会关闭普通记录的Gap Lock,只保留行锁本身。这意味着:并发插入新的唯一键值不再被间隙锁挡住,锁冲突显著减少;快照读会导致不可重复读,但对多版本业务影响通常有限;使用RC时,主从复制binlog格式必须为ROW,否则容易产生数据不一致。如果业务对幻读不敏感,比如社交动态、商品SKU列表,RC往往能获得更好的并发度。我在很多高并发项目中都会主动把隔离级别调整为RC,并在数据库初始化时固定binlog_format=ROW。6.5 热点行的三种处理思路热点行是锁冲突最极端的情况,典型场景就是秒杀库存。假设库存表就一行product_stock,每次扣减都对这个热点行加锁,并发一高,所有请求都在排队等这把锁,吞吐量上不去。处理思路通常有三个:异步化:把扣减请求放到消息队列,数据库侧单线程或小并发慢慢消费,牺牲实时性换吞吐;分桶:把库存拆成多个“库存桶”,比如50个桶,每个桶存一部分库存。扣减时随机选桶,行锁就从一把变成50把,冲突大幅降低;条件更新:用UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0,直接利用行锁与条件判断完成扣减,避免“先SELECT再UPDATE”的两步操作,减少了事务持锁时间。分桶方案要注意桶间负载均衡和退换库存的回收策略,落地时需要多一些设计。但对热点行优化来说,它很有效。6.6 监控与告警的兜底最后,再好的经验也不如一套监控可靠。线上至少要监控三个指标:Innodb_row_lock_current_waits:当前正在等待行锁的事务数;Innodb_row_lock_time_avg:平均行锁等待时间;死锁计数器:Innodb_deadlocks。再配合巡检SQL,比如定期查看超过5秒的长事务:SELECT trx_id, trx_mysql_thread_id, trx_query, trx_started FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 5;一旦发现长事务数上升,就要人工介入排查。把这些检查固化到监控系统或发布流程里,锁冲突才真正变得可控。最后再分享一个小技巧:每次上线涉及DDL或批量数据变更之前,先跑一遍上面那条长事务巡检SQL,把潜在的老大难事务掐掉;上线后如果锁等待指标突然上涨,优先看sys.innodb_lock_waits,而不是闷头翻慢日志。一步一步定位,锁问题其实没有想象中那么神秘。
凌晨两点,我被一条告警短信叫醒:核心订单库的更新成功率掉到了40%。登录备库一看,show processlist里一大片线程卡在Waiting for table metadata lock,后面还排着一大串正在执行的SELECT。经验告诉我,这一定是某个“顺手”执行的ALTER TABLE把整张表的读写全堵死了。MySQL锁机制这种东西,平时不见面,一见面就是事故。从全局锁到表级锁再到行级锁,每一层都有它的职责,也都有自己的坑。如果你是一个需要经常跟数据库打交道的开发或运维,理解这套机制不只是为了应付面试,更是为了在线上出问题时能快速定位、快速止血。这篇内容我打算把MySQL的锁体系从头到尾捋一遍,重点讲清楚每种锁到底锁的是什么、为什么这样设计、以及碰到锁等待和死锁时该怎么排查。1. 锁的本质:粒度越细,并发越高,但成本也越高1.1 一个UPDATE背后到底有几把锁很多刚开始接触MySQL的同学以为,执行一条UPDATE account SET balance = balance - 100 WHERE id = 123;就是简单地锁住这一行。实际上,这句SQL在InnoDB里会经历好几层加锁动作。首先,InnoDB会根据主键索引找到id=123那一条记录,在它上面加一把X型记录锁,也就是Record Lock。这个X锁的意思是排他锁,其他事务既不能修改这条记录,也不能对它加共享锁或排他锁。几乎同时,这个事务还会在表上获得一个IX锁,也就是意向排他锁。这个IX锁是表级别的,用来告诉其他事务“我在这张表的某些行上加了排他锁”,避免后续有人再从表级别粗暴地加一把X表锁。如果这条UPDATE的WHERE条件走的是二级索引,情况还会更复杂。InnoDB会在二级索引记录上加锁,同时还要在对应的聚簇索引记录上加锁,防止通过不同索引路径访问同一行时产生冲突。再极端一点,如果WHERE条件没有命中任何索引,InnoDB就不得不扫描聚簇索引的全部记录,那每扫到一条记录都可能会加锁,结果就是整张表的所有行都背上了锁。明明说的是行级锁,实战中却经常锁出“表锁”的效果。所以,别看一句话更新,背后其实是“行锁+间隙锁+意向锁”一整套组合拳。理解了这个,后面分析锁等待就顺理成章了。1.2 三种粒度如何各司其职MySQL的锁按粒度可以分为三层:全局锁、表级锁、行级锁。全局锁锁的是整个数据库实例,拿到它之后,所有数据库的写入操作都会被阻塞。表级锁锁的是某一张表,可以同时锁多张。行级锁锁的是InnoDB索引记录中的一行(或者一个间隙),是所有锁里粒度最细的。它们的关系可以类比成家里防盗门、卧室门和保险柜锁。全局锁相当于把整栋房子的大门反锁,所有人都别想进;表级锁相当于把卧室门锁了,你可以在客厅活动,但进不了卧室;行级锁相当于连保险柜的某一格都单独锁住了,其他格子的东西照常取用。显然,防盗门最重,保险柜锁最轻。但轻不代表没代价——锁越细,InnoDB需要管理的锁对象就越多,检测死锁的成本也越高,内存开销也会涨。MySQL之所以保留三层,不是因为设计者喜欢复杂,而是每个层级对应着不同场景下的不同诉求:全局锁服务于全库一致性备份、全实例只读切换这类极端操作;表级锁中的MDL锁保护的是表结构元数据,让DDL和DML之间保持秩序;行级锁则是为了支撑在线业务的高并发读写,把冲突面降到最小。1.3 MySQL为什么不能只用行锁可能有人会问:既然行锁粒度最细,是不是可以把所有锁都替换成行锁?答案是不行,因为有一些操作的本质决定了它必须要“锁全部”。比如修改表结构。ALTER TABLE在执行过程中需要保证表结构的元数据不被其他并发读写干扰。如果只锁某一行,怎么保证整个表的结构一致?数据库的表结构是全局的,任何一个连接访问这张表时都要读取元数据,所以必须在Server层加一把“元数据锁”(MDL锁),让所有访问这张表的请求都排队。再比如,做物理备份时,如果希望得到一个时间点完全一致的数据快照,在MyISAM这种不支持MVCC的存储引擎下,唯一简单的办法就是把整个库或整张表锁起来,禁止所有写入,然后复制文件。即便是InnoDB,它在很多操作中也依赖表级意向锁来与行锁配合。没有意向锁,判断“这张表能不能加表锁”就需要去遍历所有行的锁状态,效率极低。因此,行锁不是万能的,它是MySQL锁体系里最精细但绝非唯一的一环。2. 全局锁:一锁全库,那是早期备份时代的粗放手段2.1 FTWRL的完整流程与代价MySQL里执行全局锁的经典命令是:FLUSH TABLES WITH READ LOCK;俗称FTWRL。这条命令会做两件事:先把当前所有打开的表关闭并刷到磁盘,然后对整个实例加一把全局读锁。锁加上之后,其他连接只能执行SELECT操作,任何UPDATE、DELETE、INSERT、DDL都会被阻塞,连提交中的事务也会卡在后面。解锁命令是:UNLOCK TABLES;注意,FLUSH TABLES WITH READ LOCK只读锁对线上业务的影响就是灾难级的。假设此时有一个非常频繁的写流量,一个全局锁可能让所有写请求在几秒内全部堆积,连接数瞬时飙升,应用层超时,甚至引发雪崩。我在早年间参与过一些用MyISAM的表建的业务,备份时一执行FTWRL,立刻能感受到全站写暂停的“窒息感”。这个代价不是单纯“慢几秒”的问题。如果持有全局锁的会话因为客户端断开而异常退出,MySQL会自动释放锁;但如果持有锁的是一个长期活跃的事务,它可能一直不提交,后续所有写线程会一直等待。所以,使用全局锁必须非常谨慎,要确保持有时间极短,并且有超时机制兜底。2.2 用--single-transaction做无锁备份的底层逻辑现在做InnoDB逻辑备份,主流方案是mysqldump带上--single-transaction参数。这个参数的核心逻辑是:在备份开始时启动一个一致性快照事务,让备份进程通过MVCC(多版本并发控制)读取数据的历史版本,而不是直接锁住正在被修改的行。具体来说,mysqldump --single-transaction会先执行START TRANSACTION WITH CONSISTENT SNAPSHOT,在REPEATABLE READ隔离级别下,事务得到一个一致性视图。之后备份进程读取任何一条记录时,InnoDB都会根据视图版本去构建对应版本的数据,于是即使业务还在并发更新,备份也能看到一致的数据。整个过程不需要加全局读锁,业务写操作完全不受影响。但有两个前提必须记住:所有要备份的表必须都是InnoDB。如果有MyISAM表,--single-transaction对它们无效,备份时仍会退化为FTWRL,或者至少对MyISAM表加锁。备份过程中不能执行DDL。因为一致性快照是针对旧表结构的,中途ALTER TABLE会让备份进程读到不匹配的元数据。所以在决定用--single-transaction之前,我通常先确认一遍库里有没有MyISAM表,有的话先规划迁移到InnoDB,否则备份方案会偷偷“加锁”而不自知。2.3 什么时候你不得不手动动用全局锁尽管FTWRL很暴力,但不是完全没有用。一个典型场景是混合引擎备份:如果库里既有InnoDB又有MyISAM,且暂时无法迁移,--single-transaction就拍不了板。这时为了保证备份数据的一致性,只能牺牲一小段时间的写,用FTWRL锁住全库复制文件,并且在锁的窗口内完成文件复制。另一个场景是节点切换前的“只读保险”。比如主库要做存储迁移或版本升级,希望所有业务连接只能读不能写。虽然可以设置SET GLOBAL read_only = ON,但如果连接具备SUPER权限,read_only对它无效。在特殊情况下,DBA会用FTWRL强行把写入全部挡在外面,确保切换过程没有新写入发生。我的建议是:能用MVCC解决的不碰FTWRL,能用read_only解决的不碰FTWRL。全局锁是粗放年代留下的手段,现代架构里应该尽量避免。3. 表级锁中最容易翻车的MDL锁:坑过无数人的“在线DDL”3.1 MDL锁的加锁与排队模型MySQL从5.5版本开始引入了元数据锁(Metadata Lock,简称MDL)。它不在InnoDB引擎层,而是MySQL Server层统一管理的锁。只要一个连接访问某张表,无论是SELECT还是UPDATE,都会先为这张表获取MDL读锁(SHARED)。而执行ALTER TABLE、DROP TABLE这类DDL时,则需要获取MDL写锁(EXCLUSIVE)。MDL读写锁的兼容规则很简单:多个读锁可以共存,但读锁和写锁互斥,写锁和写锁也互斥。一个事务持有MDL读锁时,DDL就只能等待;而一旦DDL开始等待MDL写锁,MySQL会让后续所有对该表的请求(包括读请求)都排队等着,而不是让后来者插队继续读。这就产生了经典的“一个人排队,所有人陪堵”现象。一条普通的SELECT可能只需要几十毫秒就拿到MDL读锁并释放,但如果你在一个长事务里做了一次查询后又长时间不提交,这个事务就一直握着MDL读锁。此时另一个会话执行ALTER TABLE,它需要MDL写锁,只能等待。随后新进来的所有SELECT、UPDATE都在MDL队列里排着,数据库的这张表瞬间“假死”。3.2 DDL阻塞DML的经典场景与online DDL的真实限制上面说的假死场景我至少遇到过三次,最典型的一次是数据分析部门发了一条ALTER TABLE ADD INDEX,原本预算跑几分钟,结果因为前面有一个跑了十几分钟的分析查询,把整个业务表卡住,线上订单更新全部超时。可能有人会觉得,MySQL 5.6之后的online DDL不是可以并发DML吗?没错,InnoDB的ALGORITHM=INPLACE确实允许部分DDL在执行阶段不阻塞DML,但有两个限制:DDL的begin和end阶段仍然需要MDL写锁,只是持有时间大幅缩短;如果前面有长事务握着MDL读锁,DDL连进入执行阶段的“入场券”都拿不到,ALGORITHM=INPLACE也无济于事。online DDL解决的是“DDL过程中能不能做DML”,而不是“DDL怎么排队”。只要MDL读锁一直不被释放,任何DDL都会堵在后面,进而带崩整张表。3.3 如何安全执行DDL:三条实战建议第一,执行DDL之前先查长事务。用information_schema里的innodb_trx表找出运行时间超过阈值的事务,确认没有长事务之后再发DDL。SELECT trx_id, trx_mysql_thread_id, trx_started, trx_query FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 10;第二,查看MDL锁的持有情况。如果已经出现Waiting for table metadata lock,可以用performance_schema.metadata_locks看当前谁持有锁:SELECT OBJECT_SCHEMA, OBJECT_NAME, LOCK_TYPE, LOCK_STATUS, OWNER_THREAD_ID FROM performance_schema.metadata_locks WHERE OBJECT_SCHEMA = 'your_db' AND OBJECT_NAME = 'your_table';第三,给DDL设置超时,避免无限等待。MySQL可以通过lock_wait_timeout控制等MDL锁的最长时间,超时后主动报错,宁可让DDL失败,也不要让它挂在队列里传染所有访问。我自己在管理高并发表时,还会额外把DDL安排在业务低峰,并且先用从库测试一遍执行耗时,评估在线的风险。4. 行级锁:InnoDB的三板斧——Record、Gap、Next-Key4.1 三类行锁的定位与关系InnoDB的行级锁并不是“锁住每一行”这么简单,严格意义上有三种核心行锁类型:Record Lock(记录锁):直接锁住索引记录。它锁的是索引项,不是物理存储行。比如WHERE id=100,如果主键索引上有100这条记录,就锁住这个索引项。Gap Lock(间隙锁):锁住索引记录之间的间隙,防止其他事务在这个间隙中插入新记录。值得注意的是,间隙锁之间不互斥,它们甚至可以被多个事务同时持有。真正和间隙锁冲突的是“插入意向锁”。Next-Key Lock(临键锁):是Record Lock和Gap Lock的合体,同时锁住记录本身和它前面的一个间隙,形成一个左开右闭的区间。另外还有一个“插入意向锁”,它不是意向锁,而是一种特殊的间隙锁。当一个事务要插入记录时,它先检测插入位置是否被间隙锁覆盖,如果没有,就获得插入意向锁,允许和其他插入意向锁并存;如果被覆盖,则必须等待。用书架来类比:Record Lock就是锁住某一本具体的书;Gap Lock就是锁住两层书架挡板之间的空位,别人不能往这个空位塞新书;Next-Key Lock就是锁住从某一本书开始,到下一本书之间的所有位置。这样既能保护既有记录,也能防止幻读。4.2 加锁规则,以及为什么无索引更新会锁全表InnoDB在可重复读(REPEATABLE READ)隔离级别下,加锁规则有一套逻辑:等值查询命中唯一索引,只需要在目标记录上加Record Lock;等值查询命中普通索引,会对这条记录加Next-Key Lock,同时在对应的主键记录上加Record Lock;范围查询,会对扫描到的所有索引记录及其间隙加Next-Key Lock;等值查询没有命中任何记录,也会在目标区间加Gap Lock。这套规则最容易被忽略的触发点是条件列没有索引。比如:UPDATE user SET status = 1 WHERE name = '张三';如果name字段上没有索引,InnoDB会扫描聚簇索引全表,把每一条扫描到的记录都加上Next-Key Lock。虽然InnoDB对外说的是“行级锁”,但实际效果几乎等同于表锁:所有现有记录被锁住,所有间隙也被锁住,任何其他事务都无法插入或者修改数据。这也是为什么我反复强调“索引设计不只是走查询,更决定锁的粒度”。一个合理索引可能让锁影响范围从十万行缩小到一行,代价和收益完全不成比例。4.3 可重复读下Next-Key Lock对幻读的抑制机制“幻读”指的是同一个事务里,同样的查询语句在两次执行时返回了不同的行集合。InnoDB的MVCC快照读(普通SELECT)天然可以解决幻读,因为快照读看到的是事务开始时的版本。但对于SELECT ... FOR UPDATE、UPDATE、DELETE这类当前读,必须读取最新版本并加锁,这时预防幻读就得靠Next-Key Lock。举个例子:-- 事务A SELECT * FROM orders WHERE id BETWEEN 100 AND 200 FOR UPDATE;这条语句会对100到200之间所有已有记录加Record Lock,同时对id<100和id=100之间的间隙、100到200之间所有记录之间的间隙都加Gap Lock。此时事务B如果尝试插入id=150的新订单,插入时就会因为命中间隙锁而阻塞,直到事务A提交。这样事务A再次执行同样的FOR UPDATE查询时,扫描结果仍然是那一批记录,不会多出幻影行。需要提示的是,Gap Lock只在REPEATABLE READ隔离级别下生效。如果改成READ COMMITTED,InnoDB会关闭大部分间隙锁,仅保留外键约束和特殊校验所需的间隙锁,因此并发压力会更小,但也意味着业务需要接受可能出现的幻读。5. 从死锁日志到锁等待:实战排查链路5.1 show engine innodb status中锁信息的正确打开方式当遇到死锁时,InnoDB会自动检测并回滚其中一个事务,同时把死锁信息写到错误日志里。我们可以手动查看最近一次死锁的详细信息:SHOW ENGINE INNODB STATUS\G输出中有一个LATEST DETECTED DEADLOCK段,里面记录了参与死锁的两个事务编号、各自执行的SQL语句、隔离级别、已经持有/等待的锁,还有最后回滚的对象。因为输出很长,很多新手会被海量信息吓到,其实只需要关注几个关键点:WAITING FOR THIS LOCK TO BE GRANTED:当前事务正在等待哪一把锁;HOLDS THE LOCK(S):对面事务持有哪一把锁;WE ROLL BACK TRANSACTION:InnoDB选择回滚哪个事务,通常是代价较小的一方。SHOW ENGINE INNODB STATUS每5秒刷新一次全局状态,但LATEST DETECTED DEADLOCK是最近一次死锁的留存,不会每次刷新都覆盖,所以排查时间比较充裕。5.2 死锁日志到底该怎么读下面是我简化过的一种典型死锁日志特征:*** (1) TRANSACTION: TRANSACTION 3883, ACTIVE 12 sec starting index read MySQL thread id 721, OS thread handle 12345, query id 987665 UPDATE account SET balance = balance - 100 WHERE account_no = 'A001' *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 28 page no 4 n bits 72 index PRIMARY of table `test`.`account` *** (2) TRANSACTION: TRANSACTION 3884, ACTIVE 8 sec starting index read UPDATE account SET balance = balance - 100 WHERE account_no = 'A002' *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 28 page no 4 n bits 72 index PRIMARY of table `test`.`account` *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 28 page no 4 n bits 72 index PRIMARY of table `test`.`account`看到这种日志,我第一反应是去看两个事务分别锁了哪些账户。account_no如果走的是唯一索引,那么每个账户本来不应该互相冲突,除非账户A001和A002的account_no对应的主键都在同一个间隙上?不,那时因为account_no没有索引,导致全表锁。如果条件列有索引,这两个事务应该各锁各的行,很少会死锁。另一种常见死锁是相互交叉持有锁:事务1:先更新订单表,再更新用户表;事务2:先更新用户表,再更新订单表。两个事务在某一时刻各自持有对方下一步要更新的行的锁,于是形成循环等待,InnoDB只能牺牲其中一个事务。日志本身不会直接告诉你“因为代码顺序不对”,但通过回滚的事务SQL和锁等待方向,能帮你快速还原业务场景。5.3 用information_schema和performance_schema定位锁等待死锁之外的另一个高频问题是“锁等待”,也就是多个事务都在等同一把锁,但尚未构成死锁。出现LOCK WAIT超时时,可以用系统库视图快速定位阻塞源。MySQL 5.7及之后,有sys.innodb_lock_waits视图:SELECT * FROM sys.innodb_lock_waits\G这个视图会直接告诉你哪个线程被哪个线程阻塞、等待了多长时间的锁、两边分别执行的SQL是什么。MySQL 8.0中,更底层的信息在performance_schema.data_locks和performance_schema.data_lock_waits里。data_locks能看到当前所有事务持有的行锁和表锁,data_lock_waits则记录了等待关系。通过以下查询可以找出阻塞链:SELECT w.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx, w.BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx, b.THREAD_ID AS blocking_thread, b.OBJECT_NAME AS table_name FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks b ON w.BLOCKING_ENGINE_LOCK_ID = b.ENGINE_LOCK_ID;定位到阻塞线程后,如果确认是长事务导致,可以KILL对应线程:KILL <thread_id>;KILL之后,锁会随事务回滚释放。但这只能解决眼前问题,根治还要回到事务逻辑和索引设计上去。6. 六个降低锁冲突的实操习惯6.1 事务短小是万能药行锁的持有时间与事务生命周期完全相同。事务越长,锁被持有的时间越久,后面所有访问同一行的事务就越容易排队。秘诀很简单:把每个事务的活儿控制到最小。一个常见错误是在事务里写业务逻辑,先发一条消息队列通知,再调用外部接口,最后才提交数据库事务。外部调用如果耗时几百毫秒,这期间数据库锁就一直扛着,其他请求自然全堵住。正确做法是把外部调用放在事务外,数据库只做必要的增删改,一切非数据库IO都移出去。另外,很多开发环境默认autocommit=0,如果代码里忘记手动COMMIT,事务会无限期挂着,隐患极大。建议在连接池配置里强制autocommit=1,只有需要显式事务时才BEGIN。6.2 所有事务都按固定顺序访问资源死锁的本质是循环等待。既然循环才有死锁,那打破循环的一个有效办法就是让所有事务都按同一个顺序访问多张表或多行记录。比如现在有两个接口A和B,接口A更新订单表再更新用户表,接口B更新用户表再更新订单表,两个并发时非常容易死锁。约定俗成的做法是:团队内统一规定访问顺序,比如“先用户,后订单”。这样一来,即使并发更新同一批记录,也只是排队等待,不会互相卡住。对于更新同一张表的多个行,也建议先在应用层对主键排序,再按主键顺序执行UPDATE。这个技巧成本极低,但可以避免大量间隙锁方向相反的交叉等待。6.3 索引设计直接决定锁粒度索引对锁的影响我在前面已经说了很多。一个没有索引的UPDATE会退化成全表加锁,这是锁冲突最严重的源头。所以给高频查询和更新条件建立合适的索引,不只是为了读得快,更是为了让写锁范围尽可能小。每次对线上慢查询或锁等待复盘时,都应该先问一句:EXPLAIN看这个WHERE条件走了什么索引?扫描行数有多少?如果扫描行数从十万缩到5行,锁范围自然大幅缩小。还要小心函数或隐式转换导致索引失效。比如字符串字段存了数字,查询时没加引号,MySQL可能选择全表扫描,那锁范围同样失控。6.4 隔离级别选择:能RC就不RR在可重复读(RR)隔离级别下,InnoDB为了阻止幻读会使用大量间隙锁。但在很多业务场景中,我们并不真正需要RR,只需要行锁保证同一行数据不丢失更新。读提交(READ COMMITTED,简称RC)下,InnoDB会关闭普通记录的Gap Lock,只保留行锁本身。这意味着:并发插入新的唯一键值不再被间隙锁挡住,锁冲突显著减少;快照读会导致不可重复读,但对多版本业务影响通常有限;使用RC时,主从复制binlog格式必须为ROW,否则容易产生数据不一致。如果业务对幻读不敏感,比如社交动态、商品SKU列表,RC往往能获得更好的并发度。我在很多高并发项目中都会主动把隔离级别调整为RC,并在数据库初始化时固定binlog_format=ROW。6.5 热点行的三种处理思路热点行是锁冲突最极端的情况,典型场景就是秒杀库存。假设库存表就一行product_stock,每次扣减都对这个热点行加锁,并发一高,所有请求都在排队等这把锁,吞吐量上不去。处理思路通常有三个:异步化:把扣减请求放到消息队列,数据库侧单线程或小并发慢慢消费,牺牲实时性换吞吐;分桶:把库存拆成多个“库存桶”,比如50个桶,每个桶存一部分库存。扣减时随机选桶,行锁就从一把变成50把,冲突大幅降低;条件更新:用UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0,直接利用行锁与条件判断完成扣减,避免“先SELECT再UPDATE”的两步操作,减少了事务持锁时间。分桶方案要注意桶间负载均衡和退换库存的回收策略,落地时需要多一些设计。但对热点行优化来说,它很有效。6.6 监控与告警的兜底最后,再好的经验也不如一套监控可靠。线上至少要监控三个指标:Innodb_row_lock_current_waits:当前正在等待行锁的事务数;Innodb_row_lock_time_avg:平均行锁等待时间;死锁计数器:Innodb_deadlocks。再配合巡检SQL,比如定期查看超过5秒的长事务:SELECT trx_id, trx_mysql_thread_id, trx_query, trx_started FROM information_schema.innodb_trx WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 5;一旦发现长事务数上升,就要人工介入排查。把这些检查固化到监控系统或发布流程里,锁冲突才真正变得可控。最后再分享一个小技巧:每次上线涉及DDL或批量数据变更之前,先跑一遍上面那条长事务巡检SQL,把潜在的老大难事务掐掉;上线后如果锁等待指标突然上涨,优先看sys.innodb_lock_waits,而不是闷头翻慢日志。一步一步定位,锁问题其实没有想象中那么神秘。