未记录事实核验 · 状态:维护中

记录仅说明核验时间与维护状态,不保证适用于所有环境;使用前请留意正文中的版本、适用条件与风险提示。

warning 适用边界与维护说明

本文适合在 Linux 的 Bash 环境中学习变量、条件、循环、函数、日志与调试,并完成只列出文件、不删除文件的日志统计练习。订单实战仅适用于不含字段内逗号或换行的简单逗号分隔文本,需要自行准备带表头、至少六列的输入,其中第3列为城市、第5列为金额、第6列为状态;前文四列示例不能直接套用,正文仍缺完整六列输入样例。运行前须配置输入读取权限及输出、日志目录的创建和写入权限,并保证金额字段为有效数值,当前清洗逻辑未验证金额格式。仅有表头或表头后全为空行时,grep 无匹配会使清洗管道在 set -e 与 pipefail 下退出;城市排行在上游输出较多时也可能因 head 提前关闭管道而触发 SIGPIPE,造成任务中止和汇总不完整。平均金额的零记录除零问题已修复,set -e 的上下文例外及 bash -x 的作用也已正确说明,但订单脚本仍不宜直接用于无人值守的每日处理。

Shell 脚本基础 - 从零写一个数据处理脚本

abstract 本文概要

  • Shell 脚本的基本结构
  • 变量:定义、使用、特殊变量
  • 条件判断:if/else
  • 循环:for/while
  • 函数:封装复用
  • 完整实战:一个数据处理脚本

第一个 Shell 脚本

#!/bin/bash
# 这是注释(# 开头)
# 第一行 #!/bin/bash 叫 shebang,告诉系统用哪个解释器执行这个脚本

echo "Hello, 数据工程师!"
echo "当前时间:$(date '+%Y-%m-%d %H:%M:%S')"
echo "当前目录:$(pwd)"

创建和运行脚本:

# 1. 创建脚本文件
vim hello.sh

# 2. 添加执行权限
chmod +x hello.sh

# 3. 运行脚本
./hello.sh

# 或者直接用 bash 运行(不需要执行权限)
bash hello.sh

tip 脚本头部的最佳实践

#!/bin/bash
# 脚本名称:daily_etl.sh
# 功能说明:每日数据ETL处理
# 作者:alice
# 创建日期:2026-03-18
# 修改记录:2026-03-18 初版

# 在适用上下文中,命令返回非零状态时退出;条件判断等上下文存在例外,关键失败仍需显式检查
set -e

# 使用未声明的变量时报错
set -u

# 管道中任何命令失败都算失败
set -o pipefail

变量

定义和使用变量

#!/bin/bash

# 定义变量(等号两边不能有空格!)
name="alice"
age=28
data_dir="/data/warehouse"

# 使用变量(加 $ 前缀)
echo $name
echo ${name}   # 推荐用 ${} 形式,更清晰,避免歧义

# 错误示例(等号两边有空格)
# name = "alice"   ← 这会报错!

# 变量中包含变量(双引号内变量会被展开)
message="用户 ${name} 的数据目录是 ${data_dir}/${name}"
echo "$message"

# 单引号内不展开变量
echo '用户 $name'   # 输出:用户 $name(原样输出)

命令替换(重要)

把命令的输出赋值给变量:

# 方法一:$() 语法(推荐)
today=$(date +%Y-%m-%d)
file_count=$(ls /data/raw/ | wc -l)
current_user=$(whoami)

# 方法二:反引号(老式写法)
today=`date +%Y-%m-%d`

# 使用
echo "今天是:${today}"
echo "数据目录有 ${file_count} 个文件"

# 在字符串中使用命令替换
log_file="/var/log/etl/etl_$(date +%Y%m%d_%H%M%S).log"
echo "日志文件:${log_file}"

特殊变量

# $0  :脚本自身的文件名
# $1  :第 1 个命令行参数
# $2  :第 2 个命令行参数
# $@  :所有参数(作为独立的字符串)
# $#  :参数个数
# $?  :上一个命令的退出状态码(0=成功,非0=失败)
# $$  :当前脚本的进程 ID(PID)
# $!  :上一个后台进程的 PID

# 示例脚本(process.sh)
#!/bin/bash
echo "脚本名:$0"
echo "第一个参数:$1"
echo "第二个参数:$2"
echo "参数总数:$#"

# 运行:bash process.sh hello world
# 输出:
# 脚本名:process.sh
# 第一个参数:hello
# 第二个参数:world
# 参数总数:2

环境变量

# 查看所有环境变量
env
printenv

