☰
基于滑动窗口与Z-score的轻量日志异常检测系统实践
2026/10/22 14:48:23 网站建设 项目流程

DAIAN 是我给自己那套日志异常检测脚本起的名字,全称我拆成 Data Assisted Intelligent Alert Network,中文谐音叫“大安”,意思是让系统安安稳稳地跑,我不用老惦记它。故事的起点很朴素:家里一台树莓派一年到头不关机,上面挂着下载、转发、网页、数据库这些小服务,放在以前,出问题只能手动登上去翻日志,可日志不出事的时候没人看,一出事一晚上能刷几百行,靠肉眼找异常等于大海捞针。DAIAN 要解决的就是这件事:定时采集各类日志文件,先建立正常状态下的统计基线,然后用滑动窗口发现偏离,再经由 Webhook 把告警推到手机上。它不做完整的大数据存储,也不做花哨的界面,就是一身轻巧的“值守员”。

如果你也属于这几类人,我觉得这篇内容会有点用:一是手上跑着一个或几台小服务器、不想再单独扛一套重型监控平台的人;二是想把“日志异常”从经验判断升级成数据判断的开发者;三是刚开始接触时序数据统计,想找一个能动手练的项目的人。下文我会按整体设计、核心算法、关键代码、参数调试、踩坑实录这几个部分来讲,DAIAN 从一开始的几十行脚本,慢慢长成了现在的样子,整个过程都有可复现的依据。

1. 整体定位与技术选型:为什么自己写,而不是上现成平台

1.1 这个脚本到底在解决什么问题

日志监控这件事,听起来简单,做起来有讲究。最简单粗暴的做法是写几个关键词告警,比如日志里出现“ERROR”就通知我。但实际场景里,真正的故障往往不以单条 ERROR 的形式出现,它更像一种“节奏被打破”。比如某个服务平时每小时产生 200 条访问记录,突然改成每 10 分钟 3000 条请求,这说明可能在被刷;又比如平时每秒钟都有心跳日志,突然彻底安静了,这大概率是进程挂死。这两类异常,关键词告警一个都拦不住。

DAIAN 的核心价值,就是把这些“偏离正常节奏”找出来。它的工作方式不是判断单条日志健不健康,而是把日志变成数字,再看数字有没有跳出合理范围。这样一来,它不需要知道业务规则,也不需要为每一种故障写死判断逻辑,只需要统计规律就能发现大多数问题。从工程角度看,这也降低了扩展成本:新增一个服务,只要把日志路径写进配置,它自己会去学习什么是正常。

不过必须承认,它不是万能的。DAIAN 适合那些日志格式相对稳定、有明确时间字段、流量有统计特征的环境。如果你的日志一天只产生三五条、且内容完全随机,统计方法就没法建模,这时候该做关键词告警还是得做。DAIAN 的做法是在统计之上保留规则层,两种手段一起用,互补短板。

1.2 技术选型:为什么不上 ELK、Prometheus 之类的体系

说到日志异常监控,很多人第一反应是上一套 ES + Logstash + Kibana,或者 Prometheus + Alertmanager。这些工具确实强,但放到树莓派和家庭服务器这个场景里,有点“大炮打蚊子”。ES 本身就吃内存,树莓派那点资源跑个完整 ELK,基本没余力跑业务;Prometheus 体系擅长指标监控,对原始日志的文本解析并不直接,配起来要先做指标化改造,约束也不少。

云平台自带的监控服务当然也可以,我最初也考虑过,但有两个现实问题:一是日志出网的延迟和费用,二是这些服务大多要求把日志上传到远端,等于把运维凭证交了一部分出去,心里总不踏实。更关键的是,我想让 DAHAN 的学习、告警、存储全部落在本地,断网状态下也能工作。

所以最后的选型定为:Python 标准库负责文件读取和进程管理,NumPy 负责统计计算,requests 负责推送告警,SQLite 负责保存历史快照。没有引入消息队列,没有引入时序数据库。实现的工具链总共三个第三方库,安装非常简单,不管在 x86 机器还是 arm 架构的树莓派上都能稳定运行。采集方式也刻意选了简单的轮询,而不是 inotify 或 systemtap 这种内核级监听。轮询确实不那么“实时”,但胜在跨平台、无外部依赖、代码容易看懂。对告警类任务来说,延迟 2 到 5 秒完全可以接受,换来的却是部署成本的大幅下降。

