《互联网产品的服务端架构·产品、流量与基础能力》8.3 热点与失效的产品表现

从大 V 发帖打满单个分片、整点配置统一过期两次事故写起,说明热 key 与集中失效是两种不同故障,给出访问占比探测、样本门槛与稳定分片的可测试 Go 实现,并讨论大 V 发帖、秒杀与整点刷新三类热点的观测和处置顺序,避免用同一套手段处理两种不同事故。

两种看起来相似的故障

「回声」在一个月内遇到两次“源站 QPS 突然飙升”的事故,监控曲线几乎一样,处置方式却完全不同。

第一次是大 V 发帖。一条内容在十分钟内被反复刷新,所有请求都落在同一个缓存键上。Redis 集群其他分片很闲,只有承载这个键的分片 CPU 打满,请求排队后超时,用户看到的是“这条帖子加载失败”,而站内其他内容正常。

第二次是整点配置刷新。全站配置的缓存键在 00:00 统一过期,所有实例同时回源数据库。数据库连接池瞬间耗尽,首页、登录、下单一起变慢,用户看到的是“整个 App 都卡住了”。

两次事故的表象都是回源变多,但一次是单点过热,一次是同时失效。把它们当成同一个问题处理,就会出现“所有缓存都加随机 TTL”或者“所有热点都做本地缓存”这类错位的修复。

热点与失效不是一回事

热 key 是空间上的集中:大量请求在同一时刻指向同一个键。集中失效是时间上的同步:大量键在同一时刻一起过期。两者都会把流量压向源站,但观测特征和第一处置完全不同。

事故类型典型触发观测特征第一处置长期手段
单 key 过热大 V 发帖、秒杀单品单个缓存分片 CPU 或带宽打满,其他分片正常本地缓存加请求合并,临时复制热点键热点探测、逻辑分片、读扩散
同时失效整点配置刷新、批量预热同一 TTL所有分片同时回源,源站 QPS 阶跃上升随机抖动、限流、降级TTL 加 jitter、分层过期、后台刷新
缓存击穿单个热 key 刚过期单 key 回源并发瞬间暴涨singleflight 或互斥重建逻辑过期加异步刷新
缓存雪崩大面积键同时过期全站回源、数据库连接耗尽限流、熔断、只读降级多级缓存、容量冗余、错峰预热

先看热 key。它的危险在于“一个键打满一个分片”。缓存集群通常按 key 做哈希分片,单个键只能落在一个分片上,所以这个键的 QPS 上限就是单分片的处理能力。请求量再大,也只能排队或超时。产品上的表现往往很局部:只有这条内容、这个商品或这个直播间不可用,其他功能正常。

再看集中失效。它的危险在于“同一时刻没有缓存”。如果大量键的 TTL 相同,或者它们在同一个批量任务里被写入,过期时间就会对齐。过期瞬间,所有请求一起回源,源站的连接数、线程池和数据库都会承受一个远高于平均值的尖峰。产品上的表现通常是大面积的:首页、详情、配置接口一起变慢。

判断当前事故属于哪一类,最直接的方法是看分片维度和时间维度:如果只有一个分片异常,先怀疑热 key;如果所有分片在同一秒出现回源尖峰,先怀疑集中失效。这个判断决定了你应该去复制一个键,还是去分散一批 TTL。

热点的识别不能只看绝对次数。产品在增长,昨天的一万次和今天的一万次含义不同;不同接口的基线也差很多。更稳的做法是看某个键在当前窗口内的访问占比,并设置最小样本数。样本太少时不判断,避免服务刚启动时少量请求就把某个键标成热点。

分片也不是随便取模。热点键需要稳定地映射到固定数量的逻辑分片,同一个键在任何进程、任何时间都应该得到同一个分片号,否则本地缓存和请求合并会失效。稳定的哈希函数比“随机分散”更重要,因为后者会让同一个键的请求落到不同实例,失去合并的意义。

失败场景:整点配置过期,零点请求全部回源

假设「回声」有 2000 个应用实例(案例假设值),全站配置缓存在启动时统一写入,TTL 设为 3600 秒。部署在 23:00 完成,所有实例几乎同时写入同一个配置键,于是 00:00 所有实例同时过期。

零点本来就是一个流量小高峰,配置接口的回源 QPS 从平时的 200 涨到 4 万(案例假设值)。数据库连接池只有 500 个连接,很快被打满;配置查询开始排队,依赖配置的首页、登录和下单接口一起超时。更糟的是,客户端看到超时后自动重试,重试流量又放大了回源压力,恢复时间被拉长到十几分钟。

同一周还有一次热 key 事故。一个大 V 发了一条抽奖帖,十分钟内这条内容的读取 QPS 达到 30 万(案例假设值),全部落在同一个 Redis 分片上。单分片的处理上限大约是 8 万 QPS,剩下的请求排队后超时。站内其他内容不受影响,但这条帖子的评论区、点赞数和作者主页一起不可用,产品侧看到的是“只有这条内容挂了”。

