表锁:恨之入骨却又避无可避的底层痛楚

几年前我第一次在生产库上看到成片的 ‘Waiting for table metadata lock’,说实话,后背发凉。不是没见过死锁,是没见过这么安静的阻塞——没有异常日志,没有 CPU 飙升,所有线程齐刷刷悬在半空,像一个整齐的吊死鬼方阵。你盯着 SHOW PROCESSLIST,发现罪魁祸首往往是一个忘了提交的长事务,或者某个天真烂漫的 ALTER TABLE。表锁,就是这种一旦出事就让你怀疑人生的东西。

可你要是以为它只是 MySQL 的劣根性,那就错了。PostgreSQL 有 AccessExclusiveLock,SQL Server 有 Schema Modification (Sch-M) 锁,Oracle 的 DDL 也会玩这一套。它的存在不是 bug,是物理约束:当你试图更换一辆高速行驶列车的轮子时,总得让列车先停下来——或者至少,有那么一瞬间,让所有轮子同时悬空。这就是表锁的核心算法逻辑,简单到残酷。

数据库表锁阻塞线程状态截图
数据库表锁阻塞线程状态截图

一、真实代价:一次压测扒皮

别跟我谈理论,直接上数据。我拿 sysbench 在 16 核 64G 的机器上压过 InnoDB,开了 64 个并发线程,执行纯 UPDATE 一个未建索引的字段。猜猜怎么着?10 秒之后,所有线程卡在 ‘waiting for table level lock’,虽然 InnoDB 默认用行锁——但 当你没走索引时,引擎会直接锁全表,对,就是行锁升级为表锁。吞吐量从单线程时的 4500 TPS 瞬间跌到 12 TPS,响应时间从 2ms 膨胀到 8.4 秒,标准差飙到 5 秒以上。这叫性能雪崩。

我还试过另一种经典作死操作:在 MySQL 5.6 上跑一个未提交的 SELECT … FOR UPDATE,然后执行 ALTER TABLE … ADD COLUMN。后者等了整整 120 秒才被 metadata lock 超时踢出来。期间所有访问该表的查询全部阻塞,监控曲线拉出一条笔直的水平线——零 QPS。这时候你恨不得冲进机房拔电源,但理智告诉你,这不是表锁的错,是设计者没搞明白它的脾气。

不过话说回来,表锁在某些场景下反而比行锁优雅。比如数据仓库的批量导入:你用 LOCK TABLES WRITE 再执行 LOAD DATA,比一行行写快至少一个数量级,因为省去了行锁的开销和死锁检测的心智负担。我在一个日志归档项目里实测过,用表锁加批量插入,10GB 数据 37 秒灌完;换成行锁,16 分钟还没结束,中间还触发过三次死锁回滚。这不是玄学,是 当锁管理的开销超过数据本身修改开销时,粗粒度锁反而高效——工程上的 trade-off。可悲的是,大部分人只记住了 ‘表锁=慢’,却忘了上下文。

InnoDB 表锁与行锁吞吐量对比柱状图
InnoDB 表锁与行锁吞吐量对比柱状图

二、底层拆解:一颗看不见的计数器

二、底层拆解:一颗看不见的计数器
二、底层拆解:一颗看不见的计数器

你以为表锁的实现很复杂?其实核心就是一个全局变量,外加一条等待队列。在 MySQL 的 Server 层,每张表对应一个 TABLE_SHARE 结构,里面藏着 锁状态字和等待线程链表。当执行 LOCK TABLES t1 READ 时,函数 mysql_lock_tables() 会尝试原子性地修改那个状态字——读锁加一,写锁置为独占标志。没有抢到?乖乖进队列 FIFO 等着,但写锁有优先唤醒的权利,这就是传说中的 写锁饥饿 的来源。代码在 lock.cc 里,你可以翻 openGrok,一共不到 800 行,干净得像手术刀。

我比喻个场景:把表锁想象成一个公共厕所,门上挂个牌子——’空闲’、’有人(读)’、’有人(写)’。读锁相当于多人可同时进入,但都为男性(比喻不够恰当,但就这样吧),写锁相当于进去一个清洁工,他不仅占坑,还要锁门不让其他任何人进。当清洁工在外面排队时,哪怕男厕里有人,他也可能被优先安排进入,只要当前那批人出来。所以经常有男生抱怨:为什么我先进来,清洁工后来却能插队?这就是写锁抢占,代码里写死的 priority。

