☰
构建高效运维工具箱:Java排查与Shell提效脚本实战
2026/10/23 1:29:55 网站建设 项目流程

1. 为什么我劝你早点把排查脚本攒成一套工具箱

线上出问题的时候,最怕的不是问题本身,而是你手忙脚乱找不到趁手的工具。CPU 飙高、内存泄漏、线程死锁、日志里翻不出关键信息,这些场景我在过去几年里反复遇到,每次都在临时拼命令、临时写脚本,效率低得让人抓狂。后来我干脆花时间把常用的排查动作和日常提效操作沉淀成一套脚本集合,也就是标题里说的 useful-scripts 这类东西——它本质上是一个自用的脚本仓库,把 Java 应用排查和 Shell 日常提效两类需求装在一起,随取随用。

这套东西能解决什么问题?简单说就是两件事:一是把 Java 线上排查里那些高频但繁琐的动作(线程栈抓取、堆内存快照、GC 状态观察、进程资源定位)固化成一条命令就能跑完的脚本;二是把 Shell 里那些每天重复的脏活(批量文件处理、日志切割统计、环境巡检、定时任务封装)写成可复用、可维护、带错误处理的函数库。适合谁来参考?我觉得三类人最合适:刚接手线上运维、还在靠搜索引擎拼命令的新手;写了几年脚本但一直停留在“能跑就行”阶段的中间层;以及想把团队排查流程标准化的负责人。

我写这套东西的出发点很朴素:排查是和时间赛跑,脚本的价值在于把思考成本前置。出事那一刻你没空想参数怎么传、路径怎么拼、异常怎么兜底,这些都应该在平时写好、测好、放好。所以这篇内容不是简单罗列几个命令,而是把安装组织、实战用法、以及生产级 Bash 开发里那些真正会咬人的细节讲透。你照着做,能直接搭出一套自己的工具箱;你已经有基础,也能从里面的错误处理、参数校验、日志规范这些地方抠出不少能立刻用上的东西。

2. 整体设计与思路拆解:为什么这么组织一套脚本

2.1 分类逻辑:按“使用场景”而不是按“语言”切分

很多人攒脚本的习惯是按语言分目录,java 一个文件夹、shell 一个文件夹、python 一个文件夹。我一开始也这么干,结果用了半年发现找东西特别费劲——因为你的大脑在出问题时想的是“我要看线程栈”,而不是“我要找一个 Java 脚本”。所以这套 useful-scripts 的组织逻辑我改成了按场景切:java-diag/放 Java 排查相关,shell-utils/放通用提效,ops-check/放环境巡检,每个目录下再按具体动作分子目录。

这个改动看着小,实际收益很大。举个例子,线上告警说某服务响应变慢,你脑子里第一个动作是“抓线程栈看卡在哪”,直接进java-diag/thread/就能找到dump-thread.sh,不用回忆它是用 jstack 还是 jcmd 写的。按场景组织等于把你的排查直觉直接映射成目录结构,这是这套脚本能用起来的第一前提。

2.2 单文件自包含:能一个文件解决的绝不拆成三个

脚本拆分有个常见的坑:为了“模块化”把一个功能拆成主脚本加一堆被 source 的库文件,结果换台机器部署就找不到依赖,或者路径写死导致换个目录就跑不起来。我的原则是单个排查脚本必须自包含,所有函数、常量、默认配置都写在同一个文件里,需要复用的公共逻辑才抽到lib/下,并且用相对路径加BASH_SOURCE定位,保证脚本被移动到任何位置都能找到自己的依赖。

这么做的理由很实在:排查脚本经常要在跳板机、容器里、甚至临时拷贝到某台机器上跑,环境不可控。自包含意味着你scp一个文件过去就能用,不用管目录结构。而公共库只在“确实多处复用且改动频繁”时才抽出来,避免过度设计。我见过太多脚本仓库死于“架构太漂亮,实际没人愿意维护”。

2.3 默认安全:所有破坏性操作必须显式确认

