☰
Shell循环脚本生产化改造:从课堂demo到可靠定时任务
2026/9/26 11:50:23 网站建设 项目流程

直接开工。咱们聊一个我特别有感触的话题:Shell 循环脚本怎么从课堂那种“能跑出结果就行”的状态,改成生产环境里敢让定时任务、报警系统、同事和领导都放心的状态。我见过太多项目,业务逻辑不复杂,就是一堆for和while循环在批量处理文件、批量调接口、批量巡检机器,但脚本本身还是课堂实验的水平。今天这篇东西,我就结合自己的实际改造经验,把循环脚本生产化这条路走一遍,讲清楚为什么改、怎么改、有哪些坑。

这篇内容适合的人很具体:刚入职一两年的运维、后端和测试同学,以及那些已经在生产环境里跑过 Shell 脚本、但被“脚本又挂了”“日志什么都看不出来”“重复执行出了两份结果”这些问题折腾过的人。你会看到一个从零开始的改造全过程,包括代码、参数、设计思路,以及在真实服务器上踩过的坑。文章里所有例子都是我实际改过的模式脱敏后的版本,可以直接抄作业。

1. 从课堂脚本到生产脚本:差距到底在哪

1.1 课堂脚本的典型画像

先还原一个典型的课堂级脚本。老师布置任务:用 Shell 写一个批量备份脚本,把/data/logs下的所有子目录打包成 tar.gz 存到/backup。学生通常几行就写完了:

