长假回来第一天,某生产库的报警群就先炸了。业务方贴了一串截图:MySQL 写入报错,订单明细表插不进数据,应用日志里明晃晃写着 No space left on device。可我登录机器敲 df -lh 一看,根分区用了 58%,数据目录所在挂载点也剩着十几个G。df -lh 显示空间充足,MySQL 却无法写入,这种矛盾放谁身上都容易慌,甚至会怀疑是数据库文件损坏、表空间坏了。先别急着动库,这类问题其实不罕见,而且九成有固定套路可查。
按我个人的处理习惯,遇到“磁盘明明没满但写入失败”,第一个动作永远是拉开一张清单,把系统层和数据库层分开排查,而不是一上来就 kill、重启、删日志。这个坑我踩过不止一次,尤其是在节后复产这个特殊时间点:假期积攒的任务日志、备份残留、临时文件,加上日志轮转偶尔没跑干净,很容易把系统的 inode 或某个特定挂载点打满,而表面上的容量使用率却欺骗性十足。下面把这次完整的排查过程和恢复操作写出来,给同样踩坑的人一个参考。
1. 先把现象和影响范围界定清楚
1.1 故障现象:磁盘指标和写入行为打架
当天我们拿到机器权限后,先做了一件事:复现问题。应用侧报错是:
SQLSTATE[HY000]: General error: 1114 (HY000): The table 'order_detail_2024' is full应用日志里还出现了操作系统的 errno 28,也就是 No space left on device。登录数据库执行几条 insert,MySQL 直接返回磁盘空间不足。但同一时刻 df -lh 看到的是 / 分区 58%,/data 分区 72%,怎么看都不像“满”。更诡异的是,同一台机器上的其他应用写入正常,只有 MySQL 报错。这种组合很容易给人一个暗示:问题出在 MySQL 内部,和操作系统没关系。
其实我当时瞥了一眼 df -i,心里基本有底了,inode 使用率几乎 100%。系统层指标分“容量”和“可用文件索引”两个维度,df -lh 只看前者,后者要用 df -i 才看得到。MySQL 写入表数据,不只是往已有的数据文件里追加内容,它还要频繁创建临时文件、扩展表空间、分配新文件块。任何一个环节拿不到“编号”,都会直接返回 no space left on device。
1.2 为什么 df -lh 会给你“错误的安全感”
df -lh 这个命令展示的是文件系统块的使用情况,也就是以 KB/GB 为单位统计的容量。它本身没错,但它只回答一个问题:存储块还有多少空闲。而文件系统里还有另一套资源叫 inode,中文一般叫索引节点,每个文件或目录都占一个 inode。创建新文件、创建新临时文件,都要消耗一个 inode。inode 一旦耗尽,哪怕磁盘上还有几十G空块,你也新建不了文件。
我用一个类比解释给当时在线的同学听:文件系统就像一个停车场,存储块是车位,inode 是闸机口的停车票。df -lh 告诉你停车场里还有多少个空车位,但它不告诉你票已经发完了。MySQL 现在不是停不进车位,而是压根进不了闸机,它在闸机口拿不到票,系统就回一句“没有空位了”。这就是为什么 df -lh 显示空间充足,MySQL 却无法写入。
还有两个隐藏的“欺骗点”。第一,普通用户写文件还会受 root reserved blocks 限制,ext4 文件系统默认保留 5% 的块给 root,普通用户的可写容量不等于 df 显示的总可用量。第二,MySQL 的 datadir、binlog 目录、tmpdir 可能分布在不同的挂载点上,你以为查了数据目录所在的分区,实际上写满的是日志所在的分区。排查时两个维度都要看,单独盯着一个 df -lh 很容易漏。
1.3 “写入失败”和“空间不足”并不完全等价
MySQL 的一次普通 insert,可能涉及三类磁盘动作:往表空间数据文件写入数据页、在 tmpdir 创建排序或临时表文件、写 binlog 和 redo/undo 日志。任何一个“必须新建文件”的环节失败,都可能回传 errno 28。所以排查问题时,我一般把“数据库报错”当成结果,把“哪个文件、哪个目录、哪个进程要新建文件”当成真正的排查目标。
这也决定了处理顺序:先搞清楚是哪个环节分配文件失败,再决定清哪里、怎么清。直接重启 MySQL 或盲目删大文件,往往会把现场破坏掉,甚至让问题更难定位。下面第二部分的排查顺序就是围绕这个思路展开的。
2. 排查顺序:先系统层,后 MySQL 层
2.1 拿到故障先稳住:看错误日志和系统消息
我处理这类问题的第一步从来不是执行命令刷屏,而是先确认三样东西:MySQL 自身的 error log、系统 dmesg 里的文件系统报错、当前所有挂载点的状态。
MySQL error log 默认路径一般在 datadir 下面,文件名通常是主机名加 .err,可以用下面命令找到:
show variables like 'log_error';重点看最近有没有这类内容:
[ERROR] InnoDB: Write to file /data/mysql/ibtmp1 failed with error 28 [ERROR] InnoDB: Could not allocate a new pagedmesg 里可能出现 ext4 文件系统相关告警:
dmesg -T | grep -i ext4 | tail -20这一步的核心目的是区分“文件系统本身坏了”“容量问题”“inode 问题”三类方向。不要一上来就重启数据库,否则如果文件系统已经处于异常状态,重启可能是雪上加霜。
2.2 系统层“四查”:容量、inode、挂载点、文件句柄
确认日志后,我习惯按顺序跑四个命令,每个命令解决一类问题。
第一查容量,用 df -hT 而不是 df -lh,T 参数能把文件系统类型也显示出来:
df -hT第二查 inode:
df -i如果某个挂载点的 IUse% 到了 100% 或者接近 100%,那基本可以确认问题方向了。
第三查挂载关系,确认 MySQL 数据目录、日志目录、临时目录到底落在哪个分区:
lsblk mount | grep -E ' / |/data|/var|/tmp'这一步能揪出“查错分区”的坑:比如数据目录是 /data/mysql,但你刚才 df -lh 只看了根分区;又比如 binlog 写在单独挂载的 /var/log/mysql 分区,这个分区悄悄满了。
第四查文件句柄,看有没有“已被删除但仍被进程占用”的大文件:
lsof +L1 | head -30如果这行命令输出很多 deleted 文件,或者里面出现了 mysqld、java、nginx 等进程名,说明有进程握着已经删除的文件句柄,磁盘空间实际上被占着,但你用常见命令在目录里找不到这些文件。这种情况在 logrotate 轮转日志后尤其常见。
2.3 MySQL 层“三查”:目录配置、进程状态、引擎状态
系统层查完没发现问题,再进 MySQL 层。我一般做三件事。
第一,确认目录配置:
show variables like '%dir%'; show variables like 'log_bin_basename'; show variables like 'tmpdir';拿到的 datadir、tmpdir、log_bin_basename,再和 df -hT、mount 的结果做匹配,确认每个目录落在哪个挂载点、剩余容量是多少。
第二,看进程状态:
show full processlist;重点看有没有长时间 running 的查询、临时表排序、阻塞事务。长事务在假期期间可能积压过大量 undo 日志和临时文件,节后释放时容易造成磁盘瞬时写满。
第三,看引擎状态:
show engine innodb status\G show variables like 'innodb_data_file_path'; show variables like 'innodb_file_per_table';InnoDB 的共享表空间 ibdata1 如果开启了自动扩展,理论上会持续增长;如果系统表空间或临时表空间所在分区满了,也会出现写入失败。这个环节的目标是把“是不是 MySQL 内部逻辑导致的分区写满”和“是不是外部文件系统问题”彻底分开。
3. 完整排查实录与恢复操作
3.1 第一刀:df -i 暴露了 inode 100%
我们的第一步确认就是 df -i。输出大概是这样的:
Filesystem Inodes IUsed IFree IUse% Mounted on /dev/sda3 19922944 19922810 134 100% /根分区 inode 已经 100% 耗尽。mysql 的 datadir 在 /data 上,但运行过程中产生的临时文件、日志碎片、进程间的 socket 文件、以及系统层面的各种小文件,都可能落在 / 分区下。MySQL 在写文件时如果需要在 / 分区下新建临时文件(比如 tmpdir 或进程写日志),就会撞上 inode 耗尽。
接下来定位 inode 消耗大户。我用的命令是:
find / -xdev -type f -printf '%h\n' 2>/dev/null | sort | uniq -c | sort -rn | head -20这条命令的思路是:进入根分区(-xdev 防止跨文件系统),把所有文件的目录名打印出来,按目录统计文件数量,排在前面的就是 inode 消耗重灾区。我们当时的前几名里有 /tmp、/var/spool、/var/tmp。打开一看,/tmp 下有几十万个 session 文件,/var/spool 下也积压了大量临时任务文件。
节后出现这个状态不奇怪:放假期间定时任务、临时脚本、监控探测都在持续运行,有些任务只写不删,小文件一多,inode 就先撑爆了。容量看起来还够,但新的文件已经建不出来了。
3.2 第二刀:lsof 揪出删除后未释放的旧句柄
清理小文件之前,我又顺手查了一遍 lsof,这一步后来证明非常关键:
lsof +L1 | grep -iE 'deleted|mysqld' | head -30输出里出现了一行类似这样的记录:
mysqld 12345 mysql 11u REG 8,3 4294967296 0 /tmp/mysql_sort_XXXX (deleted)这个文件是一个 MySQL 创建后就删除的排序临时文件,大小约 4G,虽然文件已被删除,但 mysqld 进程仍然持有句柄。也就是说,如果在 inode 清理完之前重启 MySQL,这个句柄会被正常释放;但如果我们把注意力全放在删小文件上,这 4G 并不会消失,而且如果在删除文件后、重启前持续写入,占用的空间还可能继续增长。
我这里用的验证命令是:
ls -l /proc/$(pgrep -x mysqld)/fd 2>/dev/null | grep deleted如果看到 deleted 文件,处理办法通常是:确认这个进程可以重启后,选一个维护窗口重启服务;或者至少记录下进程名、文件大小,纳入后续处理计划。MySQL 排序临时文件产生的 deleted 句柄,在重启 mysqld 后就会释放。生产环境要不要马上重启,要看业务容忍度,但方向一定要明确:不释放句柄,它占的空间永远回不来。
3.3 第三刀:清理、验证、恢复写入
整个恢复过程分四步。
第一步先清理小文件。我们当时把 /tmp 和 /var/tmp 下超过 7 天且无进程占用的临时文件删了一批,并清理了 /var/spool 下已经处理完的残留任务。清理后立刻再看一次 df -i,inode 使用率从 100% 跌到 78%,这说明方向正确。清理临时文件的命令要谨慎,先看再删,删除时建议带 mtime 条件:
find /tmp -type f -mtime +7 -delete第二步处理 deleted 句柄。因为当时是上午业务高峰,直接重启 MySQL 影响太大,我们选择在低峰窗口重启了一次 mysqld,重启后那 4G 空间立刻释放。重启前确认了主从同步状态和连接数正常,避免重启造成二次故障。
第三步验证写入。验证包括三件事:再看一遍 df -i 和 df -hT,确认两个维度都健康;在临时目录做一个写入测试:
dd if=/dev/zero of=/tmp/testfile bs=1M count=100然后进 MySQL 执行一条简单 insert,确认不再报 1114 错误。测试文件记得删掉,别留下新的垃圾。
第四步,把这次故障涉及到的问题点全部加固一遍,后面单开一节讲。
3.4 事后加固:把这台机器拉回“健康基线”
故障恢复只是上半场,下半场是防止它再次发生。我们当时做了四件事。
第一,监控里补上 inode 指标。Prometheus 的 node_exporter 本身就暴露了 node_filesystem_files_free、node_filesystem_inodes 这些指标,直接加一条告警规则就行;Zabbix 对应的是 vfs.fs.inode 监控项。告警阈值设在 80% 就要开始关注,别等 100%。
第二,把 /tmp 分区独立出来,并限制大小。当时 /tmp 在根分区上,依赖根分区的 inode 资源。后来我们给 /tmp 单独挂了一个 2G 的 tmpfs,重启后自动清空,既能保证不写满根分区,也让临时文件的清理变得无脑。注意 tmpfs 会占用内存,大小要按业务实际情况评估。
第三,检查 MySQL 的 tmpdir 配置。临时表排序文件最好写到独立挂载点,并且和生产数据目录分开,避免相互挤占。这个改动需要重启 MySQL 生效,建议好好规划和验证。
第四,binlog 过期时间确认一下。MySQL 8 默认用 binlog_expire_logs_seconds,旧版本是 expire_logs_days。如果 binlog 长期不清理,日志分区被写满只是时间问题。检查命令:
show variables like 'binlog_expire_logs_seconds';顺带说一下,在确认从库都跟得上进度的情况下,可以用 PURGE BINARY LOGS 清理已经过期的 binlog,不要直接手工 rm binlog 文件,否则会破坏 binlog index 文件的一致性。
4. 常见原因速查表与快速定位
4.1 典型问题对照表
这次故障只是“磁盘显示充足但写不进去”里最常见的一种。实际生产环境中还有好几类原因也会造成同样的表面现象,我把它们整理成一张对照表,遇到问题可以直接按表格方向排查。
| 表面现象 | 可能原因 | 快速确认方式 | 处理方式 |
|---|---|---|---|
| df -h 显示满 | 容量真的耗尽 | df -hT 看具体挂载点 | 清日志、清 binlog、扩容 |
| df -h 有剩余,df -i 100% | inode 耗尽 | df -i | 清理小文件,优化目录规划 |
| 删除大文件后 df -h 不变 | 进程持有已删除文件句柄 | lsof +L1 / /proc/*/fd | 重启对应进程,或记录后处理 |
| df -h 还剩 5%+,普通用户写入失败 | ext4 保留块耗尽 | tune2fs -l /dev/xxx | 调整 reserved-blocks 比例 |
| mount 显示 ro | 文件系统异常或强制只读 | mount、dmesg | 检查修复文件系统,避免异常重启 |
| 单表 insert 报 1114,磁盘正常 | 表空间满或共享表空间达到限制 | show table status、检查 innodb_data_file_path | 清理表数据、调整表空间策略 |
| 大排序、临时表导致写入失败 | tmpdir 空间不足 | show variables like 'tmpdir' | 清理临时文件,调整 tmpdir 位置 |
| binlog 目录写满但数据目录正常 | 日志与数据目录分离 | 对比 datadir 和 log_bin_basename | 清理过期 binlog,调整保留天数 |
| 应用报 no space 但文件系统正常 | 磁盘配额 quota 限制 | quota 相关状态查询 | 调整配额或清理文件 |
| 能读不能写、报权限错误 | SELinux 或属主问题 | audit2why、ls -lZ | 调整上下文或权限设置 |
| 网络盘场景下写入卡死 | NFS 服务端故障或锁问题 | mount、nfsstat | 联系存储侧处理,切换副本 |
这张表不能覆盖所有场景,但能覆盖生产环境里 90% 以上的“假空间不足”问题。遇到新问题,先按表里的顺序逐项排除,别急着重启数据库。
4.2 容易被误判的“伪空间不足”
这里补充两个我在实际中经常看到的误判。
第一个是 ext4 保留块。ext系列文件系统默认给 root 用户保留约 5% 的块,用来防止磁盘写满后系统无法完成关键写操作。这 5% 在 df 输出里是计入“已用”还是“可用”?实际表现是:非 root 用户写文件到 95% 时就会报 no space left on device,但 root 还能继续写。如果 MySQL 以普通用户运行,遇到 df -h 显示还剩 5% 但写入失败,就要往这个方向想。
查看和调整保留比例的命令:
tune2fs -l /dev/sda3 | grep -i 'reserved block count' tune2fs -m 1 /dev/sda3把保留比例从 5% 降到 1% 能让普通用户可写容量变大,但会降低磁盘满时的系统可用空间,生产环境调整前要评估风险和团队规范,不要私自乱改。
第二个是文件系统被强制重新挂载成只读。Linux 在文件系统出现异常时,有时会主动把挂载状态切成只读,防止数据进一步损坏。此时 df -h 显示正常,MySQL 写入却报错,而且报错可能不是 errno 28,而是 “read-only file system” 或 “Permission denied”。遇到这种,先看 mount 输出:
mount | grep -E ' / | /data'如果出现了 ro,优先确认服务器物理状态、磁盘健康度,并尽快准备切换或迁移,不要试图直接在 ro 状态下去改数据。
4.3 节后复产专项检查清单
节后第一天是生产事故高发时段,主要原因是假期里各种定时任务照常跑,但很少有人在现场盯告警。建议按照下面的清单花十到十五分钟快速过一遍,特别是数据库机器。
- 第一屏看监控:磁盘容量使用率、inode 使用率、进程文件句柄数,三个指标放一起看。
- df -hT 和 df -i 两条命令必须同时跑,只有 df 没有 df -i 等于只看了一半。
- 检查 MySQL error log 里有没有近期出现 error 28、1114、read-only 等关键词。
- 查看 logrotate 状态,确认假期轮转任务是否正常完成。
systemctl status logrotate.timer cat /var/lib/logrotate/status | tail -20- 检查 /tmp、/var/tmp、备份暂存目录的文件数量和大小。
- 看一下 show full processlist,找有没有跨假期未结束的长事务。
- 确认 binlog 目录大小和过期策略是否正常。
- 节后第一周,数据库变更和重启操作尽量收敛,除非有明确故障,否则不折腾。
这个过程用不了多少时间,但能把我们这次踩的坑提前挡在门外。
5. 踩过几次坑之后,我留下的几个习惯
这个坑我前前后后遇到过三四次,印象最深的不是 inode 到底消耗在哪,而是屏幕上那个刺眼的 100% 和 df -lh 的从容剩余同时出现时,人很容易被误导。现在我处理生产告警有个固定习惯:任何 disk 相关报警,第一屏永远同时放 df -h 和 df -i;监控面板上磁盘使用率必须挂 inode 曲线。这俩指标是孪生兄弟,看一个不看另一个一定会出事。
另一个习惯是,遇到 MySQL 写入失败,我一定会先问自己一句:它要写的那个文件,落在哪个目录、哪个挂载点、谁能新建文件?把这个想清楚,排查范围就锁死了一大半。对 MySQL 临时目录、日志目录和 binlog 目录,平时就做好 80% 预警线和自动化清理,比出了事再抢修舒服太多。最后留一句经验:怀疑 MySQL 坏了之前,先确认你看到的剩余空间,真的是它可以用得了的剩余空间。