
老读者都知道我分享过不少脚本技巧但每次后台收到的问题里有没有完整的高级脚本可以直接参考总是高频出现。很多人学完if、for、awk这些基础语法之后反而不知道该往哪个方向使劲碰到实际问题还是只会写能跑就行的二三十行小脚本一旦涉及真实场景比如备份、日志处理、批量部署就处处碰壁。这篇内容就是把高级 Shell 脚本这件事讲透我会结合一个开源脚本库里的真实例子解析从脚本设计到参数解析、错误处理、并发控制这些核心环节并且把完整的示例文件拆给你看文末的免费下载资源也留好了你可以直接拿去改造成自己的工具。先说这个项目是干嘛的。标题里的 Advanced Shell Script With Examples 翻译过来就是带示例的高级 Shell 脚本但它不是某个单一脚本而是一整套实战脚本集合——里面包含日志管理、定时备份、系统巡检、文件监控等多个场景的完整脚本。每个脚本都遵循了同一种工程化风格严谨的参数校验、完善的错误处理、模块化的函数封装、可读性强的输出日志。这些恰恰是初级脚本和高级脚本之间的分水岭。我建议所有写过一年以上 Shell、却总觉得脚本不够扎实的人把这篇从头看到尾。基础语法的东西不会重复讲重点放在从会写到能上生产之间缺的那些环节上。1. 项目整体设计与思路拆解1.1 高级脚本到底高级在哪很多人刚开始学 Shell 时觉得语法就是全部会写循环、会管道、会正则就是高手了。真正工作几年才发现完全不是这么回事。高级 Shell 脚本的高级之处从来不是某个冷门语法而是工程素养。同样的功能初级脚本可能是这样的cp /data/logs/*.log /backup/而高级脚本需要考虑的问题是如果/data/logs目录不存在怎么办如果有日志文件正在被进程写入复制到一半进程崩溃了怎么办如果上次备份还没结束这次又启动了怎么办备份完成后磁盘空间满了怎么提示日志输出格式要不要统一方便接入监控系统这些问题每一个都对应一个脚本设计决策。比如set -e决定脚本遇到错误是否立即退出trap决定中途失败时临时文件怎么清理锁文件机制决定脚本是否允许并发。把这些机制组合起来脚本才从命令序列进化为程序。这套示例项目最打动我的地方也在这里它把上述这些问题全部用规范化的方式处理了一遍。每个脚本都不是功能堆砌而是一套可复用的代码骨架我可以把里面的参数解析函数、日志函数、锁文件函数直接抄到自己的项目里用。1.2 为什么必须用示例来学高级脚本学到一定程度你会发现Shell 脚本的知识和能力之间有个巨大的鸿沟。语法文档只教你每个命令能干什么但不告诉你真实场景里这些命令该怎么搭配。举个例子find和while read的组合是处理文件列表的标准姿势但如果你不知道要用IFS和-r来防止文件名空格被拆开脚本一碰到带空格的目录就炸。这种细节没有任何教程会主动教你只能从实战中踩坑或者从别人写好的示例里领悟。免费示例库的价值就在这里它相当于一个经验仓库把许多人踩过坑之后沉淀下来的写法打包给你。你不需要每行都自己发明一遍只需要读懂它为什么这么写然后替换成自己的业务逻辑。另外示例脚本还有一个容易被忽略的好处——它们提供了正确的参照系。很多人在自己小圈子里写的脚本风格各异有些写法虽然能用但隐患很大比如用if [ $var x ]而不加引号变量一为空就报错。看了好的示例之后你才会意识到自己原来的写法差在哪里这是纯看文档得不到的。1.3 这套脚本适合谁来读从这三类读者的角度来说这套示例库都值得入手。运维工程师不用多说日常巡检、发版、日志归档这些事每天都离不开脚本拿到一套健壮性好的模板能省下大量返工时间后端开发者经常需要写数据同步、定时任务、环境初始化脚本同样能从这套示例里学到怎么写才不会半夜被人从床上叫醒即便是数据分析师或者产品经理只要你的日常涉及到批量处理文件、定期跑数据任务这套脚本里的参数解析和日志模块也能让你的脚本从临时工具升级成可持续维护的小服务。需要提醒的是这里不推荐完全零基础的小白直接看高级示例如果连变量赋值和for循环还没搞明白直接上手这套脚本会有点吃力。建议先花一周把基础语法过一遍再看效果会好很多。2. 核心细节解析与实操要点2.1 头部的保命三件套打开这套项目里的任意一个脚本第一眼看到的几乎都是同一组配置#!/usr/bin/env bash set -Eeuo pipefail IFS$\n\t这五行代码看起来简单但每一行背后都是有讲究的。先说#!/usr/bin/env bash用env定位 bash 而不是写死/bin/bash是为了兼容不同发行版的 bash 安装路径macOS 和部分 Linux 的最小化环境里 bash 不一定在/bin下这个写法要稳得多。重点说set -Eeuo pipefail这是四个开关的合集分别管四件事-e表示脚本只要有任何一条命令返回非零状态就立即退出避免出错之后继续往下执行造成更严重的后果-u表示变量在使用前必须先声明一遇到未定义变量就报错专门治那种手滑打错变量名、脚本却闷声跑完的问题-o pipefail则让管道命令的返回状态取所有命令中失败的那一个而不是默认的只取最后一个命令的状态。举个例子mysqldump db | gzip backup.sql.gz这条命令如果mysqldump失败了在没有pipefail的情况下gzip还会正常执行完并返回 0脚本就会认为备份成功了但实际生成的是个残缺文件。开了pipefail之后这个链条上任何一环失败整个命令都返回非零脚本会立刻停下来报警。至于额外的大写-E是让-e的行为在函数和子shell里也保持一致避免某些错误被trap静默吞掉。IFS$\n\t这一行则把系统默认的空格、制表符、换行分隔符改成了换行和制表符。这样做的直接效果是遍历文件列表时遇到带空格的文件名不会自动拆词$展开也不会因为路径里的空格而错乱。这是我见过最容易忽视但性价比最高的安全设置之一。注意set -u刚上手时容易遇到一个烦恼——某些工具脚本里引用了未使用的环境变量会直接报错退出。解决办法是先给变量赋默认值比如: ${MY_FLAG:default}这样既声明了变量又给了默认值兼容性和严谨性都有了。2.2 参数解析的两种靠谱写法高级脚本和玩具脚本在参数处理上的差距是最明显的。玩具脚本直接$1、$2往下用调用的人只能靠猜来记住参数顺序参数一多必乱。这套示例库采用的是getopts加shift的组合usage() { cat EOF 用法: $0 [-c 配置文件] [-l 日志目录] [-f] [-h] -c FILE 指定配置文件路径 -l DIR 指定日志目录 -f 强制运行跳过锁检查 -h 显示帮助信息 EOF } while getopts c:l:fh opt; do case $opt in c) CONFIG_FILE$OPTARG ;; l) LOG_DIR$OPTARG ;; f) FORCE1 ;; h) usage; exit 0 ;; *) usage; exit 1 ;; esac done shift $((OPTIND - 1))getopts的好处是能自动识别-c value、-cvalue、-l dir这些写法还能处理-abc这样的组合参数并且自带?分支来拦截非法参数。shift $((OPTIND - 1))的作用是把已解析的位置参数从$里清掉剩下的$就是真正的非选项参数这样就能混用选项参数和位置参数逻辑非常清晰。如果场景再复杂一点比如参数之间互相依赖或者同一个参数可以出现多次我建议直接升级到bash内置的关联数组配合parse-options函数或者干脆引一个轻量级的参数解析库。示例库里的做法是在getopts之上包了一层parse_options函数把解析结果统一装进全局变量这样脚本主体只需要读变量名代码可读性好很多。2.3 错误处理与人情味的 trap脚本最怕的不是报错而是报错之后留下一堆临时文件、锁文件、后台进程。示例脚本里统一用trap解决了这个问题TEMP_FILES() cleanup() { rm -f ${TEMP_FILES[]} rm -f $LOCK_FILE kill 0 2/dev/null || true log WARN 脚本异常退出已清理临时文件 } trap cleanup EXIT INT TERM这里把EXIT、INT、TERM三种信号都挂到了同一个清理函数上EXIT是正常退出或exit命令触发的INT是用户按 CtrlC 触发的TERM是kill命令默认发送的终止信号。日常最常踩的坑是只挂了EXIT结果用户 CtrlC 中断时临时文件没有清理下次运行就报目录已存在之类的诡异错误。有了trap之后脚本在退出路径上做到了统一收口。不管你是正常跑完、参数错误退出、还是被信号打断都会走同一个清理逻辑这比在每个分支后面手写清理代码要可靠得多。很多新手写脚本时觉得trap是加分项优先级很低实际上应该反过来错误处理是必需品普通语法才是顺手的工具。2.4 日志函数与可观测性这套示例库里有个全局风格所有输出都走统一的日志函数而不是散落一地的echo。核心函数是这样的declare -A LOG_LEVELS( [DEBUG]0 [INFO]1 [WARN]2 [ERROR]3 ) log() { local level$1 shift local msg$* local timestamp timestamp$(date %Y-%m-%d %H:%M:%S) if [[ ${LOG_LEVELS[$level]} -ge ${LOG_LEVELS[$CURRENT_LOG_LEVEL]} ]]; then if [[ -t 1 ]]; then local color case $level in DEBUG) color\e[1;34m ;; INFO) color\e[1;32m ;; WARN) color\e[1;33m ;; ERROR) color\e[1;31m ;; esac echo -e ${color}[$timestamp] [$level] ${msg}\e[0m else echo [$timestamp] [$level] $msg fi fi }日志函数里一个关键细节是[[ -t 1 ]]这是判断标准输出是不是终端。是终端的时候加颜色不是终端比如重定向到日志文件或者管道给 cron的时候自动降级成纯文本避免日志文件里塞满\e[1;32m这类转义字符。对于日志模块的使用我在实际项目中还会加一个--quiet参数来控制日志级别默认是 INFO调试时用--debug切到 DEBUG。这样脚本在正常跑的时候信息干净排查问题时又能有完整的调试输出不用改代码。2.5 并发控制与进度感知脚本处理大规模任务时串行和并行的性能差距是数量级的。示例库里的做法是用xargs -P控制并行度而不是像某些教程那样用后台进程加waitfind $LOG_DIR -name *.log -mtime 1 -print0 2/dev/null \ | xargs -0 -r -P 8 -I {} gzip -f {}-P 8指定同时跑 8 个进程-0配合-print0让输入以 null 分隔而不是换行这样文件名里的换行符和空格都能安全处理-r则保证没有输入时xargs不会空跑一次。这套写法比手写循环外包后台进程要优雅得多天然支持并发和等待回收还不容易写出资源泄漏的问题。不过需要留意不是所有任务都适合无脑高并发。如果任务是磁盘密集型的比如大量压缩、复制并发太高反而会因为 IO 争抢导致整体变慢如果是 CPU 密集型的以核数为上限就好。我自己的经验是备份场景并发数压在 4~8日志处理场景可以放到 CPU 核数的两倍左右具体值还是要跑一次压测看磁盘 IO 能不能吃得消。3. 实操过程与核心环节实现3.1 场景设定写一个日志切割归档工具理论讲再多不如直接走一遍工程实现。这一节我带你从零搭建一个可以上线的日志归档脚本场景是每天凌晨把前一天的访问日志从不同服务目录里收集起来按日期归档压缩后保存到备份盘并保留最近 30 天。整个工具会拆成三个文件config.sh放配置logger.sh放日志函数archive_daily.sh是主脚本。拆文件的目的很朴素——主脚本保持可读性配置和函数分成独立模块后换环境只需要改配置不用动逻辑。这也是大型脚本和小脚本在组织方式上一个非常本质的区别。3.2 模块一配置文件配置文件不搞复杂设计就是一堆简单的变量赋值# config.sh # 需要归档的日志目录支持多个 SHIP_LOG_DIRS( /data/logs/nginx /data/logs/app ) # 归档存放目录 ARCHIVE_ROOT/backup/logs # 保留天数超过这个天数则删除 RETENTION_DAYS30 # 并发压缩进程数 COMPRESS_JOBS4 # 日志级别: DEBUG INFO WARN ERROR LOG_LEVELINFO注意这里用了一个 bash 数组SHIP_LOG_DIRS而不是用空格分隔的字符串。原因很简单目录路径本身就可能包含空格用空格分隔的话/data/my logs这种路径会直接断开。数组是 bash 的原生数据结构用${SHIP_LOG_DIRS[]}展开时每个元素都保持完整这才是正确的姿势。3.3 模块二日志函数封装logger.sh直接用上一节设计的log函数再补一个log_error_exit# logger.sh source $(dirname ${BASH_SOURCE[0]})/config.sh declare -A LOG_LEVELS( [DEBUG]0 [INFO]1 [WARN]2 [ERROR]3 ) # 初始化日志级别 CURRENT_LOG_LEVEL${LOG_LEVELS[$LOG_LEVEL]:-1} log() { local level$1 shift local msg$* local timestamp timestamp$(date %Y-%m-%d %H:%M:%S) if [[ ${LOG_LEVELS[$level]} -ge $CURRENT_LOG_LEVEL ]]; then echo [$timestamp] [$level] $msg fi } log_error_exit() { log ERROR $* exit 1 }source语句用了$(dirname ${BASH_SOURCE[0]})/config.sh而不是相对路径这样即使主脚本在别的目录下被调用也能正确找到同目录的配置和函数文件。这种写法在脚本做成定时任务时特别关键因为 cron 的工作目录经常和脚本目录不一致。3.4 模块三主脚本完整实现主脚本是核心我把每个关键段都配上注释让你能看懂设计思路#!/usr/bin/env bash set -Eeuo pipefail IFS$\n\t SCRIPT_DIR$(cd $(dirname ${BASH_SOURCE[0]}) pwd) source ${SCRIPT_DIR}/config.sh source ${SCRIPT_DIR}/logger.sh TODAY$(date %Y-%m-%d) YESTERDAY$(date -d yesterday %Y-%m-%d) LOCK_FILE/tmp/archive_logs_${TODAY}.lock # 锁文件防止重复执行 exec 9${LOCK_FILE} if ! flock -n 9; then log WARN 检测到今天的归档任务已在运行退出 exit 0 fi cleanup() { rm -f $LOCK_FILE log INFO 清理完成 } trap cleanup EXIT mkdir -p ${ARCHIVE_ROOT}/${TODAY} # 对每个日志目录分别归档 for log_dir in ${SHIP_LOG_DIRS[]}; do log INFO 开始处理日志目录: ${log_dir} if [[ ! -d $log_dir ]]; then log WARN 目录不存在跳过: ${log_dir} continue fi # 找出昨天产生的 .log 文件 log INFO 查找目录下昨天的日志文件... while IFS read -r -d log_file; do log INFO 发现日志文件: ${log_file} # 文件名带上日期再复制到今天的归档目录 base_name$(basename $log_file) target_file${ARCHIVE_ROOT}/${TODAY}/${base_name%.log}_${YESTERDAY}.log cp $log_file $target_file done (find $log_dir -maxdepth 1 -type f -name *.log \ -newermt ${YESTERDAY} 00:00:00 ! -newermt ${TODAY} 00:00:00 -print0) log INFO 日志目录处理完成: ${log_dir} done log INFO 开始压缩归档文件... find ${ARCHIVE_ROOT}/${TODAY} -type f -name *.log -print0 \ | xargs -0 -r -P ${COMPRESS_JOBS} -I {} gzip -f {} log INFO 开始清理超过 ${RETENTION_DAYS} 天的旧归档... find $ARCHIVE_ROOT -mindepth 1 -maxdepth 1 -type d -name 20*-* \ -mtime ${RETENTION_DAYS} -exec rm -rf {} log INFO 归档任务全部完成归档目录: ${ARCHIVE_ROOT}/${TODAY}这段代码里有几个写法非常值得细说。锁文件那块exec 9${LOCK_FILE}打开文件描述符 9 指向锁文件然后flock -n 9尝试抢锁-n表示拿不到锁就立即返回失败而不是阻塞等待。这个机制保证了即使 cron 任务上一次没跑完、这次又触发了也不会两个进程同时写同一批文件。用完之后不用手动删锁文件flock在进程结束时自动释放这个特性比单纯用mkdir当锁要靠谱得多不会有上次进程崩溃导致锁一直卡住的问题。处理文件列表时while IFS read -r -d log_file配合find ... -print0是处理任意文件名最稳的组合。-d 让read以 null 作为行分隔符-r禁止反斜杠转义IFS避免去首尾空白三个设置合在一起保证任何奇葩文件名都能被完整读进来。下面那句done (...)用了进程替换而不是管道因为管道里的while是在子 shell 里执行的子 shell 里修改的变量在主 shell 里看不到——这是 Shell 脚本里极其经典的坑用进程替换就能绕开。时间条件用了-newermt ${YESTERDAY} 00:00:00 ! -newermt ${TODAY} 00:00:00非常精确地选出昨天零点到今天零点之间修改过的文件比用文件名匹配日期更可靠因为某些日志文件可能滚动得没那么准时。3.5 验证与交付脚本写完后在跑真实数据之前我先验证了三件事第一是语法检查bash -n archive_daily.sh这步不执行脚本只检查语法能拦掉最明显的中括号不匹配、引号没闭合这类低级错误。第二是静态检查用shellcheck archive_daily.sh。这个工具是 Shell 脚本的林检察官能直接指出未加引号的变量、可移植性问题、find的误用隐患等 200 多种常见问题。强烈建议所有脚本进生产环境前过一遍 shellcheck这是成本最低的提效手段。第三是在本地沙箱目录造了一组测试日志故意包含带空格的文件名和昨天的、今天的文件混在一起跑了一遍确认了只有昨天的文件被归档压缩今天的文件没被动过带空格的文件也能正确处理。都验证过了再放到 cron 里每天凌晨一点执行0 1 * * * /opt/scripts/archive_daily.sh /var/log/archive_daily.log 21的用法容易漏掉一个点如果你只写一个而不是每次跑 cron 都会把上一次的日志文件清空重写查问题时就看不到历史的运行记录。这里的21让标准错误重定向到同一个文件保证报错信息也能留在日志里而不是丢进 cron 的邮件里无声无息。4. 常见问题与排查技巧实录4.1 脚本看起来没报错结果却是错的的几类经典坑这类问题最让人头疼因为脚本没有崩输出也正常但产出的结果是错的。我把这个项目示例里反复踩到的几种典型问题汇总成了速查表你在自己的脚本里遇到同症状可以直接照着排。现象根本原因解决方案处理文件名时出现奇怪的拆分文件名含空格for file in $(find ...)按空格切分改用find ... -print0搭配while read -d 管道里while循环中变量修改不生效管道右端运行在子 shell主 shell 拿不到变更用进程替换 (...)或改用coproc脚本报未定义变量变量名打错或环境变量未被导入set -u会立刻暴露配合: ${VAR:default}给默认值压缩任务卡死或很慢并行数过高磁盘 IO 扛不住观察iostat把COMPRESS_JOBS降到 2~4trap没执行清理只挂了EXIT没挂INT TERMtrap cleanup EXIT INT TERMfind 筛选时间不准用-mtime 1匹配的是 24 小时前不是昨天用-newermt指定精确时间范围空目录时脚本报错没判断文件数量直接对空列表执行命令xargs -r没有输入时不执行或者先[[ -n $(ls -A ...) ]]4.2 排查问题三板斧当脚本行为不符合预期的时候永远不要瞎猜和盲改建议按这个顺序排查第一步用bash -x script.sh跑一遍。这个参数会把脚本每一行执行的命令完整打印出来变量展开后的值也一并显示能直接看出哪一行和你的预期不符。跑完把输出重定向到文件里还能留着慢慢比对。这套示例脚本里每个脚本我都用-x跑过效果立竿见影——你以为传进去的参数是这个实际却是那个一眼就能发现。第二步在你的脚本里局部开set -x和set x。全局-x输出太吵的时候只对可疑的某段前后加上这两个开关就能只打印你关心的那一小段执行过程不会噪音淹没焦点set -x # 可疑代码段 set x第三步结合PS4变量增强 debug 输出。默认-x打印的前缀只有号但你可以改成export PS4${BASH_SOURCE}:${LINENO}: ${FUNCNAME[0]:${FUNCNAME[0]}(): }这样每次打印都会带上文件名、行号和函数名定位到具体位置的速度快很多。尤其在函数很多的脚本里没有行号信息的号前缀基本等于大海捞针。4.3 ShellCheck 的正确使用方式不夸张地说shellcheck 至少帮我提前拦掉了 60% 的生产事故。装好之后可以把它集成进编辑器比如 VS Code 的 shellcheck 插件写代码的时候就实时标出问题比最后再统一检查要高效得多。它的提示分 severity 等级error是必须修的warning建议修info是风格建议。实际项目里我的底限是零 errorwarning 可视情况处理info 只要不违背自己的风格就忽略。注意别走向另一个极端为了迎合 shellcheck 把一个简单脚本改成掉书袋风格那是本末倒置。它的价值是提醒你哪里有风险至于要不要改你心里得有数。5. 免费下载资源与扩展建议5.1 示例库目录结构的设计智慧如果你下载过一些做得好的免费脚本库会发现它的目录结构本身就在传达经验。我这次分享的下载包里目录是这样组织的advanced-shell-script/ ├── README.md ├── bin/ │ ├── archive_daily.sh │ ├── backup_mysql.sh │ ├── monitor_disk.sh │ ├── rotate_nginx_log.sh │ └── deploy_app.sh ├── lib/ │ ├── logger.sh │ ├── parse_options.sh │ ├── lock.sh │ └── sendmail.sh └── conf/ ├── archive_daily.conf ├── backup_mysql.conf └── monitor_disk.confbin/放主脚本lib/放公共函数库conf/放配置模板。主脚本里只保留流程逻辑自定义的参数解析、日志、锁机制、邮件通知全部收敛到lib/下配置则全部外置。这样你换一个环境部署时只改conf/下的配置文件就行一行主脚本代码都不用动。很多半路出家的脚本作者不习惯这种分层。他可能不分目录所有逻辑塞在一个 800 行的文件里改配置要打开主脚本翻半天。这种脚本自己写自己用还行一旦要交接给别人或者放到服务器上跑定时任务维护成本就上来了。这个示例库暴露出来的目录结构本身就是很好的工程示范。5.2 拿示例脚本的正确姿势免费下载的示例脚本给了你一个很好的起点但如何用好这些示例同样有讲究。这里有一些经验分享。第一先在本机读一遍再跑不要下载完直接往生产环境丢。逐行确认它做的事情不会破坏你现在系统里的任何内容尤其注意有没有rm -rf、有没有强制覆盖操作、有没有写/etc下的文件再决定是否试跑。第二先在沙箱目录里用假数据跑通。把脚本里的路径全部改成/tmp/test_xxx之类的临时目录造一批和线上结构一样但内容无害的文件观察它的完整行为链。这些做验证的成本极低却能最大程度避免线上事故。第三把下载包的版本记录下来。脚本不是软件产品很多人拿到之后改了就不管了。但后续如果下载包出了新版本你想对比差异时没有版本记录会非常痛苦。我的习惯是保留一份原始包自己改动的脚本里加一行# Modified by xxx 2025-xx-xx注释好歹能追溯到改过什么、为什么改。5.3 这套示例还能扩展出什么拿到免费示例库之后二次开发空间很大。举几个我觉得特别实用的扩展方向。一是把日志函数接上 Webhooklog ERROR的时候顺便调用 curl 把消息推到群里或者通知系统出故障的感知时间能从第二天早上看日志缩短到五分钟内收到消息。二是加一个统一的pre_flight_check函数在主脚本真正动手之前先检查关键依赖命令是否存在、磁盘剩余空间是否充足、是否有可写的临时目录任何一项不满足就直接报错退出。这一招对定时脚本来说特别管用能避免很多半夜跑挂了一半第二天起来才发现的尴尬。三是把lock.sh里flock的用法扩展成超时机制。比如flock -w 300表示如果 5 分钟内抢不到锁就放弃这样就不需要手动处理等待很久的任务了直接把锁策略的灵活性提升一个档次。四是在主脚本里加入--dry-run模式。这种模式会完整打印出每一步将要执行的操作但不真的执行。对备份、归档这种出问题成本很高的脚本来说--dry-run的价值怎么强调都不过分。它既可以在上生产前帮你验证逻辑也可以在日常维护时快速确认配置变更的影响范围非常实用。我个人在实际项目里的习惯是每拿到一个新场景先找到对应示例脚本理解它的骨架然后按自己的环境和业务规则去替换配置与核心逻辑最后用 dry-run 和沙箱验证一遍再上生产。这样做的效率比自己从零写高得多而且踩坑率明显低。你也别把免费下载资源当成一劳永逸的答案它更像是一份高质量的标准答案——最好的用法是读懂并吸收然后长出你自己的版本。