# 查看特定环境变量
echo $HOME          # 家目录
echo $PATH          # 命令搜索路径
echo $USER          # 当前用户名
echo $SHELL         # 当前 Shell
echo $PWD           # 当前目录(等同于 pwd 命令)

# 在脚本中设置环境变量
export MY_VAR="my_value"   # export 让子进程也能用到这个变量

条件判断

if/else 基本语法

if 条件; then
    # 条件为真时执行
elif 另一个条件; then
    # 第二个条件为真时执行
else
    # 以上条件都不满足时执行
fi

数值比较

a=10
b=20

if [ $a -eq $b ]; then    # -eq:等于
    echo "相等"
elif [ $a -lt $b ]; then  # -lt:小于(less than)
    echo "$a 小于 $b"
else
    echo "$a 大于 $b"
fi

# 常用比较运算符
# -eq  :等于(equal)
# -ne  :不等于(not equal)
# -lt  :小于(less than)
# -le  :小于等于(less or equal)
# -gt  :大于(greater than)
# -ge  :大于等于(greater or equal)

字符串比较

str="hello"

if [ "$str" = "hello" ]; then     # = 或 ==
    echo "字符串相等"
fi

if [ "$str" != "world" ]; then    # != 不等于
    echo "字符串不同"
fi

if [ -z "$str" ]; then            # -z:字符串为空
    echo "字符串为空"
fi

if [ -n "$str" ]; then            # -n:字符串非空
    echo "字符串非空"
fi

文件和目录判断

file="/data/orders.csv"
dir="/data/output"

if [ -f "$file" ]; then        # -f:是普通文件且存在
    echo "文件存在"
fi

if [ -d "$dir" ]; then         # -d:是目录且存在
    echo "目录存在"
fi

if [ -e "$file" ]; then        # -e:文件或目录存在(any type)
    echo "路径存在"
fi

if [ -r "$file" ]; then        # -r:文件可读
    echo "文件可读"
fi

if [ -w "$file" ]; then        # -w:文件可写
    echo "文件可写"
fi

if [ -x "run.sh" ]; then       # -x:文件可执行
    echo "脚本有执行权限"
fi

if [ -s "$file" ]; then        # -s:文件存在且非空
    echo "文件非空"
fi

# 实际应用
if [ ! -d "$dir" ]; then       # ! 取反:目录不存在
    mkdir -p "$dir"
    echo "创建目录:$dir"
fi

逻辑运算

# && 与(两个条件都要满足)
if [ -f "$file" ] && [ -r "$file" ]; then
    echo "文件存在且可读"
fi

# || 或(满足其中一个即可)
if [ "$status" = "ERROR" ] || [ "$status" = "FAILED" ]; then
    echo "任务失败"
fi

# 现代写法([[ ]] 支持更多特性)
if [[ -f "$file" && -r "$file" ]]; then
    echo "文件存在且可读"
fi

循环

for 循环

# 遍历列表
for item in apple banana cherry; do
    echo "水果:$item"
done

# 遍历数字范围
for i in {1..10}; do
    echo "第 $i 次"
done

# 带步长
for i in {0..100..10}; do
    echo $i
done

# C 语言风格
for ((i=0; i<10; i++)); do
    echo $i
done