两次事故的修复也印证了分类的价值。配置事故的修复是给 TTL 加随机抖动、把全量刷新改成后台分批刷新、在过期前主动续期;热 key 事故的修复是加本地缓存、用 singleflight 合并同一键的并发回源、把热点键复制到多个逻辑分片。把两者的手段互换,效果会很差:给热 key 加随机 TTL 没有意义,给配置键做本地副本也只能让 2000 个实例各自多撑一会儿。

用 Detector 与 Shard 识别并分散热点

下面的实现来自 webapp-lab 的 internal/cache 包。Detector 用访问占比判断热点,并要求达到最小样本数;Shard 用 FNV-1a 把键稳定地映射到 n 个逻辑分片。两者都很小,但分别对应“发现热点”和“分散热点”两个动作。

// file: internal/cache/hotkey.go
// tier: L1
// verify: go test ./internal/cache/

package cache

import (
	"hash/fnv"
	"sync"
)

// Detector 用累计计数判断一个键是否已经成为热点。
// Threshold 是访问占比阈值,MinSamples 是开始判断前需要的最小样本数。
type Detector struct {
	Threshold  float64
	MinSamples int

	mu     sync.Mutex
	counts map[string]int64
	total  int64
}

// Observe 记录一次键访问。调用方通常把它放在缓存读取旁边。
func (d *Detector) Observe(key string) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.counts == nil {
		d.counts = make(map[string]int64)
	}
	d.counts[key]++
	d.total++
}

// Hot 返回该键是否达到阈值,以及它当前的访问占比。
// 样本不足时永远返回 false,避免启动瞬间的少量请求制造假热点。
func (d *Detector) Hot(key string) (bool, float64) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.total == 0 || d.total < int64(d.MinSamples) {
		return false, 0
	}
	share := float64(d.counts[key]) / float64(d.total)
	return share >= d.Threshold, share
}

// Shard 用 FNV-1a 把键稳定地映射到 n 个分片之一。
// 同一个键在任何进程、任何时间都得到同一个分片号。
func Shard(key string, n int) int {
	if n <= 0 {
		return -1
	}
	h := fnv.New32a()
	_, _ = h.Write([]byte(key))
	return int(h.Sum32() % uint32(n))
}

Detector 用互斥锁保护计数,因为真实调用会来自多个请求协程。Observe 在第一次调用时惰性创建 map,零值 Detector 可以直接使用;Hot 在总样本为 0 或不足 MinSamples 时返回 false, 0,避免启动瞬间的少量请求制造假热点。

返回占比而不是只返回布尔值,是为了让调用方能记录和调参。阈值是产品—工程约定,占比是事实;只暴露布尔值会让线上排障失去依据。Threshold 和 MinSamples 都是导出字段,可以在配置加载时设置,但应该在启动日志里打印出来,避免不同实例用了不同阈值。

Shard 只负责稳定映射,不负责选择物理节点。它的典型用法是把一个热键拆成 key#0、key#1 等多个逻辑副本,让请求按分片号分散到不同实例或不同缓存节点。FNV-1a 的计算成本很低,分布对短字符串也足够均匀;如果键的格式高度相似,可以在哈希前加一个前缀,避免哈希聚集。

Shard 对 n <= 0 返回 -1,而不是 panic 或取模零。调用方拿到 -1 就知道分片配置有问题,可以选择回退到不分片或直接报错,而不是让进程崩溃。

测试:样本不足、占比与分片稳定性

热点探测最容易错在两个地方:样本不够就下结论,以及同一个键在不同调用中得到不同分片。下面的测试把这两类边界固定下来。

// file: internal/cache/hotkey_test.go
// tier: L1
// verify: go test ./internal/cache/

package cache

import "testing"

func TestHotWaitsForMinSamples(t *testing.T) {
	d := &Detector{Threshold: 0.5, MinSamples: 10}
	for i := 0; i < 9; i++ {
		d.Observe("post:42")
	}
	hot, share := d.Hot("post:42")
	if hot {
		t.Fatalf("hot = true with only 9 samples, share = %v", share)
	}
	if share != 0 {
		t.Fatalf("share = %v, want 0 while samples are insufficient", share)
	}

	d.Observe("post:42")
	hot, share = d.Hot("post:42")
	if !hot {
		t.Fatalf("hot = false at 10 samples, share = %v", share)
	}
	if share != 1 {
		t.Fatalf("share = %v, want 1", share)
	}
}

func TestObserveCountsEachKey(t *testing.T) {
	d := &Detector{Threshold: 0.7, MinSamples: 4}
	d.Observe("a")
	d.Observe("a")
	d.Observe("a")
	d.Observe("b")

	hot, share := d.Hot("a")
	if !hot || share != 0.75 {
		t.Fatalf("a: hot = %v, share = %v; want true, 0.75", hot, share)
	}
	hot, share = d.Hot("b")
	if hot || share != 0.25 {
		t.Fatalf("b: hot = %v, share = %v; want false, 0.25", hot, share)
	}
}