排查脚本里有一类操作是有风险的,比如清理临时文件、kill 进程、截断日志。这类动作我的设计原则是默认只读、写操作必须显式加参数。比如clean-tmp.sh不加--force只打印将要删除的内容,加了才真删;kill-by-port.sh不加--yes只列出占用端口的进程信息。这个设计救过我一次——某次手滑把参数顺序搞错,因为默认是 dry-run,只是打印了一堆信息,没有真的把生产进程干掉。

提示:dry-run 默认开启不是啰嗦,是给手滑留一条命。任何会改状态的脚本,第一版都应该先写成只打印不执行。

2.4 输出结构化:人能看,机器也能解析

排查脚本的输出经常要贴到工单里、喂给监控、或者被另一个脚本消费。所以我在输出格式上做了统一约定:人读的部分走 stderr,可解析的数据走 stdout,并且关键脚本支持--json输出。比如线程栈分析脚本,默认打印带高亮的可读摘要,加--json就输出结构化的线程状态统计。这样同一个脚本既能人肉看,也能被上层自动化流程调用,不用维护两套。

3. 安装组织与目录落地:从零搭起你的脚本仓库

3.1 目录结构设计与初始化

先把骨架搭出来。我用的结构大致是这样,你可以按自己习惯微调,但分层逻辑建议保留:

useful-scripts/ ├── bin/ # 统一入口,软链或包装脚本 ├── java-diag/ # Java 排查 │ ├── thread/ # 线程相关 │ ├── memory/ # 内存与堆 │ └── gc/ # GC 观察 ├── shell-utils/ # 通用提效 │ ├── file/ # 文件批量处理 │ ├── log/ # 日志统计切割 │ └── net/ # 网络与端口 ├── ops-check/ # 环境巡检 ├── lib/ # 公共函数库 └── conf/ # 默认配置

初始化就三条命令的事,但有个细节要注意:bin/里的入口我用的是包装脚本而不是软链,因为软链在跨文件系统拷贝时会失效,包装脚本里用BASH_SOURCE定位真实路径更稳。

mkdir -p useful-scripts/{bin,java-diag/{thread,memory,gc},shell-utils/{file,log,net},ops-check,lib,conf} cd useful-scripts && git init

3.2 公共库的定位技巧:让脚本在任何目录都能跑

这是新手最容易翻车的地方。脚本里写source ./lib/common.sh,在当前目录跑没问题,一旦被别的脚本调用或者换目录执行就报“找不到文件”。正确做法是用BASH_SOURCE反推脚本自身所在目录:

#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LIB_DIR="$(cd "${SCRIPT_DIR}/../lib" && pwd)" source "${LIB_DIR}/common.sh"

BASH_SOURCE[0]拿到的是脚本被调用时的路径(可能是相对路径),dirname取目录,cd ... && pwd转成绝对路径。这一套组合下来,无论你从哪个目录、用相对还是绝对路径调用,都能准确定位到库文件。这个模式我建议你背下来,几乎所有正经 Bash 脚本都该这么写开头。

3.3 环境依赖检查:跑之前先确认工具在不在

Java 排查脚本依赖 jstack、jcmd、jmap 这些工具,不同 JDK 版本工具位置和可用性还不一样。与其跑到一半报 command not found,不如开头就检查:

require_cmd() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then echo "缺少依赖命令: $cmd" >&2 return 1 fi } require_cmd java || exit 1 require_cmd jcmd || exit 1

把require_cmd放进lib/common.sh,每个脚本开头按需调用。这样缺什么一目了然,而不是让用户对着半截报错猜。实测下来,这个几行的小函数省掉的沟通成本远超预期。

3.4 把脚本挂到 PATH:用 bin 目录做统一入口

脚本写好了,每次敲全路径太累。我在bin/下放包装脚本,然后把bin/加进 PATH:

# bin/dump-thread #!/usr/bin/env bash exec "$(cd "$(dirname "${BASH_SOURCE[0]}")/../java-diag/thread" && pwd)/dump-thread.sh" "$@"

exec替换当前进程,"$@"原样透传所有参数,这样入口脚本不产生额外进程也不吞参数。然后在 shell 配置里加一行export PATH="$HOME/useful-scripts/bin:$PATH",之后直接敲dump-thread 12345就能用。入口层和实现层分离的好处是:实现可以随便重构挪位置,只要入口不变,你的肌肉记忆就不受影响。

