「速通Shell」Shell编程的错误处理与日志

我们再前几篇所讲的脚本有个隐含假设:所有命令都会成功,所有数据都干净,所有依赖都在。这种假设在你自己的笔记本上跑没问题,但放到生产环境------挂在 crontab 里、写进 CI/CD 流水线、跑在用户的机器上------脚本就必须在出错时知道怎么办。

这就是这一篇要解决的问题:让脚本从能跑变成靠得住。我们聊三件事------退出码、错误处理、日志。它们三个加起来,决定了脚本出问题时的行为是静默失败还是清晰可控。

这一篇跟前几篇风格不太一样,前面我们一直在加能力,这一篇是在约束能力。你会看到很多不要这样做、要这样写的规矩。规矩听上去无聊,但生产事故里 90% 的 shell 脚本问题,根源都是这些规矩没守住。

一、退出码

在聊错误处理之前,必须先把退出码这个基础概念说清楚。它是 shell 进程之间沟通成功与失败的唯一语言,所有错误处理都建立在它之上。

1.1 什么是退出码

每个命令执行完,都会返回一个数字------退出码 (exit code)。0 表示成功,其他任何值表示失败。听起来简单,但这个任何值是重点:shell 不规定具体数字的含义 ,只规定 0 = 成功,其他都是出了点事。

bash 复制代码
#!/bin/bash
ls /tmp/does_not_exist
echo "退出码:$?"

输出:

bash 复制代码
ls: cannot access '/tmp/does_not_exist': No such file or directory
退出码:2

$? 是上一条命令的退出码。这是最常用的上一条命令发生了什么的查询方式。注意 $? 是个"瞬时状态"------一旦你执行了其他命令,它就被覆盖了。所以查询 $? 必须紧跟目标命令:

bash 复制代码
# 错的写法
ls /tmp/not_here
result=$?
echo "$result"   # 正确
​
# 错的写法
result=$?
ls /tmp/not_here
echo "$result"   # 永远是 0(echo 自己的退出码)

第二条写法里 $? 拿到的其实是 ls 之前那条命令的退出码,而 echo 的退出码永远是 0。这是 shell 新手最常犯的错之一。

1.2 常见的退出码约定

虽然 shell 不强制规定,但业界有一套约定俗成的语义:

退出码 含义
0 成功
1 通用错误(很多命令用它表示"出错了")
2 命令参数错误(bash builtin 通常用这个)
126 命令找到了但无法执行(无执行权限)
127 命令找不到(command not found)
128+N 进程被信号 N 终止(130 = Ctrl+C,SIGINT)
130 Ctrl+C 退出(128 + 2)
137 被 SIGKILL(128 + 9)
143 被 SIGTERM(128 + 15),常见于 docker stop

记住几个常用的就行:1 = 通用错误、2 = 参数错、126 = 不可执行、127 = 命令不存在、130 = Ctrl+C。其他的交给 $? 自带的语义。

1.3 exit 命令

写脚本时,你想让脚本自己返回某个退出码,就用 exit:

bash 复制代码
#!/bin/bash
if [ ! -f "$1" ]
then
    echo "错误:文件 $1 不存在" >&2
    exit 1
fi
​
# 正常处理
process "$1"
exit 0

几个要点:

  • exit 0 通常可以省略------脚本执行完最后一条命令的退出码会自动成为脚本的退出码。但如果想明确表达成功,显式写 exit 0 更清晰。
  • exit 不带参数,等价于 exit $?,返回最后一条命令的退出码。
  • 退出码范围是 0-255,超过 255 会取模 (exit 300 实际返回 44)。自定义错误码建议控制在 0-127 之内。

1.4 把错误信息送到对的地方

一个常被忽视的细节:脚本的错误信息应该输出到 stderr 而不是 stdout。两者的区别是:

  • stdout(标准输出):程序的正常结果,可以重定向到文件或管道传给下一个命令。
  • stderr(标准错误):程序的诊断信息,默认直接显示在终端。
bash 复制代码
echo "正常输出"           # stdout
echo "错误信息" >&2        # stderr

>&2 是个文件描述符重定向,把输出送到文件描述符 2(也就是 stderr)。这么做的好处是:当用户把脚本输出重定向到文件时,错误信息仍然能显示在终端上:

bash 复制代码
$ ./script.sh > output.log
错误:参数缺失!            # 仍然显示在终端
$ cat output.log
正常输出                  # 正常输出在文件里

