简介:面向数据分析初学者与运维监控方向学习者的应用系统负载分析与磁盘容量预测实践源码,围绕数据挖掘建模流程,帮助理解如何从历史磁盘与负载数据中提取模式、训练预测模型并完成容量趋势判断。压缩包共6个文件,约20KB,以4个xls数据表为主,配套1个txt导入模块说明与1个py源码脚本,覆盖原始数据、预处理数据与预测数据的组织方式,便于按步骤复现实验。已有563人学习,适合用作课程实验、毕业设计或运维容量规划的原型参考。读者可据此掌握数据预处理、特征整理、模型参数设定与预测结果输出的基本思路,并借鉴目录结构和脚本组织方式,快速搭建自己的负载分析与磁盘容量预测流程。
1. 从「磁盘又快满了」这句提醒开始:这套负载分析与容量预测源码要解决什么
值班群里弹出一句「磁盘又快满了」,比任何大屏都管用。这套应用系统负载分析与磁盘容量预测的 Python 源码,就围绕这句提醒展开:按固定周期采集 CPU、内存、磁盘 IO 与分区使用率,写进本地库,再用时间序列模型外推「还剩几天写满」。它面向三类人——要交毕设或课设、需要一条从采集到展示完整链路的学生;要出内部容量规划方案、不想从零搭原型的工程师;想把「超过阈值才告警」升级成「按趋势提前预警」的运维同学。后面按选型、架构、预测实现、踩坑排查、验收答辩的顺序,把这套源码完整拆解。
2. 技术选型与运行环境:psutil 采样、SQLite 落库、statsmodels 预测这套轻组合
这套源码的选型思路只有一句话:用最少的依赖,把「采集—存储—分析—预测—展示」这条链路跑通,且每一环都能在答辩时讲清楚为什么这么选。Python 作为开发语言在这里的优势不是性能,而是 psutil、pandas、statsmodels 这几个库把系统指标读取、序列处理和统计建模的门槛压得很低,几十行代码就能从「读一次 CPU 使用率」走到「外推一条容量曲线」。下面把采集端、存储与预测选型、工程结构三块拆开讲。
2.1 采集端:一次采样要拿哪些字段,周期定多少
采集端是整个方案的入口,也是最容易被低估的一环。psutil 能拿到的东西很多,但真正对容量预测有用的就那么几个:CPU 使用率、内存与 swap 占用、每个挂载点的已用容量、总容量、使用率,以及磁盘累计读写字节数。前四项刻画当前负载,累计读写刻画增长趋势;至于进程列表、线程数、句柄数这类字段,采下来也用不上,只会把库撑大、把聚合逻辑搞复杂。
# collector/sample.py import time import psutil def sample_once(interval=1): """采一次当前主机的负载快照,返回可直接入库的字典。""" vm = psutil.virtual_memory() disks = {} for part in psutil.disk_partitions(all=False): try: usage = psutil.disk_usage(part.mountpoint) except PermissionError: continue # 光驱、未挂载的盘会抛异常,跳过即可,不能让整轮采样中断 disks[part.mountpoint] = { "total": usage.total, "used": usage.used, "percent": usage.percent, } io = psutil.disk_io_counters() return { "ts": int(time.time()), # 必须显式给 interval,否则进程刚启动时没有参照基线,首采恒为 0.0 "cpu_percent": psutil.cpu_percent(interval=interval), "mem_percent": vm.percent, "swap_percent": psutil.swap_memory().percent, # 注意这两个是开机以来的累计值(counter),不是本轮增量 "disk_io_read": io.read_bytes if io else 0, "disk_io_write": io.write_bytes if io else 0, "partitions": disks, }函数里有两个参数值得单独说。interval决定cpu_percent的统计窗口:传 1 表示阻塞一秒,用这一秒内的两次读数算差值;不传则取「上次调用到现在」的差值,而这个差值在进程刚起来时没有参照,只能返回 0.0——很多人第一次跑监控发现 CPU 曲线是一条压在地板上的直线,原因就在这。disk_partitions(all=False)会过滤掉 proc、sysfs 这类虚拟文件系统,但光驱和未挂载的分区仍可能抛PermissionError,所以循环里必须用 try 包住,一处异常不能让整轮采样失败。
采样周期按用途分两档。做负载分析、看 CPU 毛刺和 IO 峰值时,我一般用 30~60 秒,一天 1440~2880 条,单机存一个月也就几万行,SQLite 毫无压力;做容量预测则不必这么密,采集端照常跑,聚合时按天压成一条就够。周期统一写进config.py,后面调整频率不用满仓库找魔法数字,这一条在改需求时能省下大量返工。
2.2 存储与预测:为什么不上时序数据库,也不用 LSTM
存储和预测是选型分歧最大的两块。有人上来就想上时序数据库,理由听起来很充分:写入快、自带降采样、查询方便。但对一套要交付、要答辩的方案来说,多一个组件就多一份部署说明和一份故障排查,被问一句「这个库挂了怎么办」就够呛。SQLite 单文件、零部署、Python 标准库直接带,几万到几十万行毫无压力,把数据文件拷走就能换台机器复现,这个特性在演示环节价值极高。真到了多机采集、每秒写入上千点的规模,再换时序库也不迟,表结构基本能平移过去。
预测模型也是同样的取舍。LSTM 在这类题目里出镜率很高,但容量序列按天聚合后通常只有几十到一百多个样本点,深度模型既学不出什么,也没法解释「为什么第 43 天写满」。我一般选 ARIMA 或 Holt-Winters:趋势项对应磁盘的线性增长,季节项对应工作日与周末的写入差异,参数含义清楚,答辩时能一条条讲。再配一条「最近 14 天线性回归」当基线,基线和高阶模型的预测差得离谱,说明数据本身有问题,先查数据再信模型,而不是急着调参。
还有一点常被忽略:预测的输入应该是「已用容量」而不是「已用百分比」。百分比的分母会随扩容变化,历史序列里一旦发生扩容,百分比会突然掉下去,模型会把这次扩容学成一个下降通道。用字节数建模、阈值判断时再换算成百分比,扩容这个动作在序列里就是分母变了而分子继续长,处理起来干净得多。
2.3 目录结构与启动方式:一条命令建库、一条命令起看板
# 源码包解压后的分层结构(按职责分目录,不按文件类型分) load_monitor/ ├── collector/ # 采样进程:定时把指标写进 SQLite ├── storage/ # 建表脚本与读写封装 ├── analysis/ # 按天聚合、环比计算、异常点剔除 ├── forecast/ # ARIMA 与线性基线、剩余天数推算 ├── web/ # Flask 路由与前端页面 ├── config.py # 采样周期、库路径、告警阈值集中在这 └── run.py # 统一入口,按子命令启动不同角色 # 首次运行三步走 python run.py initdb # 建表,幂等,重复执行不报错 python run.py collect --once # 先采一轮,验证字段和权限没坑 python run.py web --port 5000 # 起看板,浏览器打开即可看到曲线统一入口的好处是把「启动哪个脚本、带什么参数」收在一处。initdb走的是CREATE TABLE IF NOT EXISTS,反复执行安全;collect --once用于在正式挂定时任务之前验一遍权限和数据形态,尤其是在容器里跑、/proc被限制的场景,先采一轮能最快暴露问题;web只读数据不写数据,可以随时重启。真正部署时,采集用系统定时任务每分钟拉一次collect --once,预测用每小时一次的任务跑forecast,比常驻进程少操很多心。
3. 总体架构与数据模型:四层结构与三张核心表的职责边界
架构上不做过度设计,四层就够:采集层、存储层、分析预测层、展示层。层与层之间只通过数据表耦合,采集进程挂了不影响看板读历史数据,预测脚本挂了看板照样显示真实曲线。这种「局部故障不扩散」的特性在演示时很能加分——可以当场结束掉采集进程,页面依然正常刷新,比任何口头说明都有说服力。
3.1 分层与模块职责:谁读哪张表,谁写哪张表
| 层 | 主要模块 | 输入 | 输出 | 异常时的表现 |
|---|---|---|---|---|
| 采集层 | collector/sample.py | 主机实时状态 | metric_raw 新行 | 曲线停在某个时间点 |
| 存储层 | storage/db.py | 采样字典、聚合结果 | 三张表的读写 | 写入报唯一键冲突 |
| 分析预测层 | analysis/aggregate.py、forecast/* | metric_raw | metric_daily、forecast_result | 预测为空、剩余天数为 inf |
| 展示层 | web/api.py 与前端页面 | metric_daily、forecast_result | JSON、图表 | 接口 500 或图表空窗 |
读写的边界要卡死:采集层只写metric_raw,聚合任务只读metric_raw、只写metric_daily,预测任务只读metric_daily、只写forecast_result,展示层只读后两张表。这种单向数据流的好处是排查问题时不用顺着调用栈满世界找,看哪张表的时间戳停了,就知道是哪一层出的问题。
具体到数据流:采样进程每分钟往metric_raw追加一行;聚合任务每天凌晨把前一天的数据压成metric_daily的一行;预测任务读最近 90 天的日粒度数据,拟合模型、外推 30 天、算出剩余可用天数,把结果连同回测误差一起写进forecast_result;前端只请求最后这张表的最新一行。整条链路上没有一次「实时训练模型」,这也是接口能保持毫秒级响应的关键。
3.2 三张核心表:原始、聚合、预测结果
-- storage/schema.sql CREATE TABLE IF NOT EXISTS metric_raw ( id INTEGER PRIMARY KEY AUTOINCREMENT, host TEXT NOT NULL, -- 主机标识,多机采集靠它区分 ts INTEGER NOT NULL, -- 采样时间戳(秒) cpu_percent REAL, mem_percent REAL, disk_read INTEGER, -- 累计读字节,是 counter 不是增量 disk_write INTEGER, UNIQUE(host, ts) -- 幂等:定时任务重叠也不会写重复行 ); CREATE TABLE IF NOT EXISTS metric_daily ( host TEXT NOT NULL, day TEXT NOT NULL, -- 'YYYY-MM-DD' cpu_avg REAL, cpu_p95 REAL, -- 峰值用 p95,避开瞬时毛刺造成的假告警 mem_avg REAL, disk_used INTEGER, -- 当天最后一个采样点的已用字节 disk_total INTEGER, disk_percent REAL, sample_cnt INTEGER, -- 当天实际采样点数,判断这天数据够不够 PRIMARY KEY (host, day) ); CREATE TABLE IF NOT EXISTS forecast_result ( id INTEGER PRIMARY KEY AUTOINCREMENT, host TEXT NOT NULL, mountpoint TEXT NOT NULL, -- 一个主机多个挂载点,各自预测各自告警 run_at INTEGER NOT NULL, -- 本次预测执行时间 model TEXT NOT NULL, -- 'arima' / 'linear',便于横向对比 horizon_day INTEGER NOT NULL, -- 外推天数 pred_json TEXT NOT NULL, -- 预测序列,JSON 数组 days_left REAL, -- 预计多少天触达阈值,负数表示已超 mape REAL -- 回测误差,作为可信度参考 );三张表各司其职,不要合并。metric_raw保留原始粒度,是为了以后想换聚合口径(比如改成 6 小时粒度)时还有回旋余地,删了就再也算不回来。metric_daily存在的意义是降噪,日粒度把一天里的毛刺抹平,模型学到的是趋势而不是抖动。forecast_result单独存表而不是放进缓存,是因为每次预测都留痕,同一台机器不同时间跑的预测可以并排比较,模型改了一版到底变好还是变差,翻历史记录就知道。
几个字段容易被写错。sample_cnt看着多余,实际是判断这一天数据可靠不可靠的唯一依据,机器中途关机、采集器崩了半天,这一天的均值都是失真的,靠它才能把这些天识别出来。days_left允许为负,表示已经越过阈值,前端据此显示「已超额」。mape是回测误差,它不是给用户看的,是给自己看的——误差超过 20% 的预测,前端应该弱化显示或者直接标注「数据不足,仅供参考」。
3.3 聚合与脏数据处理:计数器回绕、采样缺口、口径漂移
# analysis/aggregate.py import sqlite3 MIN_SAMPLES = 720 # 一分钟一采,一天 1440 条,低于 720 条认为这天不可用 def build_daily(conn: sqlite3.Connection, host: str, day: str, start_ts: int, end_ts: int): rows = conn.execute( "SELECT ts, cpu_percent, mem_percent, disk_read, disk_write " "FROM metric_raw WHERE host=? AND ts BETWEEN ? AND ? ORDER BY ts", (host, start_ts, end_ts), ).fetchall() if len(rows) < MIN_SAMPLES: return None # 数据不够,宁可缺这一天也不要造一个假点 cpus = sorted(r[1] for r in rows) p95 = cpus[int(len(cpus) * 0.95) - 1] # 用排序取分位,避免引入 numpy 依赖 # 累计计数器做差分,出现负值说明机器重启或磁盘热插拔,跳过这一段 read_delta = 0 for prev, cur in zip(rows, rows[1:]): diff = cur[3] - prev[3] if diff > 0: read_delta += diff return { "host": host, "day": day, "cpu_avg": sum(cpus) / len(cpus), "cpu_p95": p95, "mem_avg": sum(r[2] for r in rows) / len(rows), "disk_used": rows[-1][3], # 占位,真实实现取分区快照 "sample_cnt": len(rows), }聚合这一步最容易出问题的是计数器。disk_io_counters()返回的是开机以来的累计值,直接对相邻两点做差拿到的是增量;但机器重启、磁盘热插拔、容器重建都会让这个累计值归零,差值变成一个大负数。如果不判正负直接累加,日增量会被这一个负值吃掉一大截,画出来的 IO 曲线会莫名其妙地掉一个坑。处理方式很简单:差值为负就跳过这一段,让这一天的增量偏小而不是变成负数,宁可低估也不要让曲线出现不可能的拐点。
采样缺口是第二个坑。机器中途关机两小时,这一天的 1440 条只剩 1200 条,均值看起来正常,但cpu_p95会因为样本变少而偏移。用一个固定的MIN_SAMPLES阈值卡住,低于阈值直接不生成这一天的聚合行,预测时 pandas 会把它当缺失值处理,比塞一个失真的点进去安全得多。阈值取全天应有采样数的一半,这个经验值在多数场景够用,采样周期改了就跟着改,写在配置里而不是硬编码在函数中间。
口径漂移是最隐蔽的一种。同一个挂载点在disk_partitions里可能带尾斜杠也可能不带,/data和/data/在字符串层面是两个不同的 key,聚合时会被当成两块盘,各自算出一半的样本量,然后双双被MIN_SAMPLES干掉。统一在采集入口做一次os.path.normpath归一,比在每一层都做防御要省事得多。
4. 容量预测实现:从滑动基线到 ARIMA 的剩余可用天数外推
预测模块是整个方案的核心,也是最容易做成「调用一个库然后贴张图」的地方。真正要交付的版本,得有序列准备、定阶、外推、精度评估四步,每步都能说清楚为什么这么做。判断标准很朴素:换一台机器、换一个挂载点,同一套代码不改一行还能跑出合理结果,才算合格。
4.1 建模前的序列准备:重采样、缺失补齐、异常点识别
# forecast/prepare.py import pandas as pd EXPECT_SAMPLES = 1440 # 与采集周期保持一致,改周期时同步改 def load_series(conn, host: str, mountpoint: str, days: int = 90) -> pd.Series: df = pd.read_sql_query( "SELECT day, disk_used, sample_cnt FROM metric_daily " "WHERE host=? AND disk_used IS NOT NULL ORDER BY day DESC LIMIT ?", conn, params=(host, days), ) df["day"] = pd.to_datetime(df["day"]) df = df.sort_values("day").set_index("day") # 采样点不足八成视为缺口,先置空再插值,避免半天数据被当成全天 df.loc[df["sample_cnt"] < EXPECT_SAMPLES * 0.8, "disk_used"] = None df["disk_used"] = df["disk_used"].interpolate(limit=3, limit_direction="both") # 单日跌幅超过 5% 判定为人为清理,从序列中断开,不让它影响趋势项 drop = df["disk_used"].pct_change() < -0.05 df.loc[drop, "disk_used"] = None return df["disk_used"].dropna()这段代码做了三件事,每件都对应一个真实场景。第一件是按sample_cnt筛掉不可靠的天,插值参数limit=3表示连续缺失超过三天就不补了,硬补出一段直线会让趋势项的斜率失真。第二件是识别清理动作:磁盘被清理一次,已用容量会断崖式下降,这种点是「事件」不是「趋势」,喂给模型会把整条预测线拽下来。第三件是把结果转成pd.Series并丢掉空值,保证索引是连续的时间点,后面 ARIMA 对时间索引的连续性是有要求的。
days=90这个窗口不适合放太长。太短(比如 14 天)学不到趋势,太长(比如一年)会把半年前的扩容遗留问题带进来。90 天大致覆盖一个季度的业务变化,是我的默认值;如果这台机器的写入量本身有明显的月度周期,可以拉到 120 天。这属于按场景调整的参数,不是定死的规矩。
4.2 ARIMA 定阶与外推:把「还剩几天」算成一个数
# forecast/arima_model.py import warnings import numpy as np from statsmodels.tsa.arima.model import ARIMA def fit_best(series, max_p=2, max_q=2): """小范围网格按 AIC 选阶。容量序列样本少,不值得上自动定阶的大炮。""" best_aic, best_model, best_order = np.inf, None, None for p in range(max_p + 1): for q in range(max_q + 1): try: with warnings.catch_warnings(): warnings.simplefilter("ignore") # 不收敛告警不影响选阶,但会刷屏 model = ARIMA( series, order=(p, 1, q), # d 固定为 1,先差分去趋势 enforce_stationarity=False, enforce_invertibility=False, ).fit() except Exception: continue # 这一组参数不可行,跳过 if model.aic < best_aic: best_aic, best_model, best_order = model.aic, model, (p, 1, q) return best_model, best_order, best_aic def days_left(model, current_used: float, total: float, threshold: float = 0.9, horizon: int = 30) -> float: """按预测均值判断首次触达阈值的天数。""" mean = np.asarray(model.get_forecast(steps=horizon).predicted_mean) limit = total * threshold for i, v in enumerate(mean, start=1): if v >= limit: return float(i) # 30 天内没触达,用整体斜率外推一个粗值,前端标注为「超过 30 天」 slope = (mean[-1] - current_used) / horizon if slope <= 0: return float("inf") # 容量在下降或持平,不产生告警 return float((limit - current_used) / slope)定阶为什么要限制在 p、q 不超过 2 的小网格里?因为容量序列的样本量太少,光看 AIC 很容易选出高阶模型,高阶模型在训练集上拟合得漂亮,外推两天就发散了。样本量 60 上下时,p、q 都放在 0~2,d固定为 1,实际只有九种组合,跑一遍不到一秒,结果也稳定。enforce_stationarity=False和enforce_invertibility=False关掉的是模型对平稳性和可逆性的强校验,这两个校验在样本量小时经常把可用的模型也一起否掉;关掉之后预测值可能略差一点,但不会直接报错退出。
days_left里的threshold=0.9是一个需要讲清楚的约定。以 90% 作为告警线,留出的 10% 是给运维的反应时间:容量越大,这 10% 代表的绝对空间越多,处理窗口越长。对一块 1T 的盘,10% 是一百多 G,够跑好几天的清理和扩容流程;对一块 40G 的小盘,10% 只有 4G,可能一天就没了,这时候应该把阈值调高到 0.85,配合日志清理脚本一起用。这个参数不要写死,放到配置里按挂载点分别设置。
4.3 精度评估:滚动回测与 MAPE 分级
# forecast/evaluate.py import numpy as np from statsmodels.tsa.arima.model import ARIMA def rolling_mape(series, window: int = 14, horizon: int = 7) -> float: """滚动原点回测:每次只用窗口内的历史训练,预测未来 horizon 天。""" errors = [] for i in range(len(series) - window - horizon): train = series.iloc[i:i + window] test = series.iloc[i + window:i + window + horizon] try: pred = ARIMA(train, order=(1, 1, 1)).fit().get_forecast(horizon).predicted_mean except Exception: continue errors.append(np.mean(np.abs((test.values - pred) / test.values)) * 100) return float(np.mean(errors)) if errors else float("nan")回测是最容易被跳过、又最能救命的一步。训练好模型直接画一条未来的线,看起来很美,但没人知道这条线可信度多高。滚动回测的做法是:取第 1~14 天训练,预测第 15~21 天,和真实值比;然后窗口往前挪一天,重复。这样得到的是模型在「历史多个时间点」上的平均误差,比单次训练集、验证集划分靠谱得多,也更接近上线后的真实表现。
MAPE 的分级我一般这么定:低于 10% 视为可信,前端正常展示预测曲线和剩余天数;10%~20% 之间展示曲线但把剩余天数标灰,提示「仅供参考」;超过 20% 说明这段序列本身不适合预测(波动太大、清理太频繁、或者数据缺口太多),前端只显示历史曲线,不显示预测。这个分级写在前端而不是后端,因为后端保留原始数值才能做横向对比,判断标准变了不用重新跑一遍全部数据。
4.4 接口与看板:把历史曲线和预测曲线拼在一张图上
# web/api.py import json from flask import Flask, request, jsonify app = Flask(__name__) @app.route("/api/forecast/<host>") def api_forecast(host): mount = request.args.get("mount", "/") row = query_latest_forecast(host, mount) # 只读 forecast_result 最新一行 if row is None: return jsonify({"error": "no forecast yet"}), 404 return jsonify({ "host": host, "history": load_history(host, days=90), # 90 天真实曲线 "pred": json.loads(row["pred_json"]), # 30 天预测序列 "days_left": row["days_left"], "mape": row["mape"], "model": row["model"], })// web/static/chart.js fetch(`/api/forecast/${host}?mount=/`) .then(r => r.json()) .then(d => { // 预测序列要左移一位接在历史末尾,否则两条线之间会断开一截 const pad = new Array(d.history.length - 1).fill(null).concat(d.pred); chart.setOption({ xAxis: { type: 'category', data: d.history.map(x => x.day).concat(d.pred.map(x => x.day)) }, series: [ { name: '实际使用量', type: 'line', data: d.history.map(x => x.used) }, { name: '预测', type: 'line', data: pad, lineStyle: { type: 'dashed' } } ] }); });接口设计的关键是「只读不算」。所有模型计算都在离线任务里完成,接口拿到请求只是查一行、拼一个 JSON,响应时间稳定在几十毫秒,也不会因为某个挂载点数据异常把整个页面拖死。前端那段pad数组是必写的,历史 90 个点、预测 30 个点,直接首尾相接的话预测线会从第一个点开始画,图上看起来像是两条无关的线。补上history.length - 1个null,预测线的起点就落在历史最后一天,视觉上连成一条。
5. 常见问题排查:采样、预测、展示三段里最容易翻车的五处
这套东西跑通了很顺,出问题的时候却往往一句话都没有——曲线不动、预测发散、接口报错,症状各不相同,根因集中在少数几个地方。下面五条是我在不同机器上反复遇到的,按「现象、原因、解决」拆开写,遇到问题先对照这里过一遍,通常能省掉大半排查时间。
5.1 采集阶段:CPU 恒为 0、时间戳撞车
现象:跑了一整天,metric_raw里有很多行,但cpu_percent全是 0.0,其余字段都正常。
原因:psutil.cpu_percent()在不传interval时,返回的是「距上次调用」的差值。采集进程是每次定时任务拉起一个新进程,进程一启动就立刻调用,此时没有任何历史读数可以对比,只能返回 0.0。这属于接口设计使然,不算 bug,但第一次写监控的人几乎都会踩到。
解决:两种改法选一种。要么显式传interval=1,让函数阻塞一秒拿到真实差值,代价是每轮采样多花一秒;要么在进程启动时先调一次psutil.cpu_percent(interval=None)把基线建立起来,丢掉这次结果,等下一轮再正常取。定时任务模式下我选后者,不额外占用时间。
现象:某个时间点附近出现两条一模一样的记录,forecast_result里同一次预测被写了两遍。
原因:定时任务没加锁。上一轮采样还没跑完(比如机器负载高、SQLite 在等写锁),下一轮的 cron 又拉起来了,两个进程读到同一个时间戳,各写各的。表上如果没有唯一约束,就是两条重复数据;有唯一约束,就是第二条写入报错。
解决:metric_raw上建UNIQUE(host, ts),写入统一用INSERT OR IGNORE,重复的自然被吞掉;同时在采集入口加一个进程级文件锁,拿不到锁就直接退出,不排队等待。两道防线里,唯一约束是兜底,文件锁才是治本。
5.2 预测阶段:曲线一路上冲、结果全是 NaN
现象:历史曲线看着挺正常,预测线却像被拉直了一样往上冲,30 天的预测值远超磁盘总容量,剩余天数算出来是 2 天,实际还有大半年。
原因:八成是序列里混进了扩容或者清理的记录。扩容会让分母变大,如果建模用的是使用率,序列上会出现一次断崖式下跌;清理会让分子变小,情况类似。这两种「事件」在模型眼里都是趋势的一部分,ARIMA 会把它学成一个斜率很大的线性项,外推几期就冲出天际。另一种可能是序列里混进了两个不同挂载点的数据,比如/和/data的记录被按时间混在一起,波动无规律,模型只能瞎猜。
解决:建模前先按mountpoint严格筛选,一条序列只对应一个挂载点;再用 4.1 那段负增长率检测,把单日跌幅超过 5% 的点置空。如果扩容已经发生,就把序列从扩容点截断,只用扩容之后的数据训练——扩容前的容量口径和现在不一样,硬拼在一起没有意义。截断之后样本变少,模型会自动退化成线性基线,反而更稳。
现象:ARIMA(...).fit()抛出LinAlgError或者一串ConvergenceWarning,预测结果里混着nan。
原因:样本太少,或者序列几乎是完全线性的。容量序列如果每天固定长 100M,差分一次之后剩下的全是常数,极大似然估计在这种退化序列上很容易算不出协方差矩阵的逆。参数组合选得偏大(比如 p=3、q=3)时,样本支撑不住这么多参数,也会不收敛。
解决:给样本量设一个下限,低于 20 个点直接放弃 ARIMA,退到最近 14 天线性回归,效果不会差,还省得解释。定阶范围收到 0~2,d固定为 1,并在fit时关掉平稳性校验。真遇到不收敛,捕获异常跳过这一组参数,而不是让它把整轮预测打断。
5.3 展示阶段:接口越用越慢、图表空窗
现象:刚上线时接口几十毫秒,跑了几天涨到两三秒,刷新页面明显卡顿。
原因:把模型训练放进了接口里。每次前端请求都现场读数据、拟合、外推,样本越多越慢,而且多个用户同时刷新时 CPU 会直接打满。这种写法在演示时看不出问题,真正连续访问几次就露馅了。
解决:训练离线化。定时任务每小时跑一次预测,结果写进forecast_result,接口只SELECT ... ORDER BY run_at DESC LIMIT 1。历史曲线也做一层缓存,90 天的日粒度数据量很小,进程启动时读进内存,之后每小时刷新一次。改造完接口响应时间能从秒级回到毫秒级,而且预测频率和数据量彻底解耦。
现象:历史曲线正常,预测曲线是空的,或者只画出来一个点。
原因:forecast_result里有记录但pred_json解析失败,或者前端数组拼接时长度对不上。另一种常见情况是days_left返回了inf,JSON 序列化时被写成Infinity,浏览器的JSON.parse直接抛异常,后面的绘制代码根本没执行。
解决:后端序列化前把inf和nan统一替换成null,前端拿到null就显示「容量持平,无告警」,不要让它进到图表里。pred_json存取都用json.dumps(..., allow_nan=False),写不进去的时候就该在日志里报错,而不是塞一个非法值进库,等到前端才发现。
6. 验收与答辩:用一条能复现的链路证明这套方案真的跑得通
验收别搞成「打开页面转一圈」。我习惯按一条固定链路走:清库、灌一段模拟历史数据、跑一次预测、打开看板、手动把阈值调低触发告警,五步全过才算交付。这条链路的好处是可复现——换台机器按同样的顺序走一遍,结果应该一致;不一致就说明某一步依赖了环境,得先解决这个。
| 验收项 | 通过标准 | 常用检查手段 |
|---|---|---|
| 采集完整性 | 连续跑 10 分钟,行数与预期采样点数误差小于 5% | SELECT COUNT(*) FROM metric_raw WHERE ts > ? |
| 聚合正确性 | 日粒度行的sample_cnt与原始行数对得上 | 逐日比对计数 |
| 预测可解释 | 剩余天数与手工按斜率估算的结果差异在合理区间 | 拿最近 14 天线性回归做交叉验证 |
| 精度达标 | 回测 MAPE 低于 20%,前端据等级展示 | 查forecast_result.mape |
| 告警可用 | 阈值调到 0.5 后,下一次预测能正确产出告警 | 手动改配置重跑预测任务 |
答辩时最常被追问的其实不是模型细节,而是「你这个剩余天数是怎么算的」「数据缺了怎么办」「换块盘要改哪里」。前两个问题在上面都有明确答案,第三个问题的正确回应是打开config.py指出阈值和采样周期在哪一行——能被当场改配置演示的方案,比讲十分钟原理更有说服力。还有一点,把mape主动亮出来。预测误差是客观存在的,主动说「这台机器的回测误差是 12%,所以剩余天数只能当参考」,比被问出来再解释要从容得多。
我第一次做这类方案时,把所有精力都花在把 ARIMA 调出好看的曲线,验收时被问了一句「机器重启那天你的 IO 曲线为什么是个负数」就卡住了。后来每次交付前,我都强制走一遍「人为制造异常」的流程:手动改一条原始数据的计数器、删掉半天的采样、把阈值调到必然触发,看整套系统能不能扛住、能不能给出合理的降级表现。这套自检习惯比任何调参技巧都管用,希望帮到你。
本文还有配套的精品资源,点击获取