实现日志量异常检测:基线 ±Kσ 基带 + 灵敏度切换(Vue2 实战)
前言:为什么固定阈值不好用
做日志监控的同学大多配置过这样的告警:日志量超过 5000 条/分钟就报警。上线第一天就会发现,业务流量本身是有周期性的------白天高、夜里低、午间有个小高峰。固定阈值要么在白天业务高峰时疯狂误报,要么在深夜把真正的异常漏掉。
一、数据模型
先约定数据结构,后面所有逻辑都围绕它展开:
js
{
labels: ['10:00', '10:05', ...], // 时间轴,5 分钟粒度
raw: [2015, 1892, 2130, ...], // 原始值(每分钟日志量)
baseline: [1938, 1951, 1907, ...], // 基线(历史同期规律)
sigma: 60 // 波动标准差
}
生产环境中 baseline 和 sigma 一般由后端(或 Flink 任务)按历史同期数据计算好返回;前端拿到后只做一件事:
js
const k = this.sensitivity // 1 / 2 / 3
const upper = baseline.map(b => b + k * sigma) // 基带上限
const lower = baseline.map(b => Math.max(0, b - k * sigma)) // 下限,防止为负
K 越小基带越窄、报警越灵敏;K 越大基带越宽、越保守。这就是"灵敏度调节"的全部原理。
二、基带怎么画:堆叠面积
ECharts 没有原生的"置信区间带",标准做法是用两个堆叠的 line 系列:
- 第一个系列画
lower,线条和面积全部透明------它只是"垫底"; - 第二个系列画
upper - lower(差值),叠上去之后,它的面积就正好落在 lower 和 upper 之间。
js
series: [
// 垫底的 lower,不可见、不响应鼠标
{
name: '基带下限',
type: 'line',
stack: 'band',
data: lower,
symbol: 'none',
silent: true,
lineStyle: { opacity: 0 },
areaStyle: { opacity: 0 }
},
// 差值 (upper - lower),半透明蓝色就是基带
{
name: '基带',
type: 'line',
stack: 'band',
data: upper.map((u, i) => u - lower[i]),
symbol: 'none',
silent: true,
lineStyle: { opacity: 0 },
areaStyle: { color: 'rgba(37, 99, 235, 0.10)' }
},
// 基线:浅蓝虚线
{
name: '基线',
type: 'line',
data: baseline,
smooth: true,
symbol: 'none',
lineStyle: { color: '#93c5fd', type: 'dashed', width: 2 }
},
// 原始值:蓝色实线
{
name: '原始值',
type: 'line',
data: raw,
smooth: true,
symbol: 'none',
lineStyle: { color: '#2563eb', width: 2 },
z: 5
}
]
三、异常点:遍历 + scatter 标红
异常判定本身只有一行:v > upper[i] || v < lower[i]。把所有越带点收集成 [下标, 值] 数组,交给散点图:
js
const anomalyPoints = []
raw.forEach((v, i) => {
if (v > upper[i] || v < lower[i]) anomalyPoints.push([i, v])
})
// 追加到 series
{
name: '异常点',
type: 'scatter',
data: anomalyPoints, // [[54, 208], [55, 195], ...]
symbolSize: 8,
itemStyle: { color: '#dc2626' },
z: 10
}
注意 scatter 的 data 用 [index, value] 二元组,它会自动对齐 category 轴的下标,不需要自己拼时间字符串。
四、悬浮提示:越基带时标红
tooltip.formatter 里可以拿到 dataIndex,反查四个数组就能同时展示原始值、基线、上下限,并按是否越带切换颜色:
js
tooltip: {
trigger: 'axis',
formatter: (ps) => {
const idx = ps[0].dataIndex
const v = raw[idx]
const out = v > upper[idx] || v < lower[idx]
const flag = out ? ' <span style="color:#dc2626;font-weight:700">(越基带)</span>' : ''
return `<div style="font-size:12px;line-height:1.8">
<div style="font-weight:600">${labels[idx]}${flag}</div>
<div style="color:${out ? '#dc2626' : '#1e293b'}">原始值:${v.toLocaleString()}</div>
<div>基线:${baseline[idx].toLocaleString()}</div>
<div>基带上限:${upper[idx].toLocaleString()}</div>
<div>基带下限:${lower[idx].toLocaleString()}</div>
</div>`
}
}
五、灵敏度切换:一个变量驱动全部重算
把灵敏度 K 放在 data 里,radio 切换时重新调用渲染函数即可。顶部统计卡(越基带点数、最新状态)用 computed 联动:
js
computed: {
overviewStats() {
const k = this.sensitivity
const n = raw.length
let total = 0, max = 0, outCount = 0
raw.forEach((v, i) => {
total += v
if (v > max) max = v
if (Math.abs(v - baseline[i]) > k * sigma) outCount++
})
const current = raw[n - 1]
const lastOut = Math.abs(current - baseline[n - 1]) > k * sigma
return { total, avg: Math.round(total / n), max, current,
outCount, status: lastOut ? '越基带' : '正常' }
}
}
同一个 K,既控制图表上基带的宽窄,也控制统计口径,两边天然一致。
六、几个踩坑点
1. 下限为负会拉长 y 轴。 日志量不可能为负,lower 计算时务必 Math.max(0, b - k * sigma),否则灵敏 ±1σ 模式下 y 轴会出现一大段无意义的负值区域。
2. 切换指标时旧系列残留。 从"日志量"切到"QPS"时 series 数量可能变化(比如异常点从 10 个变 0 个),要用 chart.setOption(option, true) 开启 notMerge,否则上一次的散点会残留在图上。
3. 垫底系列要 silent: true。 堆叠基带的 lower 系列如果响应鼠标,tooltip 里会多出一条"基带下限"干扰信息,设为 silent 一了百了。
4. 记得 dispose。 单页应用里切走路由前 chart.dispose(),并移除 window.resize 监听,否则内存泄漏 + 重复初始化警告。
5. 数据缓存。 演示/调试时如果用随机数造数据,每次重算 sensitivity 都重新 generate 会导致曲线"抖动",按指标缓存一份数据,切换 K 值时只重算带和异常点,不重新造数。
七、演示数据怎么造
没有后端时,用"余弦周期 + 三角分布噪声 + 尾部骤降"可以造出非常接近真实流量的曲线:
js
for (let i = 0; i <= 66; i++) {
// 基线:周期性波动(12 个点一个周期 ≈ 1 小时)
const b = base - amp * Math.cos((2 * Math.PI * i) / 12)
baseline.push(Math.round(b))
// 三角分布噪声:三个均匀随机数之和,中间值概率高,更像真实噪声
const noise = (Math.random() + Math.random() + Math.random() - 1.5) / 1.5
let v = b + noise * sigma
if (i >= 54) v = anomalyValue + noise * sigma * 0.3 // 14:30 起骤降
raw.push(Math.max(0, Math.round(v)))
}
三个 Math.random() 相加再归一化,得到的是近似正态的三角分布,比单一 Math.random() 的均匀噪声自然得多。
结语
整套实现的核心其实就三件事:堆叠面积画基带、scatter 标异常点、一个 K 值驱动全部重算。剩下的都是 ECharts 的常规配置。建议把它做成独立的图表组件,基线计算交给后端按"历史同期"(上周同时段、昨的同时段)滚动计算,前端只负责渲染和灵敏度交互,职责会很清晰。
你们的基线是用历史同期算的还是滑动平均?遇到周期性业务(比如整点抢购)时有没有更稳的基线算法?欢迎评论区聊聊。