# 遍历文件
for file in /data/raw/*.csv; do
    echo "处理文件:$file"
    # 在这里处理文件
done

# 遍历命令输出
for user in $(cat /etc/passwd | cut -d: -f1); do
    echo "用户:$user"
done

while 循环

# 基本 while 循环
count=1
while [ $count -le 5 ]; do
    echo "第 $count 次"
    count=$((count + 1))   # 算术运算
done

# 逐行读取文件(处理数据文件的常用方式)
while IFS=',' read -r col1 col2 col3; do
    echo "列1: $col1, 列2: $col2, 列3: $col3"
done < data.csv

# 死循环 + break(监控类脚本常用)
while true; do
    if [ 条件 ]; then
        break
    fi
    sleep 5
done

tip 逐行处理 CSV 数据

#!/bin/bash
# 跳过表头,逐行处理 CSV
# 文件格式:order_id,user_id,amount,status

skip_header=true
while IFS=',' read -r order_id user_id amount status; do
    if $skip_header; then
        skip_header=false
        continue
    fi

    if [ "$status" = "failed" ]; then
        echo "失败订单:$order_id, 金额:$amount"
    fi
done < orders.csv

函数

# 定义函数
函数名() {
    # 函数体
    echo "这是函数"
}

# 带参数的函数
greet() {
    local name="$1"    # local 声明局部变量
    local greeting="$2"
    echo "${greeting}, ${name}!"
}

# 调用函数
greet "Alice" "你好"
greet "Bob" "Hello"

# 返回值(Shell 函数只能返回 0-255 的整数作为状态码)
check_file() {
    local file="$1"
    if [ -f "$file" ]; then
        return 0   # 0 表示成功
    else
        return 1   # 非 0 表示失败
    fi
}

# 调用并检查返回值
if check_file "/data/orders.csv"; then
    echo "文件存在,开始处理"
else
    echo "文件不存在,退出"
    exit 1
fi

# 函数输出字符串(用 echo)
get_date_str() {
    echo $(date +%Y-%m-%d)
}

today=$(get_date_str)
echo "今天:$today"

实用脚本技巧

错误处理

#!/bin/bash
set -e   # 在适用上下文中遇到非零退出状态时退出,并非所有失败都会触发

# 自定义错误处理函数
error_exit() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') ERROR: $1" >&2
    exit 1
}

# 检查命令是否存在
if ! command -v python3 &>/dev/null; then
    error_exit "python3 未安装"
fi

# 检查参数数量
if [ $# -ne 2 ]; then
    error_exit "用法: $0 <输入文件> <输出目录>"
fi

日志函数

# 带时间戳的日志函数
LOG_FILE="/var/log/etl/etl_$(date +%Y%m%d).log"

log_info() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') INFO: $1" | tee -a "$LOG_FILE"
}

log_error() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') ERROR: $1" | tee -a "$LOG_FILE" >&2
}

log_info "开始处理数据"
log_error "文件不存在"

参数解析

#!/bin/bash
# 处理命令行参数

usage() {
    echo "用法: $0 [-d 数据目录] [-o 输出目录] [-v]"
    echo "  -d  数据输入目录"
    echo "  -o  结果输出目录"
    echo "  -v  详细模式"
    exit 1
}

DATA_DIR=""
OUTPUT_DIR=""
VERBOSE=false

while getopts "d:o:v" opt; do
    case $opt in
        d) DATA_DIR="$OPTARG" ;;
        o) OUTPUT_DIR="$OPTARG" ;;
        v) VERBOSE=true ;;
        ?) usage ;;
    esac
done

# 调用:bash script.sh -d /data/raw -o /data/output -v

完整实战:每日数据处理脚本

#!/bin/bash
# =======================================================
# 脚本名:daily_data_process.sh
# 功能:每日订单数据处理
#   1. 验证输入文件存在且非空
#   2. 数据清洗(去除空行、异常值)
#   3. 统计汇总
#   4. 输出结果并记录日志
# 用法:bash daily_data_process.sh <日期,格式YYYYMMDD>
# =======================================================

set -e
set -o pipefail

# ---- 配置 ----
BASE_DIR="/data"
RAW_DIR="${BASE_DIR}/raw"
OUTPUT_DIR="${BASE_DIR}/output"
LOG_DIR="/var/log/etl"
DATE="${1:-$(date +%Y%m%d)}"   # 默认用今天的日期

INPUT_FILE="${RAW_DIR}/orders_${DATE}.csv"
OUTPUT_FILE="${OUTPUT_DIR}/orders_summary_${DATE}.txt"
LOG_FILE="${LOG_DIR}/etl_${DATE}.log"

# ---- 日志函数 ----
log() {
    local level="$1"
    local message="$2"
    echo "$(date '+%Y-%m-%d %H:%M:%S') [${level}] ${message}" | tee -a "$LOG_FILE"
}

info()  { log "INFO " "$1"; }
warn()  { log "WARN " "$1"; }
error() { log "ERROR" "$1" >&2; }

# ---- 初始化 ----
mkdir -p "$OUTPUT_DIR" "$LOG_DIR"
info "====== 开始处理 ${DATE} 的数据 ======"

# ---- 1. 检查输入文件 ----
info "检查输入文件:${INPUT_FILE}"

if [ ! -f "$INPUT_FILE" ]; then
    error "输入文件不存在:${INPUT_FILE}"
    exit 1
fi

if [ ! -s "$INPUT_FILE" ]; then
    error "输入文件为空:${INPUT_FILE}"
    exit 1
fi

file_size=$(du -sh "$INPUT_FILE" | cut -f1)
line_count=$(wc -l < "$INPUT_FILE")
info "文件大小:${file_size},行数:${line_count}"

# ---- 2. 数据清洗 ----
info "开始数据清洗..."

CLEAN_FILE="/tmp/orders_${DATE}_clean.csv"

# 保留表头,去除空行,去除金额为0或负数的异常记录
head -1 "$INPUT_FILE" > "$CLEAN_FILE"
tail -n +2 "$INPUT_FILE" \
    | grep -v "^$" \
    | awk -F',' '$5 > 0 { print }' \
    >> "$CLEAN_FILE"

clean_count=$(wc -l < "$CLEAN_FILE")
dropped=$((line_count - clean_count))
info "清洗完成:保留 $((clean_count-1)) 条有效记录,丢弃 ${dropped} 条异常记录"

# ---- 3. 统计汇总 ----
info "开始统计汇总..."

{
    echo "=============================="
    echo "日期: ${DATE}"
    echo "生成时间: $(date '+%Y-%m-%d %H:%M:%S')"
    echo "=============================="
    echo ""

    echo "【总体概况】"
    awk -F',' 'NR > 1 {
        count++
        sum += $5
        if ($5 > max_amount || max_amount == "") max_amount = $5
    }
    END {
        printf "总订单数: %d\n", count
        printf "总金额: %.2f\n", sum
        printf "平均金额: %.2f\n", (count > 0 ? sum/count : 0)
        printf "最大订单金额: %.2f\n", max_amount
    }' "$CLEAN_FILE"

    echo ""
    echo "【各城市订单量 TOP 10】"
    awk -F',' 'NR > 1 { city[$3]++; city_sum[$3]+=$5 }
    END { for (c in city) printf "%s\t%d\t%.2f\n", c, city[c], city_sum[c] }' "$CLEAN_FILE" \
    | sort -t$'\t' -k2 -rn \
    | head -10 \
    | awk -F'\t' '{ printf "  %-15s 订单数: %d  金额: %.2f\n", $1, $2, $3 }'

    echo ""
    echo "【各状态订单分布】"
    awk -F',' 'NR > 1 { status[$6]++ }
    END { for (s in status) printf "  %-15s %d\n", s, status[s] }' "$CLEAN_FILE" \
    | sort -k2 -rn

} > "$OUTPUT_FILE"

info "统计结果已保存到:${OUTPUT_FILE}"

# ---- 4. 清理临时文件 ----
rm -f "$CLEAN_FILE"

# ---- 完成 ----
info "====== 处理完成 ======"
info "输出文件:${OUTPUT_FILE}"

# 打印汇总到日志
cat "$OUTPUT_FILE" >> "$LOG_FILE"

exit 0

运行脚本:

# 处理今天的数据
bash daily_data_process.sh

# 处理指定日期的数据
bash daily_data_process.sh 20260318

# 实时查看日志
tail -f /var/log/etl/etl_20260318.log

调试脚本

# 方法 1:bash -x 执行(跟踪并打印执行前展开后的命令;命令自身的输出仍按原有重定向处理)
bash -x myscript.sh

# 方法 2:在脚本中加 set -x 开启调试
set -x    # 开启
# ... 要调试的代码 ...
set +x    # 关闭

# 方法 3:输出调试信息
DEBUG=true
if $DEBUG; then
    echo "DEBUG: 变量值 = $variable"
fi

# 方法 4:检查退出状态码
command
echo "退出状态:$?"   # 0 表示成功

快速参考

语法 示例
定义变量 name="alice"
使用变量 echo ${name}
命令替换 today=$(date +%Y-%m-%d)
条件判断 if [ -f "$file" ]; then ... fi
for 循环 for f in *.csv; do ... done
while 循环 while [ $i -lt 10 ]; do ... done
定义函数 my_func() { echo "$1"; }
退出码 exit 0(成功),exit 1(失败)
算术运算 result=$((a + b))

下一步


info 前置知识


掌握检查

  • [ ] 能写一个有 #!/bin/bash 声明行、变量赋值、if 判断和 for 循环的基础脚本
  • [ ] 知道 $1、$#、$? 分别代表什么(第一个参数、参数个数、上条命令的退出状态)
  • [ ] 理解 set -e 的退出条件及其上下文例外,以及 set -o pipefail 如何让管道反映失败状态;知道关键步骤仍需显式检查
  • [ ] 能在脚本中实现带时间戳的日志函数,区分 INFO 和 ERROR 级别输出
  • [ ] 能用 bash -x script.sh 调试脚本,知道如何读懂调试输出

实践练习

基础题:写一个脚本 cleanup.sh,接受一个目录路径作为参数,统计该目录下有多少个 .log 文件、总大小是多少,并将超过 30 天未修改的日志文件列出来(不删除,只列出路径)。

进阶题:在基础题脚本的基础上,增加以下功能:(1) 添加参数校验,目录不存在时打印错误并退出;(2) 用带时间戳的日志函数记录每一步操作;(3) 增加 -d 天数 参数,允许调用者指定"多少天以上"的文件(默认 30 天)。


下一步

#Shell #Bash #脚本编程 #自动化 #数据工程