☰
Shell练习题第五组:从基础语法到生产级脚本实战
2026/10/15 7:09:29 网站建设 项目流程

Shell练习题做到第五组,说明你已经越过了“照着教程敲两行就没耐心”的阶段,开始认真对待这套看起来不起眼、实际在运维和开发里天天都在用的语言。第五组这个位置其实挺微妙:前面练过变量、循环、判断,基础语法基本过了一遍;如果只是继续堆题目,意义不大。真正该做的,是把之前零散的知识点串起来,做成几个能直接拿到生产环境用的完整脚本。

这篇文章我会按“先明确练什么,再逐个题目拆解答疑”的方式来讲。每组题都围绕一个真实的使用场景,比如批量改文件名、分析访问日志、写服务监控、做文件去重。代码不追求炫技,但每一行都有存在的理由,连为什么这样写、换种写法会踩什么坑,我也会一并说清楚。适合正在学Shell的初学者,也适合带新人的工程师拿去做组内练习参考。

1. 先搞清楚这一组题要练什么

1.1 五个练习域的设置逻辑

第五组练习我刻意避开了那种“输出个三角形”“算个斐波那契”的纯语法题。不是说那些没用,而是到了这个阶段,你更需要的是把语法组合起来的能力:变量截取、循环遍历、awk处理、数组存储、函数封装、防重入控制,这些能力分散在单道题里看不出来,放在一个完整场景里才能看出差距。

所以我把题目分成了五个域:批量重命名、日志统计、服务监控、文件去重、调试技巧。前四个是四个可以独立上线的脚本,第五个不是一道题,而是你在前面四个题里必然会遇到的那类“脚本跑不起来”的问题合集。

这五个域选得是有讲究的。批量重命名考的是字符串处理和for循环的配合,日志统计考的是awk数组和管道排序,服务监控考的是脚本健壮性(锁、计数器、超时),文件去重考的是关联数组和find遍历。把这些组合起来,基本覆盖了日常自动化脚本八成的知识点。

1.2 你真正要带走的核心命令清单

这一组练完,我希望你闭着眼都能写出来的东西有这么几个:${var%.ext}这种变量截取、awk '{array[$1]++} END {for ...}'这种统计套路、find ... -print0 | while read -d ''这种安全遍历、flock这种防并发锁、trap这种退出清理、sort -rn这种Top N排序。

这些命令单拎出来都不难,难的是在合适的地方想起它们。比如for f in $(ls)大多数情况下能跑,但文件名里一旦出现空格就崩,这时候你要想到“glob加引号”或者“find加print0”。这种经验没法靠背命令记下来,只能在练习里一次一次摸出来。下面每道题我都会把对应坑点摆在旁边,你练的时候可以对照着看。

2. 批量重命名:变量截取和for循环的典型配合

2.1 题目要求和参考实现

题目是这样的:某个业务服务器上有个/data/logs目录,里面堆积了一批以.log结尾的日志文件,比如app.log、api.log、worker.log。现在要求把所有日志文件名统一追加当天的日期后缀,变成app.log.20250305这种格式。注意两个条件:一是脚本要能重复执行,重复跑不会给文件加第二遍后缀;二是文件名里如果包含空格,脚本也不能坏。

先看我给的参考实现:

#!/bin/bash # 批量重命名脚本:给 .log 文件追加日期后缀 TARGET_DIR="/data/logs" DATE_SUFFIX=$(date +%Y%m%d) cd "$TARGET_DIR" || { echo "目录不存在: $TARGET_DIR"; exit 1; } for f in *.log; do # *.log 没有匹配到任何文件时,bash 会保留字面量 *.log [ -e "$f" ] || { echo "没有找到 .log 文件"; break; } base="${f%.log}" new_name="${base}.log.${DATE_SUFFIX}" if [ -e "$new_name" ]; then echo "跳过,目标已存在: $new_name" continue fi mv "$f" "$new_name" echo "重命名: $f -> $new_name" done

