从一次凌晨三点的误报说起
做运维第八年,我被自己的监控系统吵醒的次数,比被真实故障吵醒的次数多得多。印象最深的一次是凌晨三点,短信告警说某台核心业务服务器 CPU 使用率超过 85%,我顶着黑眼圈爬起来远程登录一看——那台机器正在跑计划任务的数据备份压缩,CPU 高是正常的,每天这个点都会高。也就是说,这条告警我其实"知道"它会出现,但静态阈值不知道。
这件事让我下决心把监控从"人告诉系统什么是异常"升级为"系统自己学会什么是正常"。我们线上有两千多个监控对象,每个对象几十个指标,靠人工逐个设阈值既不现实也不准确。最终我选了孤立森林(Isolation Forest)算法,用 Python 把这套方案落了地,稳定运行到现在。
为什么静态阈值会失灵
静态阈值的根本问题在于:它假设指标的"正常范围"是一个固定区间,但真实的业务系统不是这样。
- 周期性波动:电商类业务白天高晚上低,CPU 在早高峰冲到 70% 是常态,凌晨 20% 反而可能有问题;
- 个体差异:同样是 80% 的内存使用率,在一台 8G 的小机器上是危险信号,在 128G 的大机器上可能只是缓存占用;
- 多维关联:单看一个指标很难定性,CPU 高 + 网络流量大可能是正常扩容,CPU 高 + 磁盘 IO 飙升则大概率有问题。
孤立森林的思路恰好绕开了这些问题。它的核心直觉很简单:异常点是"少数派",行为模式和大多数点不一样,所以用随机切分的方式去隔离它们时,所需的切分次数更少、树上的路径更短。它不需要你定义"正常长什么样",只依赖数据本身,这对我们这种根本没有完整故障标注数据的运维场景非常友好。
我的整体方案
整个链路分四步:
- 取数:从 Prometheus 拉取每台机器最近 30 天的 CPU、内存、磁盘 IO、网络吞吐等指标,按分钟聚合;
- 特征工程:不做原始值检测,而是构造滑动窗口统计特征(均值、标准差、环比变化率),让算法看到"趋势"而不只是"瞬时值";
- 训练与打分:用历史正常时段数据训练 IsolationForest,线上实时计算异常分数;
- 告警收敛:只有连续 N 个周期分数超限才触发告警,并附上分数供值班同学排序。
第一步:构造带上下文的特征
下面这段代码演示如何把原始时序指标加工成训练样本。为了方便复现,我用随机数模拟了一份指标数据,实际使用时把数据加载部分换成从 Prometheus 或 MySQL 读数即可:
import pandas as pdimport numpy as npdef build_features(metrics_df: pd.DataFrame, window: int = 15) -> pd.DataFrame: ””” 将原始指标时序转换为滑动窗口统计特征 :param metrics_df: 包含 timestamp/cpu/mem/net_in/net_out 列的 DataFrame :param window: 滑动窗口大小(单位:分钟) :return: 特征 DataFrame ””” df = metrics_df.sort_values(”timestamp”).copy() feature = pd.DataFrame({”timestamp”: df[”timestamp”]})# 对每个原始指标构造窗口均值、标准差和环比变化率三类特征 for col in [”cpu”, ”mem”, ”net_in”, ”net_out”]: feature[f”{col}_mean”] = df[col].rolling(window, min_periods=window).mean() feature[f”{col}_std”] = df[col].rolling(window, min_periods=window).std()# 变化率能捕捉突增突降,比绝对值更敏感;clip 防止极端值爆炸 feature[f”{col}_pct”] = df[col].pct_change(periods=window).clip(-10, 10)# 前几行无法构成完整窗口(含 NaN),直接丢弃 return feature.dropna().reset_index(drop=True)# 模拟一份 7 天、分钟级的指标数据用于演示rng = np.random.default_rng(42)minutes = 7 * 24 * 60ts = pd.date_range(”2026-08-01”, periods=minutes, freq=”min”)base = 30 + 25 * np.sin(np.arange(minutes) / (24 * 60) * 2 * np.pi)# 日周期波动raw = pd.DataFrame({ ”timestamp”: ts, ”cpu”: base + rng.normal(0, 5, minutes), ”mem”: 55 + rng.normal(0, 3, minutes).cumsum() * 0.01, ”net_in”: rng.gamma(2, 50, minutes), ”net_out”: rng.gamma(2, 30, minutes),})features = build_features(raw)print(f”共生成 {len(features)} 条样本,{features.shape[1] - 1} 个特征”)
第二步:训练孤立森林并输出异常分数
from sklearn.ensemble import IsolationForestfrom sklearn.preprocessing import StandardScalerimport joblibfeature_cols = [c for c in features.columns if c != ”timestamp”]X = features[feature_cols].values# 树模型理论上对量纲不敏感,但统一缩放后 score 分布更稳定,便于统一定阈值X_scaled = StandardScaler().fit_transform(X)model = IsolationForest( n_estimators=200,# 树的数量,200 棵基本够用,再多收益很小 max_samples=256,# 每棵树的子采样数,论文推荐值,兼顾效果与速度 contamination=0.01,# 预估异常比例,只影响 predict 的分界线,不影响分数本身 random_state=42, n_jobs=-1,)model.fit(X_scaled)# 用分数而不是硬标签:decision_function 越小越异常features[”anomaly_score”] = model.decision_function(X_scaled)# 取分数最低的 10 个点看看最可疑的时刻suspects = features.nsmallest(10, ”anomaly_score”)[[”timestamp”, ”anomaly_score”]]print(suspects.to_string(index=False))# 序列化模型,供线上检测服务加载joblib.dump(model, ”iforest_metrics.pkl”)
效果:数字不会骗人
这套方案上线前后对比了三个月的数据:
- 原来静态阈值日均告警约 320 条,其中事后确认的误报占比约 40%;上线孤立森林后日均告警降到 45 条左右,误报率降到 6%;
- 抓到了两次静态阈值完全没报的问题:一台机器网卡劣化导致的重传率缓慢抬升(每次都在阈值下方徘徊),以及一次内存缓慢泄漏(每天涨 0.5%,两周后才暴露);
- 单台机器 30 天数据的训练耗时不到 4 秒,线上打分单次毫秒级,两千台机器轮询一轮毫无压力。
踩过的坑与经验
- 训练数据必须"干净"。第一次训练我把包含一次真实故障的时段也喂进去了,结果模型把故障模式当成了"正常",上线后对同类故障集体沉默。后来固定用故障复盘确认过的健康时段做训练集,并在每次大促后重新训练。
- contamination 不是精度参数。它只决定
predict() 输出 -1 的比例线。我一开始反复调它想提升检出率,方向就错了——正确做法是用 decision_function 的分数分布自己定阈值。 - 一定要按机器分别建模或至少按机型分组。把 8G 小机和 128G 大机的数据混在一起训练,特征分布互相打架,两边都会误报。
- 分数排序比二分类实用得多。值班同学真正需要的是"今天最可疑的 10 个点",而不是一堆非黑即白的标签。
写在最后
孤立森林不是什么新算法,但它简单、快、不需要标注数据,特别适合作为运维团队落地 AIOps 的第一站。我的建议是:先挑一类你最头疼的误报指标试点,跑通"取数—特征—打分—收敛"这条链路,再逐步铺开。另外要记住,算法解决的是"识别"问题,而告警分级、值班响应这些配套流程不跟上,再准的模型也会被淹没在噪音里。