>&2 的写法看着有点怪,但任何诊断信息、错误信息、警告信息都应该用 >&2 。这是 shell 编程的第三铁律------前两条铁律是"加双引号"和"${arr[@]}" 带引号。

二、set 命令

写 shell 脚本的人,多多少少都被静默失败坑过------脚本跑完没报错,但结果不对。set 命令的几个选项就是用来给脚本加防护栏的,强烈建议每个脚本开头都开。

2.1 set -e:遇错就退出

bash 复制代码
#!/bin/bash
set -e
​
cd /nonexistent_dir
echo "这行不会执行"

set -e 告诉 shell:任何命令返回非零退出码,就立刻退出脚本(除非在 if/while/&&/|| 这种本就要判断退出码的上下文里)。

这个选项的争议不小。有人说加了 set -e 写起来麻烦,到处要手动处理错误,但对生产脚本来说,set -e 是必须的------它把忘记检查错误的代价降到最低。

2.2 set -u:未定义变量就报错

bash 复制代码
#!/bin/bash
set -u
​
echo "$undefined_var"

默认行为是输出空行;开了 -u 之后,访问未定义变量会直接报错退出。这一条比 -e 更值得开,因为它能把拼写错误暴露出来。

bash 复制代码
#!/bin/bash
set -u
​
user_nmae="张三"   # 注意是 nmae 不是 name
echo "$user_name"  # 没开 -u:输出空;开了 -u:报错

不写 -u,这种 typo 会让脚本静默地用空值继续跑,bug 藏得深。

2.3 set -o pipefail:管道失败别被掩盖

这是三个选项里最容易踩坑的一个。看例子:

bash 复制代码
#!/bin/bash
set -e
​
ls /nonexistent | sort
echo "管道执行完毕"

没开 pipefail 时,ls 失败了,但管道的退出码取的是 sort 的退出码(0),set -e 检测不到,于是脚本继续执行------但 ls 的错误信息完全被吃掉了。

开了 pipefail:

bash 复制代码
#!/bin/bash
set -e
set -o pipefail
​
ls /nonexistent | sort   # 立刻报错退出

pipefail 让管道的退出码是所有命令中最后一个非零的。这样一来,管道里任何一步失败都会被捕获。

2.4 三个一起开:标配

生产脚本的标准开头:

bash 复制代码
#!/bin/bash
set -euo pipefail

这一行浓缩了三个安全网:错误就退出、变量未定义就报错、管道失败别掩盖。几乎所有靠谱的 shell 脚本都会开这一行。

但有几个边界要注意:

第一,set -e 在某些命令之后不生效。 比如 command || true 后面的命令即使失败也不会触发退出,因为 || 已经"消化"了失败。

bash 复制代码
set -e
false || true
echo "会执行"   # 正常
false || echo "失败但被消化"
echo "会执行"   # 正常

第二,函数返回值不会被 set -e 触发。 看个反直觉的例子:

bash 复制代码
#!/bin/bash
set -e
​
func() {
    return 1
}
​
func           # 函数返回 1,但脚本不会退出
echo "这行会执行"

func 的退出码是 1,但 set -e 不把函数最后一条命令作为整体判断。要让函数能触发 set -e,要么在调用时显式判断:

bash 复制代码
func || { echo "func 失败了" >&2; exit 1; }

要么让函数返回时用 return 显式声明:

perl 复制代码
func() {
    [ "$1" -gt 0 ] || return 1
    # ...
}

第三,if 条件里也不生效。 这是设计如此------if 本来就是判断成功失败的,如果 set -e 在这里也生效,if 就没法用了。

2.5 set -x 和 set -v:调试用

bash 复制代码
#!/bin/bash
set -x
​
name="张三"
echo "$name"

输出:

bash 复制代码
+ name='张三'
+ echo '张三'
张三

set -x 在每条命令执行前打印展开后的样子,是调试 shell 脚本的核武器。日常开发时加进去排查问题,发版时去掉。

set -v 比 -x 更原始------它打印脚本的原始行(不展开变量)。一般 -x 就够用。

三、错误处理模式

set -euo pipefail 解决了基础防护问题,但脚本逻辑里总有些"不能简单失败"的场景------比如"找不到文件要不要继续"、"某个步骤失败要不要回滚"。这些就需要显式的错误处理模式。

3.1 || 和 && 短路

shell 提供了 ||(或)和 &&(与)来做"基于上一条命令结果"的逻辑:

bash 复制代码
mkdir -p /tmp/work || { echo "创建目录失败" >&2; exit 1; }
grep "pattern" file.txt && echo "找到了" || echo "没找到"

cmd1 && cmd2 表示"cmd1 成功才执行 cmd2",cmd1 || cmd2 表示"cmd1 失败才执行 cmd2"。这是 shell 里写"成功/失败分支"最简洁的姿势。

|| 后面跟多条命令时,记得用 { ... } 括起来(注意 { 后面和 } 前面要带空格,结尾要带 ;):

bash 复制代码
mkdir -p /tmp/work || { echo "创建失败" >&2; exit 1; }

3.2 防御式 vs 快速失败

两种风格在 shell 圈里争论不休:

防御式(出问题别崩溃,继续往下走):

bash 复制代码
#!/bin/bash
process() {
    for file in *.txt
    do
        if [ -f "$file" ]
        then
            if ! gzip "$file"
            then
                echo "压缩 $file 失败,跳过" >&2
                continue
            fi
        fi
    done
}

快速失败(出问题立刻退出,别留隐患):

bash 复制代码
#!/bin/bash
set -e
​
for file in *.txt
do
    [ -f "$file" ] || continue
    gzip "$file"   # 失败会立刻退出整个脚本
done

哪种更好?取决于场景。批量处理任务("对 100 个文件做 X,能做多少做多少")用防御式;事务性任务("部署代码,要么成功要么回滚")用快速失败。

实操经验:能快速失败就快速失败。很多静默 bug 都是防御式处理吞掉了错误造成的。

3.3 错误处理函数

如果脚本里有多处需要打印错误并退出,抽成一个函数:

bash 复制代码
#!/bin/bash
set -euo pipefail
​
# 错误处理
die() {
    echo "[ERROR] $*" >&2
    exit 1
}
​
# 使用
[ "$#" -ge 1 ] || die "用法: $0 <参数>"
[ -f "$1" ] || die "文件不存在: $1"

die 是 shell 脚本里约定俗成的错误退出函数名。配合 set -e 使用,每个 || die "..." 都是显式的失败检查。

3.4 清理与回滚

有些操作有副作用(创建临时文件、修改配置、启动进程),失败时要清理:

bash 复制代码
#!/bin/bash
set -e
​
work_dir=$(mktemp -d)
trap "rm -rf '$work_dir'" EXIT
​
cd "$work_dir"
​
# 处理逻辑
download_file > data.txt
process < data.txt > result.txt
cp result.txt /final/path/

trap ... EXIT 表示"脚本退出时(无论成功失败)执行这段命令"。这是清理临时资源的标准模式,下一节细讲。

更复杂的场景需要事务回滚------记录每一步的副作用,失败时反向撤销:

bash 复制代码
#!/bin/bash
set -e
​
# 记录需要回滚的操作
rollback=()
​
register_rollback() {
    rollback+=("$*")
}
​
rollback_all() {
    for cmd in "${rollback[@]}"
    do
        eval "$cmd" || echo "回滚失败: $cmd" >&2
    done
}
​
trap rollback_all EXIT
​
# 业务逻辑
create_user "alice"
register_rollback "delete_user 'alice'"
​
grant_permission "alice" "admin"
register_rollback "revoke_permission 'alice' 'admin'"
​
echo "用户创建成功"

这套"register_rollback + trap"模式在数据库迁移、配置变更脚本里很常见。

四、trap退出

trap 是 shell 里最被低估的命令之一。它能让你在脚本收到信号或退出时执行指定代码------这正是优雅退出并清理资源的基础。

4.1 基本语法

bash 复制代码
trap 'commands' SIGNAL...
  • commands:收到信号时要执行的命令(用单引号包起来,延迟展开)
  • SIGNAL:要捕获的信号名(SIGINT、SIGTERM、EXIT 等)

4.2 三个最常用的场景

场景一:捕获 Ctrl+C

bash 复制代码
#!/bin/bash
trap 'echo "收到 Ctrl+C,正在清理..."; cleanup; exit 130' INT
​
cleanup() {
    rm -f /tmp/workfile
}
​
# 模拟工作
while true
do
    echo "工作中..."
    sleep 1
done

按 Ctrl+C 触发 SIGINT,trap 接收到后清理资源并退出。exit 130 是"Ctrl+C 中断"的标准退出码(128 + 2)。

场景二:清理临时文件