这也是整个项目的一设计原则:能用一行代码解决的问题,绝不用一个框架;能用标准库搞定的事情,绝不引入重依赖。项目需要的不是功能堆叠,而是维护者能充分理解每一行代码。

2. 核心设计思路:采集、聚合、统计偏差

2.1 日志采集层的落地细节

采集层是 DAIAN 与日志文件打交道的部分,最容易出问题的也在这里。日志文件不是静态的,它会被写入、被轮转、被压缩,甚至突然清空。如果只按照普通的文件读取逻辑来处理,第一天可能没问题,第二天就能看到各种诡异现象。

我的处理思路是把每个日志任务看成一个位点:记录它在操作系统里的 inode、文件当前偏移位置、上次读取的文件大小。每一轮采集时,先重新 stat 一下文件,发现 inode 变了,说明日志轮转了,比如 logrotate 把文件改名并生成了新文件,那就关闭旧句柄,打开新文件,并直接跳到文件尾部。如果 inode 没变,但文件大小比记录的位置还小,说明有人截断或清空了文件,这时也需要把文件指针重新定位到结尾。有了这两条保护,采集过程就不会把陈旧内容当成新日志处理。

轮询间隔我默认设成 2 秒。间隔太长,突发流量的统计颗粒度会变差;间隔太短,空载时也会产生无意义的开销。实测下来,树莓派上挂 6 个日志任务,每轮读取几百行,CPU 占用率维持在百分之二以内,完全可以接受。

编码问题也需要提前处理。大多数系统的日志是 UTF-8,但总有些第三方程序输出 GBK,或者日志中间夹杂二进制乱码。如果按严格的 UTF-8 去读,可能一整行都被编码错误中断。我的做法是统一指定 errors='replace',读取时遇到无法解码的字节用占位符代替,宁可损失一行信息,也不能让整个采集线程崩掉。

采集到的日志不会直接进入检测引擎,它会先经过一个“解析口”。解析规则一般两种形式,要么按正则从文本里抽时间戳和级别,要么在配置里写明“无视时间戳,按整体内容处理”。前者的好处是能拿到真实的日志时间,不依赖系统当前时间;后者的好处是兼容毫无结构的输出。DAIAN 对两种都支持,但推荐尽量用正则解析,因为时间戳对异常归因太重要了。

2.2 滑动窗口与统计基线的算法原理

接下来是整个项目的核心:怎么判断“异常”。我采用的是“前 N 个观测值建立基线,当前值与之比较”的方法。具体来说,每一轮采集都会得到一个新产生的日志数量 delta,把最近的 60 个 delta 存成数组,时间窗口就是这个数组对应的长度。算法流程是:

  1. 计算前 59 个观测值的平均值 mu 和标准差 sigma。
  2. 取最后 1 个观测值作为当前值 current。
  3. 计算 Z-score:z = (current - mu) / (sigma + 1e-6)。
  4. 当 z 大于阈值时,认为日志量异常飙升;当 z 小于负阈值时,认为日志量异常停滞。

这里为什么用前 59 个而不是全部 60 个计算基线,是为了避免把当前值自身纳入统计。如果当前值本身就是一次暴涨,把它算进均值里,均值会被抬高,Z-score 反而变小,异常就被稀释没了。这一点很多简单实现没注意到,结果就是永远报不出告警。

再解释为什么需要 sigma 而不是只看均值。假设一个服务日志量本身就是抖动的,每秒 10 到 30 条来回跳,这时第二秒变成 35 条,单独看确实比均值高,但仍在日常抖动范围内,不该告警。sigma 刻画的就是抖动幅度,Z-score 把“数值高”转化为“相对正常波动偏移了几个身位”,这样阈值设置才有跨服务的通用性。

