「速通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:要捕获的信号名(SIGINTSIGTERMEXIT 等)

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 是"无论脚本怎么退出都执行清理"的标准模式。mktemptrap "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_SOURCELINENO 自动加位置信息:

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_SOURCEBASH_LINENO 在 bash 3.0+ 就支持。logger 命令需要 syslog 服务,容器化部署时建议改用文件日志。date '+%3N'(毫秒)需要 GNU date。

相关推荐
柒号华仔1 天前
「速通Shell」Shell 数组、关联数组与 mapfile
shell
苏灿烤鱼3 天前
一套进程代替八件套,桌面更稳还是单点更大
linux·github·shell
茶本无香3 天前
通用报表自动化框架:Java调用Shell传参执行PostgreSQL SQL模板
java·sql·postgresql·shell
柒号华仔4 天前
「速通Shell」Shell 循环与遍历
shell·编程语言
十里春风_jzh5 天前
Tabby 修改版:更美观的现代终端
shell·tabby
茶本无香5 天前
Java调用Shell脚本执行SQL数据库操作:从入门到实战
java·sql·shell
柒号华仔7 天前
「速通Shell」织线为面,Shell条件测试和判断
shell
苏灿烤鱼8 天前
把 Agent 做成一家公司,真比通用提示词好用吗?
python·agent·shell
寺中人9 天前
Linux 基础命令入门实战教程:从零掌握常用操作,新手快速上手
linux·运维·服务器·shell·linux 命令·linux 基础教程·linux 入门