bash 复制代码
#!/bin/bash
set -e
​
tmpfile=$(mktemp)
trap "rm -f '$tmpfile'" EXIT
​
# 使用 tmpfile 做事情
echo "重要数据" > "$tmpfile"
​
# 模拟出错
[ 1 -eq 1 ] || true   # 占位
​
# 无论成功失败,EXIT trap 都会清理

trap ... EXIT 是"无论脚本怎么退出都执行清理"的标准模式。mktemp 配 trap "rm -f" EXIT 是 shell 脚本里最经典的清理模式。

场景三:忽略某些信号

bash 复制代码
#!/bin/bash
trap '' HUP    # 忽略 SIGHUP
trap '' TERM   # 忽略 SIGTERM
trap '' INT    # 忽略 Ctrl+C

trap '' SIGNAL 是"忽略该信号"------收到信号就当没看见。这种用法在守护进程里偶尔会用,但绝大多数情况下不要忽略 SIGTERM------这是运维同事要杀你进程的标准手段,忽略它会让服务没法正常关停。

4.3 EXIT 陷阱的妙用

EXIT 不是真正的信号,它是 bash 提供的"伪信号"------脚本退出时触发(无论是正常结束、set -e 退出、还是信号终止)。

bash 复制代码
#!/bin/bash
set -e
​
start_time=$(date +%s)
trap 'echo "脚本耗时 $(( $(date +%s) - start_time )) 秒"' EXIT
​
# 业务逻辑
sleep 2
echo "完成"

输出:

复制代码
完成
脚本耗时 2 秒

EXIT 陷阱配合 set -e 是无论成功失败都执行的清理------临时文件清理、耗时统计、状态上报都能用它。

4.4 陷阱里的命令

trap 的命令是字符串 ,会在收到信号时由 shell 重新解析执行。这意味着单引号和双引号的选择很关键:

bash 复制代码
#!/bin/bash
file="/tmp/workfile"
​
# 单引号:延迟展开,trap 触发时才计算
trap 'rm -f "$file"' EXIT   # 正确
​
# 双引号:立即展开,trap 里写死了空字符串
trap "rm -f '$file'" EXIT   # 也能用,但陷阱里变量变化不会反映

单引号版的 $file 是在 trap 触发时才展开的,能拿到最新的值;双引号版是脚本加载时就展开。如果 trap 里的命令里需要用变量,用单引号。

4.5 实战:一个"可中断"的批量处理

bash 复制代码
#!/bin/bash
set -euo pipefail
​
total=0
done=0
failed=0
​
cleanup() {
    echo ""
    echo "===== 中断/退出汇总 =====" >&2
    echo "处理: $done / $total, 失败: $failed" >&2
    exit 130
}
​
# Ctrl+C 时调用 cleanup
trap cleanup INT
# 正常退出时打印汇总
trap 'echo "===== 完成 ====="; echo "处理: $done / $total, 失败: $failed"' EXIT
​
files=(*.log)
total=${#files[@]}
​
for file in "${files[@]}"
do
    if grep -q "ERROR" "$file"
    then
        if ! gzip "$file"
        then
            ((failed++)) || true
        fi
    fi
    ((done++))
    echo "进度: $done/$total"
done

注意 ((failed++)) || true------set -e 下,如果 failed 之前是 0,自增后变 1,((...)) 返回 0(因为结果非零,bash 把它当作"成功"),但反过来如果 failed 之前是 1 而自增后是 2,((...)) 返回非零值触发 set -e 退出 。这是个反直觉的 bash 特性,加 || true 保险一下。

五、日志

错误处理解决的是出问题怎么办,日志解决的是出问题怎么查。两者配对才完整。

5.1 为什么要日志

新手常觉得我自己写的脚本,我自己跑,加日志干嘛。但生产脚本的真相是:

  • 你可能不在现场(crontab 凌晨跑)
  • 失败后没有现场(一次性脚本跑完就退出)
  • 排错靠现场(出错那一刻的输入、状态、调用链)

日志是脚本的黑匣子。出问题后没有日志,排查就是猜谜。

5.2 日志的基本要求

一个合格的日志至少要回答这几个问题:

  • 什么时候:时间戳(精确到秒或毫秒)
  • 什么级别:INFO / WARN / ERROR / DEBUG
  • 哪里出的:脚本名、函数名、行号(可选)
  • 发生了什么:清晰的一句话描述

最简版:

css 复制代码
[2024-01-15 10:23:45] [INFO] 开始处理文件 input.log
[2024-01-15 10:23:46] [ERROR] 解析失败:第 12 行格式错误
[2024-01-15 10:23:46] [INFO] 处理完成:成功 95 条,失败 5 条

5.3 写一个日志函数

bash 复制代码
#!/bin/bash
set -euo pipefail
​
LOG_LEVEL=${LOG_LEVEL:-INFO}
LOG_FILE=${LOG_FILE:-}
​
log() {
    local level=$1
    shift
    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M:%S')
​
    # 过滤低级别日志
    case $level in
        DEBUG) [[ "$LOG_LEVEL" =~ DEBUG ]] || return 0 ;;
        INFO)  [[ "$LOG_LEVEL" =~ (DEBUG|INFO) ]] || return 0 ;;
        WARN)  [[ "$LOG_LEVEL" =~ (DEBUG|INFO|WARN) ]] || return 0 ;;
    esac
