简介:面向数据分析初学者、运维人员及课程设计学习者的一套应用系统负载分析与磁盘容量预测Python实践材料,围绕数据挖掘建模流程,演示如何从历史负载数据中提炼模式并用于容量趋势判断。压缩包共6个文件,以4个xls数据表为主体,分别承载原始记录、预处理结果与待预测样本,另含1个py源码文件和1个txt说明文件,整体约20KB,体量轻便,适合本地快速运行与对照阅读。已有564人学习,说明该主题在负载分析与容量评估练习中具有一定参考需求。读者可据此了解数据清洗、特征整理、模型参数试探与预测输出的基本组织方式,并借助现成脚本和样例数据复现分析过程,为运维容量规划、数据挖掘课程作业或算法入门实验提供可操作的参考。
1. 磁盘在凌晨三点写满,监控大屏却还是绿的
凌晨三点,某公司一套业务系统的日志分区写满,写入失败连锁触发接口超时。前一晚监控大屏是绿的——告警规则写的是「使用率 > 85%」,而真正要命的是增长速度:过去七天它每天涨 1.2%,从 70% 走到 100% 只要二十五天,没人算过这笔账。这份应用系统负载分析与磁盘容量预测的 Python 源码,干的就是把这笔账提前算出来:定时采集 CPU、内存、磁盘与 I/O 指标,清洗成规整时间序列,用统计模型和神经网络两条路子外推,最后给出「还有多少天见底」和一条带置信区间的趋势曲线。它适合手上管着几十台到上百台机器、又不想直接买商业容量平台的运维开发,也适合后端同学想给自家服务补一块容量趋势能力。
2. 项目结构与技术栈:拆开压缩包先看模块边界
拿到一个 .rar 压缩包,别急着pip install -r requirements.txt。先花十分钟把目录扫一遍,判断它是一份「能跑的单体脚本」还是「有分层设计的工程」,决定了你后面是照着改还是照着抄。
2.1 目录结构:六个包各自的职责边界
这套源码的分层是比较典型的采集—清洗—建模—输出四段式,外面套一层配置和一层存储。目录大概长这样:
load-capacity-predict/ ├── config/ │ ├── config.yaml # 数据源、采样间隔、模型选择的统一入口 │ └── metrics_schema.json # 指标名到中文名、单位、方向的映射 ├── collector/ │ ├── base_collector.py # 采集器抽象基类,定义 collect_once 契约 │ ├── host_collector.py # 本机 /proc、shutil.disk_usage 采样 │ └── db_collector.py # 从时序库按 SQL 拉历史数据 ├── preprocess/ │ ├── cleaner.py # 缺失值、重复点、毛刺处理 │ ├── resampler.py # 对齐到统一频率 │ └── feature.py # 滞后项、滑动窗口、周期性编码 ├── models/ │ ├── base_model.py # fit / predict 两个方法的统一接口 │ ├── moving_average.py # 基线模型,用来兜底和做对比 │ ├── linear_fit.py # 最小二乘趋势外推 │ ├── arima_model.py # SARIMA 季节性模型 │ └── lstm_model.py # 小样本 LSTM 分支 ├── predictor/ │ ├── engine.py # 模型注册、调度、结果汇总 │ └── capacity.py # 预测值翻译成「剩余天数」和扩容建议 ├── reporter/ │ ├── chart.py # 趋势图与置信带绘制 │ └── alert.py # 触线判定与消息拼装 ├── storage/ │ ├── sqlite_dao.py # 轻量落库,默认 SQLite 免部署 │ └── schema.sql ├── main.py # 命令行入口,支持 collect / train / predict 三个子命令 └── requirements.txt这个划分最关键的一点是models/base_model.py里定下的接口。所有模型都必须实现fit(series)和predict(horizon),返回的是同样形状的数组,因此上层的predictor/engine.py可以完全不关心底下用的是滑动平均还是 LSTM。你后面想加 Prophet 或者自己写的灰色预测模型,只要继承基类注册进去就行,不用动主流程——这是这套代码里最值得学的一处设计。
collector和storage之间的解耦也做得不错:采集器只负责吐字典,落库逻辑在 DAO 里,两边靠字段名约定连接。缺点是字段名硬编码在metrics_schema.json里,加一个新指标要同时改采集器、schema 和模型侧的特征列表三处,这是后面二次开发时最容易漏的地方。
2.2 技术选型:为什么是 pandas + statsmodels + scikit-learn 这套组合
从依赖清单看,这套源码的技术栈是标准的「数据分析三件套 + 一个深度学习可选分支」:
| 依赖 | 用途 | 备注 |
|---|---|---|
| pandas / numpy | 时间序列重采样、缺失值处理、特征构造 | 全项目的地基,几乎所有模块都依赖 |
| statsmodels | SARIMA 建模与置信区间输出 | 比 sklearn 更擅长带季节性的单变量预测 |
| scikit-learn | 线性回归、标准化、误差指标 | 拿来算 MAE / RMSE 做模型对比 |
| matplotlib | 趋势图和置信带绘制 | 默认后端要设成 Agg,否则无头服务器上会报错 |
| torch / tensorflow | LSTM 分支 | 可选装,没装时lstm_model.py会跳过注册 |
| PyYAML | 配置文件解析 | 唯一一个非数据类的必需依赖 |
这个组合的取舍很清楚:容量预测在绝大多数场景下是「单变量、样本量小、强季节性」的问题。一台机器的磁盘使用率,一天 1440 个点、一个月四万多个点,看着不少,但对深度学习来说完全不够。所以主线走 SARIMA,把季节周期设成 7(按天粒度的周周期)或 24(按小时粒度的日周期),既能吃掉业务高峰带来的规律波动,又能给出预测区间。LSTM 那条分支是给数据量足够、又有明显非线性拐点的场景准备的,默认不启用。
我一般会提醒一句:不要看到「预测」两个字就上神经网络。磁盘容量的核心诉求是「什么时候触线」,一个带置信区间的线性外推往往比一个黑匣子模型更有说服力——至少在评审会上你能解释清楚为什么是 23 天而不是 24 天。
2.3 配置与入口:一份 config.yaml 决定跑哪种模式
整个项目的运行行为几乎都由config/config.yaml控制,main.py只是一个薄薄的命令行壳:
# config/config.yaml datasource: type: host # host=本机采集, db=从时序库拉 dsn: "sqlite:///data/metrics.db" lookback_days: 30 # 建模回溯窗口,太短拟合不稳,太长会混入旧基线 sampling: interval_seconds: 60 resample_freq: "5min" # 原始 1 分钟,建模前统一降到 5 分钟 collect: mount_points: ["/", "/data"] model: active: sarima # moving_average | linear | sarima | lstm seasonal_period: 7 # 按天粒度就是 7 forecast_horizon: 60 # 向前预测多少步 threshold: disk_usage: 0.85 # 触线水位 safety_margin: 0.10 # 预留空间,触线判定时从容量里扣掉 alert: channels: ["console", "webhook"]几个参数值得单独说。lookback_days决定训练窗口,设太小(比如 7 天)会把一次正常的日志轮转误判成持续增长;设太大(比如 180 天)会把半年前那次扩容前的低水位混进来,拟合出的斜率偏小,预测偏乐观。我一般取 30~45 天,正好覆盖一个完整的业务周期加一次月度批处理。
resample_freq和interval_seconds必须成整数倍关系,1 分钟降 5 分钟没问题,1 分钟降 7 分钟就会在重采样时产生大量 NaN,最后被dropna()吃掉大半数据,模型训练时样本量莫名其妙少一半。这个坑在第 5 章还会再提一次。
命令行入口支持三个子命令,各管一段:
# 采集一轮,写进 SQLite python main.py collect --config config/config.yaml # 用回溯窗口内的数据训练并落模型 python main.py train --config config/config.yaml --model sarima # 输出预测结果与触线天数,可选推送告警 python main.py predict --config config/config.yaml --horizon 60 --alertcollect通常挂 cron 或 APScheduler 每分钟跑一次;train每天凌晨跑一次就够,容量曲线不会在一小时内变天;predict跟在train后面,或者按需手动触发。把这三件事拆开而不是塞进一个脚本,好处是训练失败不影响采集,采集断了也能用历史数据先出个预测结果顶着。
3. 数据采集与指标口径:把原始数据清洗成可建模的序列
模型再花哨,喂进去的是脏数据,输出就是精致的错误。这一章集中在数据进入模型之前的三个环节:怎么采、怎么洗、怎么构造特征。
3.1 采集层:从 /proc 和数据库采样到统一时间序列
本机采集这部分代码不复杂,但有细节。以host_collector.py为例:
# collector/host_collector.py import time import shutil from collector.base_collector import BaseCollector class HostCollector(BaseCollector): """本机指标采集:CPU、内存、各挂载点磁盘使用率""" def __init__(self, mount_points, interval=60): super().__init__(interval) self.mount_points = mount_points self._last_cpu = None # 保存上一次的累计值,用于做差 def _read_cpu_raw(self): # /proc/stat 第一行是 cpu 汇总,字段依次为 # user nice system idle iowait irq softirq steal ... with open("/proc/stat") as f: fields = [float(x) for x in f.readline().split()[1:]] idle = fields[3] + fields[4] # idle + iowait total = sum(fields) return idle, total def collect_once(self): row = {"ts": int(time.time())} idle, total = self._read_cpu_raw() prev = self._last_cpu or (idle, total) d_total = total - prev[1] # 分母为 0 说明两次采样间隔太短,直接给 0 而不是除零崩掉 row["cpu_usage"] = 0.0 if d_total <= 0 else 1.0 - (idle - prev[0]) / d_total self._last_cpu = (idle, total) mem = {} with open("/proc/meminfo") as f: for line in f: key = line.split(":")[0] mem[key] = float(line.split()[1]) # 用 MemAvailable 而不是 MemFree,前者才算得上「真正可用」 row["mem_usage"] = 1.0 - mem["MemAvailable"] / mem["MemTotal"] for mp in self.mount_points: usage = shutil.disk_usage(mp) key = f"disk_used_{mp.strip('/') or 'root'}" row[key] = usage.used / usage.total return row这段代码里有两个容易翻车的点。第一,/proc/stat给的是开机以来的累计值,不是瞬时使用率,必须两次采样做差;如果你直接拿1 - idle/total去算,得到的是「开机至今平均使用率」,曲线会平得像一条直线,模型拟合出来的斜率接近 0,预测永远说「不会满」。第二,内存要用MemAvailable而不是MemFree,Linux 会把空闲内存大量拿去做 page cache,MemFree常年只剩几百兆,看着像要 OOM,其实随时可回收。
db_collector.py走的是另一条路,从已有的时序库按 SQL 拉数据。它和本机采集共用同一个输出契约——一个以ts为时间戳、其余字段为指标的扁平字典列表。这个契约是整套代码能同时支持「新装一台机器从零采」和「接进已有监控体系」的关键。常见做法是在 SQL 里就把时间范围裁好,别拉全量再在 pandas 里过滤,几十万行数据在内存里过一遍很浪费。
3.2 清洗与重采样:缺失值、毛刺、时区三个坑
原始数据进来之后第一件事是清洗,preprocess/cleaner.py里的主函数大概是这样:
# preprocess/cleaner.py import pandas as pd def clean_series(df, metric, freq="5min", z_threshold=4.0): """把原始打点清洗成等间隔、无缺失、无明显毛刺的序列""" s = df.set_index("ts")[metric].sort_index() # 1) 时间戳统一成带时区的 DatetimeIndex,避免夏令时/跨时区错位 s.index = pd.to_datetime(s.index, unit="s", utc=True).tz_convert("Asia/Shanghai") # 2) 同一时刻重复打点,保留最后一条 s = s[~s.index.duplicated(keep="last")] # 3) 重采样到统一频率,取均值而不是取首值,减少抖动 s = s.resample(freq).mean() # 4) 毛刺识别:用滚动 MAD 而不是 3σ,磁盘这种阶梯数据不服从正态 med = s.rolling(12, min_periods=4, center=True).median() mad = (s - med).abs().rolling(12, min_periods=4, center=True).median() mask = (s - med).abs() > z_threshold * 1.4826 * mad.fillna(0) # 5) 把毛刺点挖成 NaN,再插值;连续超过 6 个点说明是真故障,不插 s = s.mask(mask).interpolate(limit=6).ffill() return s.dropna()逐段说。第 1 步统一时区这件事看着啰嗦,但不做的话后面画图和告警对不上时间——采集机和数据库服务器不在一个时区时,预测出的「三天后触线」会整整错开几个小时,凌晨的低谷被当成白天的高峰。第 3 步重采样取均值,是因为原始打点存在秒级抖动,磁盘使用率在两次采样之间可能因为写日志跳 0.3%,直接喂给模型会把这些抖动当趋势。
第 4 步是整个清洗里最关键的。传统做法用 3σ 剔除离群点,前提是数据近似正态分布;磁盘使用率是典型的阶梯函数,长期在一个窄区间内小幅上升,偶尔因为日志清理断崖式下跌,标准差极小,3σ 会把正常的台阶全判成异常。改成滚动中位数绝对偏差(MAD),对离群点更稳:中位数本身不被极端值拉动,MAD 衡量的是「偏离中位数多远算异常」,1.4826这个常数是把 MAD 折算成近似标准差的换算系数。
z_threshold=4.0是宽松档,意味着偏离中位数 4 个折算标准差才判为毛刺。调小到 2.5 会削掉太多真实台阶,调大到 6 基本不生效。interpolate(limit=6)里的 6 对应「半小时」,按 5 分钟频率算;超过半小时的连续缺失一般是采集器挂了,插值就是自欺欺人,宁可让dropna()把这段丢掉。
3.3 特征构造:滞后项、滑动窗口与周期性编码
preprocess/feature.py负责把一维序列扩成模型能吃的特征矩阵。线性模型和 LSTM 都需要这一步,SARIMA 用不到(它内部自己处理滞后结构),但项目里保留了统一接口,方便切换模型时不改上游。
# preprocess/feature.py import numpy as np import pandas as pd def build_features(series, lags=(1, 6, 24), windows=(12, 72)): """构造滞后项、滑动统计量和周期编码""" df = pd.DataFrame({"y": series}) # 滞后项:让线性模型能捕捉短期惯性 for lag in lags: df[f"lag_{lag}"] = df["y"].shift(lag) # 滑动统计:均值看趋势,标准差看波动是否变大 for w in windows: df[f"roll_mean_{w}"] = df["y"].rolling(w).mean() df[f"roll_std_{w}"] = df["y"].rolling(w).std() # 周期编码:一天 288 个 5 分钟点,用正余弦避免 0 和 287 被当成距离最远 idx = np.arange(len(df)) df["sin_day"] = np.sin(2 * np.pi * idx / 288) df["cos_day"] = np.cos(2 * np.pi * idx / 288) return df.dropna()参数上的讲究:lags里放 1、6、24,对应 5 分钟、半小时、两小时。放太长的滞后项(比如 288)对趋势预测意义不大,反而稀释了近期信息。windows=(12, 72)是 1 小时和 6 小时的滚动窗口,够捕捉「业务开始放量」这个量级的变化。
周期编码那两行容易被人忽略。如果直接用「当前是今天第几个点」当特征,模型会认为第 0 点和第 287 点差异最大,实际上它俩是首尾相邻的。换成正余弦之后,周期性被映射到单位圆上,首尾自然衔接。这是处理时间特征的标准做法,很多教程里不写,但一旦你的数据有明显日周期,加不加这两列,验证集上的误差能差出 15% 以上。
最后一行dropna()会把前 72 个点(最长窗口长度)全丢掉。样本量本来就有限的时候,这是笔不小的损失。我一般会在数据预处理阶段就多回溯几天,宁可多采一点也不要让特征构造吃掉两天的数据。
4. 预测模型链路:从滑动平均到 SARIMA 的容量外推
模型层是这套源码的核心,也是二次开发时改动最频繁的部分。它没走「一个大模型打天下」的路子,而是给了一条从简单到复杂的阶梯,你可以按数据质量挑一档。
4.1 基线模型:滑动平均和线性回归先跑通
models/moving_average.py和linear_fit.py加起来不到八十行,但作用不小——它们是判断「复杂模型到底有没有必要」的参照物。
# models/linear_fit.py import numpy as np from models.base_model import BaseModel class LinearFitModel(BaseModel): """最小二乘趋势外推,输出斜率和触线时间""" name = "linear" def fit(self, series): self.y = series.values.astype(float) self.x = np.arange(len(self.y), dtype=float) # polyfit 一次拟合:y = k * x + b,k 就是每个采样点的增量 self.k, self.b = np.polyfit(self.x, self.y, deg=1) return self def predict(self, horizon): x_future = np.arange(len(self.y), len(self.y) + horizon, dtype=float) return np.clip(self.k * x_future + self.b, 0, 1) def days_to_threshold(self, threshold, points_per_day): if self.k <= 0: return None # 不涨反降,永远不会触线 current = self.y[-1] remain_points = (threshold - current) / self.k return max(remain_points / points_per_day, 0)关键在于k <= 0这个分支。很多容量预测脚本在这里翻车:磁盘已经被清理过一轮,整体趋势是下降的,线性拟合出的斜率为负,公式硬算会得到一个负的「触线天数」,图表上表现为「已触线」,实际水位才 60%。必须显式返回None,上层才能把它渲染成「当前无增长趋势」。
days_to_threshold里的points_per_day是每天有多少个采样点,按 5 分钟频率就是 288。这个参数不写死而是传进来,是因为不同指标的采样频率可能不同,硬编码会导致换频率之后天数全错。
滑动平均那条更简单,取最近 N 个点的均值做预测。它不适合做长期外推——均值的斜率天然趋近于 0,预测曲线会很快变平——但它是个极好的异常检测基线:当 SARIMA 的预测值和滑动平均差出好几个百分点时,多半是数据里混进了异常段。
4.2 SARIMA:季节性容量曲线的参数怎么定
主模型走 SARIMA,代码不长,但参数和返回值处理有讲究:
# models/arima_model.py import numpy as np from statsmodels.tsa.statespace.sarimax import SARIMAX from models.base_model import BaseModel class SarimaModel(BaseModel): name = "sarima" def __init__(self, order=(1, 1, 1), seasonal=(1, 1, 0, 7)): self.order = order self.seasonal = seasonal def fit(self, series): model = SARIMAX( series, order=self.order, seasonal_order=self.seasonal, enforce_stationarity=False, # 别强行约束,否则容易不收敛 enforce_invertibility=False, ) self.res = model.fit(disp=False) return self def predict_with_ci(self, horizon, alpha=0.05): fc = self.res.get_forecast(steps=horizon) mean = np.clip(fc.predicted_mean.values, 0, 1) ci = fc.conf_int(alpha=alpha) # 95% 置信区间 lower = np.clip(ci[:, 0], 0, 1) upper = np.clip(ci[:, 1], 0, 1) return mean, lower, upper def days_to_threshold(self, threshold, points_per_day, horizon): mean, lower, upper = self.predict_with_ci(horizon) hits = np.where(mean >= threshold)[0] if len(hits) == 0: return {"days": None, "confidence": "low"} # 乐观情况用下界,悲观用上界,给扩容留出决策窗口 optimistic = np.where(lower >= threshold)[0] pessimistic = np.where(upper >= threshold)[0] return { "days": int(hits[0] / points_per_day), "earliest": int(optimistic[0] / points_per_day) if len(optimistic) else None, "latest": int(pessimistic[0] / points_per_day) if len(pessimistic) else None, }参数解释。order=(1,1,1)里的三个数分别是 AR 阶、差分阶、MA 阶;差分阶取 1 是为了让非平稳的上升序列变平稳,这一项几乎是必填的,取 0 的话模型会去拟合绝对水位而不是增量。seasonal=(1,1,0,7)里的 7 是季节周期,按天粒度就是一周七天——工作日和周末的写入量差得很明显,不建模这个周期,预测曲线会太平。
enforce_stationarity=False这两行是血泪经验。默认开启时,statsmodels 会检查并拒绝非平稳参数,遇到那种「一直单调上升」的磁盘序列,很容易抛LinAlgError或者警告刷屏。关掉之后不收敛的概率大幅下降,代价是参数估计的理论保证弱一些,但对容量预测这种「大致方向对就行」的场景完全够用。
days_to_threshold返回三个数而不是一个,是这套代码里最实用的一个设计。只给「23 天」这样一个点估计,评审会上没人敢拍板;给出「最乐观 18 天、最可能 23 天、最悲观 31 天」这个区间,扩容决策就有了腾挪空间——按 18 天准备资源,用 23 天做正式排期。np.clip把结果夹在 0 到 1 之间,是因为预测到后半段经常冲破 100%,画在图上很难看。
4.3 LSTM 分支:什么时候值得上,什么时候是过度设计
lstm_model.py提供一个两层的 LSTM,输入是滑动窗口(默认 72 个点,即 6 小时),输出是下一步的值。它做的是单步预测,多步靠滚动递推。
什么时候该启用它?我的判断标准是三条同时满足:历史数据超过 90 天、日均采样点超过 288、且序列里有 SARIMA 拟合不出来的非线性拐点(比如某次业务上线后增长斜率明显变了)。三条缺一条,都不建议开。
原因很直接。LSTM 参数量比 SARIMA 大两三个数量级,而一台机器的容量序列往往只有几万个点,训练集和验证集之间的 gap 很容易拉得很大。更麻烦的是它不天然输出置信区间,得靠 MC Dropout 或者分位数回归去估,多一层实现就多一层出错空间。而容量预测的业务沟通里,「这个数有多不确定」比「这个数是多少」更重要。
项目里的默认配置是model.active: sarima,LSTM 分支装不装 torch 都能跑——models/__init__.py里用 try/except 包了导入,装不上就跳过注册。这个设计挺务实,避免了「为了一个可选功能拖累整个部署」。
4.4 容量见底时间与置信区间:把预测值翻译成扩容决策
模型输出的是 0 到 1 之间的水位数组,predictor/capacity.py负责把它翻译成人话:
# predictor/capacity.py def build_capacity_report(metric, series, forecast, thresholds): current = float(series.iloc[-1]) peak = float(forecast.max()) report = { "metric": metric, "current_usage": round(current, 4), "peak_in_horizon": round(peak, 4), "free_ratio": round(1 - current, 4), "action": "none", } if peak < thresholds["warn"]: report["action"] = "none" elif peak < thresholds["critical"]: report["action"] = "watch" # 观察,准备扩容工单 else: report["action"] = "expand" # 直接触发扩容流程 return report这段逻辑的价值在于分级。只用一个阈值的话,要么太松(等到 90% 才报,扩容来不及),要么太吵(80% 就报,天天弹窗没人看)。分成 warn 和 critical 两档,warn 触发的是「进观察列表、排进下周的变更窗口」,critical 触发的是「今天就处理」。peak_in_horizon用的是预测窗口内的最大值而不是终值,因为有些曲线是先涨后跌(比如月底日志归档),看终值会漏掉中间的峰值。
还有一个细节:free_ratio这一项单独算出来是有意的。运维看「剩余 15%」和看「使用率 85%」的心理感受完全不同,前者更容易推动动作。这套代码在报告里同时给出两个视角,接进告警文案时按受众挑一个用。
5. 常见问题排查:跑不通、预测不准、结果飘的五个现场
这一章集中记录这套源码在实际部署里最容易踩的五个坑,每个都按「现象 → 原因 → 解决」写。你要是在自己环境里跑不起来,先对着这五条过一遍。
5.1 重采样后数据只剩三分之一
现象:采集了一周,每分钟一个点,结果clean_series出来只剩几百行,模型训练报样本不足。
原因:resample_freq和实际采样间隔不是整数倍关系。比如采集是 60 秒一次,重采样设成"7min",pandas 会按 7 分钟切桶,每个桶里有 7 个点但只落一个均值,看似没问题——真正的问题出在时间戳没有对齐到桶边界,加上前面dropna()把首尾不完整的桶全删了,累积几天就吃掉大半数据。另一个常见原因是采集器漏跑,某些时段根本没数据,重采样后全是 NaN。
解决:先跑一次df.groupby(df.ts.diff()).size()看实际打点间隔的分布,确认真实频率之后再设resample_freq。同时把dropna()改成dropna(how="all"),只删全空的行,部分缺失交给插值。
5.2 预测曲线的时间对不上告警时间
现象:报告说「第 23 天触线」,实际第 20 天就满了,差了三天。
原因:时区没统一。采集机用 UTC 打点,数据库存的是本地时间,pandas 解析时又按 UTC 处理了一遍,tz_convert前后差了 8 小时。按 5 分钟频率算,8 小时是 96 个点,折算成天数正好是 0.33 天——但如果你的采样频率更低(比如 1 小时一个点),误差就会被放大。另一个变体是夏令时切换,本地时间会凭空多出或少掉一小时。
解决:在clean_series第一行就统一成 UTC 带时区索引,展示层再tz_convert到本地。所有中间计算都用 UTC,只在最后出图出报告时转一次。这个规则定死之后,时区问题基本绝迹。
5.3 SARIMA 报 LinAlgError 或一直不收敛
现象:model.fit()抛LinAlgError: Singular matrix,或者警告刷屏「Maximum Likelihood optimization failed to converge」。
原因:三种可能。一是序列里有大量连续重复值(磁盘使用率小数点后两位,几天不变),导致协方差矩阵奇异;二是差分后序列长度太短,参数比样本还多;三是前面说的enforce_stationarity默认开启,卡住了非平稳序列。
解决:先把enforce_stationarity=False和enforce_invertibility=False加上;再检查序列的唯一值比例,低于 5% 的话做一次轻微抖动(加np.random.normal(0, 1e-4))或者干脆降采样;最后把order降到(1,1,0)试试,MA 项在小样本上最容易出问题。
5.4 一次日志清理把预测带偏了
现象:某天凌晨日志轮转,磁盘使用率从 82% 断崖跌到 61%,之后模型预测「永远不满」。
原因:断崖被当成了趋势。线性拟合会把那个巨大的负增量摊到整个窗口里,斜率被拉平甚至拉负;SARIMA 的差分项也会被这个异常点污染,后面几十步的预测都被压着。
解决:在清洗阶段做「单调性修正」——容量类指标除了清理和扩容这两种已知事件之外,正常情况下不应该大幅下降。常见做法是设定一个跌幅阈值(比如 5 分钟内跌超 3%),把这类点标记为「已知事件」,在训练时用事件前的水平做前向填充,或者直接从训练集里剔除事件前后各一天的窗口,等新数据积累够再重新训练。这套源码里cleaner.py留了event_windows参数但默认是空的,需要你在配置里手动填。
5.5 LSTM 训练 loss 一路降,验证 loss 一路飘
现象:切到model.active: lstm之后,训练集 MAE 降到 0.002,验证集 MAE 在 0.08 上下震荡,预测图完全对不上。
原因:样本量不够,典型的过拟合。一台机器 30 天的 5 分钟数据也就 8600 个点,按 72 步滑窗切完不到 8500 个样本,两层 LSTM 几十万参数,几轮就记住了。加上容量序列本身信噪比高(长期看就是一条缓慢上升的线),模型很容易学到「输出约等于输入的最后一个值」,在验证集上表现为「预测曲线整体滞后一拍」。
解决:三条同时上——把hidden_size从 128 降到 32,加dropout=0.3,并把训练窗口从 72 缩到 24。还不行就别硬上,老老实实用 SARIMA。我在一个真实场景里试过,同样的数据,SARIMA 的 7 天预测 MAE 是 0.9 个百分点,调过参的 LSTM 是 1.4 个百分点。模型复杂度不等于效果好。
6. 二次开发改造:把预测引擎接进自有监控体系
源码能跑通只是起点,真正落地时你八成要把它接进已有的监控平台。改造主要集中在三处,我按改动量从小到大排。
第一处是换数据源。collector/base_collector.py已经把契约定好了——子类实现collect_once()返回扁平字典就行。接 Prometheus 的话,写一个PromCollector,把query_range的结果转成{"ts": ..., "disk_used_root": ...}的形状即可,不用动清洗和模型层。
第二处是加模型。项目用注册表模式管理模型,加新模型只要继承基类再打上装饰器:
# models/__init__.py MODEL_REGISTRY = {} def register(name): def _wrap(cls): MODEL_REGISTRY[name] = cls return cls return _wrap # models/prophet_model.py @register("prophet") class ProphetModel(BaseModel): name = "prophet" def fit(self, series): from prophet import Prophet df = series.reset_index() df.columns = ["ds", "y"] self.m = Prophet(weekly_seasonality=True, daily_seasonality=False) self.m.fit(df) return self def predict_with_ci(self, horizon, alpha=0.05): future = self.m.make_future_dataframe(periods=horizon, freq="5min") fc = self.m.predict(future).tail(horizon) return (fc["yhat"].clip(0, 1).values, fc["yhat_lower"].clip(0, 1).values, fc["yhat_upper"].clip(0, 1).values)注册完之后,配置里写model.active: prophet就能切过去,engine.py一行都不用改。这里要注意基类约定的返回值是三个数组(均值、下界、上界),Prophet 天生就有置信区间,转换起来最省事。
第三处是接告警出口。reporter/alert.py里目前只实现了 console 和通用 webhook,接内部告警平台时改_send_webhook的 payload 结构即可。我一般会把build_capacity_report的整个字典序列化后塞进 payload,而不是只发一句话——下游可以拿去做报表聚合,比自己再拼一遍省事。
改完之后必须验证。最直接的办法是回测:拿历史数据滚动预测,看模型在「已知结局」上的表现。
# 回测:每次用前 N 天训练,预测接下来 horizon 步,和真实值比 def backtest(series, model_cls, horizon=288, folds=4): errors = [] for i in range(folds, 0, -1): train = series.iloc[: -(i * horizon)] test = series.iloc[-(i * horizon): -(i - 1) * horizon] model = model_cls().fit(train) pred = model.predict(horizon) errors.append(float(abs(pred - test.values).mean())) return {"mae_mean": sum(errors) / len(errors), "folds": errors}horizon=288是预测一天(按 5 分钟频率),folds=4是往前滚四次,相当于用最近四天的真实数据检验模型。folds列表要单独看,如果某一次误差明显高于其他几次,说明那段时间有事件干扰(扩容、清理、业务变更),值得单独查一下而不是简单求平均。
有一套判断标准我一直在用:回测 MAE 低于 2 个百分点,模型可以上线做辅助决策;2 到 5 个百分点,只能做趋势参考,触线天数要人工复核;超过 5 个百分点,先别信模型,回去查数据质量。这套标准不完美,但它至少能拦住「模型看起来很高级、结论完全不能用」的情况。
从那以后我每次接新的容量预测,都强制先跑一遍回测、把四个 fold 的误差单独打出来看一眼,再决定要不要让它进告警链路——比起上线后被一条错误的「还有 30 天」误导着推迟扩容,多花这半小时太值了。希望帮到你。
本文还有配套的精品资源,点击获取