除了统计量之外,DAIAN 还有一个指纹聚合层。它会把日志行做归一化,比如把 IP 地址、数字端口、请求耗时统一替换为占位符,然后计算指纹。同一指纹出现的频率如果短时间成倍上升,即便总量没达到统计告警阈值,也一样会触发规则告警。这实际上是在统计异常之外补了文本聚类能力,用来捕捉“同一种错误忽然多次出现”这类典型故障模式。

对小项目而言,这套组合已经覆盖了大部分真实故障。用户要做的就是根据服务特点,设定好窗口长度和阈值,剩下的交给统计去判断。

3. 关键代码实现:检测引擎与告警推送

3.1 检测引擎的完整实现

下面给出 DAIAN 中检测引擎的部分核心代码,逻辑上拆成三个类:Tailer 负责读文件,MetricsBuffer 负责保留滑动窗口数据,Detector 负责判断异常。先看 Tailer:

import os import time from pathlib import Path class Tailer: def __init__(self, path, encoding="utf-8"): self.path = path self.encoding = encoding self.fp = None self.ino = None self.position = 0 self._open() def _open(self): self.fp = open(self.path, "r", encoding=self.encoding, errors="replace") self.fp.seek(0, os.SEEK_END) self.position = self.fp.tell() self.ino = os.stat(self.path).st_ino def read_new_lines(self): current_stat = os.stat(self.path) if current_stat.st_ino != self.ino: self.fp.close() self._open() return [] if current_stat.st_size < self.position: # 文件被截断 self.fp.close() self._open() return [] lines = self.fp.readlines() self.position = self.fp.tell() return lines

这段代码里我特意把文件被截断的场景补上了。实际运行中,很多程序会定时把日志清空,如果不做大小判断,文件指针会一直停留在旧位置,可能导致重复读取或什么都不读。处理过这个问题之后,Tailer 在常规轮转和截断场景下都很省心。

接着是统计缓冲区和检测逻辑:

import numpy as np from collections import deque class MetricsBuffer: def __init__(self, window=60): self.window = window self.counts = deque(maxlen=window) def append(self, delta): self.counts.append(delta) def is_ready(self, min_samples=20): return len(self.counts) >= min_samples class Detector: def __init__(self, z_threshold=3.5): self.z_threshold = z_threshold def check(self, buffer): if not buffer.is_ready(): return None arr = np.asarray(buffer.counts, dtype=float) previous = arr[:-1] current = arr[-1] mu = previous.mean() sigma = previous.std(ddof=1) if sigma < 1e-9: # 完全没有抖动的场景,只要不等于均值就认为异常 sigma = 1e-9 z = (current - mu) / sigma if z > self.z_threshold: return {"type": "surge", "z": z, "current": current, "mean": mu} if z < -self.z_threshold: return {"type": "silence", "z": z, "current": current, "mean": mu} return None

这里有几个细节值得说明。第一,deque 对象天然支持 maxlen,超出长度会自动丢弃旧值,比手写 list 切片方便得多。第二,ddof=1 表示计算样本标准差,当样本量小时会略微放大抖动幅度,让基线更保守。第三,日志完全没有抖动时 sigma 会等于 0,必须加上一个极小值防止除零,同时把它转成“只要偏离就告警”的模式。

因为这类任务会有单位换算问题,建议在把 delta 送入缓冲区前先做一个标准化,把每轮采集的实际行数换算成每秒行数。换算公式是 delta / 实际间隔秒数。这样即便采集线程偶尔被阻塞,波动也不会被误判。

3.2 告警通知、去抖与静默窗口

检测出异常后,不能立刻把消息发出去,否则一次持续几分钟的故障就会带来几十条重复告警。DAIAN 引入了两个机制:告警去抖和静默恢复。

告警去抖的逻辑是为每个检测规则维护一个时间戳,同一规则触发后,在设定间隔内不会再推送第二遍。间隔默认 600 秒,意思是同一类问题最多十分钟报一次,但内部状态持续更新。

静默恢复则解决另一个痛点:连续异常期间,规则一直处于触发状态,等故障恢复后,用户其实想知道的是“是否已恢复正常”。所以 DAIAN 在告警去抖之外,还挂了一个“恢复通知”逻辑:某规则之前处于异常状态,后来连续两个检测周期都回归正常,就主动发一条“已恢复”的消息。看起来很简单,但对使用体验的提升非常明显。