​
    local msg="[$timestamp] [$level] $*"
    if [[ -n "$LOG_FILE" ]]
    then
        echo "$msg" | tee -a "$LOG_FILE" >&2
    else
        echo "$msg" >&2
    fi
}
​
# 使用
log INFO  "开始处理"
log WARN  "配置文件不存在,使用默认值"
log ERROR "数据库连接失败"
log DEBUG "内部变量: $internal_var"   # 默认不显示

几点设计要点:

  • 日志输出到 stderr (>&2)------正常结果和数据走 stdout,诊断信息走 stderr,互不干扰。
  • 支持日志级别过滤 ------LOG_LEVEL=DEBUG ./script.sh 可以看到所有调试信息,默认 INFO 级别。
  • 支持输出到文件 ------LOG_FILE=/var/log/myscript.log ./script.sh 可以保存日志。
  • tee -a 同时输出到文件和 stderr------既能在终端看到,又能保存到文件。

5.4 调试日志

调试日志是排查问题的关键,但生产环境又不想太吵。一个常见做法是用 BASH_SOURCE 和 LINENO 自动加位置信息:

bash 复制代码
#!/bin/bash
set -euo pipefail
​
debug() {
    [[ "${DEBUG:-0}" == "1" ]] || return 0
    local timestamp
    timestamp=$(date '+%H:%M:%S.%3N')
    echo "[$timestamp] [DEBUG] ${BASH_SOURCE[1]##*/}:${BASH_LINENO[0]} $*" >&2
}
​
# 使用
debug "进入 process 函数,参数: $1"
​
process() {
    debug "正在处理 $1"
    # ...
}

输出(设 DEBUG=1):

less 复制代码
[10:23:45.123] [DEBUG] script.sh:14 进入 process 函数,参数: input.log
[10:23:45.124] [DEBUG] script.sh:19 正在处理 input.log

BASH_SOURCE[1] 和 BASH_LINENO[0] 是 bash 内部维护的"调用栈",下标 1 表示"调用当前函数的那个文件"和"那一行"。这个技巧在大型脚本里调试非常好用。

5.5 用 logger 命令写系统日志

Linux 自带 logger 命令,能把消息写到 syslog:

bash 复制代码
#!/bin/bash
log() {
    local level=$1
    shift
    logger -t "myscript" -p "user.$level" "$*"
}
​
# 使用
log INFO "开始处理"
log ERROR "数据库连接失败"

输出会进 /var/log/syslog(Debian/Ubuntu)或 /var/log/messages(CentOS/RHEL),可以用 journalctl -t myscript 查看。适合长期运行的服务脚本,不适合一次性任务。

5.6 日志轮转

日志写多了会把磁盘撑爆。生产脚本的日志文件需要轮转------定期切割、压缩、清理。

如果用 logger 走 syslog,轮转由系统的 logrotate 负责。如果自己写文件,可以用 logrotate 配置文件:

javascript 复制代码
# /etc/logrotate.d/myscript
/var/log/myscript.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    postrotate
        # 通知脚本重新打开日志文件
        kill -HUP $(cat /var/run/myscript.pid 2>/dev/null) 2>/dev/null || true
    endscript
}

或者脚本里自己处理(用 SIGHUP 触发重新打开日志文件)。日常脚本用不到这么复杂,知道有这件事就行。

六、完整实战

这一节我们看下怎么将数据处理脚本加固成生产级,看错误处理和日志是怎么在真实项目里落地的。