4. Java 排查脚本的实战用法与原理

4.1 线程栈抓取:jstack 与 jcmd 怎么选

抓线程栈是排查卡顿、死锁、CPU 飙高的第一步。工具上有两个选择:jstack和jcmd <pid> Thread.print。我的建议是优先用 jcmd,原因是 jstack 在新版本 JDK 里对某些场景(比如 attach 机制受限的环境)会失败,而 jcmd 走的是同一套 attach 但接口更统一,且能顺带做别的诊断。不过老版本 JDK(比如 8 早期)jcmd 功能没那么全,所以脚本里我做了自动降级:

dump_thread() { local pid="$1" out="$2" if command -v jcmd >/dev/null 2>&1 && jcmd "$pid" Thread.print >"$out" 2>/dev/null; then return 0 fi jstack "$pid" >"$out" }

抓栈有个关键细节:连续抓多次比抓一次有用得多。单次栈只能看到某一瞬间,如果线程在快速切换,你根本判断不出它卡在哪。我的做法是默认抓 3 次、间隔 5 秒,然后对比三次里都停在同一个方法上的线程——那才是真正的热点或阻塞点。

for i in 1 2 3; do dump_thread "$pid" "thread-$i.txt" sleep 5 done

注意:抓栈本身有轻微 STW 影响(尤其 jstack),生产环境别抓太频繁,3 次间隔 5 秒是个比较稳的折中。如果服务对延迟极敏感,把间隔拉长到 10 秒。

4.2 堆内存快照:jmap 与 jcmd 的取舍及触发时机

内存泄漏排查绕不开堆快照(heap dump)。工具上jmap -dump和jcmd <pid> GC.heap_dump都能做,我倾向 jcmd,因为它的参数语义更清晰,且不会像某些 jmap 版本那样默认触发 Full GC(老版本 jmap 的-dump行为有坑)。命令大致是:

jcmd "$pid" GC.heap_dump /tmp/heap-$(date +%s).hprof

这里有个必须讲清楚的原理:heap dump 会暂停应用(STW),文件越大暂停越久。一个 4G 堆的应用,dump 出来可能几百 MB 到 1G 多,暂停时间从几百毫秒到数秒不等。所以脚本里我加了两道保险:一是 dump 前打印当前堆使用情况让操作者心里有数,二是要求显式传--confirm才执行,避免误触。

heap_used=$(jcmd "$pid" GC.heap_info 2>/dev/null | grep -oE 'used=[0-9]+K' | head -1) echo "当前堆使用: ${heap_used:-未知},dump 将触发 STW,请确认" >&2 [[ "${1:-}" == "--confirm" ]] || { echo "加 --confirm 才会执行" >&2; exit 1; }

4.3 GC 状态观察:别只看次数,要看停顿分布

很多人看 GC 就盯jstat -gc里的次数,其实更有价值的是停顿时间分布。次数多不一定有问题(小对象频繁回收很正常),但单次停顿长、或者老年代回收后释放比例低,才是真信号。我常用的观察命令是:

jstat -gcutil "$pid" 1000 10

每秒采样一次共 10 次,重点看FGC(Full GC 次数)是否在涨、FGCT(Full GC 总时间)增速、以及OU(老年代使用率)回收后是否明显下降。如果 FGC 在涨但 OU 降不下来,基本可以怀疑内存泄漏或堆设置不合理。脚本里我把这些指标做成一个简单的趋势判断,连续采样后给出“正常/关注/告警”三档提示,省得人肉盯数字。

4.4 进程资源定位:从 CPU 高到具体线程

CPU 飙高的经典排查链路是:top找到高 CPU 进程 → 找到进程内高 CPU 线程 → 线程 ID 转十六进制 → 在栈里搜这个 ID。这套动作手动做要敲五六条命令,脚本一条搞定:

top -H -p "$pid" -b -n 1 | awk 'NR>7 {print $1, $9}' | sort -k2 -rn | head -5

拿到线程 ID 后转十六进制(printf '%x'),再去线程栈里 grepnid=0x...。这个链路我封装成cpu-hot-thread.sh,输出直接是“线程 ID、十六进制、CPU 占比、栈顶方法”四列,一眼就能定位到是哪段代码在烧 CPU。这个脚本是我用得最频繁的一个,没有之一。

