一、问题背景
周三上午10点左右,公司核心交易系统的Web层突然开始大量报错。监控平台连续发出"应用日志写入失败"和"业务健康检查超时"的告警,短短5分钟内收到的告警从0飙升至47条。
受影响的是部署在阿里云ECS上的Spring Boot交易网关服务,共3个节点,运行在CentOS 7.9系统上。该服务负责接收前端交易请求并将处理日志写入本地文件,再由Filebeat采集发送到ELK。因日志写入失败,服务开始返回HTTP 500错误,交易成功率从99.8%骤降至73%。
前一天运维同事刚做过一次应用版本发布,第一反应是"新版本有bug"。但在回滚到上一版本后问题依旧,才意识到这很可能不是代码问题,而是基础设施层面的故障。
二、故障现象
登录问题节点后,首先检查应用日志:
1
2
3
4
5
6
7
|
$ tail -f /var/log/trade-gateway/application.log
2026-06-27 10:03:42.127 ERROR [pool-3-thread-7] c.t.g.service.OrderService - Failed to write operation log
java.io.IOException: No space left on device
at java.base/java.io.FileOutputStream.writeBytes(Native Method)
at java.base/java.io.FileOutputStream.write(FileOutputStream.java:354)
at java.base/java.io.BufferedOutputStream.flushBuffer(BufferedOutputStream.java:81)
...
|
“No space left on device”——这个报错非常明确:磁盘没空间了。
习惯性地先查磁盘空间:
1
2
3
4
5
6
|
$ df -h
Filesystem Size Used Avail Use% Mounted on
/dev/vda1 80G 45G 31G 60% /
devtmpfs 7.8G 0 7.8G 0% /dev
tmpfs 7.8G 16K 7.8G 1% /dev/shm
tmpfs 7.8G 1.2M 7.8G 1% /run
|
看得我一愣——根分区总共80G,使用了45G,还有31G可用空间。磁盘并没有满。
不信邪,手动创建文件试试:
1
2
3
4
5
|
$ touch /tmp/test_write
touch: cannot touch '/tmp/test_write': No space left on device
$ echo "test" > /var/log/test.log
-bash: /var/log/test.log: No space left on device
|
磁盘明明有31GB空闲,却无法创建任何文件。这就是典型的"文不对题"——报错说没空间,df却说空间充裕。此时心里已经有了方向:大概率是inode耗尽了。
三、排查过程
3.1 确认inode使用情况
遇到"磁盘有空间但无法创建文件",第一个要检查的就是inode:
1
2
3
4
5
6
|
$ df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/vda1 5242880 5242880 0 100% /
devtmpfs 2039552 407 2039145 1% /dev
tmpfs 2041888 19 2041869 1% /dev/shm
tmpfs 2041888 833 2041055 1% /run
|
真相大白。根分区一共5242880个inode,全部耗尽,IUse% = 100%。文件系统已经没有可用的inode来创建新文件了,哪怕磁盘物理空间还有大量剩余。
3.2 定位inode消耗大户
接下来的任务是找出哪些目录消耗了大量inode。先对整个根目录做一级统计:
1
2
3
4
5
6
7
8
9
10
11
12
|
$ for dir in /*; do
if [ -d "$dir" ]; then
count=$(find "$dir" -xdev -printf '.' | wc -c)
echo "$count $dir"
fi
done | sort -rn | head -10
4892012 /tmp
247163 /var
89321 /usr
31002 /opt
...
|
接近490万个inode集中在/tmp目录下,几乎占了全部inode的93%。这个发现让排查目标变得非常明确。
3.3 深入/tmp目录分析
进入/tmp目录,用ls看看情况:
1
2
|
$ cd /tmp && ls | wc -l
4850000
|
四百八十五万个文件!这个数字令人震惊。进一步查看文件类型分布:
1
2
3
4
5
6
|
$ ls -lh /tmp | head -20
-rw-r--r-- 1 appuser appuser 0 Jun 24 03:00 .trade_lock_20260624_030001_8372
-rw-r--r-- 1 appuser appuser 0 Jun 24 03:00 .trade_lock_20260624_030002_9104
-rw-r--r-- 1 appuser appuser 0 Jun 24 03:00 .trade_lock_20260624_030003_2841
...
-rw-r--r-- 1 appuser appuser 0 Jun 27 10:00 .trade_lock_20260627_100001_5539
|
全是以.trade_lock_开头的隐藏文件,大小全部为0字节。这些是应用用于进程互斥的锁文件。
查看文件的创建时间分布:
1
2
3
4
5
6
7
|
$ find /tmp -name '.trade_lock_*' -printf '%TY-%Tm-%Td\n' | sort | uniq -c | sort -rn
1520000 2026-06-25
1438000 2026-06-24
1087000 2026-06-26
696000 2026-06-23
104000 2026-06-22
...
|
这批锁文件从6月22日开始累积,最近3天每天产生超过100万个。但关键问题是:为什么这些临时锁文件没有被清理?
3.4 追查清理机制
查了一下项目内部的定时任务和代码逻辑:
1
2
3
|
$ crontab -l -u appuser
# 每天凌晨3点清理/tmp下超过24小时的临时文件
0 3 * * * find /tmp -name '.trade_lock_*' -mtime +1 -delete
|
crontab里有一条清理任务:每天凌晨3点删除/tmp下24小时以前的锁文件。逻辑看起来没问题。
手动验证清理逻辑:
1
2
|
$ find /tmp -name '.trade_lock_*' -mtime +1 | wc -l
4870000
|
这个结果非常奇怪——几乎全部文件都满足"-mtime +1"条件,说明它们超过24小时了,但cron任务却没能清理掉它们。
继续深挖,查看cron的执行日志:
1
2
|
$ grep "find /tmp" /var/log/cron | tail -5
Jun 27 03:00:01 trade-gw-01 CROND[19238]: (appuser) CMD (find /tmp -name '.trade_lock_*' -mtime +1 -delete)
|
cron确实按时执行了。那为什么没删掉?把命令抄出来单独跑一下:
1
2
|
$ find /tmp -name '.trade_lock_*' -mtime +1 -delete
-bash: /usr/bin/find: Argument list too long
|
破案了!“Argument list too long”——find命令在内部处理-delete操作时,对于数百万级别的文件,exec的参数列表超出了系统限制。
补充说明:find 的 -delete 参数会尝试在内部批量调用 unlink(),当匹配到的文件数量极其庞大时,仍然可能触发内核的 ARG_MAX 限制(可以通过 getconf ARG_MAX 查看,通常为2MB)。这个限制不仅影响 -exec ... {} \+ 的显式调用,在文件基数达到百万级时也可能影响 -delete。
3.5 定位cron失败的根本原因
进一步验证:
1
2
3
4
5
|
$ getconf ARG_MAX
2097152
$ find /tmp -name '.trade_lock_*' -mtime +1 | wc -c
195482103
|
所有过期文件路径的总长度约186MB,远超ARG_MAX的2MB限制。find命令在执行-delete时一次性匹配的文件太多,路径总长度超过了内核参数上限,导致删除操作完全失败。
这也解释了为什么故障出现在周三——锁文件从上周日开始累积,到了周三总量突破500万,find -delete彻底失效形成"死锁":cron定时删→delete失败→文件继续累积→下一次cron更不可能成功→恶性循环,直到inode彻底耗尽。
四、解决方案
4.1 紧急止血
当务之急是让服务恢复。不能直接用rm -rf /tmp/*——/tmp下还有其他进程的运行时文件。需要精准批量删除:
1
2
|
# 用find配合xargs分批删除,避免ARG_MAX限制
$ find /tmp -name '.trade_lock_*' -mtime +1 -print0 | xargs -0 -n 1000 rm -f
|
执行过程中持续监控inode恢复情况:
1
|
$ watch -n 2 'df -i / | tail -1'
|
删除持续了约12分钟,inode使用率从100%降至8%。服务随即恢复正常。
4.2 修复清理机制
原有的cron删除命令需要改造,核心思路是让find通过管道将文件列表传给xargs分批处理,彻底绕过ARG_MAX限制:
修改crontab:
1
2
3
|
# 将原来的单条find -delete
# 改为 find + xargs 分批删除模式
$ crontab -e -u appuser
|
1
2
|
# 每天凌晨3点清理/tmp下超过24小时的锁文件(xargs分批避免ARG_MAX)
0 3 * * * find /tmp -name '.trade_lock_*' -mtime +1 -print0 | xargs -0 -n 500 rm -f
|
4.3 调整inode监控
在Prometheus中添加inode监控告警规则:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
groups:
- name: filesystem_alerts
rules:
- alert: InodeUsageHigh
expr: (node_filesystem_files_free / node_filesystem_files) * 100 < 10
for: 5m
labels:
severity: warning
annotations:
summary: "文件系统inode使用率超过90%"
description: "主机 {{ $labels.instance }} 上 {{ $labels.mountpoint }} 的inode使用率为 {{ $value | humanize }}%,仅剩 {{ $value }}% 可用。"
- alert: InodeUsageCritical
expr: (node_filesystem_files_free / node_filesystem_files) * 100 < 5
for: 1m
labels:
severity: critical
annotations:
summary: "文件系统inode使用率超过95%,即将耗尽!"
description: "主机 {{ $labels.instance }} 上 {{ $labels.mountpoint }} 的inode仅剩 {{ $value }}%,请立即处理。"
|
同时配置Node Exporter采集inode指标,确保node_filesystem_files和node_filesystem_files_free指标正常上报。
五、根因分析
这个问题的根本原因有三层:
第一层(直接原因):应用代码使用File.createTempFile()创建的锁文件没有在进程退出时正确清理。代码中虽然注册了deleteOnExit(),但当进程被kill -9强制终止时,JVM的shutdown hook不会执行,锁文件被遗留在磁盘上。
第二层(放大因素):cron清理脚本使用find -delete一次性处理百万级文件时触发了ARG_MAX限制,导致清理完全失效。一旦清理失败一次,后续每次cron执行都面临更多的文件,形成"越积越多、越积越难清"的死循环。
第三层(监控缺失):监控体系中只覆盖了磁盘空间(df -h),从未关注inode使用率(df -i)。运维团队直到故障发生才意识到inode是一个独立的资源维度。
六、预防措施
6.1 应用层面
将锁文件从/tmp迁移到应用专属的工作目录,并在应用启动脚本中增加自清理逻辑:
1
2
3
4
5
6
7
|
# 在应用启动前清理过期锁文件(配合xargs分批处理)
cleanup_locks() {
local LOCK_DIR="/data/app/trade-gateway/locks"
local MAX_AGE_MINUTES=60
find "$LOCK_DIR" -name '.trade_lock_*' -mmin "+${MAX_AGE_MINUTES}" -print0 \
| xargs -0 -n 500 rm -f 2>/dev/null
}
|
同时修改Java代码,使用try-finally确保锁文件在finally块中释放,搭配FileLock机制代替纯文件存在性判断做互斥。
6.2 操作系统层面
调整/tmp的挂载参数,限制inode使用上限:
1
2
|
# /etc/fstab 中为 /tmp 增加nr_inodes限制
tmpfs /tmp tmpfs defaults,size=4G,nr_inodes=500000 0 0
|
另外,如果业务允许,可以将频繁产生小文件的目录单独分区并分配更多inode:
1
2
|
# 创建文件系统时指定更多inode(默认情况下每16KB分配1个inode)
mkfs.ext4 -i 4096 /dev/vdb1 # 每4KB分配1个inode,inode数量变为默认的4倍
|
6.3 监控层面
强制要求所有生产服务器部署inode监控告警,与磁盘空间监控同等对待。告警阈值:inode使用率 > 80% 预警,> 90% 严重告警,> 95% 紧急告警。
同时建立巡检制度,每周检查一次所有生产服务器的inode使用趋势,提前发现异常增长:
1
2
3
4
5
|
# 巡检脚本示例
for host in $(cat /etc/ansible/hosts | grep '\[prod\]' -A 100 | grep -v '\['); do
echo "=== $host ==="
ssh $host "df -i / /data 2>/dev/null | grep -v '^Filesystem'"
done
|
6.4 清理工具加固
将cron清理脚本统一升级为带有结果校验的版本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
#!/bin/bash
# /usr/local/bin/cleanup_tmp_locks.sh
LOCK_DIR="/tmp"
PATTERN=".trade_lock_*"
MAX_AGE="+1"
BATCH_SIZE=500
ERROR_LOG="/var/log/tmp_cleanup_error.log"
# 分批删除
DELETED=0
while IFS= read -r -d '' file; do
rm -f "$file" && ((DELETED++))
done < <(find "$LOCK_DIR" -name "$PATTERN" -mtime "$MAX_AGE" -print0 2>/dev/null)
# 结果校验
REMAINING=$(find "$LOCK_DIR" -name "$PATTERN" -mtime "$MAX_AGE" 2>/dev/null | wc -l)
if [ "$REMAINING" -gt 0 ]; then
echo "$(date): 清理不完整!已删除 $DELETED 个文件,仍有 $REMAINING 个过期文件未清理。" >> "$ERROR_LOG"
fi
|
七、总结
这次故障给我最大的教训是:运维监控不能只盯着"磁盘空间"这一个维度。
Linux文件系统中的inode和blocks是两个独立的资源。df -h只看blocks(数据块),df -i看inodes(索引节点)。很多运维工程师只关注前者,直到某天服务器磁盘"明明有空间却写不进去"才恍然大悟——原来还有inode这回事。
这次排查也让我意识到ARG_MAX这个内核参数在批量文件操作中的真实影响。日常运维中find -delete用得很多,通常不会出问题,但当文件数量达到百万级时,它就会成为隐藏的炸弹。xargs分批处理是一个简单而有效的解决方案,成本极低但能把系统稳定性提升一个量级。
最后,“能写文件的磁盘就是好磁盘"是一个危险的假设。生产环境中,inode的消耗通常比blocks更隐蔽、更难以发现,因为小文件对物理空间的占用微乎其微,但每个文件都必须消耗一个inode。对于频繁创建临时文件的应用,inode耗尽的速度可能远超你的想象。