先聊个真实的场景。有一天凌晨两点,运维群里突然有人喊“线上订单查询全都卡死了”,我上去一看,一个再普通不过的SELECT * FROM orders WHERE order_id = ...在被锁等待折磨了六十多秒。当时第一反应是:查询怎么会锁表?第二条SQL连上来还是卡住,第三条、第四条全部排队。整个业务像被按了暂停键,但CPU、IO、内存全都正常,MySQL的线程状态里几乎全是Waiting for table metadata lock。
这就是我写这篇文章的起因。很多人对“数据库查询是否锁表”这个问题,理解停留在“SELECT又不写数据,怎么可能锁表”的直觉上。但真实的生产环境不是这么简单。你跑一个慢查询,它确实不锁表;但一条慢查询拖住了DDL,DDL反过来卡死所有后续查询,这种事情在线上太常见了。今天这篇文章,我就把MySQL查询与锁机制的关系彻底拆开,从原理到锁类型,从高频事故场景到完整排查链路,一次性讲透。
1. 一条查询卡死整个库:先搞清楚锁从哪来
1.1 MVCC:为什么普通查询天生不锁表
要回答“MySQL的查询到底锁不锁表”,第一步必须先理解InnoDB引以为傲的MVCC(Multi-Version Concurrency Control,多版本并发控制)机制。MVCC的核心思路非常聪明:一份数据在数据库里并不是只有一份“最新值”,而是会保留多个历史版本,每个版本都带有一个事务ID时间戳。当你执行一条普通的SELECT查询时,InnoDB会基于当前事务的Read View(可见性视图),顺着记录的版本链往回找,找到一条“在当前时刻对你可见”的版本返回给你。
这个过程中,查询压根不需要去碰什么锁。它只是在读一份有历史版本的数据快照,就好像你透过窗户看房间里的东西——你只是在看,不需要敲门锁。这就是为什么所有教材都会告诉你:普通SELECT不加任何锁,它走的一致性读(consistent read),也叫快照读。在Repeatable Read(可重复读)隔离级别下,同一个事务内的多次查询拿到的永远是同一个快照,哪怕其他事务中途把数据改了提交了,你也看不到。
MVCC的出现,就是为了让“读”和“写”互不阻塞。底线是:如果没有MVCC,读必须等写事务提交才能进行,或者读会锁住整张表让写事务排队,那并发性能就彻底没法看了。所以,“普通查询不锁表”这句话,从原理上是成立的,但是注意,它有严格的前提——事务没有长到不可控、查询走的是纯SELECT、没有加上任何锁读子句。真实世界的坑,恰恰就出在“但是”之后。
1.2 查询的两条路径:快照读与当前读
很多后端开发干了几年,可能都没意识到:MySQL里表面看起来都是SELECT,实际走的却是两条完全不同的执行路径。
- 快照读:就是上面说的普通SELECT,读历史版本,不加锁。
- 当前读:
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE,以及所有写操作(UPDATE、DELETE、INSERT)内部必须先执行的那次“读”。当前读读到的一定是最新已提交版本,并且读完之后立即对记录加锁,防止其他事务并发修改。
这第二条路径才是“查询锁表”问题的真正战场。你执行UPDATE table SET ... WHERE ...,InnoDB在执行计划阶段会先执行一个隐式的当前读,把所有需要修改的行找出来并加上排他锁。如果这个查询的过滤条件命中不了索引,MySQL只能走全表扫描,那么理论上它扫描过的每一行都要加锁,加到最后就相当于锁了整张表。
所以我现在再回答一次“MySQL数据库查询是否锁表”:纯粹的SELECT不锁表,但查询的变体——写操作内部的那个“读”、带锁子句的读、以及因为糟糕SQL引发的范围扩大——全部都可能锁表,甚至锁全表。这也是整篇文章的核心。
2. InnoDB的锁家族:行锁、表锁、元数据锁的分工
2.1 行锁锁的是索引记录,不是存数据的行
如果你看过InnoDB的官方文档,会看到一句非常重要的话:InnoDB的行锁,实际上是加在索引记录(index record)上的锁,而不是直接锁在某一行物理数据上。这意味着,如果你的表没有合适的索引可供MySQL使用,InnoDB就只能退回到全表扫描,最终的效果是——在表的所有记录上都加上了行锁。
这也是面试和线上排查中最容易混淆的一点。很多人以为“行锁锁的是某一行数据”,但实际执行计划里没有索引时,InnoDB会对聚簇索引的全部记录挨个加锁。加上间隙锁(gap lock)的存在,锁定范围还会扩展到记录之间的间隙。表现出来,就是你明明只更新了一个WHERE name = 'zhangsan',结果整个表都写不了了。这已经不是“行锁”能解释的现象,从业务视角来看它就是锁表。
所以这里必须先给InnoDB的锁家族排一个谱:
| 锁类型 | 作用对象 | 典型来源 | 是否影响普通SELECT |
|---|---|---|---|
| 行锁(Record Lock) | 索引记录 | UPDATE/DELETE/当前读 | 不影响快照读 |
| 间隙锁(Gap Lock) | 索引记录之间的间隙 | 范围条件当前读 | 不影响快照读 |
| 临键锁(Next-Key Lock) | 记录+间隙,RR级别默认 | 范围查询/走索引的UPDATE | 不影响快照读 |
| 表锁(Table Lock) | 整张表 | LOCK TABLES、DDL等 | 影响 |
| 元数据锁(MDL) | 表的元数据 | DDL、DML | 影响(间接) |
2.2 表锁与MDL锁:结构变更才是查询卡顿的常见元凶
InnoDB下,表锁并不像MyISAM那样高频出现,真正让运维脑溢血的是MDL锁(Metadata Lock,元数据锁)。MySQL 5.5之后引入了MDL机制,用来保证在修改表结构(ALTER TABLE、DROP TABLE等DDL操作)期间,不会有其他会话还在用旧表结构执行DML或查询,否则数据会出错。
MDL的设计很清晰:普通查询、写入(DML)需要拿MDL的共享锁(S锁),DDL需要拿MDL的排他锁(X锁)。共享锁与共享锁兼容,排他锁与所有锁互斥。也就是说,如果有一个事务拿着MDL共享锁迟迟不提交,后到的DDL就只能排队等待;更麻烦的是,由于锁等待队列是先进先出的,排在DDL后面的所有新查询、新写入,全都会被这个排队的DDL挡住。
这个机制带来的连锁反应,是我在线上遇到过的所有“查询被卡死”事故的根源。生产环境里经常是这样:一个业务事务开启后执行了一条慢查询,持有了MDL读锁没有释放;这时候DBA在凌晨执行变更表结构的DDL命令,它需要MDL写锁,但发现拿不到,于是进入等待;在它等待期间,所有新到的查询都需要MDL读锁,可是写锁请求已经排在了队列最前面,新的读锁请求全部被它压住。于是,一个半小时前的那条慢查询,让它之后的所有正常的、无辜的、微小的查询全部排队卡死。数据库CPU忙得很,但所有业务全在等锁。这类事故,本质上不是“查询锁表”,而是“查询的慢”加上“DDL的急”共同制造的一场雪崩。
2.3 当前读的关键锁:gap lock 与 next-key lock
如果只是锁住已存在的记录,那InnoDB其实还防不住幻读。比如你用SELECT ... FOR UPDATE WHERE status = 'pending'锁住了两条pending记录,这时另一个事务新插入了一条pending记录,第一次查询的范围内就出现了一条“幽灵记录”(幻读)。为了应对这个问题,InnoDB在Repeatable Read隔离级别下默认开启了间隙锁。
间隙锁锁的不是哪条记录,而是“记录之间那段空荡荡的间隙”。它保证在锁被释放之前,任何事务都不能在这个间隙里插入新数据。而临键锁(next-key lock)则是“记录锁+间隙锁”的合体:既锁住记录本身,又锁住记录前面的间隙。你执行一个范围条件查询,InnoDB会把从第一个匹配记录到最后一个匹配记录之间的整个区间全部罩住。
这就是为什么即使是走索引的UPDATE/DELETE,只要条件范围稍微大一点,它的加锁范围就可能大得超乎想象。你以为自己在改十条记录,实际上下次插入新数据时,可能被锁挡在门外。而普通SELECT依然毫发无伤,因为它走快照读,根本不关心这些行锁和间隙锁。
3. 真实世界里“查询锁表”的五个典型场景
3.1 长事务拖住undo,连带阻塞DDL
先说一个我踩过的坑。一个事务里面不仅有SQL,还调了一个外部接口,业务流程跑了快半小时才提交。在这半小时里,它持有自己读过的那些记录的MVCC版本信息,InnoDB的undo log里也保存着更早的版本数据。这时候,数据库后台的purge线程发现:有个很老的事务还活着,版本链上有历史版本不能清理。于是undo log文件持续膨胀,磁盘空间肉眼可见地往下掉。
更直接的影响是,这个长事务的事务ID非常老,导致整个库里所有新产生的数据版本都要保留一条历史记录供它读取,其他事务的读写压力都会上升。而如果你这时候恰好要执行一个DDL,比如给某个重要表加索引,MDL共享锁迟迟等不到释放,在线DDL又不是真正的零锁——它需要在一个短时窗口内获取MDL写锁,结果就是DDL被卡住,接着引发连锁阻塞。
所以“长事务”虽然不会主动锁表,但它是锁问题的温床。代码评审时我很强调一点:任何事务都要短平快,严禁在事务里做远程调用、网络等待、消息发送这类IO操作。你让一个事务活太久,不是它在锁表,是它占着茅坑不拉屎,后面的人全都等着。
3.2 DDL排队:一个慢查询引发的MDL雪崩
把这次的连锁反应单独拉出来,因为它在真实生产环境里出现过太多次。完整的时间线是这样的:
- 某个业务会话A执行了一条大表上的慢查询(比如没走索引的
SELECT COUNT(*)),耗时正常需要一分钟。它持有了该表的MDL共享锁。 - DBA在这个时间点执行了
ALTER TABLE t ADD INDEX ...。DDL需要MDL排他锁,发现读锁没释放,于是会话B进入“Waiting for table metadata lock”状态。注意,这个等待没有超时时间,DBA可能以为命令还在正常执行,就一直等着。 - 从MDL锁队列有等待者那一刻起,MySQL对新的MDL读锁请求的处理规则就变了,因为要让写锁先进入,防止读锁不断插队导致写操作饿死。结果就是,所有后续的普通SELECT全部排在DDL后面,大家一起等。
- 线上所有查询都变成
Waiting for table metadata lock。
在这个场景里,查询本身没有加任何写锁,是“一个从未提交的慢查询 + 一次DDL + MySQL的MDL排队机制”三者的组合拳,把整张表所有访问全部冰封住。事后复盘你会发现,这三件事单拆开任何一件都不可怕,但凑在一起就是雪崩。所以MySQL官方才推出了LOCK_WAIT_TIMEOUT参数来控制MDL锁等待超时,可惜很多线上环境的默认配置并不合适,后面我会细说。
3.3 索引失效让UPDATE变成全表加锁
数据库查询是否锁表,第三个高频场景是由于索引问题导致的。假设表里有一个status字段,分布非常不均匀,你执行:
UPDATE orders SET status = 'refunded' WHERE status = 'pending';如果优化器判断走status索引需要扫描的行数占比太高,不如全表扫描划算,它就会放弃这个索引,转而扫描整个聚簇索引。在Repeatable Read隔离级别下,InnoDB会在扫描过程中对每一行都加临键锁。哪怕你只是想改其中一小部分行,但实际上所有被扫描过的行、以及行与行之间所有间隙,全部被罩住了。业务后续的INSERT、UPDATE、DELETE全部阻塞,表现和“锁表”一模一样。
这里有个必须澄清的误区:很多人说“行锁升级成表锁”,这个说法在InnoDB里是不严谨的。InnoDB没有真正的“锁升级”机制,它只是用大量行锁覆盖了全部行和间隙,结果上等效于锁表。你通过performance_schema.data_locks去查,会看到一条条锁记录,但几乎覆盖了整张表。理解这一点很重要,因为排查的时候你得意识到:问题不是某个锁把表锁了,而是索引选择失误导致一行接一行地被加锁。
解决这类问题,核心还是回到索引。一是确保WHERE条件上的字段有合适的索引;二是注意函数、隐式类型转换、%like%这类会导致索引失效的写法;三是如果优化器就是不走索引,可以通过FORCE INDEX强制使用索引。这些都是在真实业务里挨过打之后的经验。
3.4 FOR UPDATE 当前读引发的死锁
如果你写过订单支付、库存扣减这类代码,大概率用过SELECT ... FOR UPDATE。当前读是解决并发超卖的最直观手段,但它也是死锁的重灾区。
一个典型的双事务场景:事务A先锁定了订单表order_id=1,然后再去锁库存表sku_id=100;事务B先锁定了库存表sku_id=100,再去锁订单表order_id=1。如果两个事务刚好执行到中间步骤,互相等待对方释放锁,一个标准的死锁就出现了。MySQL默认开启了死锁检测(innodb_deadlock_detect=ON),它会维护一张等待图,一旦检测到循环等待,就会选择回滚其中一个代价较小的事务,然后返回Deadlock found when trying to get lock。
死锁这个东西,很多团队第一次遇到时会被吓到,觉得系统坏了。其实它是数据库的正常保护机制,反而比“两个事务永远僵住不动”好得多。每次报错都说得很清楚:哪个语句、哪个事务持有哪把锁。我建议线上保存死锁日志,定期分析,大部分死锁都和加锁顺序不一致有关。解决办法也很工程化:所有事务内访问多个表时,严格按同一顺序加锁。
3.5 备份与大批量查询的隐性影响
备份工具mysqldump在执行时会加全局读锁(FLUSH TABLES WITH READ LOCK),然后在备份开始后瞬间释放。如果不加--single-transaction,它会用普通的SELECT去读数据,这期间DDL会被挡在外面,可能引发连锁。即便是用--single-transaction,它也会开启一个REPEATABLE READ的一致性读事务,长时间持有Read View,导致重复清除跟不上。
大批量的数据分析查询也是同样的道理。一条SELECT * FROM big_table跑半小时,期间所有对这个表的DDL都得等它。它虽然没有锁任何行,但它作为一条长时间的查询,已经占用了MDL共享锁,足以间接引发“查询卡死”的事故。所以我在团队里定的规矩是:大查询必须评估影响面,生产库上的分析请求要么走从库,要么提前申请窗口,绝不能直接一把梭。
4. 锁等待排查的完整链路:一个SQL一个SQL查
4.1 先看当前有没有锁等待
遇到查询卡死,第一件事不是重启,也不是盲杀会话,而是先看当前有没有锁等待。我会先跑一条SQL:
SELECT * FROM sys.innodb_lock_waits\G这张视图会把当前正在等待锁的事务、它要的锁、谁拿着这把锁,全部列出来。如果你用的MySQL版本比较老,没有sys库,就直接查performance_schema.data_locks和data_lock_waits,或者走老牌工具SHOW ENGINE INNODB STATUS,里面专门有一节是LATEST DETECTED DEADLOCK和TRANSACTIONS。
还有一个经典排查入口是SHOW PROCESSLIST。看到大量Waiting for table metadata lock,基本就能断定是MDL问题;看到大量Waiting for next key lock、Waiting for row lock,那就是行锁层面的竞争。
4.2 找到持锁事务与阻塞源头
定位到有锁等待之后,下一个问题是谁在持有锁不放手。我常用这条SQL查当前运行中的事务及其状态:
SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;特别注意trx_started——如果一个事务已经跑了几十分钟,那基本就是它在持续占锁。再结合sys.innodb_lock_waits给出的blocking_pid,就能锁定真正的阻塞会话。
SELECT waiting_pid, waiting_query, blocking_pid, blocking_query FROM sys.innodb_lock_waits;这时候你手里就有了两条信息:谁被阻塞了(通常是大量业务查询),谁是阻塞源头(通常是那个长事务或者大查询)。如果blocking_query显示为ALTER TABLE,且它是处于等待状态而非持有状态,那要继续往上找谁持有MDL读锁——也就是最初的慢查询。这个“一层层回溯持锁者”的过程,在事故复盘里非常关键。
4.3 从慢日志还原时间线
锁状态只能告诉你“当前谁在等谁”,还原“这条锁链是怎么一步步形成的”,还是得靠慢查询日志和binlog时间戳。
我的做法是:确认那个阻塞源事务后,去看它的SQL是不是慢查询,判断它大概什么时间点开始执行、预计什么时间点结束。再对照DDL的发起时间,基本就能还原出“慢查询→DDL排队→全表阻塞”的完整链条。这一步排查习惯很重要,因为如果你只杀掉阻塞DDL的会话,下一次同类事故还是会发生;只有分析出时间线,才知道要改的是DBA的操作流程还是业务的SQL逻辑。
5. 让查询远离锁问题的几个工程习惯
5.1 索引是第一道防线
所有锁问题的根源,都可以沿着这条线往回倒:锁范围扩大→全表扫描→没有合适的索引。所以把WHERE条件的索引建好,是防空锁问题的第一道防线。一个走ref类型索引的UPDATE,锁住的只是极少数记录和很窄的间隙;一个走全表扫描的UPDATE,锁住的就是整张表。
不过索引也不是越多越好,联合索引顺序、区分度都要评估。我的建议是:对更新频繁的表,优先保证高频更新条件走索引;对大表可以上覆盖索引让查询不回表;对生产环境的新增索引,必须在低峰期用在线DDL工具操作,避免在高峰期直接ALTER。
5.2 事务要短,锁要快放
锁的持有时间,取决于事务从开始到提交的完整时长。你可以在一条UPDATE里只加一个索引记录的行锁,但如果这个事务在提交前去调外部HTTP接口、等人工审核、做一轮耗时计算,那么这一小把锁就被握了十几秒甚至几分钟,足以让并发高的业务爆发锁等待。
所以我一直强调一个原则:数据库事务里只做和数据库相关的操作。发消息、调API、写缓存、生成文件这些动作,全部挪到事务提交之后做。如果一个业务流程确实复杂,就改成领域事务拆分,把长流程拆成多个短事务,每段各自提交。这个习惯可以解决一大半说不清道不明的锁问题。
5.3 把超时和并发控制在可失败的范围
MySQL有两个重要参数值得关注。第一个是innodb_lock_wait_timeout,默认50秒,指的是普通行锁等待超过50秒,后到的事务会报“Lock wait timeout exceeded”,直接失败。这个默认值太长,对业务来说等待50秒才失败,体验已经崩了。我一般调成3~5秒,让请求快速失败,保护其他查询不被阻塞太久。
第二个是MDL锁等待超时,可以用lock_wait_timeout控制。MySQL 5.7之后支持在ALTER TABLE语句里指定等待时间:
ALTER TABLE orders ADD INDEX idx_status (status), ALGORITHM=INPLACE, LOCK=NONE; SET SESSION lock_wait_timeout = 5;配合max_execution_time限制单条SELECT的最大执行时间,可以在源头阻止超长查询变成锁等待炸弹。线上宁可让一条慢查询报错退出,也不能让它无限制地跑下去拖垮所有人。
5.4 DDL需要工具化:要不要上pt-osc/gh-ost
如果是大表加索引、改列类型这类结构变更,直接执行原生ALTER TABLE风险很大。它虽然号称在线DDL,但执行过程中依然需要短暂获取MDL写锁,而且在某些版本或某些特殊操作下会退化成锁表操作。更麻烦的是,如果表数据量上万G,原生DDL会复制整表数据,期间持续占用大量IO资源,对线上影响非常大。
我在生产环境更倾向于用pt-osc或gh-ost这类工具。它们的核心思路是先创建一张新表、在旧表上创建触发器或利用binlog同步增量数据、把存量数据分批拷贝过去,最后在极短时间内完成表切换。这个切换窗口只需拿一次很短的MDL写锁,对业务的影响微乎其微。但注意,工具不是万能的——有触发器冲突、外键约束、超大表空间等特殊情况时,还是要回到评估和演练。
6. 复盘:一次典型的MDL锁雪崩事故
6.1 事故现象与初步判断
有一次我接手一个电商系统的排查,现象是:订单查询接口全部超时,数据库监控面板上活跃会话数满屏。我当时第一句问值班同学:“执行SHOW PROCESSLIST,看State是不是Waiting for table metadata lock。”他回复“全是的”,我心里基本就有了答案。这是一次非常教科书级别的MDL锁雪崩。
接下来按顺序排查:先查sys.innodb_lock_waits,没有行锁等待记录;再查information_schema.innodb_trx,看到一个事务从凌晨两点开始,trx_started已经快一个小时,trx_query显示它是某个报表模块的SELECT,还开着事务没提交。再从慢日志找到这个报表SQL的执行计划,果然没有命中任何可用索引,跑了整整二十分钟还没结束。
6.2 根因链条
把时间线拼出来,整个过程是这样的:凌晨两点,某报表服务发起了大表查询,由于没有索引,SQL走了全表扫描,执行时间很长,持有订单表的MDL读锁;凌晨两点十分,DBA上线执行ALTER TABLE orders ADD INDEX ...,需要MDL写锁,但被读锁卡住,进入等待队列;从这一刻开始,订单服务的所有新查询都因为这个排队的DDL而排队,业务接口瞬间被打满。整个链条里,没有一条SQL在主动写数据,但整个订单表对外表现为“完全不可用”。
这事给我们的教训是深刻的:第一,报表查询没有走从库,直接打主库;第二,大查询没有设置max_execution_time,让它无限执行;第三,DBA的变更窗口和业务高峰没有错开,变更前也没有检查是否存在长时间运行的查询。这三条,任何一条做到位,这次事故都大概率不会发生。
6.3 修复动作与事后改进
修复的时候,我没有急着去杀DBA的ALTER语句,而是先处理根因:把那个持有MDL读锁的长查询会话KILL掉。MDL读锁一释放,排队的ALTER第一时间拿到了写锁,很快就执行完了;写锁释放后,所有排队的查询立刻恢复。整个过程大概十秒,业务就回归正常了。如果你反过来先杀DDL,会释放写锁请求队列,新查询倒是能恢复,但表结构变更依然没做成,属于治标不治本。
事后我和团队一起做了几件事:给报表查询涉及的所有过滤字段补上索引;在报表服务侧强制走只读从库;给核心表的DDL统一改用pt-osc并在非高峰执行;把lock_wait_timeout设成5秒;监控里把所有Waiting for table metadata lock状态当作P0告警。这些改动落地之后,半年内没再发生过第二次同类事故。
关于“数据库查询是否锁表”,我在实际运维中最大的体会就是:别把这句话当作一句静态的结论去背,而是要理解它背后有条件。普通查询靠MVCC确实不锁表,但查询的慢、事务的长、索引的缺、DDL的急,这些因素组合在一起,就会让表象变成“查询把表锁死了”。排查的思路永远是从锁等待出发,一层层追到持锁者,找到根因,而不是看到查询卡住就重启数据库或者盲目杀会话。如果你能把本文提到的原理和排查链路消化掉,以后再遇到“数据库查询是否锁表”这类问题,我相信你第一反应不再是翻文档,而是直接打开数据库看看到底是谁在排队、谁在持锁。