通知通道采用通用 Webhook,避免绑定特定平台:

import requests def push_alert(title, content, webhook_url): payload = { "title": f"[DAIAN] {title}", "content": content, "type": "text" } try: resp = requests.post(webhook_url, json=payload, timeout=5) resp.raise_for_status() except requests.RequestException as exc: # 通知失败不要影响主流程,写本地日志即可 print(f"push alert failed: {exc}")

项目经理在选型时把通知设计成通用格式,宿主环境只需要根据 webhook 平台文档做字段映射,就能接到企业微信、钉钉、自建 IM 或邮件网关。甚至可以用开源消息推送程序,把告警变成手机应用推送通知。

3.3 配置文件与部署方式

DAIAN 用一份 YAML 配置来管理所有目标,比把配置写死在代码里更合适。示例配置:

targets: - name: nginx-error path: /var/log/nginx/error.log encoding: utf-8 parse: timestamp: '^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})' format: '%Y/%m/%d %H:%M:%S' - name: app-server path: /srv/app/logs/*.log glob: true parse: timestamp: '^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]' format: '%Y-%m-%d %H:%M:%S' time_window: 60 min_samples: 20 z_threshold: 3.5 alert_interval: 600 webhook_url: "https://example.com/webhook" exclude_patterns: - "health check" - "readiness probe" - "?topic=heartbeat"

exclude_patterns是我在实际使用中加上去的字段。很多服务的健康检查会周期性刷日志,导致统计基线被大量无意义心跳抬高,真正业务异常反而被淹没。把这些固定文本模式过滤掉,基线会干净很多。

部署使用 systemd 是最稳妥的,服务随系统启动,异常退出后自动拉起。一个最小化的 service 文件如下:

[Unit] Description=DAIAN Log Watcher After=network.target [Service] ExecStart=/usr/bin/python3 /opt/daian/main.py Restart=always RestartSec=5 User=daian [Install] WantedBy=multi-user.target

注意 User=daian 那里,不要用 root 运行。日志文件如果受保护,就通过把用户加入相应组,或者调整日志目录的读取权限,而不是放开整个 root 权限来运行监控脚本。

4. 参数调试与效果优化

4.1 阈值、窗口长度怎么给

DAIAN 最常被问到的不是代码能不能跑,而是参数怎么调。参数不会有一个万能答案,但有通用的调法。我建议第一次跑时,不要马上开启告警,而是让 DAIAN 进入“学习模式”:只记录统计量,不推送告警。持续采集至少 24 小时,最好覆盖一个完整的业务周期,把这段时间的日志节奏数据导出来,观察均值、标准差、最大值,然后根据这些数字定阈值。

窗口长度和阈值的关系可以参考以下规律:

场景窗口长度阈值说明
流量平稳的内部服务30 到 603.5抖动小,阈值可以略低
有明显早晚高峰的业务120 到 1804 到 5窗口长才能建出稳定的基线
心跳类日志,平时极平稳602.5只要停跳就很反常
日志本身噪声很大60 到 1206 以上避免被正常抖动刷屏

窗口短的优点是响应快,缺点是容易把正常的瞬时波动当作异常。窗口长能容忍更大抖动,但异常发生之后,要等足够多的正常样本把异常数据挤出窗口,才能恢复基线。所以不建议把窗口设成超过 180,否则一次持续十分钟的故障会长时间影响后续的“正常”。

阈值这个数字本质上是你对误报的容忍度。设了 3.5,理论上意味着“偏离平均 3.5 个标准差”才告警,按照正态分布的经验,发生概率本来就低于千分之一。若是在消息推送成本极低的场景,可以适当降到 3;若推送通道容易让人麻痹,就调大到 4.5,宁可漏掉一些小事,也要保证报上来的每条都值得处理。

4.2 异常日志保留和误报抑制

还有一个不太起眼但影响很大的设计:DAIAN 在触发告警时,会把触发前后各 5 分钟内与触发指纹相关的原始日志摘要写入 SQLite 表。这样等用户看到告警并去查日志的时候,原始内容可能已经轮转消失了,但 DAIAN 保留了上下文,能直接定位当时发生了什么。

这个保留策略不是把全部日志存起来,而是只保留异常现场。它存储量很小,一个月最多几十 MB,却非常管用。有一次我排查一个偶发连接数暴增的问题,翻轮转后的日志已经找不到线索,最后是 DAIAN 保存的现场摘要里发现是某个爬虫的 user-agent 引起的,证据非常明确。

误报抑制方面有三件顺手的事可以做:第一,把已知的定期任务时间改成检测期外,比如数据库备份本来就在每天凌晨 2 点,日志会流量暴涨,与其调阈值,不如把备份挪个时间。第二,对于周期性特别强的任务,给日志目标单独开一个配置,用较小的窗口和较高的阈值,不让它的整体节奏影响主统计轮廓。第三,善用排除规则。大多数“误报”都来自无价值却高频的日志,把它们准确滤掉,统计模型的效果能提升一个量级。

5. 踩过的坑与问题排查实录

5.1 高频问题排查速查表

DAIAN 从雏形到现在,经历过不少奇奇怪怪的问题,我把高频的几个整理成了表格,按“症状、成因、对策”三条展开:

症状成因对策
日志正常但每轮都收到告警sigma 接近 0,微略抖动被放大提高 z_threshold,或在配置中增加最小抖动值
服务确实挂了,但没收到告警日志停更好久,历史窗口还没被挤掉,统计认为 0 仍正常增加日志停止更新的独立检测,比如最后写入时间超过 180 秒直接告警
日志文件经常轮转,漏掉一部分inode 判断逻辑缺失,旧文件句柄继续读新内容造成的错位确认实现里有 inode 和文件大小双重判断
日志太多导致 CPU 高每轮都全量读取大文件增加行数上限,一轮最多读 5000 行,超出部分留到下一轮
多行日志被拆成多条处理默认按行分割,无法处理 Java 栈轨迹在 regex 里开启多行匹配,或配置跨行拼接规则
告警推送频繁重复缺去抖逻辑开启 alert_interval 去重,配合恢复通知
中文日志乱码日志编码不是 UTF-8把 encoding 改成 GBK 或使用 errors=replace

这些内容听起来琐碎,但每一项背后都对应一次实际故障。比如“日志文件轮转漏数据”这一点,最初版本只判断了文件大小增大方向,没有判断 inode 变化,遇到按天切割的日志时就发现当天的时段数据总是丢,最后把 tail 的核心逻辑重写才解决。

5.2 部署过程中的几点执行建议

如果你要把 DAIAN 部署到小内存机器上,有几个执行层面的建议直接生效。轮询线程数不要按任务数无脑创建,一个线程轮询所有文件就够。检测计算在单线程里执行,瓶颈几乎不会出现在这里。定时任务和告警推送要解耦,推送是网络操作,延迟波动大,如果把它放在检测线程里,会导致下一轮检测被卡住。DAIAN 的做法是推送任务走一个单独的队列和后台线程,检测结束只负责放消息进队列,发送失败也不影响采集。

还有一点容易被忽略:磁盘告警。日志监控本身不会导致磁盘问题,但日志增长异常是很多系统变慢的根源。建议给 DAIAN 同时加一个简单的磁盘占用检查:当某日志目录超过预设阈值时,推一条警告,而不是等到磁盘写满才被系统强制杀掉。

部署完成后,先用故障演练验证一下整体链路。我自己的做法是向测试日志文件手动写入 1000 条“假错误”,看告警是否在预期时间到达,再清空日志,模拟服务静默,确认恢复通知是否发出。演练时间不到十分钟,但能把配置错误暴露在真正事故之前。

这套检测逻辑现在还在我家里的那台小服务器上运行,中间跨过几个月没去碰它,依然稳如老狗。如果你也打算自己写一套这样的日志守夜人,我的建议是先松后紧:第一天把阈值调高一点、通知间隔调长一点,让它在后台安静地收集样本;等统计表现足够稳定了,再慢慢压紧阈值。日志异常监控的本质,不是追求用一串公式取代经验,而是把经验数字化之后,变成可持续维护的系统能力。有了基线,有了现场,问题出现的时候,你手里就多了一张不那么慌的底牌。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询