这个脚本的思路很直白:先切进目标目录,用*.log让bash自己展开文件列表,然后对每个文件做%.log截取去尾,再拼上日期后缀,最后mv。最关键的地方是cd到目标目录后再操作,这样f就是纯粹的文件名,不需要在变量拼接上做路径处理,干净省事。

[ -e "$new_name" ]这个判断是专门为“可重复执行”准备的。脚本第一次跑完,app.log变成了app.log.20250305;第二次跑,*.log只会匹配到app.log.20250305吗?不对,app.log.20250305并不以.log结尾,它是以20250305结尾,所以正常情况下第二次跑根本匹配不到已处理的文件。但如果当天你手动建了个目标文件,或者之前一半成功一半失败,这个判断兜住了边界,不会出现mv覆盖文件的情况。

2.2 两个非常容易翻车的细节

第一个坑是新手几乎必踩的:有人会把for f in *.log改成for f in $(ls *.log)。看起来差别不大,但$(ls)的输出会被bash按空格拆词,文件名里如果有空格就裂开了。比如my app.log会被拆成my和app.log两个词。用原生glob展开*.log是bash在内部完成的,文件名里的空格不会被拆,这是两种写法的本质区别。

第二个坑是[ -e "$f" ]这个判断。bash的glob在没有匹配时不会报错,而是把模式当作字面量返回,也就是f的值变成*.log这串字符。如果不判断,脚本就会尝试mv *.log这样一条古怪的命令,轻则没有效果,重则真的创建出一个叫那名字的文件。加上这个判断后,一旦发现f不存在就直接跳出循环,脚本干干净净退出。

还有一个细节是我建议保留的:cd "$TARGET_DIR" || { ... }。cd失败是真实会发生的事情,目录被删掉、权限不对、路径拼错,都会导致cd返回非零状态。如果不接||,脚本会继续往下执行,最后的mv会在错误目录里操作,那是灾难性的。所以在每个可能失败的步骤旁边都要考虑“失败了怎么办”。

2.3 再加一点安全边界

批量重命名这种操作有个通病:不可逆。mv app.log app.log.20250305跑完,如果后悔了,虽然还能改回来,但如果日志目录是生产环境,文件名变更可能直接影响正在写入的进程,比如logrotate按文件名匹配,或者采集器按通配符读取,都会受到影响。

所以在线上执行前,我给你两个建议。第一个是先把脚本改成一个“演练模式”,加上一个环境变量DRY_RUN=1,在这个模式下只打印mv命令不实际执行。这个习惯如果你能养成,将来做任何批量操作都会安全很多。第二个是在脚本开头加一行set -u,这个参数会在变量未定义时直接报错退出,而不是悄悄用空字符串继续跑。对于批量脚本来说,空变量意味着可能把文件移动到了一个诡异的路径,早报错比晚补救强。

3. 日志IP统计:awk数组和排序组合的实用玩法

3.1 最简版实现

第二道题的背景是运维排查:某天线上访问量异常,怀疑有人刷接口,需要根据Web服务器访问日志统计每个来源IP的请求次数,找出最多的10个。访问日志的常见行格式像这样:

192.168.10.23 - - [05/Mar/2025:10:00:01 +0800] "GET /index.html HTTP/1.1" 200 1024

用awk一行就能拿到结果:

awk '{ count[$1]++ } END { for (ip in count) print count[ip], ip }' access.log | sort -rn | head -10

拆开看,count[$1]++是核心:awk的数组下标可以是任意字符串,这里直接用$1(第一列,也就是IP)做下标,每次出现就自增一次。等整个文件遍历完,count数组里存的就是每个IP的累计次数。END块在awk处理完所有行后执行,循环遍历数组打印出“次数 IP”。

如果你从没接触过awk数组,用生活化的话说就是:每个人都发一个小格子的储物柜,柜门上写着IP地址,每来一条日志就往对应格子里放一个硬币。遍历完所有日志后,数一数每个柜子有多少硬币。

