1. 为什么脚本语句是每个搞技术的都躲不开的基本功
先说实话,我刚工作那会儿对脚本的态度很轻蔑。觉得写的都是“玩具”,不够“工程”。真正让我改观的,是某次值班遇到线上日志突然暴涨,几百台机器同时报警,一台一台手动登上去清理,折腾了大半夜。后来一位前辈丢给我一个几十行的脚本,一键批量处理完,那一刻我才明白一个道理:重复劳动里省下来的每一分钟,都是脚本帮你赚的。
“常用脚本语句”这个标题看起来入门,实际上几乎所有技术方向的人每天都在接触它。做运维的靠它做自动化巡检,做后端的用它在部署流水线里跑构建和发布,做数据的用它处理日志和报表,就算是做前端的,也得懂一点脚本才能在打包、同步、定时任务里游刃有余。脚本语言本身不复杂,难的是把它写得健壮、写得高效、写得让人敢在正式环境里放心跑。
这篇文章我不打算做成语法手册,那种东西网上太多了。我想从实战角度出发,把日常使用频率最高、踩坑最多的脚本语句和套路拆开讲清楚,包括变量和参数、条件判断、循环、函数封装、文本处理、安全退出、调试技巧,最后再带一个完整的实用案例。内容以 Linux/Unix 环境下的 Shell 脚本为主,因为它是应用范围最广、最贴近服务器和自动化场景的脚本语言。看完你至少能把脚本从“能跑”提升到“好用”,少踩几个我踩过的坑。
2. 脚本的整体设计思路:先把“能跑”和“好用”分清楚
2.1 Shell脚本到底适合干什么,不适合干什么
很多新手纠结学 Python 还是学 Shell,其实两者根本不是竞争关系。Shell 的核心优势在于它是命令行的“胶水”,擅长把一个个现成的系统命令组合起来,快速处理文件、进程、文本、网络这些事情。比如批量改文件名、定时清理日志、检查服务存活状态、解析访问日志里的关键指标,这类任务用 Shell 写,几行就完了,换成 Python 反而要写一堆 os 模块的样板代码。
但 Shell 也有明显的短板:复杂的数据结构、高性能计算、需要严格异常处理的业务逻辑,这些场景 Shell 写起来很痛苦。我自己定的一个原则很简单:如果任务主要是在加工系统命令的输出,优先 Shell;如果任务需要处理复杂数据模型、后续逻辑可能频繁迭代,就老实写 Python。两个工具配合用,才是效率最大化。
2.2 一个好脚本的四个特征
我带过一些新人,发现大家起步时写的脚本都能“跑通”,可拿到别人机器上或者换一个目录就崩了。真正“好用”的脚本,经验上看有四个共性:
第一是可重入。同样的脚本重复跑两次,结果是一致的,不会因为文件已存在就报错中断。第二是好读。变量命名清晰、关键步骤有注释,三个月后你自己回来看还能一眼看懂。第三是可观测。执行到哪一步、处理了多少数据、是否符合预期,日志里都要有。第四是能容错。某个环节失败了,要么立即退出并给出明确报错,要么提前拦截不让它往下走,而不是带着错误状态继续执行。
这四条落实到具体语句上,就是下面几节要讲的内容。
3. 核心语法拆解与实操要点
3.1 变量、引号和参数传递:最容易埋坑的地方
变量是脚本的地基。Shell 变量的定义和赋值形式上很简单:
LOG_DIR="/var/log/myapp" backup_count=7 today=$(date +%F)但有几个细节新手很容易踩。赋值等号两边不能有空格,LOG_DIR = "/var/log"这样写脚本会把LOG_DIR当成命令去执行。字符串建议加双引号,尤其内容里可能带空格或特殊字符的时候。路径之类的变量强烈建议加,不然rm -rf $LOG_DIR/这种语句碰到变量为空的情况,直接变成rm -rf /,这不是开玩笑的事。today=$(date +%F)这种命令替换写法很常用,但要注意它捕获的是标准输出,并且尾部换行会被截掉。
位置参数也是基本功。$0是脚本名,$1到$9是前九个参数,$#是参数个数,$@是全部参数列表。我一般会在脚本开头就把它们翻译成有业务含义的变量:
HOST_NAME="${1:-localhost}" THRESHOLD="${2:-80}"这里的${1:-localhost}意思是:如果第一个参数存在就用它,否则用默认值localhost。这个写法能让脚本在“不传参也能跑,传参则更精细”的状态下工作,非常适合写那些要进 crontab 的定时任务。
3.2 条件判断:if、test和[[ ]]的正确用法
条件判断是脚本逻辑的关卡。Shell 里常见有三种写法:test命令、[...]和[[...]]。我个人强烈建议能用[[ ]]的地方就别用单括号。为什么?单括号是 Shell 内置命令,双括号是 Shell 关键字,后者支持更多操作符,而且做字符串比较的时候更安全。
举一个很典型的例子。判断字符串是否为空:
if [ -n "$str" ]; then echo "非空" fi如果变量内容是空字符串,$str展开后就是空,[ -n ]变成判断-n这个字符串本身非空,结果恒为真,这个坑非常隐蔽。写成双括号if [[ -n $str ]]就没这个问题。文件判断类的表达式也是日常高频:
-f "$file":文件是否存在且是普通文件-d "$dir":目录是否存在-s "$file":文件存在且非空-x "$file":是否有执行权限
数值比较用-gt、-lt、-eq,字符串比较用=、!=和<、>。这里有一个经常记混的点:>和<在双括号语法里才是字符串比较,在单括号语法里是重定向,一定要留意。复合条件在[[ ]]里可以直接用&&和||:
if [[ -f "$config" && -s "$config" ]]; then echo "配置文件存在且非空" fi3.3 循环语句:for 和 while 的典型场景
循环是脚本批量处理能力的核心。用得最多的是for循环,遍历文件列表、遍历参数、遍历数字范围都很方便:
for file in /var/log/nginx/*.log; do echo "处理:$file" done这里有一个新手必须注意的点:如果没有匹配到任何.log文件,/var/log/nginx/*.log这个通配符不会展开成空列表,而是把原字符串留作一个元素传给循环,导致循环体执行一次且拿到一个不存在的文件名。防呆做法是加一个存在性判断,或者用shopt -s nullglob让无匹配时展开为空。
while循环最常见的场景是逐行读取文件:
while IFS= read -r line; do echo "$line" done < access.log注意这里的IFS=和-r都不是可有可无的。IFS=表示行首行尾的空格不要被修剪掉,-r表示反斜杠不当作转义字符,这两个参数组合起来才能保证“原汁原味”地读到每一行。很多脚本处理 CSV 或者日志时数据莫名“变短”,通常就是漏了这两个细节。
for循环配合seq和{1..10}这种花括号语法,生成序列也很顺手:
for i in {1..10}; do echo "第 $i 次" done3.4 函数:脚本结构化的第一步
脚本一旦超过几十行,不用函数就会变成一团乱麻。Shell 函数定义的语法有两种,我更推荐带function关键字的写法,清晰可读:
function log_info() { echo "[$(date '+%F %T')] [INFO] $1" }函数参数和脚本参数一样用$1、$2,调用时直接传位置参数。函数体内的local是必须养成习惯的一个关键字,它能声明局部变量,避免变量污染全局。举个反例:
function process_file() { local total=0 for n in "$@"; do total=$((total + n)) done echo "$total" }如果不用local,total就成了全局变量,多次调用函数后会残留上一次的值,逻辑就错了。
另一个常见的点是函数返回值。Shell 函数return返回的是退出码,不是业务数据。如果你希望拿到函数输出的文本,要配合命令替换:
result=$(process_file 1 2 3)这里是先执行函数,然后把标准输出赋值给result。理解这两者的区别,写复杂脚本的时候能少走很多弯路。
4. 实战进阶:把脚本写得像个“正经程序”
4.1 文本处理的三大主力:grep、sed、awk
脚本处理日志时,文本过滤和提取几乎每天都在用。三个命令各有所长:grep负责按模式筛选行,sed负责按规则替换和编辑流,awk负责按字段切分和统计计算。
按关键字过滤日志,最常用的是grep -c统计行数,grep -v反向排除,grep -E启用扩展正则。只看匹配行的某几列,可以接awk:
grep "ERROR" app.log | awk '{print $1, $2, $NF}'awk默认按空白切分字段,$1是第一列,$NF是最后一列。统计某个接口的平均响应时间、慢请求数量这类需求,在awk里做累加和计数非常方便:
awk ' /\/api\/user\/info/ {sum+=$NF; count++} END {if (count>0) printf "平均耗时:%.2fms\n", sum/count} ' access.logsed最常见的用法是文本替换,典型的场景是批量修改配置文件里的地址或者端口:
sed -i 's|http://10.0.0.1|http://10.0.0.2|g' config/*.conf这里用|作为分隔符,避免和路径里的/打架,这是个很实用的细节。-i是原地修改,建议每写一条sed -i前先不加-i试运行一次,看看替换结果是否正确,再真正落盘。
4.2 让脚本“知进退”:退出码、set -e和信号处理
Shell 脚本里每一条命令执行完都有一个退出码,0表示成功,非零表示失败。如果脚本是一路执行下去的,失败后并没有停下来,而是继续跑后面的命令,很多线上事故就是这么来的。
最简单的粗暴防御是set -e,它让脚本在任何一条命令返回非零退出码时立刻退出。set -u则让脚本在使用未定义变量时报错,能拦截掉很多拼写错误的变量名。还有一个set -o pipefail专门解决管道问题,默认情况下管道的退出码只看最后一条命令,`grep 出错了但后面跟着 awk 成功了,整体就显示成功,这会掩盖问题。加了这个选项后,管道中任何一环失败都会导致整体失败。我写新脚本时的固定开头是:
#!/usr/bin/env bash set -euo pipefail如果你的脚本里有某些命令你预期它可能会失败,比如grep没搜到结果时返回值是 1,但你希望脚本继续跑,可以临时关闭这个约束:
set +e grep "pattern" file grep_status=$? set -e这里把退出码先保存下来,后面就可以根据grep_status决定分支逻辑。这种做法比一刀切地不管错误更精细。
4.3 日志与输出:脚本的可观测性都是靠这些细节堆出来的
脚本跑在定时任务里,出问题时最让人头疼的就是没有任何日志可查。我的经验是给脚本做一个简单的日志函数,统一格式:
function log_info() { echo "[$(date '+%F %T')] [INFO] $*"; } function log_warn() { echo "[$(date '+%F %T')] [WARN] $*" >&2; } function log_error() { echo "[$(date '+%F %T')] [ERROR] $*" >&2; }把错误信息输出到标准错误(>&2)很重要。这样可以区分正常输出和错误输出,在 cron 里单独重定向错误日志:*/10 * * * * /opt/scripts/check_health.sh >> /var/log/check.log 2>> /var/log/check.err。
如果脚本处理了大批量文件,我通常还会在关键节点输出一个汇总信息:
log_info "本次共处理 ${total_files} 个文件,成功 ${success_count} 个,失败 ${fail_count} 个。"有了这些语句,脚本跑没跑成功、跑到哪里、掉了多少数据,一眼就能从日志里看出来。这不是锦上添花的动作,而是“脚本进入生产环境”的前提条件。
4.4 临时文件与清理机制:别让你的脚本在服务器上留下垃圾
脚本处理数据时难免需要临时文件。用得多了就会发现,真正执行时临时文件可能因为异常中断残留下来,日积月累把磁盘搞满。规范做法是创建临时文件后立刻注册清理动作:
TMP_FILE=$(mktemp) trap 'rm -f "$TMP_FILE"' EXITtrap是 Shell 里一个不起眼但非常实用的命令,它可以捕捉脚本退出、中断、终止等信号并执行指定动作。上面这句的意思是:不管脚本是正常结束还是中途被中断,退出前都删掉临时文件。类似的,如果脚本会启动后台进程,trap也可以用来确保退出时把进程一并收掉。这是让脚本从“玩具”走向“工具”的关键语句之一。
5. 从需求到落地:一个批量日志巡检脚本的完整拆解
5.1 场景设定与需求分析
光讲语法记忆不牢,来看一个完整的例子。假设某公司有一套 Java 服务,日志按天滚动存放在/data/logs/app/目录下,每台机器一个专属目录。现在需要一个脚本每天凌晨扫描所有日志目录,完成三件事:
- 统计每个业务模块当天的 ERROR 日志条数。
- 如果某个模块的 ERROR 条数超过阈值(默认 100 条),输出告警摘要。
- 把统计结果追加到一个汇总文件里,方便后续推送告警和生成日报。
这个需求是典型的日常巡检场景,完全适合用 Shell 脚本实现。
5.2 脚本代码与逐段解读
脚本的完整代码如下:
#!/usr/bin/env bash set -euo pipefail LOG_BASE="/data/logs/app" TODAY=$(date +%F) THRESHOLD="${1:-100}" SUMMARY="/data/logs/scripts_output/summary_${TODAY}.txt" mkdir -p "$(dirname "$SUMMARY")" : > "$SUMMARY" log_info() { echo "[$(date '+%F %T')] [INFO] $*"; } log_error() { echo "[$(date '+%F %T')] [ERROR] $*" >&2; } for module_dir in "$LOG_BASE"/*/; do module_name=$(basename "$module_dir") log_file="$module_dir/app.log" if [[ ! -f "$log_file" ]]; then log_info "目录 $module_name 无 app.log,跳过" continue fi error_count=$(grep -c "ERROR" "$log_file" || true) echo "$TODAY $module_name error_count=$error_count threshold=$THRESHOLD" >> "$SUMMARY" if [[ $error_count -gt $THRESHOLD ]]; then log_error "模块 $module_name 错误数 $error_count 超过阈值 $THRESHOLD" fi done log_info "巡检统计完成,结果见 $SUMMARY"这段代码里面有几个关键点必须说明。
for module_dir in "$LOG_BASE"/*/; do末尾的/确保只匹配目录,不匹配普通文件。basename "$module_dir"去掉路径前缀,拿到纯目录名。grep -c "ERROR" "$log_file" || true这里特意加了|| true,因为当 ERROR 条数为 0 时,grep -c返回退出码 1,在set -e模式下会直接终止脚本。加上|| true后,退出码被强制改为 0,同时error_count的值仍然是0,这个技巧很重要,当年我被整得够呛。
通过grep -c "ERROR" "$log_file" || true你可以看到,set -euo pipefail并不是死板地一刀切禁止所有失败,而是让你显式地声明“哪些失败是允许的”。
5.3 阈值参数与调用方式的设计
脚本顶部的THRESHOLD="${1:-100}"允许调用者临时指定阈值,默认 100。这在某些特殊日子里,比如刚才、上线大版本后错误普遍偏多时,可以灵活调整而不用改脚本主体。调用方式如下:
# 默认阈值 100 /opt/scripts/daily_log_check.sh # 自定义阈值 300 /opt/scripts/daily_log_check.sh 300配合 crontab 每天固定时间执行:
05 02 * * * /opt/scripts/daily_log_check.sh >> /var/log/daily_check.log 2>&1日志重定向有一个细节:>>追加写,2>&1把标准错误合并到标准输出。这样脚本里的log_error信息也会进到同一个文件,排查问题时不用开两个文件对照。
5.4 这段脚本的进演变思路
这个例子如果想继续扩展,可以做的方向很多。比如把告警升级成带 webhook 的消息推送,在error_count超过阈值时调用curl发送 Json 消息;比如给每个模块也加上响应时间的历史记录,在脚本里维护一个小型状态文件;再比如把摘要文件按周滚动,结合find定期清理七天前的旧文件,防止脚本产生的文件堆积。
从写法的角度看,脚本的骨架始终保持不变:环境检查、循环处理、日志输出、退出清理。框架一旦建立,往里面加逻辑就是小步快跑的事。
6. 常用脚本语句的避坑指南与调试技巧
6.1 高频问题速查表:这些坑我自己都踩过
工作中遇到的问题总结成一张表,适合收藏起来对照排查。
| 问题现象 | 根本原因 | 解决办法 |
|---|---|---|
脚本报command not found,特别是变量名报错 | 等号赋值时两边有空格 | 去掉空格重新赋值 |
if条件永远成立或永远不成立 | 用了单括号判断空字符串 | 改用[[ -n $var ]] |
for file in *.log只处理了一次,还是通配符名 | 当前目录没有匹配文件,字符串被当作普通元素 | 开启nullglob或加文件存在预检 |
| 管道命令明明失败了,脚本却继续跑 | 管道退出码取决于最后一个命令 | 设置set -o pipefail |
脚本set -e后grep无匹配就中断 | grep无匹配时退出码为 1 | 追加 ` |
| 文件内容被读少了一部分 | while read没有设置IFS=和-r | 换成while IFS= read -r line |
| 变量值和逻辑预期不一致 | 函数内变量未用local声明,污染全局 | 函数内所有临时变量加local |
rm -rf删错了路径 | 变量为空,命令变成删根路径 | 关键位置加路径存在性检查 |
6.2 一个隐蔽的陷阱:set -e在函数和条件判断里的表现
set -e看似简单,但在某些上下文里它的行为会让老手也迷惑。比如在if条件里,被测试的命令即使失败也不会触发退出,因为失败结果正是if需要判断的条件。这是符合逻辑的,但新手经常看不懂。
再比如函数体内部,如果一个函数的返回值如果没人接住,set -e会对函数内部失败的语句有约束,但如果函数是被赋值给变量的方式调用的,那么这个“失败”会被整体屏蔽掉。遇到疑似set -e失效的情况,不要慌,逐条简化排查,多数是因为语句出现在条件上下文里。
调试阶段我常用的做法是加bash -x:
bash -x ./daily_log_check.sh它会打印执行每一条命令时的实际参数展开结果,引号包没包、变量传的是什么,一目了然。这个手段比肉眼读代码快得多,强烈建议养成“跑不通就-x”的习惯。
6.3 判断脚本有没有“写明白”:三条自查自检清单
写完脚本先不要急着部署,过一遍自查清单。第一,运行环境是否固定?脚本开头有没有#!/usr/bin/env bash,依赖的命令(比如awk、curl)是否为系统自带或已安装。第二,非默认情况是否处理了?日志目录不存在时脚本是报错还是优雅跳过,参数传递错误时有没有提示。第三,重跑是否安全?mkdir -p看的是你是否忽略了目录已存在的情况,>重定目前是不是先确认过文件可以被覆盖。
这自查动作追溯到脚本工程化的本质:脚本和写一次性命令不同,它会反复执行、被更多人看到、被改成各种变体。花五分钟做的防护,将来能帮你省下好几个失眠夜。
7. 脚本越写越顺的几个个人习惯
文章最后分享几个我自己整理脚本时的小习惯。
第一个习惯是,把可以复用的函数单独放在一个common.sh文件里,比如日志函数、发送告警的函数、解析 Json 字段的函数。业务脚本通过source /opt/scripts/lib/common.sh引入,既省得每个脚本都重复写,也方便统一修改。这个做法我在实际项目中用了很久,维护脚本的成本有明显下降。
第二个习惯是,每个脚本都会在顶部写清楚“脚本用途、依赖命令、用法示例、维护人”,注释虽然不参与执行,但它决定了半年后这个脚本是被人直接废弃还是被人顺手接管。有一次我接手一个注释写得特别清楚的老脚本,十分钟就看懂了思路,这种体验让我自己也坚持把注释写到位。
第三个习惯是加一个--help入口。不管脚本多简单,支持打印用法说明都是很有价值的:
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then echo "用法:$0 [阈值]" echo "示例:$0 300" exit 0 fi别小看这几行。脚本一旦交到别人手上,这一下子就能免去很多不必要的解释,让别人独立上手执行。
脚本语句学起来不复杂,但用得好不好,很大程度上决定了你在自动化这件事上的效率。我个人的体会是:把它当成一种长期打磨的能力,每次遇到重复劳动就想一想“能不能用一段小脚本把它沉淀下来”,慢慢地你手头的工具箱会越来越厚实,解决问题的速度也会越来越快。