出事之后第一件事是查日志,但现实往往是:日志文件几个 G,文本编辑器打开直接卡死;findstr / grep 能搜,但「错误率多高」「哪个接口错得最多」这类统计问题就不是一行命令的事了。这篇就写一个流式日志分析工具:几个 G 的文件也能稳稳跑完,内存占用恒定。
一、核心思路:逐行读,别整个读
新手最常写的错误版本:
# 反面教材:内存爆炸版
with open("access.log", encoding="utf-8") as f:
lines = f.readlines() # 5GB 的文件 = 至少 5GB 内存,机器当场卡死
正确姿势是文件对象本身就是迭代器,一次只在内存里放一行:
# 正确:内存占用恒定,文件多大都不怕
with open("access.log", encoding="utf-8") as f:
for line in f: # 每次只有一行在内存里
process(line)
这就是「流式处理」:吞吐无上限,内存有下限。所有大规模日志分析工具(包括 ELK 里的 Logstash)本质都是这个模式。
二、第二块积木:正则解析
Nginx / Apache 的访问日志长这样:
10.0.0.77 - - [28/Sep/2026:10:08:11 +0800] "GET /api/pay HTTP/1.1" 500 3210
用一条正则把它拆成结构化字段:
import re
LINE_RE = re.compile(
r'^(?P<ip>\S+) - - \[(?P<ts>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) \S+" '
r"(?P<status>\d{3}) (?P<size>\d+)"
)
m = LINE_RE.match(line)
if m:
print(m["ip"], m["url"], m["status"])
两个实战要点:
- 命名分组
(?P<name>...)必用。m[2]三天后没人知道是什么,m["status"]自带文档。 - 坏行是常态不是意外。日志总有截断行、二进制污染行、格式变更行。
match返回None就跳过,但必须计数——坏行数突然暴涨,往往意味着上游格式变了,这本身就是个告警信号。
三、第三块积木:Counter 统计
collections.Counter 是为这类活儿量身定做的:它就是个「会自己数数的字典」。
from collections import Counter
status_counter = Counter()
status_counter["500"] += 1
status_counter["500"] += 1
status_counter.most_common(3) # 自动按次数排序取 Top N
四、拼起来:完整工具
仿真日志生成 + 解析统计,完整代码(demos/log_analysis_demo.py):
import random
import re
from collections import Counter
from datetime import datetime, timedelta
from pathlib import Path
LOG_PATH = Path(__file__).parent / "fixtures" / "access.log"
IPS = ["10.0.0.12", "10.0.0.33", "172.16.4.9", "192.168.1.105", "10.0.0.77"]
URLS = ["/api/orders", "/api/users", "/static/app.js", "/health", "/api/pay"]
STATUS = [200, 200, 200, 200, 301, 404, 500]
LINE_RE = re.compile(
r'^(?P<ip>\S+) - - \[(?P<ts>[^\]]+)\] "(?P<method>\S+) (?P<url>\S+) \S+" '
r"(?P<status>\d{3}) (?P<size>\d+)"
)
def generate(n: int = 300) -> None:
"""真实运维里这一步不存在——日志文件本身就在那里。
这里用固定随机种子生成一份可复现的仿真日志,方便演示和测试。"""
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
rng = random.Random(2026)
base = datetime(2026, 9, 28, 10, 0, 0)
with LOG_PATH.open("w", encoding="utf-8") as f:
for i in range(n):
ts = base + timedelta(seconds=rng.randint(0, 3599))
ip = rng.choice(IPS)
url = rng.choice(URLS)
status = rng.choice(STATUS)
if rng.random() < 0.02: # 混入 2% 坏行,模拟脏数据
f.write("这是一个被截断的坏行...\n")
continue
f.write(
f'{ip} - - [{ts.strftime("%d/%b/%Y:%H:%M:%S +0800")}] '
f'"GET {url} HTTP/1.1" {status} {rng.randint(80, 9000)}\n'
)
def analyze(path: Path) -> None:
status_counter: Counter[int] = Counter()
ip_counter: Counter[str] = Counter()
url_counter: Counter[str] = Counter()
error_per_minute: Counter[str] = Counter()
bad_lines = 0
total = 0
for line in path.open(encoding="utf-8"): # 流式:一次一行
m = LINE_RE.match(line)
if not m: # 坏行不崩,计数继续
bad_lines += 1
continue
total += 1
status_counter[int(m["status"])] += 1
ip_counter[m["ip"]] += 1
url_counter[m["url"]] += 1
if int(m["status"]) >= 500:
minute = m["ts"].split(":")[1] + ":" + m["ts"].split(":")[2]
error_per_minute[minute] += 1
print(f"有效行 {total},坏行 {bad_lines}")
print("\n状态码分布 Top 5:")
for code, n in status_counter.most_common(5):
print(f" {code}: {n} 次")
print("\n请求量 Top 3 IP:")
for ip, n in ip_counter.most_common(3):
print(f" {ip}: {n} 次")
print("\n请求量 Top 3 URL:")
for url, n in url_counter.most_common(3):
print(f" {url}: {n} 次")
err_total = sum(error_per_minute.values())
print(f"\n5xx 错误共 {err_total} 次(错误率 {err_total / total:.1%})")
print("5xx 最多的分钟 Top 3:")
for minute, n in error_per_minute.most_common(3):
print(f" {minute}: {n} 次")
if __name__ == "__main__":
generate()
print(f"已生成仿真日志: {LOG_PATH}({LOG_PATH.stat().st_size} 字节)\n")
analyze(LOG_PATH)
五、真实运行输出
已生成仿真日志: demos\fixtures\access.log(23738 字节)
有效行 295,坏行 5
状态码分布 Top 5:
200: 161 次
301: 55 次
404: 40 次
500: 39 次
请求量 Top 3 IP:
10.0.0.77: 68 次
192.168.1.105: 65 次
172.16.4.9: 58 次
请求量 Top 3 URL:
/health: 65 次
/api/pay: 59 次
/api/orders: 58 次
5xx 错误共 39 次(错误率 13.2%)
5xx 最多的分钟 Top 3:
10:08: 3 次
10:27: 2 次
10:17: 2 次
一份一分钟的日志摘要就这样出来了:300 行里 5 条脏数据被识别、错误率 13.2%(明显异常)、/api/pay 出现在请求量前三——如果是真事故,下一个动作就是调出 10:08 前后的完整日志看上下文。
六、踩坑记录:正则捕获组是字符串
这个 bug 是本篇写作过程中真实踩到的,第一次运行直接崩:
Traceback (most recent call last):
File "demos\log_analysis_demo.py", line 62, in analyze
if m["status"] >= 500:
TypeError: '>=' not supported between instances of 'str' and 'int'
根因一句话:正则捕获组拿到的永远是字符串,m["status"] 是 "500" 不是 500,拿字符串和整数比较直接 TypeError。平时靠 Counter 的键随便存什么躲过去了,一碰到数值比较就现形。
修复只需显式转换:
status_counter[int(m["status"])] += 1
if int(m["status"]) >= 500:
运维脚本里这类「边界类型陷阱」很多:时间戳是字符串、psutil 的部分指标是浮点、命令行输出永远带换行符。写完解析逻辑,先用几行真实日志喂一遍,比上线后崩强得多。
七、往上长:这个工具的三条进化路线
- 接真实日志:把
LINE_RE换成你系统的日志格式即可;多格式场景可以用「逐个正则尝试」或按行首特征路由。 - 滚动窗口告警:现在按分钟统计,加上「最近 N 分钟错误率超 X% 就通知」的滑动窗口,它就升级成了日志维度的监控(和 01 篇的资源监控互补——资源监控管机器,日志监控管业务)。
- 大文件实测:流式处理的优势要在 GB 级文件上才显现,读者可以拿自己机器上的真实大日志试试——内存占用应该始终保持在几十 MB 以内。
下一篇我们处理「日志越滚越大、磁盘越占越满」的另一半问题:磁盘治理。
本篇代码:demos/log_analysis_demo.py,已在 Python 3.13.15 / Windows 真实运行,输出原样收录。