for dir in /data/logs/*/; do name=$(basename "$dir") tar -czf "/backup/$name.tar.gz" "$dir" done echo "备份完成"

这段代码在课堂演示环境里什么问题都没有:目录名没有空格,备份目录容量足够,没有并发写,只跑一次,跑了以后看一眼 tar 包在不在就算完事。但你把同样的逻辑丢到生产服务器上,它会在一周内给你上够三节课的课。

第一节课叫容错。tar命令执行失败时脚本根本不会停下来,因为 Shell 默认每条命令的退出码不会中断脚本。你看到的可能是一堆tar: Cannot open: Permission denied刷屏,但最后依然会打印“备份完成”,然后监控系统没有告警,备份清单里多了一个空文件。等哪天要恢复数据的时候,才发现那个 tar 包是坏的。

第二节课叫可重复性。生产任务不是跑一次就结束的,定时任务每天凌晨跑一次。同一个目录被重复打包,第二次生成的 tar 包会覆盖第一次。如果当天日志文件还在写入,打包出来的就会是“半个文件”,校验和永远对不上。课堂环境里不会有人关心你同一个脚本跑两次结果是否一致,生产环境天天都在关心。

第三节课叫可观测性。课堂脚本输出打印到终端,人看着就行。生产脚本跑在 crontab 里,输出进了邮件或者日志文件,出了问题是没有人守着看的。你必须自己把日志写成方便 grep、方便按时间切分、方便统计成功失败数量的格式。我见过太多生产脚本,出错信息全是tar: error while loading shared libraries这种直接抛给终端的东西,事后排查完全靠猜。

我随手梳理了一下,课堂脚本和生产脚本的基本差异其实就浓缩在这张表里:

维度课堂脚本生产脚本
退出码几乎不关注每条关键命令都要检查,整体退出码要能反映成败
运行次数跑一遍可能定时反复跑,必须幂等
输入数据干净的演示数据可能含空格、换行、大体积、权限不足的情况
错误处理直接报错继续跑失败要重试、跳过、记录并最终汇总
日志打印到终端分级日志、按时间命名、可检索、带告警
并发单任务跑可能要并行处理,但要控制并发数和资源
锁不考虑必须防重入,避免两个任务实例打架
配置写死在脚本里环境变量、配置文件、命令行参数分离

别小看这张表里的每一项,每一项背后都有真实的故障案例。我自己就经历过一次:凌晨的备份脚本在一个目录名带空格的目录上炸了,整个备份目录缺了三个文件,因为当时脚本用的是for dir in $(ls /data/logs),列目录永远按空格切分。后面排查的时候,我盯着脚本看了好久才反应过来——这不就是课堂里学过的默认分隔符问题吗?只是课堂里指导老师不会给你造一个带空格的文件名。

1.2 生产环境的硬性约束

课堂环境里,你只需要对结果负责;生产环境里,你要对“整个过程是否可控”负责。这四个约束必须刻进脑子里。

第一个约束是幂等。同一个任务重复跑,结果不能叠加、不能冲突、不能产生不一致。对于循环脚本来说,幂等设计通常要走到“先检查再执行”的思路上。批量导入任务,就要先查一下记录是否已存在;批量打包任务,就要设计成“每次生成不同的文件名,或者先写临时文件再原子改名”。我之前改造一个报表生成任务,方案就是让每次运行输出带时间戳的文件,而不是固定覆盖一个老文件,这样即使同一分钟跑了两遍,也不会互相覆盖,人工比对时还能看出是哪次运行产生的。

第二个约束是出错快速暴露。生产脚本出错了不能静默带过。这里不只是set -e这么简单,而是要设计好“哪些错误可以继续、哪些必须终止、哪些要重试”。比如批量调外部接口,单个接口返回超时可能是偶发,可以重试两三次;但如果循环到三分之一的时候磁盘写满了,再重试就没有意义,要立刻停下来并发出告警。合理的错误分级,比一个全局“碰到错误就退出”的开关要可靠得多。

第三个约束是资源可控。循环脚本跑在生产服务器上,数据量和课堂完全不一个量级。一个for循环同时把所有任务丢到后台,几百个进程一起跑起来,CPU、内存、文件句柄分分钟被打满。所以生产改造,非常重要的一个工作就是给循环加并发限制——比如用xargs -P限制同时跑几个,或者用队列配合wait控制后台任务数量。这个我会在后面专门用一小节讲。

第四个约束是可追溯。跑过的任务,哪些成功了、哪些失败了、失败原因是什么、耗时多久,都要有迹可循。这个看着是在说日志,但实际是贯穿整个脚本设计的事情。你要从一开始就给每一条任务分配一个可识别的上下文,比如文件名、任务 ID、来源 IP,然后日志里每一行都要带上这个上下文。等出故障要排查的时候,你会发现这种做法省下的时间是以小时计的。

这四个约束不是空对空的原则。接下来我会用两个最常见的循环场景,把约束落到具体代码上。

2. 循环脚本的常见模式与生产改造思路

2.1 批处理类循环:从“跑完就行”到“可断点续跑”

批处理循环几乎是所有 Shell 生产脚本里最核心的模式,典型长相是这样:有一批文件要处理,或者有一批数据要清洗,脚本遍历这批数据,对每一个执行同样的操作。课堂版大概长这样:

for file in /data/input/*.csv; do python3 process.py "$file" done

我来拆解一下生产版要做什么。

先说断点续跑。生产批处理最常见的问题是:任务跑到一半服务器重启了、依赖服务挂了、或者某个数据源卡死了,整个任务中断。如果是课堂脚本,中断就中断了,重新跑一遍就行;但生产任务如果重新跑一遍,会导致一批数据被重复处理。解决办法通常有两种:一是处理前检查目标是否已存在,也就是幂等;二是记录处理进度,中断后可以接着跑。

我给一个具体的进度记录方案。假设任务是对一批 CSV 执行数据清洗,输出目录是/data/output,那么每处理完一个文件,就在另一个目录里写入一个标记文件,标记文件名就是源文件名加上.done后缀。下次脚本启动时,先检查标记文件是否存在,存在就直接跳过。这就是最简单的断点续跑,不需要引入数据库,但已经能扛住绝大多数中断场景。

input_dir="/data/input" output_dir="/data/output" marker_dir="/data/markers" mkdir -p "$output_dir" "$marker_dir" for file in "$input_dir"/*.csv; do name=$(basename "$file") marker="$marker_dir/$name.done" if [[ -f "$marker" ]]; then log "INFO" "skip $name (already done)" continue fi if ! python3 process.py "$file" -o "$output_dir/$name.out"; then log "ERROR" "failed to process $name" continue fi : > "$marker" done

注意一个细节:标记文件必须等处理成功之后再创建,而不是在处理前就建好。否则任务中断后,你留下的是一堆“假完成”的标记,排查的时候很容易被误导。另外一个细节是,标记文件里最好写上处理时间、源文件校验和这些元信息,后续如果要做对账,这些都是好东西。

再说重复数据保护。批处理任务重跑时最怕“重复生成”。比如批量生成报表,如果输出路径固定,第二次运行会把第一次的报表覆盖掉,可能因为数据源状态不同,导致两次报表对不上。改造方式很简单:输出文件名带运行批次号。用时间戳就行,但注意精确到秒可能不够,因为同一个循环任务完全有可能在一秒内被触发两次,建议带上进程 ID 或者纳秒时间戳。我个人常用这种:

run_id=$(date +%Y%m%d_%H%M%S)_$$

$$是当前 Shell 的进程 ID,保证同一秒内多次运行也不会重名。再配合输出目录按run_id分目录,整个批处理任务的所有产物都能追溯。

2.2 巡检类循环:从“打印结果”到“结构化报告+告警”

第二种典型循环是巡检循环,比如批量检查一批服务器的磁盘使用率、批量测试一批接口的连通性。课堂脚本一般是这样:

for host in $(cat hosts.txt); do ping -c 1 "$host" >/dev/null && echo "$host ok" || echo "$host down" done

放到生产环境中,最大的问题是输出不可消费。人眼扫一屏可以,但几十上百台机器输出几百行,你想知道“到底有多少台挂了,哪几台挂得最严重”,靠眼睛是不可能的。改造方向是:输出结构化数据,做汇总统计,并且和告警通道打通。

我的做法是,在循环里把每一台的巡检结果写到临时目录下的独立小文件里,所有循环结束后统一合并。这样做的好处有两个:第一,循环里不需要维护全局变量,避免了 Shell 里管道子进程作用域导致的变量丢失问题;第二,合并阶段可以对结果做统计和排序,打印出“摘要”而不是“流水账”。

tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT for host in $(<hosts.txt); do ( if ping -c 1 -W 2 "$host" >/dev/null 2>&1; then echo "host: $host status: ok" else echo "host: $host status: fail" fi ) > "$tmp_dir/$host.result" done echo "===== 巡检摘要 =====" grep -h "status: ok" "$tmp_dir"/*.result | wc -l echo "台主机正常" grep -h "status: fail" "$tmp_dir"/*.result | wc -l echo "台主机异常" echo "===== 异常列表 =====" grep -h "status: fail" "$tmp_dir"/*.result || true

这里我刻意把每一台的结果写到独立文件而不是直接追加到同一个文件,是因为如果直接>> result.txt,多个子进程同时写同一个文件时可能出现交错。用独立文件就完全规避了并发写问题,最后grep合并即可。这个技巧在处理大量循环任务时非常管用。

巡检结果出来了,还要解决“怎么让人知道”的问题。生产上通常有两个通道:短信/企业微信/钉钉告警,以及邮件日报。Shell 脚本里最轻量的做法是:正常结果走日志,异常结果走告警。告警的判断逻辑不要散落在循环里,统一在汇总阶段判断——异常数量超过阈值就调用一次告警脚本,避免同一个故障触发几十条重复告警。

下面这段是我常用的判断模式:

fail_count=$(grep -h "status: fail" "$tmp_dir"/*.result | wc -l) if (( fail_count > 0 )); then /usr/local/bin/send_alert "巡检异常:$fail_count 台主机不可达,详见日志 $tmp_dir/summary.txt" fi

这里有个容易被忽略的点:grep没匹配到内容时会返回非零退出码,如果前面没加|| true或者没放进if判断里,整个脚本可能会被set -e直接终止,导致明明是想忽略“没有异常”的情况,结果系统反而把脚本跑断了。这个细节我后面会单独拎出来讲。

3. 实战改造案例:把一串课堂 for 循环改造成生产级脚本

3.1 原始脚本的问题清单

这一节我完整走一遍改造流程,用的是一个很典型的批量图片压缩任务。原始脚本长这样,看起来还挺简洁:

#!/bin/bash for img in /data/images/*.jpg; do convert "$img" -resize 800x "${img%.jpg}_small.jpg" done

这个脚本确实能用,但拿到生产环境里我会直接打回。问题清单如下:

第一,没有全局错误控制。convert是对图片做转换的 ImageMagick 命令,不是每张图都能转换成功。如果某张图片本身就是损坏的,convert会报错退出,但脚本不会停,后面所有图片继续处理。最后从日志里根本看不出哪张失败、失败了多少张。

第二,没有幂等保护。脚本重复执行时,会对同一批图片反复生成_small.jpg,白白消耗 CPU,而且如果原图已经被替换过,生成出来的缩略图可能和旧文件时间戳不一致,导致 CDN 缓存混乱。更严重的是,如果源图已经删了,但缩略图还在,convert命令就会执行失败,而这个失败同样不会被记录。

第三,没有日志。脚本跑完就是跑完了,一行输出都没有。半小时之后人来看,根本不知道这个任务是否跑过、消耗了多少时间、产生了多少产物。

第四,没有并发控制。一张一张处理在小数据量时没问题,但如果目录下有几千张高清图,单线程处理可能要一两个小时,而生产环境完全可以并行跑,只要控制并发数不把 CPU 打满。

第五,没有防重入机制。如果定时任务调度和手动执行撞在一起,两个脚本实例同时跑,就会同时操作同一批文件,轻则浪费资源,重则生成的文件相互覆盖、半截文件直接流入线上。

第六,没有处理文件名特殊字符。/data/images/*.jpg这个通配符展开在文件名包含空格和换行时会被切碎。用*展开还好一点,但如果你改成for img in $(find /data/images -name "*.jpg"),遇到带空格的文件名就彻底炸了。这个属于for循环的典型坑。

3.2 逐步改造:从循环骨架到生产细节

改造后的目标很明确:能无限重跑、出问题能快速定位、多实例不会打架、资源占用可控。我的改造是按下面几个步骤叠加的,每一步都有明确的理由。

第一步,把脚本头部搞干净,加上严格模式和可配置项:

#!/bin/bash set -euo pipefail input_dir="/data/images" output_suffix="_small" max_width=800 concurrency=4 log_dir="/var/log/image_compress" run_id="$(date +%Y%m%d_%H%M%S)_$$" mkdir -p "$log_dir" log_file="$log_dir/compress_${run_id}.log"

set -euo pipefail这三个选项,我拆开解释一下:set -e让脚本在命令出错时退出;set -u让未定义变量直接报错而不是当成空值继续跑,这个选项能抓出一堆因为变量名拼写错误导致的隐蔽问题;set -o pipefail让管道命令的退出码取管道中最后一个非零退出码,而不仅仅是最后一条命令的,像python3 process.py | tee -a log这类写法,如果前面的 python 挂掉了,课堂里几乎不会被发现。这三个选项一起设,是生产 Shell 脚本的第一道防线,但注意set -e有它自己的局限性,我会在常见问题里细说。

第二步,写日志函数。生产脚本里我强烈建议不要用散落的echo直接打输出,而是走一个统一的日志函数,带时间戳、级别、run_id。这样日志文件可以按运行实例分开,排查时直接 grep 某一个 run_id 就能找到一次完整运行的全部记录。

log() { local level="$1" local msg="$2" echo "$(date '+%Y-%m-%d %H:%M:%S') [$level] [run:$run_id] $msg" >>"$log_file" }

第三步,实现防重入锁。用flock命令,把锁文件和应用绑定,如果锁被占用就立刻退出。flock是系统层面的文件锁,比单纯判断锁文件存不存在可靠得多——进程意外退出时内核会自动释放锁,不会出现“锁文件残留导致任务永远跑不了”的经典问题。

exec 9>>"$log_dir/compress.lock" if ! flock -n 9; then echo "另一个压缩任务正在运行,退出" >>"$log_file" exit 1 fi

这里exec 9>>的意思是以追加模式打开文件描述符 9,然后flock -n 9尝试获取这个文件描述符上的排他锁。-n表示非阻塞,拿不到就直接失败。注意锁文件不要放在会被清理的临时目录,最好放在固定的日志或运行目录下,避免因为目录被删导致锁失效。

第四步,循环体改造。这里是整个过程的核心,我觉得全贴出来比拆着讲更直观:

find "$input_dir" -maxdepth 1 -type f -name '*.jpg' -print0 | while IFS= read -r -d '' img; do name=$(basename "$img") out="${img%.jpg}${output_suffix}.jpg" if [[ -f "$out" ]]; then log "INFO" "跳过已存在: $name" continue fi log "INFO" "开始处理: $name" if ! convert "$img" -resize "${max_width}x" "$out" >>"$log_file" 2>&1; then log "ERROR" "转换失败: $name" continue fi log "INFO" "处理完成: $name" done

这一个循环体里塞进了三个课堂脚本没有的关键设计:

find ... -print0配合while IFS= read -r -d ''是 Shell 处理文件名含空格、换行、特殊字符的标准做法,-print0意味着文件名用空字符(null)分隔而不是换行,read -d ''按空字符读取,IFS=关掉输入字段分隔符,-r防止反斜杠转义。这个组合写起来繁琐,但它是处理任意文件名的正确姿势,不是靠加两个引号就能替代的。

if [[ -f "$out" ]]是幂等检查。已经生成过的缩略图直接跳过,任务重跑不会重复消耗 CPU。注意我把判断放在日志之后、convert之前,这样日志里能看到“为什么跳过”。检查方式不是检查源文件有没有变化,而是检查输出文件是否存在,因为图片压缩的需求通常是“缺什么补什么”,这个逻辑更贴合实际。

if ! convert ...把错误处理显式化。if !的意思是取命令退出码的反义——命令失败时进入分支,记录错误后continue跳过当前文件继续下一个,而不是整个脚本崩掉。这里也回答了“为什么不用set -e + 不检查”:这个场景里单张图片失败是可容忍的,不应该终止整个任务,所以要手动捕获。

第五步,并发控制。在循环结构里加并发,最简单的办法是改成xargs -P,把循环体抽成独立函数,然后交给xargs并行调用。我用下面的写法保持日志顺序不乱:

process_one() { local img="$1" # 函数内部包含幂等检查、convert、日志,和上面循环体逻辑一致 } export -f process_one find "$input_dir" -maxdepth 1 -type f -name '*.jpg' -print0 | xargs -0 -I{} -P "$concurrency" bash -c 'process_one "$@"' _ {}

这里-P 4表示同时跑 4 个进程。你也许注意到了,xargs -I{}会为每个参数启动一个子 shell,开销比循环大一些,但对图片压缩这种 IO 密集任务来说,并发收益远大于额外开销。真正要小心的是进程间日志写入问题,所以我在函数内部把日志写入统一到同一个日志文件,并用“单条短日志多次写入”的方式尽量避免交错——日志每行都带时间戳和进程上下文,个别几行交错也不影响排查。如果你想做到严格顺序,可以给每个子进程分配一个 id,写在日志行里,最后用sort按 id 和行号收拢。

3.3 验证和灰度:改造完不能直接全量跑

这是改造里最容易忽视、但我觉得最值得写的一环。新脚本改造完,千万不能直接丢到生产环境全量跑。我自己的习惯是做三层验证。

第一层,用一个小样本集做功能验证。把环境变量指向一个只有几十张图的测试目录,跑一遍,确认产物、日志、退出码都符合预期。这里我建议加一个dry_run开关,脚本只打印“要做什么”而不实际执行,用来在代码 review 时快速说明行为,也方便别人接手时安全试运行。

dry_run="${DRY_RUN:-false}" if [[ "$dry_run" == "true" ]]; then log "INFO" "[DRY RUN] 将处理: $img" continue fi

第二层,检查并发数是否符合资源预期。用top -d 1或者pidstat观察几个convert进程启动后的 CPU 占用和平均负载,如果并发 4 就把 CPU 打满,就要把concurrency调小。机器核心数是 4 的 8 核服务器,并发压到 2 也完全合理,因为生产服务器还有别的业务在跑,不能把一个定时任务跑成资源杀手。

第三层,和旧结果做对账。如果改造前已经有旧版本的产物,跑完新脚本后,对比新旧缩略图的文件数、文件大小分布、缺失列表,确认没有多产生一份、也没有漏掉原来有的一份。这一步很重要,它本质上是验证幂等改造是否成功。

我自己的灰度策略是先跑目录下十分之一的文件,观察一天,再逐步放大到全量。定时任务调度平台如果支持分批参数,就把输入目录切换成不同的子目录。

4. 循环脚本生产改造中的常见坑与排错经验

4.1 常见坑速查表

这一节我整理一下做 Shell 循环改造时最高频踩中的坑,每一行都是真实事故的浓缩。

坑 1:set -e在循环里经常失灵。具体表现是脚本中途出错,但整体退出码还是 0。原因在于set -e不会在if条件、while条件、&&或||列表的左侧、以及管道非最后一个命令这些位置触发退出。典型例子:while read line循环里,read读到文件末尾会返回非零,set -e如果严格生效,循环根本没法正常结束。所以别迷信set -e,它只是兜底,真正的错误处理还得靠显式判断。

坑 2:$(...)里的变量在子 shell 中丢失。for循环里如果写成cat file.txt | while read line; do ... done,while是放在管道右侧的子 shell 里的,循环里赋值的变量在循环结束后全部丢失。解决办法有两个:一是换成进程替换while read ...; done < <(cat file.txt),这样while在主 shell 里执行;二是把需要保留的结果写入外部文件。我强烈建议用第二种方式积累结果,因为即使解决了作用域问题,把大量结果塞进 Shell 变量也容易碰到内存和引用问题。

坑 3:echo输出里有特殊字符导致判断失灵。比如if [ "$(command)" == "ok" ],如果command的输出里带了尾随空格或不可见字符,判断直接就 false。生产数据里这种问题尤其多。我的经验是:不要用字符串相等判断命令输出,改用退出码;必须用输出时,用[[ ... ]]加正则,或者先awk '{print $1}'把多余部分剥掉。

坑 4:循环大批量执行命令时,不限制并发导致系统 OOM。很多人图省事,用for 循环 + &把所有任务丢后台,然后wait,结果几百个进程一起起来,单台服务器内存瞬间被打满,连 SSH 都连不上。这是我在生产环境见过最危险的写法。能救命的只有一个原则:永远用xargs -P或sem给并发加闸,后台任务数要时刻盯住。

坑 5:文件名里包含换行/空格,for i in $(find ...)直接切碎。这个坑前面已经踩过一遍了,这里再说一次,因为太常见。凡是遍历文件名,一律find -print0+read -d '',或者直接for配合通配符展开加引号。千万不要用for i in $(command)的方式拿一堆文件名。

坑 6:trap只写在脚本最后。很多课堂脚本没有trap,一旦中途exit,临时文件永远残留。生产脚本要在一开始就写好trap,并且覆盖EXIT、INT、TERM三个信号。像trap 'rm -rf "$tmp_dir"' EXIT,不管正常结束还是被信号杀掉,都会清掉临时目录。这里注意trap里如果用了未定义的变量,在set -u下可能报错,所以临时目录变量必须提前定义。

我把这些坑整理成一个速查表,方便你贴到工位上:

常见坑典型后果正确姿势
set -e失灵出错继续跑,退出码为 0显式检查退出码,if ! cmd捕获错误
管道内 while 变量丢失结果统计为 0用进程替换或结果落文件
for i in $(find)文件名被空格切碎find -print0+read -d ''
无并发限制的后台任务内存打满,系统卡死xargs -P限定并发数
无 trap 清理临时文件残留开头设置trap ... EXIT
无锁直接跑多实例互相覆盖flock加锁,非阻塞抢锁
日志不统一排查靠猜统一日志函数,带 run_id

4.2 排错实操记录

这里我想记录两个我自己实际排查过的案例,都很能代表生产循环脚本的真实故障现场。

第一个案例是“凌晨定时任务偶尔失败,但日志没有任何输出”。我接手的时候,脚本长这样:

for host in $(cat host_list.txt); do ssh user@"$host" "df -h /data" >> /var/log/check_disk.log done

症状是每天早上大部分机器都有记录,但有那么两三台机器偶尔缺席,登录历史显示那几台机器当时是正常在线的。排查过程是这样的:第一步,手动跑脚本,发现ssh命令偶尔出现Connection timed out;第二步,确认是网络波动导致ssh连接偶发失败;第三步,检查日志,发现因为ssh失败后退出码非零,但脚本没做任何处理,只是安静地跳过;第四步,也是最关键的——当初写日志时没有记录退出码,所以日志里既看不到“超时”也看不到“跳过”,根本分不清是“机器没响应”还是“任务没跑到”。

修复方法是在循环体里补上结果记录:

if ! ssh user@"$host" "df -h /data" >>"$log_file" 2>&1; then log "ERROR" "$host ssh 失败,退出码: $?" fi

注意$?必须立刻在命令执行后取,中间不能插任何其他命令,否则取到的是别的东西的退出码。这个案例教会我的道理是:日志不但要记录成功,更要记录失败和失败原因,而且失败原因越具体越好。

第二个案例是“并发循环偶尔生成重复的文件”。当时脚本用了一种看起来很聪明的写法:

for url in $(cat urls.txt); do curl -s "$url" -o "/data/$(basename $url)" & done wait

多个后台任务同时下载并写入同一个输出路径,结果就是两个任务同时打开同一个文件,互相覆盖,最终文件可能是两个响应的内容拼接的。这个问题的本质是无并发限制与无冲突写入策略。修复时我做了两件事:第一,改用xargs -P限制并发;第二,每个下载任务先写临时文件再原子改名:

find /data/tmp -name "*.part" -type f -delete xargs -P 5 -I{} bash -c ' url="$1" out="/data/"$(basename "$url" .tmp) tmp="$out.$$.part" curl -s "$url" -o "$tmp" mv "$tmp" "$out" ' _ {}

mv在同一个文件系统里是原子操作,所以不会出现“别人读到一个写了一半的文件”的情况。这个案例我总结出的通用经验是:并发循环里,所有“先写后读”的产物,都应该走“临时文件 + 原子改名”的路线。

5. 从脚本到工程:后续还能怎么扩展

5.1 把循环脚本沉淀成可复用模块

改完一个脚本之后,最容易犯的错误是把它当孤品:这个业务要处理图片,那个业务要处理日志,各自写各自的循环。两个脚本里重复的逻辑其实非常多:日志函数、锁逻辑、并发框架、幂等判断、临时文件处理。我的建议是花一点时间把公共部分抽出来,单独放到一个/usr/local/lib/shell_lib.sh之类的公共文件里,脚本通过source引入。

# /usr/local/lib/shell_lib.sh init_log() { ... } acquire_lock() { ... } log_info() { ... } log_error() { ... }

抽公共库之后有一个直接的好处:公司安全规范要求日志格式统一,你只需要改一个文件,所有脚本同步生效,不需要逐个去改几十个脚本里的echo。这也符合“同一段逻辑不要维护两份”的工程原则。脚本的循环体本身可以抽成函数,放到公共库里,业务差异只体现在具体的处理函数上。

这里我必须提醒一句:公共库不是越抽象越好。Shell 脚本的调试成本本来就比 Python 高,如果为了复用写出一堆带回调函数、多级调用的“框架”,出了问题后排查难度会呈指数级上升。我自己的标准是:公共库只放三样东西——日志、锁、时间日期格式化,最多加一个并发函数,其余能写直白就不要封装。

5.2 和定时任务系统、监控系统衔接

生产脚本改造最终要落进调度系统。市面上的定时任务平台基本都是调用你写好的入口脚本,并接收退出码。所以你在改造时,要保证脚本在成功时返回 0,在处理了部分失败但业务允许完成时返回 0,在“整体严重失败”时才返回非零。

我建议为脚本设计三种退出码语义,写进文档并强制遵守:

退出码语义调度平台动作
0全部成功,或有失败但都在容忍范围内无动作
1发生错误且需要人工介入告警
2锁被占用、参数错误等环境问题告警并禁止自动重试

退出码定义好后,调度平台就可以根据退出码决定是否告警、是否触发重试。这里有一个容易忽略的点:重试策略不要一刀切。锁冲突(退出码 2)建议不要重试,等下一个周期即可;而网络超时(退出码 1)可以分两次重试,因为多数故障是偶发的。重试时最好带timeout限制,防止一个任务被重试到天荒地老。

再进一步,脚本可以主动把处理结果推送到监控系统。最轻量的集成方式是把关键指标输出成监控采集格式,例如:

echo "image_compress_success $(grep -c '处理完成' "$log_file")" echo "image_compress_failed $(grep -c '转换失败' "$log_file")"

这样监控系统可以直接采集计数器,按天观察任务的成功率和耗时趋势。生产脚本改造到此,就已经不是“循环能跑”的水平,而是纳入整个可观测体系了。定时任务从批量循环升级为数据采集源,机器脚本从 crontab 里的黑盒变成数字指标,这种质变是我觉得最有成就感的地方。

回到文章开头那个图片压缩任务。改造完之后,这个脚本能扛住几千张图片、几十个并发、文件里带空格带中文、服务器重启、定时任务重跑、甚至两个实例误调度同时运行——几乎每个课堂场景里不会出现的问题,都在生产场景里验证过了。我个人在实际操作中最大的体会是:Shell 循环脚本的生产改造,难点从来不是语法,而是思维转变。你不再是个写循环的人,而是个设计“数据处理链路”的人,得时刻想着失败路径、重跑行为、可观测性和资源边界。这种转变一开始会觉得很繁琐,但等你被生产故障教育过两三次,就会发自内心地认同。

最后再分享一个小技巧:每次改完循环脚本,先在服务器上开着bash -x跑一遍小样本,那些“以为没问题实际上执行路径和想象完全不同”的细节,全是靠这个命令抓出来的。这个东西我到现在都还在用。

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

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

立即咨询