3.2 为什么必须接管 sort 来做排序

很多人在这个练习里会问:awk不是能自己处理吗,为什么不在awk里排好序再输出?原因是awk的数组本质是关联数组,遍历顺序由内部哈希结构决定,不保证任何逻辑顺序。你拿到for (ip in count)的顺序可能是乱的,可能是插入顺序的反序,总之不可依赖。所以标准的做法是把排序交给sort,awk只负责统计。

sort -rn这里的两个参数意义不同:-n是按数值排序,这很重要——如果不加,sort默认按字典序排,结果是“9”排在“100”后面,因为字典序比较第一位字符;-r是反向排序。默认sort是升序,而我们看Top N要看降序,所以两者要一起写。

另外注意,awk输出的是“次数 IP”两列,sort默认对整行做字典序,但有了-n之后会取第一列作为排序键。这里有一个隐含约定:把次数放前面,IP放后面,就是为了便于sort直接处理。如果你一时兴起改成print ip, count[ip],sort就会拿IP去排序,结果完全不对。

3.3 顺着这个思路扩展成状态码和URL统计

这个统计模式换个字段就能玩出很多花样。比如要统计哪些IP产生的404最多,把条件加进去就行:

awk '$9 == 404 { bad[$1]++ } END { for (ip in bad) print bad[ip], ip }' access.log | sort -rn | head -5

$9在示例日志格式里是状态码字段,这里先用条件过滤只处理404的行,再按IP统计。这个脚本在排查“某个IP是不是在恶意扫描”时非常实用,经常能一眼看到异常。

如果要统计最热门的URL,把下标从$1换成$7(在默认Apache格式里是请求路径):

awk '{ path[$7]++ } END { for (p in path) print path[p], p }' access.log | sort -rn | head -10

还有一种非常常见的需求是读gz压缩的历史日志,这时候只要在awk前面加个zcat就行:

zcat access.log.20250305.gz | awk '{ count[$1]++ } END { for (ip in count) print count[ip], ip }' access.log | sort -rn | head -10

这类练习的价值就在这:学会了“awk统计 + sort排序 + head取Top”这个固定套路,后面做任何日志维度的统计都只是换下标而已。

4. 服务监控脚本:从能跑到不容易被打挂

4.1 需求拆解和框架

第三道题是真实运维场景的缩影:写一个脚本,每隔5分钟检查一次某个HTTP服务的健康状态,要求正常响应码200就算健康;一旦连续失败3次才产生告警动作(这里用写日志表示,实际可以接钉钉、企业微信或其他通知方式),并且要避免上一次检查还没跑完下一次又启动。

先看基础版的实现:

#!/bin/bash set -u URL="http://127.0.0.1/healthz" CHECK_LOG="/var/log/monitor/web-check.log" FAIL_COUNT_FILE="/var/run/web-check-fail.count" MAX_FAIL=3 check_once() { # 返回HTTP状态码,例如200、502等 curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 "$URL" } read_fail_count() { if [ -f "$FAIL_COUNT_FILE" ]; then cat "$FAIL_COUNT_FILE" else echo 0 fi } http_code=$(check_once) if [ "$http_code" == "200" ]; then echo 0 > "$FAIL_COUNT_FILE" echo "$(date '+%F %T') HTTP $http_code 正常" >> "$CHECK_LOG" exit 0 fi fail_count=$(read_fail_count) fail_count=$((fail_count + 1)) echo "$fail_count" > "$FAIL_COUNT_FILE" if [ "$fail_count" -ge "$MAX_FAIL" ]; then echo "$(date '+%F %T') 连续失败${fail_count}次,执行告警和重启动作" >> "$CHECK_LOG" # 实际环境可以在这里执行 systemctl restart 你的服务 logger -t web-monitor "webapp 连续失败${fail_count}次,需要处理" echo 0 > "$FAIL_COUNT_FILE" fi