func TestHotWithNoSamples(t *testing.T) {
	d := &Detector{Threshold: 0.5}
	hot, share := d.Hot("missing")
	if hot || share != 0 {
		t.Fatalf("hot = %v, share = %v; want false, 0", hot, share)
	}
}

func TestShardIsStableAndInRange(t *testing.T) {
	keys := []string{"post:42", "user:7", "config:home", "大V的帖子"}
	for _, key := range keys {
		first := Shard(key, 16)
		second := Shard(key, 16)
		if first != second {
			t.Fatalf("Shard(%q) changed from %d to %d", key, first, second)
		}
		if first < 0 || first >= 16 {
			t.Fatalf("Shard(%q) = %d, want in [0, 16)", key, first)
		}
	}
}

func TestShardRejectsNonPositiveCount(t *testing.T) {
	if got := Shard("post:42", 0); got != -1 {
		t.Fatalf("Shard with n=0 = %d, want -1", got)
	}
	if got := Shard("post:42", -3); got != -1 {
		t.Fatalf("Shard with n=-3 = %d, want -1", got)
	}
}

TestHotWaitsForMinSamples 先观察 9 次,确认在 MinSamples 为 10 时不判热且占比返回 0;再观察 1 次,确认样本达标后同一个键被判热、占比为 1。这个用例把“样本不足不判热”写死,防止以后为了“更灵敏”把门槛去掉。

TestObserveCountsEachKey 用 3 次 a、1 次 b 验证计数和占比:a 的占比是 0.75,达到 0.7 的阈值;b 的占比是 0.25,不达标。它同时验证了同一个键多次 observe 会累加,而不是覆盖。

TestHotWithNoSamples 覆盖空检测器。总样本为 0 时不能做除法,必须返回 false, 0,否则会得到 NaN 并让后续比较全部失真。

TestShardIsStableAndInRange 对同一个键调用两次,确认分片号一致,并检查结果落在 [0, n) 内。它还包含一个中文键,验证哈希对非 ASCII 输入同样稳定。

TestShardRejectsNonPositiveCount 固定 n=0 和 n=-3 返回 -1。这个约定让配置错误可以被调用方识别,而不是在运行期 panic。

这些测试不验证阈值是否适合某个产品,也不验证分片后各节点的负载是否真的均衡。阈值需要结合真实流量调,均衡性需要压测;单元测试只负责把探测和分片的语义固定下来。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/cache/ -count=1 -run 'TestHot|TestObserve|TestShard' -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestHotWaitsForMinSamples
--- PASS: TestHotWaitsForMinSamples (0.00s)
=== RUN   TestObserveCountsEachKey
--- PASS: TestObserveCountsEachKey (0.00s)
=== RUN   TestHotWithNoSamples
--- PASS: TestHotWithNoSamples (0.00s)
=== RUN   TestShardIsStableAndInRange
--- PASS: TestShardIsStableAndInRange (0.00s)
=== RUN   TestShardRejectsNonPositiveCount
--- PASS: TestShardRejectsNonPositiveCount (0.00s)
PASS
ok  	webapp/internal/cache	0.231s

-run 的正则覆盖热点探测、计数和分片三组测试。0.231s 是一次本机运行的耗时,会随机器和负载变化,不能当成性能结论。

小结

热 key 和集中失效都会让源站 QPS 飙升,但一个是空间上的单点集中,一个是时间上的同步过期。先看分片维度、再看时间维度,才能选对第一处置:复制热点键,还是分散一批 TTL。

Detector 用占比加最小样本识别热点,Shard 用稳定哈希把热点拆成可分散的逻辑分片。代码只覆盖“发现”和“分散”两步,真正的修复还要结合本地缓存、请求合并、TTL 抖动和后台刷新。把这些手段按事故类型组合,比给所有缓存统一加一层更有效。

下一章会换一个角度:哪些工作根本不应该阻塞用户请求,以及异步化之后如何保证结果最终可见。

阅读导航:上一节:8.2 缓存位置与一致性窗口 · 下一节:9.1 哪些事情不该阻塞用户 。

常见错误与边界

  • 把所有回源尖峰都当成热 key。先看分片维度,单分片异常和全分片同时异常的处理完全不同。
  • 只按绝对次数判热。流量增长会让昨天的阈值失效,应该看占比并设置最小样本数。
  • 样本不足就下结论。启动瞬间的少量请求会制造假热点,MinSamples 是必要的门槛。
  • 用随机数分散热 key。同一个键必须稳定映射到同一分片,否则本地缓存和请求合并都会失效。
  • 给热 key 加随机 TTL。热 key 的问题是单点容量,不是过期时间对齐。
  • 给全站配置做本地副本就以为解决了雪崩。实例各自多撑一会儿,过期后仍会同时回源。
  • TTL 全部设成同一个整数。批量写入和统一过期会把请求对齐到同一秒,应该加抖动或分批刷新。
  • 只暴露“是否热点”的布尔值。保留占比和样本数,排障时才知道阈值是否合理。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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