日志解析与聚合:从一堆文本到可告警的指标

讲解如何用 grep/awk/sed 按模式提取日志字段、按时间窗口做滚动统计与去重、计算错误率并触发阈值告警,并给出与 sort/uniq 组合的实战套路。

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 每五分钟拉起一次、异常时以非零退出码通知监控系统,日志就从「事后翻找的证据」变成了「事中可见的信号」。而说到长期可靠的数据保全,下一个主题正好是它的另一半——备份与同步策略。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 结构化数据清洗与报表生成实战