这个脚本的逻辑核心是“状态记忆”:用一个计数器文件保存连续失败次数。每次检查成功就把计数器清零,每次失败就加1,达到阈值才触发处理。为什么要有这个阈值?因为服务偶发一次超时或网络抖动很正常,如果失败一次就重启服务,反而会造成循环重启。连续失败3次意味着大概率真出了问题,这时候再动手术才靠谱。

curl的参数里有几个值得注意的:-o /dev/null是把响应body丢弃,我们只关心状态码,不关心页面内容;-w "%{http_code}"是让curl在请求结束后按指定格式输出状态码;--connect-timeout 5是连接超时,--max-time 10是总超时。这两个参数非常关键,没有它们,curl可能会在服务挂死时卡很久,整个监控脚本失去意义。

4.2 健壮性加固

基础版能跑,但离“生产可用”还有距离。第一个要解决的是并发问题:cron每5分钟触发一次,但上一次检查可能因为网络卡顿还没结束,下一次又启动了。两个实例同时读写计数文件,计数器就会错乱。

解决方案是文件锁,我用的是flock:

LOCK_FILE="/var/run/web-check.lock" exec 9>"$LOCK_FILE" if ! flock -n 9; then echo "$(date '+%F %T') 已有实例在运行,本实例退出" >> "$CHECK_LOG" exit 0 fi

原理很巧妙:exec 9>"$LOCK_FILE"把文件描述符9和一个文件绑定,flock -n 9尝试对这个描述符加锁。-n表示非阻塞,拿不到锁就立即返回失败。锁的释放也简单,脚本退出时描述符自动关闭,锁自动释放。整个过程不需要你手动删除锁文件,比传统的“创建pid文件,退出时删除”要可靠得多——传统方式在脚本被kill -9杀掉时会残留pid文件,导致后续永远无法运行,而flock没有这个问题。

第二个加固点是退出清理。服务监控脚本通常要处理临时文件或者临时状态,比如我前面提到可以用mktemp创建一个临时文件放数据,如果脚本中途出错退出,这个临时文件就残留了。所以我说一下trap的习惯用法:

tmp_file=$(mktemp) trap 'rm -f "$tmp_file"' EXIT

trap ... EXIT的意思是:无论脚本是正常结束还是异常退出,只要走到退出这一步,就执行这里的清理命令。这就像你出门前固定问自己“钥匙带了吗、手机带了吗”,借用trap把“退出前清理”变成一条硬规则,不用每次凭记忆。

第三点是关于set -u和set -e的取舍。我在这类监控脚本里通常只开set -u,不轻易开set -e。原因很简单:监控脚本里很多命令的失败是“业务上可接受”的,比如curl请求失败返回非零,但你恰好就要用它来判断服务是否异常。开了set -e反而会在中途就退出,连后面的处理逻辑都走不到。所以这里我只要求“变量必须定义”,但允许命令级别的失败存在。

4.3 部署进 crontab 的环境坑

脚本写完,自然要挂到crontab里。很多人第一次部署cron任务,会发现脚本手动执行一切正常,cron跑起来就各种诡异报错,最常见的两个原因:

第一个是PATH。cron默认的PATH非常精简,通常只有/usr/bin:/bin。如果你的脚本用到了/usr/local/bin下的工具,或者你的curl实际装在/usr/local/bin/curl,cron环境下就找不到命令,报command not found。解决办法最简单的是在脚本开头显式export一个PATH,或者把所有外部命令写成绝对路径。我习惯两者都做:开头export PATH=/usr/local/bin:/usr/bin:/bin,关键的systemctl、curl这类命令也写绝对路径。

第二个是脚本权限。cron执行脚本靠的是脚本自己指定解释器,也就是第一行的#!/bin/bash。有些新手写完脚本忘了加执行权限就丢给cron,手动是bash monitor.sh能跑,cron如果配置成直接执行脚本路径就报权限错误。给它chmod +x monitor.sh,并且用绝对路径写在cron里。