5. Shell 提效脚本的实战与生产级写法

5.1 参数解析:getopts 够用,但别硬上复杂框架

Shell 脚本处理参数,getopts能覆盖绝大多数场景,短选项加长选项的组合用getopts配一个 while 循环就够。我不建议为了“优雅”引入一堆外部参数解析库,脚本的可移植性比语法糖重要。一个典型的写法:

while getopts ":p:o:n:h" opt; do case "$opt" in p) pid="$OPTARG" ;; o) out="$OPTARG" ;; n) count="$OPTARG" ;; h) usage; exit 0 ;; \?) echo "未知选项: -$OPTARG" >&2; usage; exit 1 ;; :) echo "选项 -$OPTARG 需要参数" >&2; exit 1 ;; esac done

注意getopts字符串开头的冒号,它让错误处理走:分支而不是默认打印,这样你能自定义错误信息。\?处理未知选项,:处理缺参数,这两个分支不写,用户传错参数时脚本行为会很诡异。

5.2 错误处理三件套:set -euo pipefail 的正确理解

几乎每篇 Bash 教程都说开头加set -euo pipefail,但很多人加了之后遇到“明明该继续却退出了”或者“明明该退出却继续了”的困惑。我把它拆开讲清楚:

  • -e:命令返回非零就退出。但它有例外,比如命令在if条件里、在&&/||链里、或者前面有!,这些情况不触发退出。
  • -u:引用未定义变量报错退出。这个能抓出大量拼写错误,强烈建议开。
  • -o pipefail:管道里任何一个命令失败,整个管道返回失败。不加的话管道只看最后一个命令的退出码,前面的失败会被吞掉。

实际用的时候有个坑:grep没匹配到返回 1,在set -e下会让脚本直接退出。所以要么写成grep ... || true,要么放进if里判断。我踩过最惨的一次是巡检脚本里一个 grep 没匹配到,整个巡检静默退出,还以为是环境正常。

5.3 日志与输出规范:分级、时间戳、去向分离

生产级脚本的输出要能追溯。我的约定是:正常结果走 stdout,诊断和进度走 stderr,每条带时间戳和级别。公共库里放几个小函数:

log_info() { echo "[$(date '+%F %T')] [INFO] $*" >&2; } log_warn() { echo "[$(date '+%F %T')] [WARN] $*" >&2; } log_error() { echo "[$(date '+%F %T')] [ERROR] $*" >&2; }

这样./script.sh > result.txt时,result.txt 里只有干净的结果数据,进度和告警都跑到终端(stderr),两者互不污染。这个分离在脚本被自动化流程调用时尤其重要——上层只想要数据,不想解析一堆日志。

5.4 临时文件与清理:trap 是你的安全网

脚本里创建临时文件、临时目录,最怕中途失败留下垃圾,或者更糟——清理逻辑把不该删的删了。标准做法是用mktemp创建、用trap兜底清理:

TMP_DIR="$(mktemp -d)" cleanup() { rm -rf "$TMP_DIR"; } trap cleanup EXIT INT TERM

trap ... EXIT保证无论脚本正常结束还是异常退出都会清理,INT TERM覆盖 Ctrl-C 和 kill 信号。这里有个细节:rm -rf "$TMP_DIR"的变量一定要加引号且确认非空,否则变量为空时rm -rf ""虽然一般无害,但如果是rm -rf $TMP_DIR/这种写法,变量为空就变成rm -rf /,后果不堪设想。我见过真实事故就是这么来的,所以清理函数里我还会加一道[[ -n "$TMP_DIR" ]] || return的保险。

5.5 批量文件处理:find 加 xargs 的正确姿势

批量处理文件是 Shell 提效的高频场景,但find ... | xargs有几个经典坑:文件名带空格会断词、文件名带换行会彻底乱套。正确写法是用-print0配xargs -0:

find /data/logs -name '*.log' -mtime +7 -print0 \ | xargs -0 -r -n 50 gzip

