《互联网产品的服务端架构·规模化与架构演进》3.1 热点事件的流量特征

从一个整点开抢的闪购活动写起,拆解热点流量突发、可预测、来源集中这三个特征,说明为什么单点阈值会被日常波动误触发;给出基线加 Z 分数的 Go 实现、严格大于阈值的边界测试与真实运行输出,并讨论样本窗口、常量基线、基线污染和告警分级的取舍。

整点一到,监控曲线立起来了

「拾光」是一个做限时闪购的电商产品。每天晚上八点,运营会放出当天的主推商品。二十点整,首页、商品详情和下单接口的请求同时抬升。值班同学最初把告警阈值设在 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 倍。
  • 把可预测的热点交给自动扩容。冷启动时间可能长于流量抬升时间,提前预热才是主路径。
  • 告警不分级。所有偏离都发同一种通知,值班同学很快会把通知关掉。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查