cron配置行也给一个参考:

*/5 * * * * /usr/local/bin/monitor.sh >> /var/log/monitor/cron.log 2>&1

这里把标准输出和错误输出都重定向到同一个日志文件,不然cron执行中的输出会变成系统邮件发给你(多数服务器根本没配邮件服务,等于石沉大海)。

5. 防重复文件去重:md5与关联数组的综合练习

5.1 md5检测重复文件的参考实现

第四题又是一个实战味很浓的场景:某个资源上传目录/data/uploads里堆积了大量文件,怀疑有重复上传。要求找出所有内容完全相同的文件,并打印形如“文件A = 文件B”的记录。

文件内容是否相同的判断标准,最常用的是md5值。虽然md5在安全场景下已经不被推荐,但在“发现重复文件”这种低对抗场景里,它的速度和分布性足够胜任。实现如下:

#!/bin/bash TARGET_DIR="${1:-/data/uploads}" declare -A hash_map while IFS= read -r -d '' file; do md5=$(md5sum "$file" | awk '{print $1}') if [ -n "${hash_map[$md5]:-}" ]; then echo "重复: ${hash_map[$md5]} = $file ($md5)" else hash_map[$md5]="$file" fi done < <(find "$TARGET_DIR" -type f -print0)

这里面有四个技术点值得单独强调。第一个是declare -A,这行代码创建了一个关联数组,所谓关联数组就是下标可以是任意字符串的数组,这里用md5值当key,文件路径当value。注意关联数组需要bash 4.0以上版本,Linux发行版一般没问题,但macOS自带的bash是3.2版,直接跑会报declare: -A: invalid option,这又是一个典型的“换环境就翻车”案例。

第二个技术点是find ... -print0配合while IFS= read -r -d ''的读取方式。find -print0用空字符(而不是换行)分隔文件名,而read -d ''也用空字符作为分隔符,两者配合可以保证文件名里不管有空格还是有换行都不会被错误拆开。如果你的文件名只包含普通字符,用简单的while read file也没问题,但生产环境里什么都可能发生,我默认都采用这种安全写法。

第三个技术点是${hash_map[$md5]:-}的写法。在bash里,直接使用未赋值的数组元素不会报错,但结合set -u时就不一样了。${arr[key]:-}表示:如果这个元素为空或不存在,就返回空字符串作为默认值,同时不触发set -u的报错。这行代码的作用是判断当前md5值是否已经出现过。

第四个技术点是性能问题。脚本对每一个文件执行一次md5sum,相当于每个文件启动一个子进程。文件数量少时没问题,一旦目录里有上万个文件,速度会变得很慢。优化思路是先用文件大小过滤一遍——大小不同的文件内容肯定不同——只对大小相同的文件做md5比较。这个优化留给想继续深挖的读者,我不展开实现,因为那已经是另一个练习题的复杂度了。

5.2 通用文本合并去重的 sort 套路

和文件去重相关的还有一个更基础也更常见的需求:两个文本文件合并去重。比如你有两份用户名单,想合并成一个不含重复行的总名单。很多人第一反应是写循环逐行判断,费劲且慢。实际上几个命令就够:

# 合并两个文件并去重,输出到新文件 sort -u file1.txt file2.txt > merged.txt # 只输出两个文件共同拥有的行(交集) sort file1.txt file2.txt | uniq -d # 只输出两个文件中有差异的行(差集) sort file1.txt file2.txt | uniq -u

sort -u和sort | uniq本质是一样的,都是排序后去重。为什么必须先排序?因为uniq只去除相邻的重复行,如果相同行在文件里不相邻,它就无能为力。但sort也是有代价的:它会把整个文件载入内存排序,超大文件会吃掉大量内存。如果文件达到几个GB,需要考虑外部排序或者分批处理,这是一个进阶话题。

