Shell实战练习:日志分析、并发控制与脚本排坑指南

发布时间:2026/10/15 7:09:37
Shell实战练习:日志分析、并发控制与脚本排坑指南 这期Shell练习我拖了蛮久主要是想从“能跑”往“能扛”的方向多走几步。前面几期练的是语法、循环、判断这些基本功这期我特意换了个编排不单独考一个点而是把日志分析、文本清洗、并发控制、函数封装这几个真实场景凑到一起模拟在日常服务器上经常会碰到的活儿。一共五道题难度从两颗星到四颗星最后附上我实际运行中踩过的两个经典坑和排查思路。如果你能不看答案直接写完那处理线上那些日志、备份、批量任务基本就心里有底了。每道题我都会先给题目描述和适用场景再给参考实现最后拆解关键点。代码不追求最精简优先保证可读性和容错性。毕竟脚本这东西三个月后再回来看还能一眼看懂比少写两行更重要。1. 练习一日志里的高频IP统计如何做得又对又稳1.1 题目与基本思路题目描述给定一份nginx访问日志统计访问量最高的10个IP输出访问次数和占比并把结果写入top10.txt。这道题可以说是日志分析的“Hello World”但实际做起来有不少细节。日志样例大致长这样192.0.2.33 - - [10/Oct/2024:13:55:36 0800] GET /index.html HTTP/1.1 200 2326 198.51.100.17 - - [10/Oct/2024:13:56:02 0800] POST /api/login HTTP/1.1 200 1024 198.51.100.17 - - [10/Oct/2024:13:56:11 0800] GET /api/user HTTP/1.1 401 512新手最快能想到的一行命令是awk {print $1} access.log | sort | uniq -c | sort -rn | head -10这条命令思路没错先用awk取出第一列IPsort排序uniq -c计数再按次数倒序取前10。很多教程到这里就结束了但这条命令有两个隐患。第一个隐患是uniq只合并相邻行如果不先sort统计结果一定是错的。第二个隐患是它会把监控探针、内网健康检查的请求也算进去统计出来的结果参考价值会打折扣。我在练习里加了两个要求过滤掉内网IP段同时计算每个IP的访问占比。这样出来的结果才能直接放进周报里用。1.2 用awk数组一次搞定过滤、统计和占比直接给参考实现awk { ip $1 # 过滤内网和本机探针 if (ip ~ /^127\./) next if (ip ~ /^10\./) next if (ip ~ /^192\.168\./) next if (ip ~ /^::1$/) next count[ip] total } END { if (total 0) { print no valid records /dev/stderr exit 1 } for (ip in count) { printf %s %d %.2f%%\n, ip, count[ip], count[ip] * 100 / total } } access.log | sort -k2 -rn | head -10 top10.txt这里用到了awk的关联数组count[ip]以IP字符串作为下标不需要预先知道有哪些IP。每读到一行如果命中过滤规则就直接next跳过否则对应计数加一同时total加一。END块在所有行处理完后执行遍历数组输出结果。因为awk数组遍历顺序不固定所以管道后面接了sort -k2 -rn按第二列数字倒序排序。这个写法比第一版好在哪第一过滤逻辑集中在一个地方以后要加新的过滤条件比如排除某个爬虫UA直接在if里加一行就行。第二占比在awk内部就算好不用再起一个进程做除法。第三total在END里统一输出不会因为提前head而把total截断。另外我把错误信息写到了/dev/stderr这样就算脚本在crontab里跑错误也不会混进正常输出。实际使用中还有一个细节有些日志第一列不一定就是IP比如用了CDN之后第一列可能是-真实IP藏在X-Forwarded-For头里。这种时候不要改awk逻辑先用head -5 access.log确认字段位置再动手。这是我在真实环境里吃过亏后养成的习惯。2. 练习二散落各处的文件按扩展名归并统计体积2.1 题目与需求拆解题目描述给定一个项目目录里面文件散落在各个子目录要求统计每个扩展名的文件总数和总体积输出Top 10。这个需求在备份和清理磁盘时特别常见。比如某个业务目录越来越大你想知道到底是什么类型的文件占了空间是日志、临时文件还是上传的图片。用find加du也能看出大概但要按扩展名精确汇总就不够方便了。我的参考实现是这样的#!/bin/bash declare -A file_count declare -A total_size while IFS read -r -d f; do ext${f##*.} # 没有扩展名的文件归入 noext if [[ $f ! *.* ]]; then extnoext fi size$(stat -c %s $f 2/dev/null || stat -f %z $f 2/dev/null) (( file_count[$ext] )) (( total_size[$ext] size )) done (find $1 -type f -print0) for ext in ${!file_count[]}; do printf %s\t%d\t%d\n $ext ${file_count[$ext]} ${total_size[$ext]} done | sort -k3 -rn | head -10这里有两个关键点值得展开说。第一个是${f##.}这个参数展开它表示从字符串右边开始匹配尽可能多地匹配字符直到遇到第一个点号为止。举个例子文件名叫archive.tar.gz${f##.}的结果是gz。如果只想拿到tar.gz要用${f%.*}它也是从右边匹配但尽量少匹配。很多人在这个细节上翻车把压缩包gz和tar.gz分成了两类统计结果看起来就很乱。第二个关键点是循环体里stat拿文件大小。Linux和macOS的stat参数不一样Linux用-c %smacOS用-f %z所以我写了两条命令用||连接哪条成功用哪条。这种跨平台写法在脚本里很实用可以避免换一台机器就报错。2.2 为什么必须用关联数组while循环这个问题我每次带人写脚本都要强调一遍。把find和while放在一起时有三种写法坑全在写法选择上。第一种常见写法是管道find . -type f | while read f; do ... done这种写法最大的问题是while循环在管道右边会开一个子shell循环里所有的变量赋值都发生在子shell中等循环结束主shell里的file_count还是空的。我见过很多新手在这个问题上卡一下午明明循环里echo是正常的循环外面printf就是空。第二种写法是命令替换for f in $(find . -type f); do ... done这个写法更不建议因为for会按空格切分文件名只要带空格就会被拆成多个词。假如有个文件名是“年度报表 2024.xlsx”这个循环会把它当成两个文件处理stat就会报错。所以我用了第三种写法进程替换加-print0。find -print0用空字符分隔文件名read -d 也用空字符作为行分隔符两者配套可以正确处理包含空格、换行甚至特殊符号的文件名。而整个循环在主shell里执行变量赋值能带出来。关于关联数组还有个直观的类比普通数组像一排抽屉编号是0、1、2关联数组像柜子上贴了标签的收纳盒标签是什么都行。我们要按扩展名分类扩展名本身就是字符串标签所以必须用declare -A声明关联数组。稍微提醒一句bash 4.0以下版本不支持关联数组macOS自带的bash 3.2就只能换其他方案建议用brew装新版bash或者改用awk处理。3. 练习三给命令加上超时控制避免脚本卡死3.1 场景引出与基础实现写脚本最怕的一件事不是报错而是卡住。网络请求挂起、远程主机无响应、NFS目录访问不到这些情况命令可能几分钟都没动静脚本就僵在那里。crontab里跑的任务如果卡住后续所有任务都会叠加积压严重的能把服务器拖垮。所以这道题要求实现一个run_with_timeout函数给任意命令加超时时间超时就杀掉进程并返回124GNU timeout的约定退出码。run_with_timeout() { local timeout$1 shift local pid local n0 $ pid$! while kill -0 $pid 2/dev/null; do if (( n timeout )); then kill -TERM $pid 2/dev/null wait $pid 2/dev/null return 124 fi sleep 1 ((n)) done wait $pid return $? }用法示例if run_with_timeout 5 curl -s https://example.com/api/check; then echo curl finished in time else echo curl timed out or failed with code $? fi3.2 关键细节kill -0、wait和信号传递这个函数看着简单但每个细节都有讲究。kill -0 pid不是杀进程而是探测进程是否存在。如果进程存在kill -0返回0进程不存在返回非0。这样while循环就能在进程结束后自动退出。需要说明的是kill -0能探测的前提是你是这个进程的属主或有足够权限在同一个脚本里启动的子进程一般没问题。wait $pid有双重作用。进程还在跑的时候wait会阻塞等待它结束进程已经结束的时候wait会返回它的退出码。第一次wait配合kill -0能避免在进程尚未结束时就误判为超时。函数最后wait拿到的是真实退出码这样调用方可以区分命令是超时了还是正常失败。这个函数还有个简单的超时补偿逻辑因为循环是sleep 1加一次判断实际超时时间可能比设定值多1秒。对于秒级监控场景这完全够用不用上升到毫秒级。但如果要用在严苛的超时控制场景sleep 0.5或者按需调整轮询粒度即可。3.3 子进程残留问题的处理建议实际使用中发现kill -TERM $pid杀掉的只是直接启动的那个进程。如果curl内部又调起了子进程或者命令本身是个脚本、脚本里又启动了后台进程这些孙进程可能不会被kill到。它们会继续跑完造成“任务看起来结束了但后台还有残留进程”的情况。解决方案是杀整个进程组。在启动命令前先执行set -m或者用setsid让被监控的进程独立成组然后kill -- -$pid就能把整个进程组一起杀掉。不过这样处理会让函数复杂度明显上升练习阶段建议先记住问题存在等真正需要长期稳定运行的系统脚本时再升级方案。还有一个实用小技巧如果在超时前想给进程一个优雅退出的机会可以先kill -TERM睡2秒后再kill -KILL。很多命令收到TERM信号会自己清理临时文件、关闭连接而KILL信号是无论如何都无法被捕获的。4. 练习四并发下载与xargs -P并发数控制4.1 场景与基础命令有一批文件URL几十个到几百个不等。按顺序下载太慢一次性全部并发又会把带宽或者目标服务器打爆甚至触发对方的限流策略。这个场景最适合用xargs -P来控制并行度。题目描述给定url_list.txt每行一个URL要求最多同时8个下载任务下载完成后打印HTTP状态码和URL。#!/bin/bash input_fileurl_list.txt workers8 xargs -P $workers -I {} \ curl -s -o /dev/null -w %{http_code} {}\n {} \ $input_file这里的核心是xargs的三个参数配合使用。-P指定最大并发进程数-I {}让每一行输入都替换到后面的{}占位符位置curl的-w参数负责输出HTTP状态码。整个命令读入url_list.txt的每一行起最多8个curl进程并行执行哪一个先结束就补一个新任务进来。用xargs而不是手动在脚本里写和wait好处在于xargs自己会维护一个任务队列始终控制并发数不超过-P指定的值。手动写法也能实现但线程池、任务分发、退出码收集这些逻辑都要自己写代码量会多出好几倍还容易写出并发数失控的bug。4.2 xargs参数避坑与进阶用法这里有一个新手必踩的坑直接执行xargs curl url_list.txt不带-I。默认情况下xargs会把尽可能多的参数拼接在命令后面比如100个URL可能会一次性拼成一条超长命令形成类似这样的情形curl url1 url2 url3 ...。这当然不是我们想要的大多数版本curl只会处理第一个URL。加上-I {}之后每个输入行单独替换成一个命令配合-P的并发调度行为才符合预期。另外注意curl -s静默模式会隐藏进度条和错误信息调试时建议去掉-s或者改成-w %{http_code} %{size_download} {}\n把下载字节数也打出来。这样看到状态码200但size_download是0时能立刻判断是目标文件本身为空还是被重定向了。如果需要限制总下载时长可以把练习三的run_with_timeout函数和xargs组合起来给每个下载任务设置超时。两个练习拼在一起就是一套带超时、带并发控制的批量下载方案这个组合在真实执行数据迁移任务时非常有用。还有一个容易被忽略的点xargs默认在管道退出时返回值是最后一批进程的返回状态。如果一批里有几个失败xargs不会自动帮你汇总失败数量。想要严格判断是否全部成功建议把curl的退出码也写进日志里事后用grep去扫xargs -P 8 -I {} sh -c curl -s -o /dev/null -w %{http_code} {}\n $1 || echo download failed: $1 _ {}这样失败的任务会在输出中出现download failed标记状态码和失败信息一目了然。5. 练习五CSV文件清洗与字段重排5.1 题目描述与简化场景这是一道很贴近日常处理的题。题目描述拿到一个CSV文件字段顺序不对还有多余的引号部分行用分号做分隔符。要求输出为标准逗号分隔CSV提取第1、4列并把第2、3列交换位置。先看一个简化的输入样例张三,28,北京,engineer 李四,32,上海,designer 王五,26,广州,manager这里字段2是年龄字段3是城市要求输出的时候把年龄和城市对调同时去掉所有多余的引号。参考实现awk -F, BEGIN{OFS,} { # 去掉每个字段外面的引号 for (i 1; i NF; i) { gsub(//, , $i) } # 交换字段2和字段3 tmp $2 $2 $3 $3 tmp # 输出第1、2、3、4列注意顺序已经交换过 print $1, $2, $3, $4 } raw.csv clean.csv这段代码的思路先用-F,指定逗号分隔然后在处理每一行时循环去掉所有引号再交换$2、$3。gsub是awk里全局替换的函数//匹配双引号字符。交换字段的写法和其他语言里交换两个变量的方式一样引入一个临时变量tmp。5.2 为什么CSV用awk处理容易翻车说实话真实世界里的CSV远比这个复杂。字段里可能包含逗号比如“北京市,朝阳区”还可能包含换行符甚至字段本身也被引号包裹。一旦出现这些情况awk按行处理和按逗号切分的机制就会彻底失效。我处理这类问题有一条经验原则先判断数据是否干净。如果打开文件能明确看到每一行就是一个完整记录字段内没有逗号和换行用awk和sed快速处理完全没问题。但如果数据是从业务数据库导出的字段内容完全不可控我会直接建议用专门的解析工具或Python的csv模块而不是在Shell里硬刚。那么在Shell练习题里遇到这种情况怎么做我给的练习版本做了一次简化假设假设字段内不带逗号和换行。同时我在脚本开头加了一个检查用grep去探测行内是否有多余的引号导致字段数量异常awk -F, {print NF} raw.csv | sort -u正常的数据应该每个字段数一致如果输出结果出现多个数字就说明存在内嵌逗号或者引号把字段拆开的问题这个时候就不要继续用awk处理了。另外提醒一句如果输入文件同时混用了逗号和分号作为分隔符建议先做一次统一的预处理把分号统一替换成逗号再交给awk处理。sed命令可以这样写sed -i s/;/,/g raw.csv用sed -i直接改原文件之前先备份一下别把原始数据弄丢。我在练习里踩过一次这样的坑改了原文件之后发现问题想恢复已经来不及只能重新导出。6. 常见问题与排查实录6.1 典型问题的速查表这五道练习写下来我整理了运行脚本时最常碰到的几类问题列成一张速查表方便对照问题现象根本原因排查方法处理方法uniq统计结果偏大或偏小uniq只合并相邻行数据未排序先观察源文件是否乱序在uniq前加sortwhile循环内变量循环外为空白管道创建子shell变量改在子shell里在循环末尾加echo调试改用进程替换(...)stat命令报错找不到文件文件名含空格被for循环拆开用echo打印每个文件路径改用find -print0加read -d xargs一次传参过多默认把所有参数拼一条命令加echo看拼接后的命令加-I {}或-n 1awk按逗号切分字段数不一致字段内含逗号或换行打印每行NF值换用Python等专业解析方案这些问题的共同根源其实都是对Shell“单词切分”机制的理解不够深入。Shell天生会把字符串按空格拆成多个词很多应用于文件名的操作不额外处理就会出错。6.2 两个值得单列的经典坑第一个坑是管道的子shell问题这也是练习二特别拿出来讲的原因。有一次我写磁盘清理统计脚本循环里明明已经累加了各个目录的大小循环结束后echo出来却全是0。排查了很久最后发现是管道右侧的while循环跑在子进程里所有变量修改都没能带回主shell。这个坑最迷惑的地方在于循环体里面echo完全正常因为子shell内部一切都在正常工作只有出了循环才暴露问题。解决方式就是进程替换把find的输出通过(...)引入让循环在主shell进程里执行。第二个坑是crontab里环境变量和PATH不完整导致的找不到命令。脚本在终端手动执行一切正常放进crontab里就疯狂报command not found。原因是cron执行环境极其精简PATH通常只有/bin和/usr/bin而你手动登录时看到的PATH是shell初始化后加上了大量系统路径和用户路径的版本。curl、python这些命令如果装在/usr/local/bincron里直接找不到。我的习惯是在脚本开头统一设置环境#!/bin/bash export PATH/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH export LANGen_US.UTF-8脚本要定时跑就用绝对路径或者直接在开头把PATH显式拼全。这一类问题排查起来非常浪费时间因为错误信息看起来像脚本写错了其实是运行环境变了。另外提醒一句脚本开头加上set -u可以自动提示那些未初始化的变量对排查这类问题帮助很大。7. 给这期练习的自测清单五道题全部写完后我习惯用一份自测清单检查脚本质量也在这些练习中实际应用了。列出来供参考脚本开头是否有set -euo pipefail没有的话逐条命令失败能被发现吗是否使用了管道管道的最后一条命令之外的失败能被捕获吗所有变量是否显式初始化关联数组是否用declare -A声明文件名可能包含空格吗如果有是否用了-print0配合read -d 循环体内产生的变量循环体外能访问到吗临时文件是否使用mktemp生成而不是写死在固定路径超时控制、并发数控制是否在可配置位置脚本运行结果是否可用grep一键验证把这些点对应到题解里练习一检查的是管道和排序练习二检查的是子shell和关联数组练习三检查的是信号处理和退出码练习四检查的是参数传递和并发练习五检查的是对数据边界条件的敬畏。五道题正好把Shell脚本日常最核心的几个风险面覆盖了一遍。我个人的体会是Shell脚本真正的难点从来不是语法而是对它“一切皆字符串”“一切皆进程”这两条底层规律的把握。搞清楚命令替换会开子进程、管道会改变执行环境、xargs默认行为是大批量拼接参数这些理解透之后再看任何脚本都会觉得豁然开朗。做练习的目的就是把这种直觉磨出来而不只是背几个命令。最后再分享一个小习惯我写完练习里的函数都会顺手把函数注释、参数说明、返回值约定写在代码上方。这样过两个月再翻回来不用重新读代码逻辑就能知道这个函数怎么用。脚本是写给两个月后的自己看的这一条始终是我的首要原则。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询