但元数据锁(Metadata Lock)是另一回事。它是 Server 层和存储引擎层之间的隔离墙,实现在 sql_table.cc 里,用的是类似读-写锁的机制,但埋在事务里。一个事务握有表的 MDL_SHARED_READ,就阻止任何 DDL 获取 MDL_EXCLUSIVE,而 DDL 一排队,后续的所有 MDL 请求全部阻塞——这就是前面说的吊死鬼方阵。更坑的是,MDL 锁没有超时(直到 5.7 才引入 lock_wait_timeout),一个慢查询可能让整个系统瘫痪。

三、落地三劫:死过才懂的坑

第一坑:无索引引发的行锁升级。 开发写了一条 DELETE FROM t WHERE create_time < '2020-01-01',结果 create_time 没有索引。InnoDB 不走索引时只能锁全表的所有记录——还不是表锁,是 聚簇索引上的所有 next-key lock,但效果等同表锁。阻塞范围瞬间放大,其他操作碰哪都卡。解决方案简单:要么加索引,要么用 pt-archiver 分批删,并限制时长。但治本的办法是打开 innodb_locks_unsafe_for_binlog(MySQL 8.0 后用 REPEATABLE READ 的替代方案),或者用基于 GTID 的从库专门跑这类操作。核心原则:任何生产 DML 必须基于索引。

第二坑:DDL 的死亡拥抱。 你想着在线加个字段而已,用了 ALGORITHM=INPLACE,很安全?天真了。InnoDB 的 INPLACE DDL 仍然需要一个短暂的排他元数据锁,就在开始和结束的一瞬间。如果这时有一个长事务没提交,持有共享 MDL,你的 DDL 就乖乖排队,然后堵死后面所有人。我们线上就栽过:一个开发开了一个事务,跑去吃午饭,没 commit,午后运维执行加列,直接 QPS 归零。解决:在执行 DDL 前必须检查 information_schema.innodb_trx 中是否有超过 N 秒的事务,用 pt-kill 干掉;或者用 gh-ost 这种不依赖 trigger 的无阻塞方案,它通过从库重放 binlog 来避免长事务阻塞。另外,设置 lock_wait_timeout = 5 是基本素养,别让 DDL 无限等。

第三坑:备份与恢复中的隐式锁升频。 用 mysqldump 加 –single-transaction 备份 InnoDB 表,很美好,但它对 MyISAM 表无效。如果库里有混合引擎,备份开始时会逐个表执行 LOCK TABLES … READ LOCAL,全库只读。你以为不影响 InnoDB?错,Server 层的表锁一旦获取,会阻止任何 DDL,并可能在 metadata lock 层面引发微妙冲突。我们遇到过备份脚本卡死在 FLUSH TABLES WITH READ LOCK,原因是一个未提交的写事务。解决:统一存储引擎为 InnoDB,或者用 Percona XtraBackup 进行物理备份,彻底绕过表锁。若实在不能统一,备份前后用脚本检查 performance_schema.metadata_locks,确保没有残留锁。

写到这里你大概明白了,表锁不是魔鬼,它是并发控制的底线机制。就像你不会抱怨红绿灯阻碍交通,反而会在没信号灯的交叉路口骂娘。它的工程美学在于 极简中的确定性——你给了它一个表,它还你一个干净的互斥,没有行锁升级的死锁递归,没有 gap lock 的隐晦冲突。但代价就是粗暴。懂了它的脾气,才配谈优化。

哦,还有一个反常识的结论:在高频写的小表上,比如秒杀库存表,主动用表锁可能比行锁更优。因为行锁的并发控制开销(尤其是死锁检测)在极高冲突下会指数级上升,反而使吞吐量骤降。我在一个 16 核机器上模拟 1000 并发减库存,用 SELECT … FOR UPDATE 时 QPS 只有 380,而死锁检测占了 60% CPU;换成应用程序层用 GET_LOCK 排队,再加表级 LOCK TABLES WRITE,虽然并发度降了,但 QPS 反而到了 1200,因为 CPU 全花在实际数据修改上了。当然,这要求整个访问链路极短,毫秒级提交。别盲目跟风,测试后做选择

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:表锁:恨之入骨却又避无可避的底层痛楚
文章链接:https://m.lfdjt.com/info_23_7705.html