这套sort去重套路在日常工作中出勤率极高,比手写循环处理大文件快了不止一个数量级。练习的价值也在这里,刻意练一次,以后遇到同类问题就能条件反射想到这个组合。

6. 配套调试技巧与常见报错速查

6.1 五个高频报错怎么定位

前几道题练下来,你大概率会遇到一些典型的运行时报错。这里按我的经验整理成一张速查表,训练时可以直接对照排查:

报错或现象常见原因处理方式
command not foundPATH问题,或脚本里命令写错先which 命令确认路径;脚本开头显式export PATH
unary operator expected[ $var = 1 ]里var为空,表达式变成[ = 1 ]变量一律加引号[ "$var" = 1 ]
脚本能打开但显示No such file or directory脚本是从Windows编辑的,换行符是CRLFsed -i 's/\r$//' script.sh去掉回车符
declare: -A: invalid option关联数组用了但bash版本过低检查bash --version,macOS要补装新版bash
脚本手动跑正常,cron跑不正常cron环境PATH精简,且没有加载shell配置文件脚本内显式PATH,所有命令用绝对路径

command not found是最常见也是最容易误导人的。很多新手第一反应是“系统没有这个命令”,但实际上只是当前PATH没包含它所在目录。排查时先which 命令,如果输出路径,再看脚本的PATH环境;如果输出为空,再判断是没安装还是安装位置不在默认路径。

CRLF换行符的问题也值得多说一句。在Windows上用记事本或某些编辑器写的脚本,换行符是\r\n,Linux下的bash读到行尾的\r,会把它当成命令名的一部分,于是报出诡异的$'\r': command not found。我见过有人为了这个问题折腾一晚上,最后就一行sed -i 's/\r$//'解决。

unary operator expected则是“引号没加够”的典型受害者。[ -f $file ]当$file为空时,实际展开成[ -f ],语法不完整。解决方案就是一条铁律:在[ ]条件里永远给变量加双引号,除非你有绝对把握变量非空。

6.2 我习惯的调试顺序

调试这个事,其实解决的是“脚本不符合预期时怎么建立信任感”。我的经验是三步走。

第一步,跑语法检查:bash -n script.sh。这一步只检查语法不执行代码,能快速过滤掉缺了then、少了done这类低级问题。

第二步,跑追踪模式:bash -x script.sh。它会打印每条命令执行前后的展开结果。这个工具的效果非常好,比如你写了[ "$var" = "1" ],它会把var在那一刻的真实值打出来,是不是空、有没有多余空格,一眼就能看清。如果你的脚本很长,也可以只在怀疑的段落前后加上set -x和set +x,做到局部追踪。

第三步,如果有条件,装一个shellcheck。这是Shell脚本的lint工具,能检查出未加引号的变量、误用的grep -E等上百种问题。它不是万能的,但能在你自查的盲区之外再兜一层。

这三个步骤执行完,多数问题都能定位到行。真到还查不出来的时候,我的经验是回到需求本身重新读一遍代码——很多时候不是代码写错了,而是需求理解错了,比如统计的字段不是你以为的那个字段,或者文件的编码格式和你预期的不一样。这时候我通常会在关键逻辑处加一句临时echo,输出当前变量值验证自己的假设。调试就是这样,一层层排除,最终代码会告诉你真相。

我个人练Shell练习题时有个习惯,做完一道题不会立刻看下一道,而是反过来给这道题加三个问题:脚本被中途打断怎么办?同一时间跑两次会怎样?换一台没有同样环境变量的机器还能不能跑?这三个问题逼着我去处理边界情况。你会发现,很多题目本身不难,难的是把脚本从“能跑”变成“可靠”。这也是我把并发锁、trap清理、绝对路径这些内容揉进这组练习的原因。这一组如果你能独立写到这里,Shell的基本功就算真的立住了。

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

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

立即咨询