两种看起来相似的故障
「回声」在一个月内遇到两次“源站 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 全部设成同一个整数。批量写入和统一过期会把请求对齐到同一秒,应该加抖动或分批刷新。
- 只暴露“是否热点”的布尔值。保留占比和样本数,排障时才知道阈值是否合理。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。