原始版本:

bash 复制代码
#!/bin/bash
declare -A total_amount
declare -A order_count
​
while IFS=',' read -r order_id product quantity price date
do
    [[ "$order_id" == "order_id" ]] && continue
    qty=${quantity:-0}
    prc=${price:-0}
    [[ ! "$qty" =~ ^[0-9]+$ ]] && continue
    [[ ! "$prc" =~ ^[0-9]+$ ]] && continue
    amount=$((qty * prc))
    total_amount[$product]=$(( ${total_amount[$product]:-0} + amount ))
    ((order_count[$product]++))
done < sales.csv
​
echo "product,total_amount,order_count,avg_amount"
for product in "${!total_amount[@]}"
do
    total=${total_amount[$product]}
    count=${order_count[$product]}
    avg=$((total / count))
    echo "$product,$total,$count,$avg"
done | sort -t',' -k2 -rn

加固后:

bash 复制代码
#!/bin/bash
#
# analyze_sales.sh - 分析销售数据
# 用法: analyze_sales.sh <输入CSV> [输出CSV]
#
​
set -euo pipefail
​
# ============================================================
# 配置
# ============================================================
SCRIPT_NAME=$(basename "$0")
LOG_LEVEL=${LOG_LEVEL:-INFO}
LOG_FILE=${LOG_FILE:-}
​
# ============================================================
# 日志函数
# ============================================================
log() {
    local level=$1
    shift
    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M:%S')
​
    # 级别过滤
    case $level in
        DEBUG) [[ "$LOG_LEVEL" =~ DEBUG ]] || return 0 ;;
        INFO)  [[ "$LOG_LEVEL" =~ (DEBUG|INFO) ]] || return 0 ;;
        WARN)  [[ "$LOG_LEVEL" =~ (DEBUG|INFO|WARN) ]] || return 0 ;;
    esac
​
    local msg="[$timestamp] [$level] $SCRIPT_NAME: $*"
    if [[ -n "$LOG_FILE" ]]
    then
        echo "$msg" | tee -a "$LOG_FILE" >&2
    else
        echo "$msg" >&2
    fi
}
​
die() {
    log ERROR "$*"
    exit 1
}
​
# ============================================================
# 参数校验
# ============================================================
[ "$#" -ge 1 ] || die "用法: $SCRIPT_NAME <输入CSV> [输出CSV]"
INPUT=$1
OUTPUT=${2:-}
​
[ -f "$INPUT" ] || die "输入文件不存在: $INPUT"
[ -r "$INPUT" ] || die "输入文件不可读: $INPUT"
​
log INFO "开始分析 $INPUT"
​
# ============================================================
# 临时文件
# ============================================================
work_dir=$(mktemp -d)
trap "rm -rf '$work_dir'" EXIT
​
# ============================================================
# 业务逻辑
# ============================================================
declare -A total_amount
declare -A order_count
declare -A invalid_rows
total_rows=0
skipped_rows=0
​
while IFS=',' read -r order_id product quantity price date
do
    ((total_rows++)) || true
​
    # 跳表头
    [[ "$order_id" == "order_id" ]] && continue
​
    # 字段校验
    if [[ -z "$product" ]]
    then
        invalid_rows[$total_rows]="缺少 product"
        ((skipped_rows++)) || true
        continue
    fi
​
    if [[ ! "$quantity" =~ ^[0-9]+$ ]] || [[ ! "$price" =~ ^[0-9]+$ ]]
    then
        invalid_rows[$total_rows]="quantity/price 非数字: $quantity/$price"
        ((skipped_rows++)) || true
        continue
    fi
​
    # 聚合
    amount=$((quantity * price))
    total_amount[$product]=$(( ${total_amount[$product]:-0} + amount ))
    ((order_count[$product]++)) || true
done < "$INPUT"
​
# ============================================================
# 输出
# ============================================================
output_file="$work_dir/result.csv"
exec 3> "$output_file"   # 打开文件描述符 3 指向输出
​
echo "product,total_amount,order_count,avg_amount" >&3
​
for product in "${!total_amount[@]}"
do
    total=${total_amount[$product]}
    count=${order_count[$product]}
    avg=$((total / count))
    echo "$product,$total,$count,$avg" >&3
done | sort -t',' -k2 -rn
​
exec 3>&-   # 关闭文件描述符
​
# ============================================================
# 收尾
# ============================================================
if [[ -n "$OUTPUT" ]]
then
    cp "$output_file" "$OUTPUT"
    log INFO "结果已写入: $OUTPUT"
