在服务器上报障排障的时候,有一类需求出现频率非常高:查询文件中指定内容出现了多少次、批量杀掉一批进程、把手头快写满的日志文件清空。这三件事拆开看都很基础,但真到生产环境,每一件都有不少容易被忽视的细节。比如统计次数时,是“按行统计”还是“按匹配次数统计”,结果能差出好几倍;批量杀进程时,一个模糊匹配可能把整个服务全带走;清空日志时,用 rm 还是用 truncate,又直接影响磁盘空间有没有真的释放。这篇文章就是记录我自己的日常命令和踩坑经验,给同样做运维、后端开发,或者刚接触 Linux 命令行的朋友一个参考。内容不搞花活,都是直接在终端里能跑、能抄走的东西。
1. 统计文本出现次数:grep、sort、uniq 的用法与坑
1.1 先分清“按行计数”和“按出现次数计数”
大部分人的第一反应是grep -c '关键词' 文件。grep -c的 c 是 count,但它统计的是“匹配的行数”,不是“匹配次数”。也就是说,一行里就算出现三次关键词,grep -c也只给你算一次。如果你只想看这个关键词到底出现了多少次,就要用grep -o '关键词' 文件 | wc -l。grep -o会把每个匹配到的片段单独输出一行,wc -l数行数,结果才是真实出现次数。
举个例子,有一条连接日志:
timeout 10.1.2.3:80 timeout 10.2.0.4:80grep -c 'timeout'返回 1,grep -o 'timeout' | wc -l返回 2。别看差别小,写监控脚本的时候非常关键。比如判断某个接口在最近日志里超时次数是否超过阈值,如果用grep -c,一次请求里重复出现多次超时就被漏掉了,数据失真。
如果你要统计多个文件里的总次数,可以这样:
grep -o 'timeout' /var/log/app/*.log | wc -lgrep -o在多个文件时也会带文件名前缀,例如/var/log/app/a.log:timeout,这种情况下直接用wc -l没问题,因为每一行都对应一个匹配项。如果还想知道每个文件分别有多少次,用grep -c 'timeout' /var/log/app/*.log,输出格式是文件名:次数,一目了然。
还有一个常见的坑:关键词里带点号、星号这类特殊字符时,会被 grep 当成正则表达式解析。比如你想统计日志里app.jar这个文件名出现了多少次,grep 'app.jar'里的点号会匹配任意字符,可能把appajar这种也统计进去。正确做法是加-F参数,固定字符串匹配:
grep -F -o 'app.jar' app.log | wc -l我见过不止一次因为没加-F导致统计结果虚高,最后排查半天才发现是点号通配的问题。类似的情况还有匹配带反斜杠的路径、匹配带方括号的字符串,统统建议用-F。
1.2 统计完还要排个名:sort | uniq -c 组合
在服务器上分析日志时,光知道某个词有多少次还不够,更常问的是“哪个 IP 访问最多”“哪个接口报错最多”。这时候需要把结果分组计数并排序,最经典的组合是:
awk '{print $1}' /var/log/access.log | sort | uniq -c | sort -rn | head -20这条命令的意思是:用 awk 取出第一列(比如 IP 地址),sort 排序,uniq -c 把连续相同行合并并计数,sort -rn 按数字逆序排,最后 head 取前 20 行。
很多人会漏掉中间的第一步 sort,直接awk '{print $1}' file | uniq -c。这里有个隐藏问题:uniq只能合并“连续”的重复行。如果相同 IP 分散在文件不同位置,不先 sort 的话,计数会碎成好几行,结果完全错误。所以使用 uniq 之前,排序是必须的。
如果不想用 sort | uniq,awk 自己也能当计数器:
awk '{count[$1]++} END {for (k in count) print count[k], k}' /var/log/access.log | sort -rn | head -20这种写法在单次扫描里累计,最后只需要排序已经压缩后的计数结果。对几十行的小日志无所谓,但对几千万行的大日志,性能要明显好于管道里反复 sort。我实际比较过,一个 2GB 的访问日志,用 sort | uniq 大约要跑 3 分钟,用 awk 计数后 sort 只需要 40 秒左右。如果你的日志量很大,建议优先选 awk 方案。
需要按特定分隔符取列时,用-F指定。比如 CSV 文件按逗号分割,取第一列:
awk -F',' '{print $1}' data.csv | sort | uniq -c | sort -rn还有一个更冷门的 awk 技巧:直接统计某个关键词在整行里出现的总次数,不需要 grep -o 管道。用gsub的返回值:
awk -v pattern='error' '{n += gsub(pattern, pattern)} END {print n}' app.loggsub返回替换的次数,这里把匹配到的内容替换成自身,不会改变原行,但能拿到次数。这个技巧在文件特别大、不想起多个管道进程时很管用。
1.3 在多文件、整个目录里统计总次数
有时候要统计一个目录下所有日志文件中某个关键词的总次数,而不是单文件。查找加统计一起做,常用 find 配合 grep:
find /data/logs -type f -name "*.log" -exec grep -o -h 'error' {} + | wc -l这里加了-h(--no-filename)去掉文件名前缀,避免干扰统计。如果不用-h,每个匹配项前面会带文件路径:error,wc -l 照样能数出次数,但如果你想继续用 sort | uniq 统计关键词频率,路径前缀会让结果变得一团糟。所以建议统计总次数时加上-h。
更直观的写法是:
grep -r -o --include="*.log" -h 'error' /data/logs | wc -l-r递归目录,--include限制扩展名。这个命令可读性好,但要注意-r和--include组合时,后面必须是指定的目录,如果指定的是单个文件,--include不生效。另外文件名带空格时,用管道加 xargs 可能会出问题:
find /data/logs -name "*.log" | xargs grep -o 'error'如果文件名里有空格,xargs 默认按空格拆分,会把一个文件名拆成两个,出现 “No such file” 错误。稳妥写法是用-print0和-0:
find /data/logs -type f -name "*.log" -print0 | xargs -0 grep -o -h 'error' | wc -l或者直接用 find 的-exec,它不会拆分文件名。我的习惯是能用-exec就用-exec,省得记-print0的规则。
2. 批量杀进程:从 pgrep 查找到 pkill 精确终止
2.1 查找进程就是避免误杀的第一步
很多同学一上来就是ps aux | grep python,然后照着输出杀进程。我不反对,但有三个细节必须先处理好。
第一,ps aux的输出里会有一行 grep 自己的进程。直接ps aux | grep python,结果里会包含grep --color=auto python这一行,如果你不加处理就把第二列的 PID 挑出来杀掉,容易把 grep 命令自己杀了。一般无伤大雅,但会让脚本后续逻辑变得混乱。一个小技巧是用grep '[p]ython',这样 grep 进程的命令行是grep [p]ython,正则表达式匹配不到它自己。
第二,更推荐用pgrep来找进程。pgrep专门按名称或命令行匹配 PID:
pgrep -a python-a表示同时显示 PID 和完整命令行,先看再杀,比ps aux | grep干净得多。不过默认pgrep只匹配进程名,不匹配参数。如果你要按完整命令行匹配,比如启动命令里包含--worker=1,就要加-f:
pgrep -f -- '--worker=1'一般我们用“服务名”或“命令行关键字”找进程的场景,都要用到-f。
第三,按用户找进程也有现成命令:
pgrep -u deploy这个会列出所有属于 deploy 用户的进程。批量杀某用户所有进程可以用pkill -u deploy,但一定要慎用,因为可能把该用户下的 sshd 会话、cron 任务一起杀掉,直接造成服务断连。
2.2 pkill、killall、kill 怎么配合用
找到 PID 之后,可以直接kill PID,也可以pkill -f按命令行批量杀。我推荐的批量“套餐”是:
pgrep -f "python app.py" | xargs -r killxargs -r表示如果 pgrep 没有输出,xargs 不执行,避免报错。或者一行搞定:
pkill -f "python app.py"pkill和pgrep共享同一套匹配语法,pkill默认发送 SIGTERM 信号,让进程自己处理退出逻辑,保存状态。如果进程没有响应,等几秒再用 SIGKILL 强制杀。下面这种写法适合放在脚本里:
for pid in $(pgrep -f "python app.py"); do kill "$pid"; done sleep 5 for pid in $(pgrep -f "python app.py"); do kill -9 "$pid"; done这里有一个我自己踩过的坑:如果脚本本身的内容里含有python app.py这一段字符串,pgrep -f会匹配到当前正在执行脚本的 shell 进程,因为 shell 的命令行参数里包含整段脚本内容。结果第一个 for 循环把自己的父 shell 杀了,脚本直接中断。避坑写法是对 PID 做排除:
for pid in $(pgrep -f "python app.py"); do [ "$pid" -ne "$$" ] && kill "$pid" done但实际场景下脚本可能不是直接执行的,$$不一定代表 pgrep 匹配到的那个解释器进程。所以我更建议在脚本里不要直接 pkill,而是先把 pgrep 的结果打印出来人工确认,确认无误后再单独执行 pkill。
killall按进程名杀,比如killall nginx会把所有叫 nginx 的进程都杀掉。注意killall在部分系统上也会匹配完整命令行参数,使用时要小心。如果系统没有killall,可以用pkill -x nginx,-x表示精确匹配进程名。
另外一个容易忽略的参数是信号等级:kill -9是 SIGKILL,kill -15是 SIGTERM。先发 SIGTERM,给进程几秒钟善后,再发 SIGKILL,是通用礼貌原则。直接-9虽然粗暴有效,但可能导致数据文件损坏、连接状态残留。我的习惯是优先kill,单次失败再kill -9。
2.3 父子进程和僵尸进程的注意事项
单个进程终止后,如果它还有子进程,子进程不一定会跟着死,而是被系统顶层进程收养。某些场景下我们想连子进程一起清理,比如父进程是调度器,杀掉父进程后一堆 worker 还在继续跑,继续占资源。这时候可以用pkill -P 父PID,按父进程 PID 匹配所有子进程:
pkill -P 12345最好是先杀子进程,再杀父进程,避免父进程检测到子进程退出后重新拉起新的子进程。
僵尸进程不能用 kill 杀死,因为它本身已经死了,只是父进程还没有调用 wait 来回收它的进程表项。处理僵尸进程的正确方式是杀掉它的父进程,或者让父进程退出后,由系统回收。我之前遇到过某个常驻脚本产生一堆僵尸子进程,排查时先找到僵尸进程的 PPID:
ps -eo pid,ppid,stat,cmd | grep Z确认是哪个常驻服务,再决定是否重启那个服务,而不是对着僵尸 PID 一顿 kill。
说到误杀,我自己有一次在清理测试环境时,想杀掉所有和某开发框架相关的进程,直接用了pkill -f 框架名。结果一瞬间把自己的终端断连了,原因是当前终端所在的环境里也有匹配该字符串的进程,更关键的是我在命令行输入的命令本身也包含这段字符串,pkill 匹配到了当前 shell。那之后我给自己定了三条原则:
- 批量杀之前,先
pgrep -a看完整命令。 - pkill 的匹配字符串尽量用更长的、唯一的路径或参数,比如
pkill -f '^/usr/bin/python.*app.py'。 - 不在涉及生产环境的自动化脚本里直接 pkill,先输出候选 PID 再人工确认。
3. 清空文件:truncate、重定向与日志轮转的选择
3.1 几种清空方式本质上的区别
清空一个日志文件,最常用的写法有> app.log、: > app.log、cat /dev/null > app.log。它们本质都一样:以截断方式打开文件,把文件大小变成 0。区别在于:
> app.logshell 重定向,如果文件不存在会创建,存在则截断。
: > app.log内建命令:什么都不干,但重定向依然生效,在某些脚本里看起来更语义化。
cat /dev/null > app.log实际读一下空文件再写,效果相同,但多启动一个进程,不推荐。
还有一个工具叫truncate:
truncate -s 0 app.logtruncate 直接把文件大小调整为 0,不需要重新打开文件。如果你想保留末尾一小段内容,还可以用负号从尾部减掉一些,比如truncate -s -100M app.log,但要注意文件如果不足 100M 会报错。
相比rm加touch,用重定向或 truncate 有一个关键优势:文件 inode 和权限、属主都不变。生产环境里日志文件可能有特定属主和权限,比如系统服务日志属主是某个专用用户,你rm掉再touch,新文件会归当前操作用户所有,权限也可能变化,服务进程重新打开日志文件时直接权限不足。所以清空文件我基本只用> file或truncate -s 0,从不用rm。
还有一个细节:如果文件有硬链接,> file会把所有硬链接指向的同一份文件内容截断,因为截断操作直接作用于 inode;而rm file只是删掉一个硬链接,原文件内容还被其他链接保留。理解这一点,能帮你解释一些“为什么日志还在变”的诡异现象。
3.2 批量清空大文件前的安全操作
有时候日志目录里已经堆了十几个超大文件,磁盘快满了。你想批量清空超过 1GB 的日志,常见做法:
find /data/logs -type f -name "*.log" -size +1G -print -exec truncate -s 0 {} \;这条命令会找到超过 1GB 的 .log 文件,打印路径并清空。但直接执行有风险,建议先只打印不执行:
find /data/logs -type f -name "*.log" -size +1G -print在真正运行前,审视一遍列表,确认没有数据库文件、临时数据文件或系统关键文件混在里面。清空是破坏性操作,要比删除更谨慎。
如果文件数量很多,-exec ... {} +会比-exec ... {} \;快很多,因为它会把批量文件名合并成一次命令调用。GNU 的 truncate 支持一次处理多个文件,所以可以写:
find /data/logs -type f -name "*.log" -size +1G -exec truncate -s 0 {} +如果你担心某条 find 语法在目标系统上不支持,先跑一遍-print确认列表,再用 xargs 转交,但记得用-print0和-0处理文件名空格。
批量清空之前,还需要想清楚日志是否需要留存审计。如果只是临时清理磁盘,可以考虑先压缩再删除,而不是直接清空。实际过程中,我在生产服务器上清空日志前,会先执行:
du -sh /data/logs看看目录总大小,清空后再执行一次,对比空间是否真的释放。如果发现磁盘空间没降下来,就要考虑下面要说的文件描述符问题。
3.3 清空正在被写入的文件时,磁盘空间为什么没释放
很多人在生产环境碰到的第一起“神秘事件”是:明明> app.log把文件清空了,du一看磁盘占用还是没变。这通常不是命令没生效,而是进程的文件写入偏移量没有归零。
当一个进程以追加模式(O_APPEND)打开日志文件时,每次写入都会自动把偏移量移到文件末尾。你截断文件后,文件末尾变成 0,下一次写入就继续从 0 开始,磁盘空间会正常释放。大多数成熟的日志框架默认采用追加模式,所以直接 truncate 问题不大。
但有些程序不是以追加模式打开文件的。它可能在启动时用普通写模式打开,然后一直维护自己的文件偏移量。你 truncate 之后,文件大小变成 0,但进程的文件偏移量还停在你截断前的位置。下一次进程写入时,内核会根据偏移量把数据写到很远的地方,于是文件大小又变成“旧位置 + 新数据长度”,ls 看文件大小可能还是几个 G,磁盘占用根本没释放。
这种情况的处理办法不是反复截断,而是要通知进程重新打开日志文件。常见做法是给进程发送信号,比如很多服务支持kill -USR1重新打开日志,或者直接重启服务。如果你不确定进程是否支持,最稳妥的就是重启服务,让文件重新以正确的模式打开。
另外一个更常见的场景是:日志文件被删除后磁盘空间不释放。假设有人用rm删掉了一个正在被写入的日志文件,进程的文件描述符仍然指向已经被删除的 inode,数据会继续写到那个不可见的文件里,直到进程退出。用lsof +L1可以查看到这类“已删除但还被占用”的文件:
lsof +L1 | grep deleted看到之后,找到对应进程 PID,重启或让它重新打开文件,磁盘空间才会真正释放。这也是我为什么一直强调:清空正在被写入的日志,优先用 truncate 而不是 rm。
3.4 logrotate 的 copytruncate 更适合长期维护
生产环境里手动清空日志只能应急,日复一日地手工操作迟早会忘。更标准的方式是用日志轮转工具,比如 logrotate。它的配置里有一个copytruncate选项,专门解决“清空不重启进程”的问题:
/data/logs/app.log { daily rotate 7 missingok notifempty compress delaycompress copytruncate }意思是:每天轮转一次,保留 7 份归档,如果文件为空就不转,归档压缩,延迟一天再压缩,利用 copytruncate 复制当前日志内容到备份后截断原文件。这个方案的优势是服务进程不需要感知日志文件被移动,不会出现文件句柄指向已删除文件的空间浪费问题。
不过 copytruncate 也有代价:在复制和截断之间,可能有少量日志丢失,因为进程仍然在写原文件,复制完再截断的瞬间,刚才写入的内容可能已经进入了备份,也可能在截断后丢了一小段。对于绝大多数业务日志来说,这种极小的丢失可以接受。如果要求绝对完整,更严谨的方式是让服务进程支持重新打开日志文件,配合create或copy来做轮转,但那样往往需要配置信号钩子。
我的建议是:能上 logrotate 就上 logrotate,手动清空只用来应急。如果你只是偶尔清空一个临时文件,记住用 truncate,不要用 rm。
4. 常见问题与排查技巧实录
4.1 六个高频问题速查表
下面这些是我在实际操作中反复遇到、也经常被同事问的问题,整理成一张速查表:
| 问题现象 | 根本原因 | 解决办法 |
|---|---|---|
grep -c统计结果比预期小很多 | 一个匹配行里有多次出现,grep -c 按行计数 | 使用grep -o pattern file | wc -l |
统计带点号的app.jar次数虚高 | 点号被当作正则通配符 | 使用grep -F -o 'app.jar' file | wc -l |
uniq -c统计频率结果不对 | uniq 只能合并连续相同行,没先 sort | 先sort再uniq -c |
| pgrep 找不到某个进程 | 默认按进程名匹配,没按完整命令行匹配 | 使用pgrep -f "完整命令片段" |
| pkill 后自己的 shell 掉线 | pkill 匹配到了当前命令行字符串 | 先pgrep -a确认,使用更精确正则 |
| 清空日志文件后磁盘空间没释放 | 进程文件偏移量还在原位置,或文件被删除但仍被占用 | 用lsof +L1排查,重启或发信号让进程重新打开文件 |
速查表之外的补充:kill -9杀不掉的进程,可能是处于不可中断睡眠状态,比如正在等待磁盘 IO。这种进程在/proc/<pid>/status里的状态是 D,你只能等待它结束,或者从根本上排查底层 IO 问题,杀掉效果为零。
4.2 提升统计性能的几个不起眼细节
处理大文件时,命令写法对性能影响非常大。首先是 locale,默认 grep 会按 UTF-8 处理多字节字符,如果日志主要是 ASCII,指定 C locale 能快不少:
LC_ALL=C grep -o 'error' big.log | wc -l我实测过,一个大文件在默认 locale 下 grep 要跑十几秒,加LC_ALL=C后只需要几秒。如果你的日志不包含中文,放心用。
第二个细节是避免无谓的管道进程。cat file | grep xxx这种写法多启动一个 cat 进程,完全没有必要,直接grep xxx file。单次命令不觉得,放到循环里或调度任务里,就是实实在在的额外开销。
第三个细节是:如果只需要判断文件里有没有某个关键词,不需要统计次数,用grep -m1 keyword file,匹配到第一个结果就停止读取,文件再大也只是瞬间返回。下面的写法比grep | wc -l高效得多:
if grep -q -m1 'OutOfMemoryError' app.log; then echo "发现内存异常关键字" fi第四个细节是关于 awk 统计的内存占用。awk 用数组计数时,所有 key 都存在内存里。如果统计的维度特别多,比如按整行日志去重,可能有几百 GB 内存风险。这种情况下最佳方案是先 sort 外排,再用 uniq 计数,用磁盘换内存。所以“awk 一定比 sort | uniq 快”也不是绝对的,要看数据量和唯一值数量。
4.3 把统计、杀进程、清空文件串成脚本骨架
这三个操作经常出现在同一个排障流程里:先统计日志里错误关键词的次数,次数超过阈值就检查相关进程,确认异常后清理进程,然后清空日志释放空间。下面这个脚本骨架可以复用,但我不建议在无人值守时自动执行杀伤性操作:
#!/bin/bash LOG_FILE="/var/log/app/app.log" PATTERN="OutOfMemoryError" THRESHOLD=10 # 1. 统计关键词出现次数 count=$(grep -o "$PATTERN" "$LOG_FILE" | wc -l) echo "关键词出现次数: $count" # 2. 超过阈值,输出候选进程,但不自动杀 if [ "$count" -gt "$THRESHOLD" ]; then echo "超过阈值,检查相关进程..." pgrep -a -f "app-server" || echo "未找到 app-server 进程" # 3. 手动确认后,再执行清理 # pkill -f "app-server" # sleep 5 # pkill -9 -f "app-server" fi # 4. 最后清理日志 # truncate -s 0 "$LOG_FILE"我一般会在脚本里把杀伤性命令注释掉,只保留查找和统计。线上服务器跑定时任务时,宁可多花 30 秒人工确认,也不要让脚本自作主张把服务杀了。
最后分享一个习惯:任何可能误伤的批量操作,我都会先写一个“dry-run”模式。比如把要执行的命令先 echo 出来,加上DRY_RUN=1判断,真正跑之前打印出即将执行的进程列表或文件列表。这个习惯帮我避免过好多次事故,也希望你能用上。