整点一到,监控曲线立起来了
「拾光」是一个做限时闪购的电商产品。每天晚上八点,运营会放出当天的主推商品。二十点整,首页、商品详情和下单接口的请求同时抬升。值班同学最初把告警阈值设在 5000 QPS,因为日常晚高峰大约 1800 QPS,看起来留了两倍余量。
问题出在两个方向。第一,周末大促前的预热脚本会在 19:55 先打一轮压测流量,曲线刚过 5000 就报警;真正的活动在 20:00 开始,流量在 40 秒内冲到 15000 QPS,告警反而和压测混在一起,没人分得清。第二,某个工作日中午的一次推荐位误配置,把全站流量放大了 1.8 倍,曲线从 900 涨到 1600,离 5000 很远,系统已经开始超时,告警一声没响。
团队后来意识到,问题不是阈值高低,而是只用一个绝对数字描述“正常”。热点流量有自己的形状,先把这个形状说清楚,才谈得上识别。
热点流量的三个特征
热点流量和日常高峰不是一回事。日常高峰是缓慢爬升、持续较久、来源分散的;热点更像一次脉冲:来得很急,但往往有迹可循,而且集中在少数入口上。把这三个特征拆开,才能决定用什么信号识别它。
| 特征 | 观测信号 | 对架构的要求 | 只看单点阈值的误判 |
|---|---|---|---|
| 突发 | 30 到 60 秒内抬升数倍,曲线接近垂直 | 入口限流、快速扩容、缓存兜底 | 缓慢爬升的日常高峰被误判成突发 |
| 可预测 | 活动排期、开售时间、推送批次、节目单 | 提前预热、预留容量、错峰调度 | 不可预知的故障流量被漏掉 |
| 来源集中 | 少数商品、少数接口、少数地域 | 热点 key 隔离、本地缓存、按 key 限流 | 全局平均值把单点打爆掩盖掉 |
突发性决定了系统没有多少反应时间。一个需要三分钟才能拉起新实例的扩容策略,面对四十秒内完成的抬升基本无效;等实例就绪,用户已经看到超时了。可预测性则是另一面:大多数热点是运营提前安排的,系统完全可以在流量到达之前把缓存、连接池和实例准备好。来源集中最容易被忽略,因为全局 QPS 可能只涨了 30%,但某个商品详情接口已经涨了 50 倍,数据库里同一行记录被反复读取。
这三条合起来指向一个判断:识别热点不能只看“现在有多高”,而要看“现在比同一时段的正常水平高多少”。正常水平本身是变化的,工作日中午和午夜、工作日和周末、普通周二和大促前夜,基线完全不同。用一条固定红线去切所有时段,误报和漏报只是哪一边更多的问题。
工程上常用的做法是维护一个分时基线:对每个统计窗口取最近若干天的同一时段样本,计算均值和标准差,再用当前值偏离均值多少个标准差来判断。这个标准分就是 Z 分数。它把“绝对值高低”换成了“相对自身历史有多异常”,阈值可以跨接口复用。
失败场景:阈值要么天天响,要么真出事不响
案例假设值下,「拾光」的日常基线是 1200 QPS,晚高峰 1800 QPS,活动峰值 15000 QPS。团队先设了 5000 QPS 的固定阈值,结果是活动前压测天天触发,真正的活动开始后曲线早已越过阈值,告警被淹没在重复通知里。后来有人把阈值下调到 1500 QPS,午间推荐位误配置把流量推到 1600 时确实响了,但每天晚高峰也会响一次,值班同学逐渐把它当成背景噪声。
更隐蔽的失败来自基线污染。某次活动持续了三个小时,监控系统把这几个小时的样本也算进了基线。第二天的均值被抬高,标准差被放大,同样的 15000 QPS 算出来的 Z 分数从 8 降到 3 以下,告警没有触发。团队以为是代码坏了,实际上是“正常”的定义被异常样本改写了。
后果分三层。第一层是响应:真正的热点来临时,值班同学没有额外的判断依据,只能靠用户投诉和错误率曲线倒推。第二层是容量:自动扩容基于 CPU 触发,而热点接口的瓶颈往往先出现在数据库连接和缓存回源上,扩容实例并不能解决。第三层是数据:告警疲劳会让团队关掉通知,下一次故障的平均发现时间从分钟级退化到小时级。
修复方向不是再找一个更准的固定数字,而是把判定改成“当前值相对同时段基线偏离多少个标准差”,并且把活动窗口从基线样本中排除。阈值仍然存在,但它描述的是偏离程度,不再绑死某个业务量级。
用 Baseline、ZScore 与 IsBurst 识别突发
下面的实现来自 webapp-lab 的 internal/burst 包。Baseline 负责从样本算出均值和总体标准差,ZScore 负责把单个值换算成标准分,IsBurst 把两者串起来,并坚持“严格大于阈值”才判为突发。
// file: internal/burst/classify.go
// tier: L1
// verify: go test ./internal/burst/
package burst
import (
"errors"
"fmt"
"math"
)
// ErrNotEnoughSamples 表示样本不足以建立基线。
// 少于 3 个样本时,均值和标准差都没有统计意义,宁可报错也不要给调用方一个假阈值。
var ErrNotEnoughSamples = errors.New("burst: at least 3 samples are required")
// Baseline 返回样本均值和总体标准差。
// 这里用总体标准差而不是样本标准差:传入的是同一个统计窗口内的完整观测,
// 不是从更大总体里抽出的样本。
func Baseline(samples []int64) (mean, std float64, err error) {
if len(samples) < 3 {
return 0, 0, ErrNotEnoughSamples
}
var sum float64
for _, v := range samples {
if v < 0 {
return 0, 0, fmt.Errorf("burst: sample must not be negative: %d", v)
}
sum += float64(v)
}
mean = sum / float64(len(samples))
var sq float64
for _, v := range samples {
d := float64(v) - mean
sq += d * d
}
std = math.Sqrt(sq / float64(len(samples)))
return mean, std, nil
}
// ZScore 返回 v 相对基线的标准分。
// std 为 0 时返回 0:常量基线没有离散度,任何偏离都不能被解释成统计显著。
func ZScore(v int64, mean, std float64) float64 {
if std == 0 || math.IsNaN(std) || math.IsInf(std, 0) {
return 0
}
return (float64(v) - mean) / std
}
// IsBurst 判断 v 是否相对样本构成突发。
// 判定条件是严格大于 z,而不是大于等于:阈值本身是容忍上限,
// 恰好踩线不算突发,否则阈值会被端点样本反复触发。
func IsBurst(samples []int64, v int64, z float64) (bool, error) {
mean, std, err := Baseline(samples)
if err != nil {
return false, err
}
return ZScore(v, mean, std) > z, nil
}
这里有几个刻意的选择。第一,样本少于三个直接报错,而不是返回一个看似可用的均值。两个点也能算均值和标准差,但统计意义很弱,调用方很容易拿它去触发生产告警。第二,使用总体标准差而不是样本标准差:传入的是同一个窗口内的完整观测,不是从更大总体里抽样,除以 n 更符合“这批数据本身的离散度”这个语义。第三,std 为 0 时 ZScore 返回 0。常量基线意味着没有离散度,此时任何偏离都不该被解释成统计显著;否则一个恒定在 100 的接口,涨到 101 就会得到无穷大的标准分。
IsBurst 的严格大于也值得说明。阈值是容忍上限,恰好踩线不应该触发。否则同一个端点值会在阈值附近反复横跳,告警忽响忽停。严格大于把端点留给“正常”,把明确越界留给“突发”。
测试:短样本、零标准差与踩线
边界比主路径更容易出错。样本只有两个、基线完全恒定、当前值恰好等于阈值,这三种情况在生产里都很常见。下面的测试把它们逐条固定下来。
// file: internal/burst/classify_test.go
// tier: L1
// verify: go test ./internal/burst/
package burst
import (
"errors"
"math"
"testing"
)
func almostEqual(a, b, tol float64) bool {
return math.Abs(a-b) <= tol
}
func TestBaselineMeanAndStd(t *testing.T) {
mean, std, err := Baseline([]int64{10, 20, 30})
if err != nil {
t.Fatalf("Baseline returned error: %v", err)
}
if !almostEqual(mean, 20, 1e-9) {
t.Fatalf("mean = %v, want 20", mean)
}
want := math.Sqrt(200.0 / 3.0)
if !almostEqual(std, want, 1e-9) {
t.Fatalf("std = %v, want %v", std, want)
}
}
func TestBaselineRejectsShortSamples(t *testing.T) {
for _, samples := range [][]int64{nil, {1}, {1, 2}} {
if _, _, err := Baseline(samples); !errors.Is(err, ErrNotEnoughSamples) {
t.Fatalf("Baseline(%v) error = %v, want ErrNotEnoughSamples", samples, err)
}
}
}
func TestBaselineRejectsNegativeSample(t *testing.T) {
if _, _, err := Baseline([]int64{1, -1, 3}); err == nil {
t.Fatal("Baseline accepted a negative sample")
}
}
func TestZScoreZeroStdReturnsZero(t *testing.T) {
if got := ZScore(999, 100, 0); got != 0 {
t.Fatalf("ZScore with zero std = %v, want 0", got)
}
}
func TestZScoreValue(t *testing.T) {
if got := ZScore(30, 20, 10); !almostEqual(got, 1, 1e-9) {
t.Fatalf("ZScore = %v, want 1", got)
}
}
func TestIsBurstRequiresStrictlyGreater(t *testing.T) {
samples := []int64{0, 10, 20}
mean, std, err := Baseline(samples)
if err != nil {
t.Fatalf("Baseline returned error: %v", err)
}
v := int64(30)
z := ZScore(v, mean, std)
burst, err := IsBurst(samples, v, z)
if err != nil {
t.Fatalf("IsBurst returned error: %v", err)
}
if burst {
t.Fatalf("value exactly at threshold %v was reported as a burst", z)
}
burst, err = IsBurst(samples, v, math.Nextafter(z, 0))
if err != nil {
t.Fatalf("IsBurst returned error: %v", err)
}
if !burst {
t.Fatal("value just above the threshold was not reported as a burst")
}
}
func TestIsBurstDetectsSpike(t *testing.T) {
samples := []int64{10, 12, 14, 10, 14}
burst, err := IsBurst(samples, 30, 3)
if err != nil {
t.Fatalf("IsBurst returned error: %v", err)
}
if !burst {
t.Fatal("30 QPS against a ~12 QPS baseline was not reported as a burst")
}
}
func TestIsBurstPropagatesSampleError(t *testing.T) {
if _, err := IsBurst([]int64{1, 2}, 100, 3); !errors.Is(err, ErrNotEnoughSamples) {
t.Fatalf("error = %v, want ErrNotEnoughSamples", err)
}
}
TestBaselineRejectsShortSamples 覆盖 nil、单样本和双样本,确认错误可以用 errors.Is 判断。TestZScoreZeroStdReturnsZero 固定常量基线的行为,避免除零产生 +Inf。TestIsBurstRequiresStrictlyGreater 先用算出来的 Z 分数本身做阈值,确认不触发;再用 math.Nextafter 把阈值往下挪一个最小单位,确认触发。这个小技巧比手写一个近似值可靠,因为它不依赖浮点误差的估计。
TestIsBurstDetectsSpike 使用一组围绕 12 上下波动的样本,当前值 30、阈值 3,标准分约为 10,明确越过阈值。它验证的是正常路径,也顺便说明阈值 3 在低波动接口上有多敏感:基线越稳,同样的绝对增长越容易被判成突发。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/burst/ -count=1 -run 'TestBaseline|TestZScore|TestIsBurst' -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestBaselineMeanAndStd
--- PASS: TestBaselineMeanAndStd (0.00s)
=== RUN TestBaselineRejectsShortSamples
--- PASS: TestBaselineRejectsShortSamples (0.00s)
=== RUN TestBaselineRejectsNegativeSample
--- PASS: TestBaselineRejectsNegativeSample (0.00s)
=== RUN TestZScoreZeroStdReturnsZero
--- PASS: TestZScoreZeroStdReturnsZero (0.00s)
=== RUN TestZScoreValue
--- PASS: TestZScoreValue (0.00s)
=== RUN TestIsBurstRequiresStrictlyGreater
--- PASS: TestIsBurstRequiresStrictlyGreater (0.00s)
=== RUN TestIsBurstDetectsSpike
--- PASS: TestIsBurstDetectsSpike (0.00s)
=== RUN TestIsBurstPropagatesSampleError
--- PASS: TestIsBurstPropagatesSampleError (0.00s)
PASS
ok webapp/internal/burst 0.227s
输出里每个用例都是 PASS,包级结果是 ok webapp/internal/burst。单条用例的 0.00s 只代表内存计算,不包含采集样本、查询监控或发送告警的时间;真正接入时,基线查询和告警去重才是延迟的大头。
小结
热点流量的三个特征是突发、可预测、来源集中。突发决定了反应窗口很短,可预测给了提前准备的机会,来源集中则要求把限流和缓存做到 key 级别。用一个固定 QPS 阈值同时应对这三件事,结果只能是要么天天误报,要么真出事不响。
基线加 Z 分数把判定从绝对量级换成相对偏离,解决了跨时段、跨接口的阈值复用问题。实现本身很短,真正需要设计的是样本窗口、活动排除规则和阈值分级。代码只能告诉你“这次偏离了几个标准差”,要不要告警、告警给谁、自动扩容还是先限流,仍然是架构决策。
阅读导航:上一节:目录与学习路径 · 下一节:3.2 弹性扩容与预热 。
常见错误与边界
- 用绝对 QPS 阈值代替基线。不同时段的正常水平不同,一条红线无法同时覆盖午间和午夜。
- 把活动样本算进基线。异常值会抬高均值和标准差,下一次同样的热点反而算不出高分。
- 样本少于三个仍然计算。统计意义不足的基线会让告警在低样本接口上频繁误触发。
- 忽略标准差为零的情况。常量基线直接做除法会得到无穷大,任何微小波动都被判成突发。
- 用大于等于判断踩线。端点值反复横跳会让告警忽响忽停,阈值应该只对明确越界生效。
- 只看全局平均,不看单 key。全局涨 30% 时,某个商品接口可能已经涨了 50 倍。
- 把可预测的热点交给自动扩容。冷启动时间可能长于流量抬升时间,提前预热才是主路径。
- 告警不分级。所有偏离都发同一种通知,值班同学很快会把通知关掉。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。