有次帮同事排查线上问题,他删掉了一个 1.8GB 的日志文件,df -h 一看磁盘占用,纹丝不动。他说第一反应是 Linux 出了 bug,我说这不是 bug,是你删掉的这个文件还被某个进程握在手里,目录项没了,但数据块还活着。这种乍一看像“灵异事件”的 Linux 行为,我这些年攒了不少,比如权限明明加了却 Permission denied、kill 完进程端口还占着、内存还剩大半但 swap 一直跳高。这篇内容就是一份长期积累的记录,适合刚从 Windows 切过来的新手、写脚本被坑的开发者,以及天天在服务器上摸爬滚打的运维同学——把 Linux 的“反直觉”变成“知其所以然”,很多线上事故都能提前避开。
1. 磁盘明明删了,空间却纹丝不动
1.1 rm 到底删了什么:从目录项到 inode 的完整链路
先把这个最经典的问题讲透。你用 rm -f app.log 删文件时,系统做的其实是两件事:
- 在文件系统层面,把这个文件名从父目录的目录项里摘掉(unlink)
- 把文件的链接计数减一,当链接计数归零、且没有进程打开它时,磁盘上的数据块才会被真正标记为可复用
问题就出在第二步。如果你的应用进程还保持这个文件的 fd(文件描述符),比如日志模块常驻打开着文件,那它的链接计数虽然已经变成 0,但文件对象本身还活着,磁盘块也就不会被释放。Linux 为了保证正在读写的文件不会被后台悄悄换掉,宁可让你“看不见”这个文件,也不会强行回收它所占的空间。
用一句话概括:rm 只能让你“看不见这个文件”,不代表“空间已经还给你”。真正回收空间的时刻,是最后一个打开这个文件的进程关闭 fd 或退出。
1.2 怎么定位到底是谁占着已删除的文件
遇到 df -h 显示磁盘快满、但 du -sh * 又找不到大文件时,别急着重启服务器。先跑一条命令:
bash复制lsof +L1
+L1 的含义是列出 link count 小于 1 的文件,通俗说就是“已经被删除但仍被进程打开”的文件。输出里能看到进程 PID、fd 编号、被删文件的大小。我实际处理过的一个例子:
bash复制java 23143 app 2w REG 253,0 1887436800 131073 /var/log/app/app.log (deleted)
很明显,PID 23143 这个 Java 进程还握着 /var/log/app/app.log,占了 1.8GB。这种情况下你有几个处理思路:
- 如果能接受业务中断,重启该进程,空间立刻释放
- 如果不能重启,可以用
ls -l /proc/23143/fd/2找到 fd 对应的目标,然后执行: > /proc/23143/fd/2把文件内容清空,腾出空间 - 如果这个进程经常写日志,且磁盘又是高水位,必须改日志策略,不能停留在“删了就行”的阶段
我见过最隐蔽的场景是 Nginx 的 access.log 被 rm 之后,master 进程和 worker 进程还各自持有 fd。你以为 kill 掉 worker 就完事了,其实 master 还握着。所以处理这类问题,最好把 lsof +L1 输出里同一个文件相关的进程全部找到,再一起处理。
1.3 日志切割的常见误区:create 与 copytruncate 的区别
既然讲到删除文件,就一定绕不开日志切割。很多人写 logrotate 配置时,习惯用 create 模式:把当前 app.log 重命名成 app.log.1,再新建一个空 app.log。但对长驻进程来说,它手里的 fd 仍然指向被重命名后的 app.log.1,新写的日志会继续进旧文件,而新建的 app.log 反而没人写。
如果应用本身不处理日志重开信号(比如 Java 应用没有接 SIGHUP 重开日志的逻辑),那正确做法是改用 copytruncate:先复制当前日志内容到旧文件,再原地把原文件截断(truncate),这样进程手里的 fd 一直有效,写入位置也被重置。代价是 copytruncate 期间可能丢失少量日志,但对多数业务场景完全可接受。
bash复制/var/log/app/app.log {
daily
rotate 14
copytruncate
compress
delaycompress
missingok
notifempty
}
日志切割配置本身很简单,真正坑人的是文件的打开方式。哪天你发现日志不涨了、或者日志切割后大文件还是占着磁盘,优先检查持有 fd 的进程是不是还在写旧文件。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 有执行权限还被拒:Permission denied 的隐蔽来源
2.1 排查链路:从 ls -l 到 mount 到 SELinux
“明明 chmod +x 了,执行时还是 Permission denied”,这个问题的经典程度不亚于磁盘删不掉。它的排查链路我每次都很固定:
bash复制ls -l script.sh
id
mount | grep /path
getenforce
getfacl script.sh
第一步确认文件权限本身没问题,属主、属组都符合预期,x 位也在。第二步确认当前用户不是被 sudo 到了别人家。第三步查挂载选项——很多服务器 /home 分区挂载时带了 noexec,这种情况下不管文件权限怎么改,它都不可能被直接执行。第四步是红帽系发行版特有的坑:SELinux 会给文件打安全上下文标签,如果你从别处拷贝了一个脚本,标签可能是 user_home_t,丢到 /usr/local/bin 后上下文不对,SELinux 会拒绝执行,即使你是 root。
最后再用 getfacl 看 ACL,有些目录可能对特定用户或组有额外限制。
可以用一张表把这个思路固化下来:
| 现象 | 可能原因 | 确认命令 | 解决方向 |
|---|---|---|---|
| 有 x 权限但无法执行 | 挂载选项 noexec | mount |
重新挂载去掉 noexec,或换路径 |
| 有 x 权限但无法执行 | SELinux 上下文不对 | ls -Z |
restorecon -v 恢复标签 |
| 有 x 权限但无法执行 | ACL 限制 | getfacl |
setfacl -b 清除 ACL |
| root 都无法执行 | 文件系统只读 | mount 状态 |
mount -o remount,rw |
| 命令存在但提示 not found | PATH 不包含该目录 | echo $PATH |
修改 PATH 或使用绝对路径 |
SELinux 这块,我遇到最多的是把 Nginx 或 PHP-FPM 的网站目录放在 /home/www 下,结果网页访问 403。这不是文件权限问题,而是 httpd_sys_content_t 这个标签没有打上去。执行 restorecon -R /home/www 就能解决。
2.2 为什么 sudo 能找到命令,普通用户找不到
还有一个高频现象:普通用户执行某个命令提示 command not found,sudo 执行却正常。这不是权限问题,是 PATH 环境变量不一致。比如 ifconfig、ip 在多数发行版里位于 /usr/sbin,普通用户默认 PATH 不包含 /usr/sbin,但 sudo 的 secure_path 包含。你可以用 sudo -V 或 echo $PATH 对照看。
这类问题背后的通用排查思路是:先确认可执行文件到底在哪,再确认你当前 shell 的 PATH 是否覆盖它,最后看权限和上下文。一个 type -a ifconfig 或 which ifconfig 就能定位。写脚本时为了避免 PATH 带来的不确定性,建议在脚本开头显式设置:
bash复制#!/bin/bash
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
2.3 我对权限类问题的实操心得
权限问题最让人抓狂的地方在于,表面现象一样,但根因可能完全不同。有一次用户报“某个脚本 root 能跑,普通用户跑不了”,我顺着 x 权限、属主属组、ACL、SELinux 一路排查都没发现问题,最后用 strace -f -e trace=file 跟了一下,发现脚本中间动态加载了 /root/ 下的一个配置文件,普通用户根本读不了 /root 目录的访问权限。
所以我的经验是:遇到 Permission denied,先不要只盯文件本身,还要追这个程序在运行过程中还要读哪些路径、连哪些 socket。strace 是这类问题的大杀器。如果不想上 strace,可以先看错误信息是不是固定的某一行,再去检查那一步对应的资源。
3. 端口、进程与负载:看似“卡死”的三个典型案例
3.1 9090 端口被占用的完整排查路径
热搜词里出现了“linux的9090端口什么再用”,这几乎是每个新手都会遇到的事。我先给你一套通用的排查顺序:
bash复制# 1. 看端口监听状态和对应进程
ss -tlnp | grep 9090
# 2. 如果权限不足看不到进程名,加 sudo
sudo ss -tlnp | grep 9090
# 3. 更精确地按端口号过滤
sudo lsof -i :9090
# 4. 如果是 TCP 连接,还想知道是谁连过来的
ss -tnp | grep 9090
ss 输出里第三列是 Local Address:Port,第四列是 Peer Address:Port,看清这两个字段能帮你区分是监听(LISTEN)还是已经建立的连接。很多人口中的“9090 端口被占用”,其实不是真被监听,而是有一堆 ESTABLISHED 状态连接占着这个端口,这时要处理的是连接发起方,而不是杀进程。
lsof -i :9090 的妙处是既能显示监听也能显示连接,还能带上进程 PID,推荐首选。如果查出来是某个进程在监听,但你不想直接杀进程,可以用 systemctl status <PID> 反查它属于哪个 systemd 服务,再考虑重启还是停服。
3.2 kill 掉进程后端口还活着:master、TIME_WAIT、僵尸进程
端口排查另一个常见现象是:kill -9 都发了,ss -tlnp 一看端口还在。你可能会遇到以下几种情况:
- 主进程和子进程模型:比如 Nginx 有 master 和 worker 多个进程。你 kill 的是 worker,master 会立刻重新拉起一个新的 worker,端口死而复生,看起来像永远杀不死。这时候要 kill 的是 master。
- TIME_WAIT 状态:主动关闭连接的一方会进入 TIME_WAIT,默认等待 2MSL(Linux 上一般是 60 秒左右)。端口在这个状态下不会被新进程监听,但
ss能看到。这不是泄漏,是 TCP 协议保证可靠性的必要设计。别急着把net.ipv4.tcp_fin_timeout调小,短连接高并发场景下会有其他负作用。 - 僵尸进程:父进程没有调用
wait()回收子进程,子进程变成 zombie。zombie 本身不占内存也不占端口,但父进程如果设计得有问题,可能导致资源一直不释放。 - 多实例监听:同一个程序起了多份,你杀了一个,另一个还活着。
判断到底属于哪一种,标准动作是:ss -tlnp 到底显示的进程名是不是你 kill 的那个,状态是 LISTEN 还是 TIME_WAIT。如果是 TIME_WAIT,什么都不用做,等它自己消失。如果进程还在,用 ps -ef | grep <name> 看看有没有同名的其他进程,再决定杀掉哪一个。
3.3 D 状态进程:为什么 kill -9 也拿它没办法
负载高但 CPU 使用率低,是另一个让人摸不着头脑的场景。这时候 top 里能看到一堆进程的 STAT 是 D。D 代表 uninterruptible sleep,不可中断睡眠,进程正在等待底层 IO 返回,比如磁盘读写、NFS 网络请求。在这个状态下,进程不会响应任何信号,包括 kill -9。
这个设计其实是为了保证数据一致性:如果进程正在往磁盘写关键数据,中间被强行打断,数据可能坏掉。所以内核直接屏蔽了信号处理。处理 D 状态进程的唯一办法,是让底层 IO 恢复。常见诱因是 NFS 服务挂了、磁盘硬件故障、或者存储链路拥塞。
我之前排查过一台机器,负载飙到 30,但 CPU 空闲,ps -eo state,pid,cmd | grep '^D' 一看全是 sshd 的子进程,最后查到是 home 目录用 NFS 挂载,NFS 服务器端网络抖动,所有涉及家目录的操作都卡在不可中断睡眠里。把 NFS 路径换成本地盘后问题立刻消失。这里有个细节:ps -eo state 显示的状态字符含义很长,遇到 D 状态,第一件事不是杀进程,而是查存储和网络链路。
如果 D 状态持续很久且 IO 看不出问题,还有一个偏硬件的原因需要留意:某些 PCIe 设备资源映射异常,内核日志里出现类似“无法给 PCIe 桥接器分配足够的内存映射空间(BAR 地址)”的记录,也会导致设备驱动反复重试,进程卡在驱动等待上。这种情况虽然少见,但一旦遇到,往往需要调整 BIOS 里的 MMIO 分配策略或升级固件。
4. find、sed、[ -d ]:高频命令那些让你懵圈的输出
4.1 find -exec 的结尾分号和 -delete 的坑
find 命令是 Linux 面试题里的常客,但真正容易踩坑的是它和 -exec、-delete 的配合。先看最常见的写法:
bash复制find /logs -name "*.log" -exec rm -rf {} \;
这里最后的 \; 是必须的,表示 -exec 命令到此结束,分号前的反斜杠是为了防止 shell 把分号当作命令分隔符吃掉。{} 是 find 替你把匹配到的文件路径填进去的占位符。如果你用 {} + 替代 {} \;,find 会把所有匹配文件一次性累积追加到命令末尾再执行,进程启动次数少,效率高很多,但要注意:{} 和 + 之间不能有其他字符,命令整体以 + 结尾时不允许再追加参数。
-delete 看起来更简单,但有个坑:很多新手在已经用了 -exec rm 的情况下又加一个 -delete,或者直接 find . -name "*.tmp" -delete,结果把自己刚解压出来的临时文件全删了。-delete 是隐式深度优先,它在进入子目录前会先删除当前目录中的匹配文件,所以用法上建议先 find 打印确认再删:
bash复制find /data -name "*.bak" -print
# 确认无误后
find /data -name "*.bak" -delete
4.2 sed 打印乱码:编码、locale 和二进制内容
sed 打印出来有乱码,这个问题在热搜词里也出现了。我处理过几类不同原因:
- 文件本身不是 UTF-8:最常见。用
file test.txt看一眼,如果是 GBK 或 GB18030,终端默认 UTF-8 渲染自然乱码。处理思路是先iconv -f GBK -t UTF-8 test.txt转码再处理。 - locale 环境不对:
LANG=C或LC_ALL=C下,一些多字节字符的匹配规则会退化,排序、打印都可能异常。可以在.bashrc里显式设置export LANG=en_US.UTF-8,或临时export LC_ALL=en_US.UTF-8后重新执行。 - sed 直接处理了二进制文件:日志文件里混入 NUL 字节或非文本内容时,sed 会把缓冲内容原样输出,终端看到一堆乱码。处理方法是在 sed 之前用
strings过滤可打印字符串,或改用grep -a按文本方式处理。 - 管道前后的编码不一致:从数据库导出的数据经过管道传给 sed,导出端是 GBK,sed 处理没问题,但输出到 UTF-8 终端就乱。
我自己写处理文本的管道命令时,习惯先加一个 file <file> 确认编码,再决定要不要先转码。别看这一步简单,能省下大量排查乱码的时间。
4.3 [ -d ] 判断里那对引号不是装饰
[ -d "/path" ] 这种写法在 shell 脚本里到处都是,但很多新手看不懂为什么方括号两边要有空格,为什么要给变量加引号。原因是:[ 在 shell 里其实是一个命令(等价于 test),命令和参数之间必须用空格分隔。写 if [ -d "$dir" ] 时,[ 和 -d 之间、-d 和 $dir 之间、$dir 和 ] 之间的空格全是语法的一部分,少一个空格 shell 就会把参数解析错。
为什么变量要加引号?因为 shell 在展开 $dir 时如果变量值里有空格,不加引号会被拆成多个参数。比如 dir="/var/log/my app",不加引号时 [ -d $dir ] 实际变成了 4 个参数,判断结果完全错误。加上双引号后 "$dir" 被视为一个整体。下面是标准写法:
bash复制if [ -d "$dir" ]; then
echo "目录存在"
fi
# bash 的扩展语法,更宽容,支持正则和模式匹配
if [[ -d "$dir" ]]; then
echo "目录存在"
fi
[[ ]] 里即使不加引号,大部分情况下也不会把含空格的变量拆开,因为它有自己的词法解析规则。但为了可移植性和一致性,我建议所有脚本都统一加双引号,别偷懒。-d 判断的是“目录是否存在”,跟 -f(普通文件)、-e(路径是否存在)、-x(是否可执行)是不同维度,面试题经常拿这些测试选项区分新手。
5. 内存充足但 swap 狂涨:回收机制的一次梳理
5.1 swap 不是“内存不够才用”的开关
很多人的直觉是:只要物理内存没用完,swap 就不应该被使用。但 Linux 的行为不是这样。它有一个 swappiness 参数,取值 0 到 100,默认通常是 60,含义是内核在内存回收时对匿名页(进程堆栈等)和文件页(缓存)的倾向性,数值越大越积极把匿名页换到 swap。换句话说,即使内存还有空闲,内核也可能因为回收策略把部分不活跃的内存页换出,以留出更多 page cache 给文件读写。
查看当前系统参数:
bash复制sysctl vm.swappiness
cat /proc/sys/vm/swappiness
free -h
vmstat 1
free -h 里 Swap 一栏如果一直大于 0,不代表系统不行。要看的是 si(swap in)和 so(swap out)这两列在不同时刻有没有持续增长。如果有,说明系统确实有内存回收压力,而不是单纯“用了 swap”。
5.2 哪些程序最容易被换出,怎么定位
在 swap 占用异常升高时,可以用下面命令按内存占用排序:
bash复制ps aux --sort=-rss | head -20
cat /proc/meminfo | grep -E "SwapTotal|SwapFree|SwapCached|Dirty|Active|Inactive"
我遇到过一个典型场景:服务器上有大量 MySQL 数据文件缓存,同时跑着一个长期空转的 Java 分析进程。系统物理内存 64G,Java 进程占 8G 但大部分时间不活跃。结果内核把 Java 进程的很多页换到 swap,导致后来真正高并发请求出现时,Java 进程要先把 swap 里的页换回来,响应时间突然飙升。这就是“内存充足但 swap 狂涨”背后的真实代价:换出和换入都有延迟。
如果你跑的是数据库或对响应时间敏感的服务,常常建议调低 swappiness:
bash复制# 临时生效
sysctl vm.swappiness=10
# 永久生效
echo 'vm.swappiness=10' >> /etc/sysctl.conf
sysctl -p
但我的建议是:先搞清楚为什么 swap 在涨,再决定要不要调参数。如果本身存在内存泄漏,调低 swappiness 只是掩盖问题,内存最终还是会耗尽。定位内存增长最快的方式是持续观察一段时间:
bash复制# 每 5 秒输出一次内存排行,观察 20 次
pidstat -r 5 20
5.3 什么时候需要警惕 swap
swap used 稳定在一个值且 si/so 几乎为 0,这说明系统已经很长时间没有把数据从 swap 换进换出,这个占用基本是无害的。最怕的是 si、so 持续增长,同时 wa(IO wait)走高。这种情况下,首先要判断是物理内存不够,还是 page cache 增长过快导致回收压力大。一个比较直观的指标是 free -h 中 available 列,它考虑了 page cache 的可回收性,比 used 列更能反映真实剩余可用内存。
如果确认是内存不足,优先考虑优化进程内存占用,比如调整 JVM 堆大小、减少连接池数量、给 Nginx worker 进程数封顶。物理加内存是最直接的方案,但在此之前,把不用的服务停掉往往立竿见影。我曾经处理过一台机器,看着内存占用 80%,实际是 50 多天没重启的进程积累了碎片化内存,重启服务后降到了 20%,swap 也自动清空了。
6. 从日常踩坑里沉淀出的排查习惯
6.1 先确认现象边界,再动手
每次遇到 Linux 行为问题,我现在的第一反应不是立刻执行命令,而是先确认三件事:问题从什么时候开始、影响范围是什么、最近有没有变更。比如磁盘空间满了,是 5 分钟前满的还是一周前满的?有没有可能是有定时任务在疯狂写某个目录?有没有同事刚部署过新版应用?
我见过太多人看到磁盘满就直接 rm,结果删的是正在被进程占用的日志,空间一点没释放。先把现象边界圈定,再用 lsof、df、du 交叉验证,往往能在 5 分钟内定位根因,比盲目操作高效得多。
6.2 用时间线和最小复现代替瞎猜
凡是能复现的问题,我都建议做一次最小复现。比如某个脚本在用户 A 下跑正常,在用户 B 下就报错,我通常会开两个终端,分别以 A 和 B 的身份执行 bash -x script.sh,对比每一步的输出差异。加上 set -x 能看到每个命令实际展开后的执行内容和结果,很多环境变量、路径、权限差异都会直接暴露出来。
如果问题不能稳定复现,就记录时间线,把当时的操作、现象、系统输出按分钟记下来。排查结束后回看,往往能发现某个细微操作就是触发条件。这套方法虽然朴素,但比任何调试工具都通用。
6.3 把排查结论固化成笔记,越攒越值钱
我自己的习惯是维护一份 ~/notes/linux-troubleshooting.md,每个问题记录三行:现象、根因、解决命令。久而久之,这份文档比任何一本 Linux 书籍都贴合自己的业务场景。热搜词里那些高频词——find、sed、端口占用、删除文件夹、Linux 常用命令——本质上都是大家反复在真实环境中遇到的行为问题,你把这些行为背后的机制搞清楚,再遇到新问题时,排查路径会非常有方向感。
最后分享一个小技巧:遇到奇怪的现象,先去看 /var/log/messages 或 journalctl -xe 里的内核和系统日志,很多疑难杂症其实系统已经帮你把根因写在日志里了,只是很少有人第一时间去看。
