不用 Excel 读 5 千行日志
一句话总结
要从几千行日志和一百多行 CSV 中提取“多少条、百分之几、前 5 名、p95、最糟糕的 1 分钟”,不需要 pandas。只用 csv、collections.Counter、statistics、datetime 就够了,而且这样做出来的工具,不需要任何依赖,在任何服务器上都能运行。
为什么需要它
在故障会议上,有人问“5xx 集中在几点几分”,于是有人把日志复制到笔记本电脑上,粘进了 Excel。5 千行还能行,到了下个月就是 50 万行。生产服务器上没有 pandas,也没有互联网。而需要的计算只有计数、汇总、分位数和按时间段切分。标准库把这四样全都有,用它做出来的工具,部署的东西只是一个文件。
工作原理
读取。 日志的每一行用正则表达式拆开。nginx combined 格式包含形如 [10/Sep/2026:03:14:07 +0900] 的时间,形如 "GET /api/orders HTTP/1.1" 的请求,状态码,最后还附有处理时间。文件按行读取(for line in f)——因为不会把整个文件加载进内存,所以 50 万行也能用同样的代码处理。对解析失败的行,要计数但不要停下来。日志里总会有损坏的行。
CSV 用 csv 模块的 DictReader 读取。它把第一行作为列名,每行返回一个字典。对含有逗号的值和引号的处理,如果手工用 split(","),一定会出错——所以才有这个模块。值全都是字符串,所以 int()、float() 的转换要自己做。
计数。 collections 的 Counter 是对可哈希的值计数的字典。Counter(status // 100 for ...) 会得到按 2、4、5 归并的状态类别,most_common(5) 返回前 5 名。defaultdict(list) 用来按服务汇总值。
分位数。 statistics 的 quantiles(data, n=100) 返回把数据分成概率相等的 100 个区间的 99 个切分点。所以 p50 是 [49],p95 是 [94],p99 是 [98]。切分点是在最接近的两个数据之间做线性插值得到的值,默认方法是 exclusive。同样的数据,方法不同,值会略有差异,所以工具必须写明使用了哪种方法,别人才能复现。只需要一个中位数时,使用 median()。
时间。 datetime 的 strptime(s, "%d/%b/%Y:%H:%M:%S %z") 会把 nginx 的时间变成带时区的(aware)对象。%z 读取 +0900。不带时区的(naive)对象与 aware 对象无法比较,所以像 --since 这样的输入,也要像 fromisoformat("2026-09-10T03:00:00+09:00") 那样带上时区来接收。“1 分钟区间”只要把 dt.replace(second=0, microsecond=0) 作为键来计数就行。
import re, statistics
from collections import Counter
from datetime import datetime
LINE = re.compile(r'\[(?P<ts>[^\]]+)\] "(?P<method>\S+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) \d+ "[^"]*" "[^"]*" (?P<rt>[\d.]+)$')
def parse(line):
m = LINE.search(line)
if not m:
return None
return {"ts": datetime.strptime(m["ts"], "%d/%b/%Y:%H:%M:%S %z"),
"path": m["path"], "status": int(m["status"]), "rt": float(m["rt"])}
by_class = Counter(); times = []
for line in open("/opt/fixtures/pyops/access.log"):
r = parse(line)
if r:
by_class[f"{r['status'] // 100}xx"] += 1
times.append(r["rt"])
q = statistics.quantiles(times, n=100)
print(by_class["5xx"], round(q[94], 3)) # 5xx 건수, p95
导出。 结果以给人看的一行和供机器读取的 JSON(json)两种形式输出。datetime 不能直接输出为 JSON,所以要转成 isoformat() 字符串。浮点数要用 round(x, 3) 确定位数,运行两次的结果才会看起来相同。
在现场相遇的样子
最常见的错误是平均值。报告说响应时间平均 0.08 秒,而 p99 却是 4 秒的情形很常见——最慢的 1% 被淹没在平均值里了。所以需要养成给出分位数的习惯。第二个是时区。日志是 +0900,而 --since 却以 UTC 或 naive 的形式输入,结果错开了一个小时,得出“那个时段没有问题”的结论。第三个是悄悄丢弃解析失败。从格式稍有变化的那一天起,工具就报告 0 条,如果像 parsed=0 skipped=52000 那样同时输出跳过的数量,当天就能发现。
下一项实验要做什么
用标准库构建分析 /opt/fixtures/pyops/access.log(约 5 千行,03:12–03:17 有一个故障时段)和 deploys.csv 的 logstat.py 与 deploys.py。输出行数与解析成功数、按状态类别的条数、请求最多的路径、p50/p95/p99、5xx 最多的 1 分钟、按服务的部署失败率和中位数、--since/--until 过滤,以及包含全部内容的 JSON 报告。