1. 日志的三种形态与解析思路
一句话总结: 日志只有三种形态——行式、固定字段式、结构化——每种都有对应的最优解析工具,选错工具会让脚本又慢又脆。
1.1 三种形态
# 1. 行式:只有时间和一句人话(应用日志、syslog)
2026-10-01T09:15:22Z ERROR failed to connect to db: timeout
# 2. 固定字段式:字段由分隔符切开(Nginx combined)
10.0.0.1 - - [01/Oct/2026:09:15:22 +0800] "GET /api/users HTTP/1.1" 500 128 "-" "curl/8.4.0"
# 3. 结构化:JSON Lines(现代应用与容器日志)
{"ts":"2026-10-01T09:15:22Z","level":"error","svc":"api","latency_ms":3012}
| 形态 | 首选工具 | 理由 |
|---|---|---|
| 行式 | grep + awk | 按正则筛行,按位置切字段 |
| 固定字段式 | awk -F | 分隔符明确,字段下标稳定 |
| 结构化 | jq | 按键取值,不怕字段顺序变化 |
一句话总结: 判断标准很简单:能用
jq就别用awk,能用awk -F就别用正则硬抠。
1.2 解析策略
#!/usr/bin/env bash
set -euo pipefail
log=/var/log/app/app.log
# 策略一:先筛后切,把数据量压到最小
grep -F 'ERROR' "$log" | awk '{print $1, $NF}'
# 策略二:一次扫描完成多件事(避免多次读盘)
awk '
/ERROR/ { err++ }
/WARN/ { warn++ }
END { printf "error=%d warn=%d\n", err, warn }
' "$log"
多次 grep 同一个大文件是常见性能陷阱:能用一次 awk 遍历完成的统计,就不要拆成三条管道。
2. 按模式提取字段
一句话总结: 提取字段的核心是「先确定字段边界」:有分隔符就用
-F,没有就用match与substr按正则定位。
2.1 awk 的字段与正则捕获
#!/usr/bin/env bash
set -euo pipefail
# Nginx combined 格式:字段由空格与引号混合分隔
# $1=ip $4=时间 $6=方法 $7=路径 $9=状态码 $10=字节数
awk '{print $1, $9, $7}' /var/log/nginx/access.log
# 处理带引号的字段:用正则把请求行整体抠出来
awk -F'"' '{print $1, $2}' /var/log/nginx/access.log | head -3
# 用 match 精确提取(适合字段位置不固定的日志)
awk 'match($0, /latency_ms=([0-9]+)/, m) { print m[1] }' app.log
# 注意:match 的第三个参数(捕获数组)是 GNU awk 扩展
# BSD/macOS 自带的 awk 不支持,需要 brew install gawk
awk 的 -F 支持正则与多字符分隔符;当分隔符本身会出现在数据里时,改用「字段 + 偏移」的方式更稳。
一句话总结: 分隔符干净就用
-F,字段位置浮动就用match,但记得 GNU awk 扩展在 macOS 上不可用。
2.2 JSON 行日志的处理
#!/usr/bin/env bash
set -euo pipefail
# 每条日志一行 JSON:直接交给 jq
jq -r 'select(.level=="error") | "\(.ts) \(.svc) \(.msg)"' app.log
# 统计每个服务的错误数
jq -r 'select(.level=="error") | .svc' app.log \
| sort | uniq -c | sort -rn
# 提取慢请求(延迟超过 1s)
jq -r 'select(.latency_ms > 1000) | "\(.ts) \(.svc) \(.latency_ms)"' app.log
# 日志里混了非 JSON 行(例如容器启动横幅)时,跳过解析失败的行
jq -R 'fromjson? // empty | select(.level=="error") | .msg' app.log
jq -R 读原始行、fromjson? 在解析失败时返回空、// empty 丢弃这些行——这三件套能优雅处理「大部分是 JSON、夹杂少量脏行」的容器日志。
3. 时间窗口统计
一句话总结: 时间窗口统计的第一步永远是「把时间字符串归一化成一个可比较、可排序、可做减法的形式」。
3.1 时间字符串归一化
# ISO8601 时间本身就可排序,直接截断到分钟即可
awk '{print substr($1, 1, 16)}' app.log | sort | uniq -c
# Nginx 的 [01/Oct/2026:09:15:22 +0800] 用 awk 手动拼成可排序形式
awk -F'[][]' '{
split($2, t, "[/:]")
mon = int((index("JanFebMarAprMayJunJulAugSepOctNovDec", t[2]) + 2) / 3)
printf "%s-%02d-%02d %s:%s:%s\n", t[3], mon, t[1], t[4], t[5], t[6]
}' /var/log/nginx/access.log | head -3
# 更省事的做法:把归一化交给 date
date -d "01/Oct/2026:09:15:22 +0800" '+%Y-%m-%d %H:%M' # GNU date
date -j -f '%d/%b/%Y:%H:%M:%S %z' "01/Oct/2026:09:15:22 +0800" '+%Y-%m-%d %H:%M' # BSD/macOS
一句话总结: 能用 ISO8601 就直接字符串截断,需要转换时优先用
date而不是手写月份映射。
3.2 按分钟与小时的滚动桶
# 按分钟聚合请求数与错误数(一次遍历,两个桶)
awk '{
m = substr($1, 1, 16)
total[m]++
if ($9 >= 500) err[m]++
}
END { for (m in total) printf "%s total=%d err=%d\n", m, total[m], err[m] + 0 }' \
/var/log/nginx/access.log | sort
# 只看最近 10 分钟(先按时间过滤,再做聚合)
cutoff=$(date -d '10 minutes ago' '+%Y-%m-%dT%H:%M')
awk -v c="$cutoff" 'substr($1,1,16) >= c {n[substr($1,1,16)]++} END {for (k in n) print k, n[k]}' \
app.log | sort
注意 err[m] + 0:awk 里未赋值的数组元素是空字符串,显式 +0 能避免某些 awk 实现打印空值。
4. 滚动聚合与去重
一句话总结: 滚动聚合的套路是「用数组当计数器、在 END 里统一输出」,去重则优先用
awk '!seen[$0]++'或sort -u。
4.1 增量聚合的套路
# 通用模板:按 key 聚合计数、求和、最大值
awk -F'|' '{
k = $1
count[k]++
sum[k] += $2
if ($2 > max[k]) max[k] = $2
}
END {
for (k in count)
printf "%s n=%d avg=%.1f max=%.0f\n", k, count[k], sum[k]/count[k], max[k]
}' metrics.log | sort -k2 -rn
# 滚动聚合:只保留最近 5 个窗口(另存顺序索引)
awk '{
w = substr($1, 1, 16)
cnt[w]++
if (cnt[w] == 1) order[++n] = w
}
END {
for (i = (n > 5 ? n - 4 : 1); i <= n; i++) printf "%s %d\n", order[i], cnt[order[i]]
}' app.log
awk 的数组是关联数组,遍历顺序不可控,所以需要有序输出时必须另存一份顺序索引(如上面的 order[]),或在管道末尾交给 sort。
一句话总结: awk 数组遍历无序是经典坑,要么显式记录顺序,要么在管道末尾
sort。
4.2 去重的几种实现
awk '!seen[$0]++' app.log # 整行去重,保留首次出现(O(n) 内存)
awk '!seen[$3]++' app.log # 按字段去重,例如同一 trace_id 只留一条
sort -u app.log # 需要有序结果时用 sort -u
sort app.log | uniq # 大文件:外部排序,内存友好
sort app.log | uniq -c | sort -rn | head -20 # 统计重复次数
awk '!seen[$0]++' 保留首次出现、内存占用与唯一行数成正比;sort -u 需要能装下排序数据或借助磁盘临时文件。日志量超过内存时优先选 sort | uniq。
5. 错误率与阈值告警
一句话总结: 错误率是「错误数 / 总数」的比率,但告警不能只看比率——分母太小的时候,2 个错误就能算出 100%,必须同时设最小请求量门槛。
5.1 计算比率
# 单次遍历算出总数与错误数
read -r total err < <(
awk '$9 >= 500 { e++ } { t++ } END { print t+0, e+0 }' /var/log/nginx/access.log
)
[ "$total" -eq 0 ] && { echo "no requests in window"; exit 0; }
rate=$(awk -v e="$err" -v t="$total" 'BEGIN { printf "%.2f", e * 100 / t }')
echo "total=$total errors=$err rate=${rate}%"
一句话总结: 先算绝对值再算比率,并且必须显式处理
total=0,否则会得到除零或误导性的 100%。
5.2 阈值与退出码
#!/usr/bin/env bash
set -euo pipefail
WINDOW_MIN=${WINDOW_MIN:-10}
RATE_THRESHOLD=${RATE_THRESHOLD:-5} # 百分比
MIN_REQUESTS=${MIN_REQUESTS:-100} # 最小样本量
LOG=${LOG:-/var/log/nginx/access.log}
cutoff=$(date -d "$WINDOW_MIN minutes ago" '+%Y-%m-%dT%H:%M')
stats=$(awk -v c="$cutoff" '
substr($1, 1, 16) >= c {
t++
if ($9 >= 500) e++
}
END { print t+0, e+0 }
' "$LOG")
read -r total err <<<"$stats"
if [ "$total" -lt "$MIN_REQUESTS" ]; then
echo "skip: only $total requests (< $MIN_REQUESTS)"
exit 0
fi
rate=$(awk -v e="$err" -v t="$total" 'BEGIN { printf "%.2f", e*100/t }')
if awk -v r="$rate" -v th="$RATE_THRESHOLD" 'BEGIN { exit !(r > th) }'; then
echo "ALERT: error rate ${rate}% > ${RATE_THRESHOLD}% (n=$total)"
# 触发告警:webhook / 邮件 / 退出码交给监控系统
exit 2
fi
echo "OK: error rate ${rate}% (n=$total)"
用 awk 'BEGIN { exit !(cond) }' 做浮点比较是 shell 里的标准技巧:awk 的退出码直接映射成 if 的条件,避免 bc 的字符串比较。
6. awk 与 sort/uniq 的组合套路
一句话总结: 「
awk切字段 →sort排序 →uniq -c计数 →sort -rn排序输出」是日志分析里复用率最高的四段式管道。
6.1 频次统计四段式
# Top 10 客户端 IP
awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10
# Top 10 请求路径(先剥掉查询串,避免参数把同一路径拆散)
awk '{print $7}' /var/log/nginx/access.log | sed 's/?.*//' \
| sort | uniq -c | sort -rn | head -10
# Top 10 User-Agent(字段含空格,需要整体抠出)
awk -F'"' '{print $6}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10
# 用 join 做两表关联:把访问日志里的 IP 映射成主机名
awk '{print $1}' access.log | sort -u > /tmp/ips.txt
awk '{print $2, $1}' /etc/hosts | sort > /tmp/hosts.txt
join -1 1 -2 1 /tmp/ips.txt /tmp/hosts.txt
一句话总结: 四段式里的
sort不只是为了排序,更是uniq -c的前置条件——uniq只合并相邻重复行。
6.2 流式处理与管道注意点
#!/usr/bin/env bash
set -euo pipefail
# 实时统计:tail -F 配合 awk,注意 awk 默认按块缓冲
tail -F /var/log/nginx/access.log \
| awk '{print $9}' \
| stdbuf -oL uniq -c
# awk 侧主动 flush,避免输出迟迟不出现
tail -F /var/log/nginx/access.log | awk '
{ cnt[$9]++; fflush() }
END { for (c in cnt) print c, cnt[c] }
'
# 反面教材:grep 三次读同一个文件
grep -c ' 500 ' access.log
grep -c ' 502 ' access.log
grep -c ' 503 ' access.log
# 正确做法:一次遍历完成
awk '{c[$9]++} END {for (s in c) if (s >= 500) print s, c[s]}' access.log
管道缓冲是流式统计最常见的「明明有数据却不输出」原因:stdbuf -oL 或 awk 的 fflush() 都能解决,但要注意 stdbuf 只对动态链接的程序生效。
7. 实战:Nginx 访问日志分析
一句话总结: 把前面的积木拼起来,就能得到一份「Top 榜单 + 状态码分布 + 延迟分位数 + 阈值告警」的完整日报脚本。
7.1 Top 榜单与状态码分布
#!/usr/bin/env bash
set -euo pipefail
LOG=${1:-/var/log/nginx/access.log}
echo "=== 状态码分布 ==="
awk '{print $9}' "$LOG" | sort | uniq -c | sort -rn
echo "=== Top 10 客户端 ==="
awk '{print $1}' "$LOG" | sort | uniq -c | sort -rn | head -10
echo "=== 5xx 最多的路径 ==="
awk '$9 >= 500 {print $7}' "$LOG" | sed 's/?.*//' | sort | uniq -c | sort -rn | head -10
echo "=== 每分钟请求量(最近 5 分钟) ==="
awk '{print substr($4, 2, 17)}' "$LOG" | sort | uniq -c | tail -5
7.2 延迟分位数与告警
#!/usr/bin/env bash
set -euo pipefail
LOG=${1:-/var/log/nginx/access.log}
# 假设日志末尾追加了 $request_time(秒)
# 用 sort -n 排序后按位置取分位(兼容非 GNU awk,不依赖 asort)
percentile() {
awk -v p="$1" '{print $NF}' "$LOG" | sort -n | awk -v p="$p" '
{ v[NR] = $1 }
END { i = int(NR * p / 100); if (i < 1) i = 1; printf "%.3f\n", v[i] }
'
}
echo "p50=$(percentile 50) p95=$(percentile 95) p99=$(percentile 99)"
# 组合成一条告警流水线
err_rate=$(awk '$9>=500{e++} {t++} END{printf "%.2f", e*100/(t+1)}' "$LOG")
p99=$(percentile 99)
awk -v r="$err_rate" 'BEGIN{exit !(r>5)}' && echo "ALERT: 5xx rate ${err_rate}%"
awk -v p="$p99" 'BEGIN{exit !(p>2)}' && echo "ALERT: p99 latency ${p99}s"
注意分位数的下标取法:v[int(n*0.99)] 是「向上取整」的近似,样本量小时会有偏差;真正严肃的延迟统计应该交给 Prometheus histogram 或 ClickHouse 的 quantile(),脚本只做粗粒度巡检。
8. 总结
| 环节 | 要点 |
|---|---|
| 形态识别 | 行式用 grep/awk、字段式用 awk -F、结构化用 jq |
| 字段提取 | 有分隔符用 -F,位置浮动用 match,注意 GNU 扩展 |
| JSON 日志 | jq -R 'fromjson? // empty' 容忍脏行 |
| 时间归一 | 优先 ISO8601 字符串截断,否则用 date 转换 |
| 窗口统计 | awk 数组当桶,一次遍历算多个指标 |
| 去重 | awk '!seen[$0]++' 保序,大文件用 sort -u |
| 告警 | 先算绝对值再算比率,设最小样本量门槛 |
| 组合套路 | awk → sort → uniq -c → sort -rn 四段式 |
| 流式 | 缓冲问题用 stdbuf -oL 或 fflush() 解决 |
日志分析脚本的价值在于「把一次性的手工排查变成可重复、可定时、可告警的流水线」。当这些统计被 systemd timer 每五分钟拉起一次、异常时以非零退出码通知监控系统,日志就从「事后翻找的证据」变成了「事中可见的信号」。而说到长期可靠的数据保全,下一个主题正好是它的另一半——备份与同步策略。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。