-print0用空字符分隔,xargs -0按空字符解析,空格换行都不怕。-r表示没有输入时不执行命令(GNU 特有,BSD 上默认行为不同,跨平台要注意)。-n 50是每 50 个文件一批,避免一次传太多参数超过命令行长度限制。这套组合我用了几年,处理过各种奇葩文件名,没出过问题。

6. 常见问题与排查技巧实录

6.1 脚本在本地能跑,换台机器就报错

这是最高频的问题,原因通常有三类:依赖命令不存在、路径写死、shell 版本差异。排查顺序我建议这样:先看报错是“command not found”还是“No such file”,前者是依赖问题,用前面说的require_cmd提前拦截;后者是路径问题,检查是否用了相对路径或写死的绝对路径;如果报错是语法层面的(比如[[报错),那多半是 shebang 写成了#!/bin/sh但用了 bash 特性,改成#!/usr/bin/env bash。

现象可能原因解决
command not found依赖未安装或不在 PATH开头加 require_cmd 检查
No such file or directory路径写死或相对路径用 BASH_SOURCE 定位
[[: not foundshebang 是 sh 不是 bash改 shebang 为 bash
变量为空导致误操作未开 -u 或未校验set -u 加参数校验

6.2 set -e 下脚本莫名提前退出

前面提过,grep无匹配、diff有差异、test为假,这些返回非零的命令在set -e下都会触发退出。排查方法是把脚本用bash -x跑一遍,看最后执行的是哪条命令。定位到之后,根据意图决定是加|| true、放进if、还是用!取反。我的经验是:凡是“预期可能失败且失败不算错”的命令,都要显式处理,别指望 set -e 帮你判断意图。

6.3 Java 排查脚本 attach 失败

jcmd/jstack报 “Unable to open socket file” 或 attach 失败,常见原因:目标进程用户和当前用户不一致(权限问题)、目标 JVM 关闭了 attach 机制、或者容器环境里/tmp挂载导致 socket 文件路径对不上。排查思路是先确认ps -o user= -p <pid>看用户是否一致,一致还失败就看 JVM 启动参数里有没有禁用 attach 相关配置,容器场景则检查/tmp是否被隔离。这类问题没有万能解,但把排查顺序固定下来能少走弯路。

6.4 脚本输出被日志污染导致解析失败

自动化流程消费脚本输出时,最怕 stdout 里混进了进度日志。根因就是没做 stdout/stderr 分离。修复方法很简单:所有echo诊断信息改成>&2,只把真正的结果数据留在 stdout。改完之后用./script.sh 2>/dev/null验证一下,输出应该只剩纯数据。这个习惯一旦养成,脚本的复用价值会高一个档次。

6.5 几个我踩过的坑和私藏技巧

第一个坑:trap cleanup EXIT里如果 cleanup 本身失败,会覆盖原始退出码。解决办法是在 cleanup 里保存并恢复$?。第二个坑:mktemp在某些系统上默认模板不同,跨平台时显式传模板更稳,比如mktemp -d -t useful.XXXXXX。第三个技巧:排查脚本里加一个--dry-run全局开关,所有写操作前判断这个变量,能极大降低误操作风险。第四个技巧:给每个脚本写一个--self-test模式,用假数据跑一遍核心逻辑,改完脚本先自测再上线,比事后回滚省事得多。

7. 把脚本仓库当成长期资产来养

这套 useful-scripts 我从最初几个零散命令,养到现在几十个脚本,最大的体会是:脚本的价值不在写得多,而在改得动、找得到、敢用。目录按场景组织让你找得到,自包含加 BASH_SOURCE 定位让你换环境也能跑,dry-run 加显式确认让你敢用,stdout/stderr 分离加结构化输出让它能被别的流程复用。这几点做到了,脚本仓库才不是一次性消耗品,而是越用越顺手的资产。

我个人在实际操作中的习惯是:每次排查完一个问题,如果发现中间有重复动作,当场就把它抽成脚本扔进对应目录,哪怕只有三行。日积月累下来,下次遇到同类问题,你打开终端敲一个命令就出结果,那种从容感是临时拼命令永远给不了的。最后分享一个小技巧:给每个脚本头部写一段注释,说明“什么时候用、依赖什么、有什么坑”,三个月后的你会感谢现在的自己。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询