从900G到2G:一次惊心动魄的MySQL磁盘危机复盘

30 阅读5分钟

478c0d1da78374bf782e0de44e4a499d6bbfaef3668352f5fbaff897185f818d_1766407543813.jpg

凌晨 1 点,告警铃声砸醒了我

企业微信群里一条消息:“磁盘满了,1 小时涨了 1TB!”

我脑子嗡地一声 —— 之前见过这阵仗,都不是什么好事儿。

最快的一次,是某个 SaaS 厂商的 MySQL 实例在 30 分钟内直接炸了,原因竟然是一条 JOIN 查询 + ORDER BY 引发的临时表失控。

当时的场景是这样的:业务方没事儿来一个关联 10 张表的复杂查询,MySQL 一看 “哎呀,我需要在内存里排序”,结果内存装不下,它就说 “好吧,我写磁盘上去”—— 然后,磁盘缓存文件就像喝了激素一样,每秒钟往上蹿。

这就是on-disk 临时表的 “杀招”—— 无声、无息、直到你的硬盘尖叫!

现象:诡异的目录暴涨

打开监控截图一,var/lib/mysql这个文件夹就好像着了魔一样,早晨 8 点的时候还一切正常,到了 9 点整,容量表直接从 100G 猛增到 900G,并且这速度…… 实在是太令人吃惊了。

第一时间我的反应就是,binlog 肯定出问题了,可一检查,binlog 的 rotate,日志完完全全是正常的,这就挺奇怪的 —— 文件大小一直在不断往上涨,不过 binlog 自己却老老实实地按照预想的那样进行 rotate,没有一点儿不正常的地方。

那东西哪儿来的?

定位:#sql 打头的 “幽灵文件”

用了个硬核的命令:

bash

运行

lsof +D /var/lib/mysql

这一查不得了 —— 屏幕上闪过一堆文件句柄,我找到了凶手: #sql-ibxxx这一堆临时表文件,占了 900G!

问题就藏在这三个字母的组合里:#sql代表 MySQL 的内部临时表。

#sql开头的文件是 MySQL 在处理复杂查询时生成的临时表,用于防止内存溢出,通常情况下,这类文件会在查询完成后立即被删除,不过这一次,这个垃圾清理员显然没有正常工作。

根因:JOIN+ORDER BY 在作妖

接下来是扒根源的环节。我去翻了一下业务的 SQL 日志,发现了这条 “定时炸弹”:

sql

SELECT * FROM orders JOIN customers ON orders.cid = customers.id JOIN products ON orders.pid = products.id ORDER BY products.description LIMIT 100;

这条 SQL 看起来再正常不过了。但从 MySQL 的角度,事情完全不一样 ——

  1. JOIN 三张表,结果集有数百万行
  2. ORDER BY 要排序,排序列是products.description这个 BLOB 字段
  3. 这个 BLOB 字段太大了,MySQL 的内存无法装下整个结果集
  4. 内存被tmp_table_size限制死了(默认 16M,真的太小了)
  5. MySQL 的处理办法:“哎呀,内存装不下,那我写磁盘上去吧”—— 于是,on-disk 临时表就被创建了,而且没完没了地写、写、写……

关键的坑在这儿:tmp_table_size这个参数在这种情况下彻底失效!

我当时就想骂人。这个参数被做了这么多年的文档宣传,但你看,它在 BLOB 排序面前就成了 “纸老虎”。

验证:EXPLAIN 拆穿真相

为了确认这个 “罪魁祸首”,我跑了一个 EXPLAIN:

sql

EXPLAIN SELECT * FROM orders JOIN customers ON orders.cid = customers.id JOIN products ON orders.pid = products.id ORDER BY products.description LIMIT 100\G

结果里赫然出现了这两个标志:Using temporary ✗Using filesort ✗

还有一个关键的数值:Select_tables>2

这三个信号加在一起,翻译成人话就是:“我要在磁盘上创建临时表来干这件事,准备好了吗?”

解决:重构 SQL,让临时表滚蛋

光知道问题不行,得改!我采取了一个激进的策略:走覆盖索引,去掉SELECT *

重写后的 SQL 长这样:

sql

-- 先只查主键和需要排序的列,改用pid避免BLOB排序
SELECT oid, cid, pid FROM orders ORDER BY pid LIMIT 100;
-- 再通过主键Join一次获得完整数据
SELECT o.*, c.*, p.* FROM orders o JOIN customers c ON o.cid = c.id JOIN products p ON o.pid = p.id WHERE o.oid IN (SELECT oid FROM ...);

结果?临时文件从 900G 直接跌到 2G!效果好到不行。

预防:一条 my.cnf,救你十次命

为了防止这种 “黑天鹅” 事件再发生,我在my.cnf里加了这一行:

ini

innodb_temp_data_file_path=ibtmp1:12M:autoextend:max:50G

这组配置所传达的含义为,InnoDB 的临时表文件最长只会达到 50G,要是超过了这个界限,MySQL 会直接出现报错情况,而不是无限制地增加。

与其等着磁盘爆炸,不如提前给它加个 “天花板”。

总结与建议

现在回过头去审视,这场磁盘危机的实质其实挺简单 —— 就是一条编写得不够简洁的 SQL,在特定数据量的情况下,引发了 MySQL 的应急机制。

你可能没意识到,这类问题,在实际生产环境中远比想象中普遍,不少团队并未发现,他们每天执行的那些看似正常的 Join Query,实际上如同在钢丝上行走,只要数据量增大、并发量提升,系统会立刻面临崩溃风险。

所以我想给你的建议就三点:

  1. 定期用EXPLAIN检查你的热点 SQL,看看有没有Using temporaryUsing filesort这俩组合拳
  2. 有事没事就调调tmp_table_sizeinnodb_temp_data_file_path,给自己留点 “呼吸空间”
  3. 最关键的 —— 养成 “不写SELECT *” 的习惯,用覆盖索引替代,这才是根本的解决方案

当再度看到磁盘告警时,不要惊慌,运用lsof +D来进行定位、借助EXPLAIN去确认、通过索引优化来解决。采用这三个办法去处理,便没有解决不了的问题。

你的关注是对我写作最大的鼓励,我们下期见!

声明

这篇文章当中九成是我自己撰写,仅有少量素材借助 AI 来生成,而且所有内容我都认真检查过了,图片素材要么是真实存在的,要么是 AI 原创的,这篇文章就是想要传播正能量,没有那些低俗不良的引导,希望读者能够明白。