else
    cat "$output_file"
fi
​
log INFO "处理完成: 总行数 $total_rows, 跳过 $skipped_rows, 成功 ${#total_amount[@]} 个产品"
​
if [[ ${#invalid_rows[@]} -gt 0 ]]
then
    log WARN "${#invalid_rows[@]} 行数据有问题:"
    for row in "${!invalid_rows[@]}"
    do
        log WARN "  第 $row 行: ${invalid_rows[$row]}"
    done
fi

对比一下前后两个版本,加固后多了这些能力:

  • set -euo pipefail:三个安全网全开
  • 参数校验:缺参数、文件不存在、不可读都直接报错退出
  • 日志函数:INFO/WARN/ERROR 三级,可控级别,可选输出到文件
  • 临时目录 + trap EXIT:脚本退出时自动清理
  • 数据校验 :缺字段、非数字都记录到 invalid_rows,不静默丢弃
  • 统计输出:总行数、跳过数、成功产品数、问题行明细
  • 退出码规范:成功返回 0,参数错误返回 1

用起来:

bash 复制代码
# 正常用法:结果输出到 stdout
./analyze_sales.sh sales.csv
​
# 输出到文件 + 详细日志
LOG_LEVEL=DEBUG LOG_FILE=/var/log/analyze.log ./analyze_sales.sh sales.csv result.csv

对比之下,没加固的版本会怎样?

  • 缺参数 → cat sales.csv 把脚本自己读进来当数据,结果错乱
  • 文件不存在 → 进入 while read 没数据,输出空,crontab 里你就看到一个空邮件
  • 数字字段异常 → 直接 continue,问题行被静默吃掉
  • 临时文件残留 → 磁盘慢慢被吃满

这就是能跑和靠得住的差距。

七、总结

错误处理和日志是把脚本从玩具变成工具的关键。回顾一下要点:

  • 退出码 是 shell 世界的通用语,$? 是查询方式,exit N 是返回方式。
  • set -euo pipefail 是脚本标配,但要知道它在 if/while/|| 上下文里不生效
  • 错误处理模式 :能用 set -e 就快速失败,需要"做多少算多少"再考虑防御式
  • trap 处理信号和清理:trap '...' EXIT 清理临时资源,trap '...' INT 处理 Ctrl+C
  • 日志 要有时间戳、级别、清晰描述;可调级别、可选输出文件、调试模式用 BASH_SOURCE+BASH_LINENO
  • 加固脚本 = set -euo pipefail + 参数校验 + trap 清理 + 日志 + 退出码规范

这一篇我们把脚本的健壮性补齐了。下一篇顺着工程化这条路继续走------聊一聊 shell 脚本怎么模块化:把常用函数抽到库文件、怎么写可被 source的脚本、怎么处理多脚本项目。脚本从几百行长到几千行的时候,能不能组织好是写得出来和写得下去的区别。


本文示例在 GNU bash 4.3+ 环境下测试通过。BASH_SOURCE 和 BASH_LINENO 在 bash 3.0+ 就支持。logger 命令需要 syslog 服务,容器化部署时建议改用文件日志。date '+%3N'(毫秒)需要 GNU date。

相关推荐
fox_charon9 小时前
Windows 下 CLI 参数的引号陷阱:为什么 --resume 'uuid' 会失败
windows·shell·cmd·cli·引号
吴声子夜歌9 天前
Shell编程实例——编写安全的shell脚本(二)
linux·运维·shell
吴声子夜歌11 天前
Shell编程实例——内务及管理任务(一)
linux·运维·shell
吴声子夜歌11 天前
Shell编程实例——高级脚本编程(一)
linux·运维·网络·shell
吴声子夜歌11 天前
Shell编程实例——bash的配置与自定义(二)
linux·运维·shell
吴声子夜歌12 天前
Shell编程实例——与解析相关的任务(二)
linux·运维·shell
吴声子夜歌12 天前
Shell编程实例——脚本编程的附加特性
linux·运维·shell
云计算练习生12 天前
什么是内核?操作系统内核到底管哪些事
linux·windows·操作系统·内核·shell
吴声子夜歌15 天前
Shell编程实例——bash入门
linux·运维·shell
Swizard15 天前
Linux 日志神器 journalctl 完全指南:彻底替代传统日志查看方式
shell