主题
缓存三大问题
缓存正常时,数据库只接住少量未命中的请求;缓存突然不好用了,数据库却可能被全部流量同时击中。“穿透、击穿、雪崩”是三种常见表现,真正要保护的是回源能力。
本文用请求是否存在、集中在哪些键、为什么同时失效来区分三者,再讲布隆过滤器、空值缓存、互斥锁、singleflight、逻辑过期、TTL 打散、多级缓存和降级怎样配合。每个办法都会留下边界:减少回源次数,不等于让数据自动一致。
实验环境为 Go 1.27.2、Linux/amd64,singleflight 固定 golang.org/x/sync v0.16.0。文中的布隆过滤器是为了看清行为而写的 64 位玩具实现,不是 Redis Bloom 的实现;没有用它声称验证 Redis、数据库或分布式锁的行为。
先看负载,不急着给事故贴名字
假设应用每秒收到 10000 次读取,缓存命中率 99%,每次未命中查一次数据库,忽略刷新与重试,则回源约 100 次/秒。命中率掉到 0 后,回源可能变成 10000 次/秒。这个 100 倍来自假设下的算术,不是数据库一定能承受的负载。
| 现象 | 请求与数据的特点 | 回源为什么增加 | 优先检查 |
|---|---|---|---|
| 穿透 | 请求的对象根本不存在,或键空间不断变化 | 查库后无法形成有效命中,下次继续查 | 非法参数比例、不存在率、唯一键数量 |
| 击穿 | 少数真实热键到期或失效 | 同一键的并发请求一起回源 | 单键请求速率、同键回源并发、重建耗时 |
| 雪崩 | 许多键同时失效,或一层缓存整体不可用 | 多种数据同时失去保护 | TTL 分布、部署/重启、连接错误、全局回源流量 |
这些中文叫法不是统一协议术语。有人把热点失效也归到雪崩,因此排障记录应写出可观察事实,例如“一个键到期后出现 2000 个并发查询”或“缓存集群断连使所有读都回源”,比争论名字有用。
正在绘制图示…
查看图源代码
flowchart TD
R["读请求"] --> V["格式、范围、权限与速率检查"]
V --> B{"存在性过滤:一定不存在?"}
B -->|是| N["返回明确 NotFound"]
B -->|否或过滤器不可确信| C{"缓存命中?"}
C -->|新鲜值| O["返回"]
C -->|可接受的旧值| S["返回旧值;受控刷新"]
C -->|未命中| M["合并同键请求"]
M --> L{"全局回源预算允许?"}
L -->|是| D["带超时查权威源"]
L -->|否| F["降级或快速失败"]
D --> U["区分存在/不存在/错误,再回填"]
U --> O
图里有两道不同的门:同键合并减少重复工作,全局预算限制不同键的总工作量。只做前者,随机请求一百万个不同键仍能绕开保护。
穿透:查不到的结果也需要管理
先做不依赖数据库的检查:ID 格式和范围、租户边界、请求大小、认证与权限。非法输入不该因为“有缓存”就直接进数据库。这里的权限检查不能被布隆过滤器代替;“对象存在”与“请求者可以读取”是两件事。
空值缓存:把“确实没有”存成一种结果
数据库权威查询确认对象不存在时,缓存一个明确的 NotFound 状态,并设置比正常数据更谨慎的 TTL。这样同一个不存在的 ID 反复被读,能在缓存层结束。
缓存的数据结构应区分三种情况:
| 读取结果 | 含义 | 是否可以写入负缓存 |
|---|---|---|
| 对象存在,内容可以为空 | 合法业务值 | 保存正常数据,不应误判为不存在 |
| 权威查询成功,确认不存在 | NotFound | 可按业务规则短期保存 |
| 超时、断连、权限不足、取消 | 无法确认是否存在 | 不能当作 NotFound 保存 |
用空字符串代替所有“没有结果”,会把合法空内容与不存在混在一起。更清楚的形式是 {found:false} 或专门的状态码,并把序列化版本纳入键设计。
负缓存的代价是新创建的数据可能暂时不可见。创建对象时应失效相关负缓存,必要时让创建者直接读权威源;即使失效成功,也要考虑正在回填的旧“未找到”结果。短 TTL 只能缩短风险窗口,不能把竞态消除。
它对不断换新 ID 的流量也无能为力:每个键只查一次,负缓存只有写入成本,没有复用收益。需要限制任意键的扩张和总回源速率,否则攻击会同时消耗数据库与缓存内存。
布隆过滤器:便宜地回答“一定没有”
标准布隆过滤器用 m 个 bit 和 k 个哈希位置。插入一个元素,把对应位置设成 1;查询时只要有一位是 0,就能判定它没有被插入。若所有位都是 1,只能说“可能存在”,因为这些位也可能由别的元素共同点亮。Redis 官方 Bloom 说明
正在绘制图示…
查看图源代码
flowchart LR
A["加入 A"] --> H1["位置 2、7"]
B["加入 B"] --> H2["位置 5、11"]
H1 --> Bits["bit 2、5、7、11 都为 1"]
H2 --> Bits
Q["未加入的 C"] --> H3["恰好映射到 2、11"]
H3 --> Bits
Bits --> P["C 查询:两位都为 1<br/>只能判为可能存在"]
文末玩具实现插入 member-0 到 member-11,再搜索一个未插入但被误判的字符串,实际输出:
text
bloom: inserted-all-present=true false-positive=absent-1所有插入过的成员仍被判为可能存在;没插入的 absent-1 也通过了检查。这就是假阳性,也叫误报。它应该继续走缓存/数据库验证,不能据此发货或授权。
“布隆过滤器没有假阴性”有严格前提:元素确实已插入、位没有被错误清除、哈希规则相同、并发与持久化没有丢更新。普通布隆过滤器不能直接把某个元素的位清零来删除,因为这些位可能被别人共享。
还有一种工程上更常见的“假阴性”:数据库刚创建一条数据,过滤器还没同步。过滤器相对于自己记录的集合没有算错,但对数据库当前集合来说,它把真实对象挡住了。重建过滤器、切换版本、恢复旧快照和增量同步,都要保证新数据不会被错误排除;无法确信同步完整时,应旁路过滤或使用可接受的降级方式。
内存与误报率如何交换
在均匀、近似独立哈希的标准模型下,加入 n 个元素后的误报概率近似为 (1-exp(-k*n/m))^k。给定目标 p,常用设计取 m≈-n*ln(p)/(ln(2)^2),k≈(m/n)*ln(2)。这是概率模型的估算,不是每一批查询的精确误报比例。
程序用 n=100 万、p=1% 计算:
text
design: bits=9585059 MiB=1.143 hashes=7约 1.143 MiB 只计算位数组,不含对象、哈希、分片、持久化等开销。实际元素数超出设计容量会提高误报率;低误报率需要更多位和更多哈希计算。可扩展布隆过滤器还会增加子过滤器,应按具体实现的容量、误报与扩容契约评估。本文没有运行 Redis Bloom 命令,不给它套用玩具实现的容量或性能结果。
击穿:让同一份重建工作只做有限次
热 key 失效时,1000 个请求同时看见未命中,如果每个都独立查询数据库,热点读就变成回源洪峰。目标是选少量请求重建,其余请求等待、使用允许的旧值,或者快速失败。
互斥锁:拿到锁后还要再查一次
一个可理解的流程是:第一次查缓存未命中 → 获取同键锁 → 再查缓存 → 仍未命中才回源 → 填缓存 → 释放锁。
第二次检查很重要。请求 A 重建完释放锁后,请求 B 终于拿到锁;如果 B 不再检查,就会继续做重复查询。锁粒度也重要:一把全局大锁会让不同 key 互相等待,按键加锁则要管理锁对象数量与生命周期。
单进程 sync.Mutex 只协调这个进程。多个实例各有自己的锁,仍可能各回源一次。分布式锁可以进一步减少副本间重复,但引入租约、超时、续期和失效切换问题。用于“少做几次昂贵查询”与用于“绝不能同时执行两个业务写”,所需保证不同;后者还要考虑下游 fencing,不能只靠抢一个 Redis 键。Redis 锁模式
等待锁也占资源。给等待者截止时间,给重建者独立且有上限的工作预算,不要在数据库已经卡住时无限堆积 goroutine。
singleflight:共享正在进行的一次函数调用
singleflight.Group 为相同 key 合并正在进行的调用。第一个调用执行函数,重叠的调用等待同一结果,包括错误;完成后这次记录被移除。它不负责保存数据,也不是分布式协调。v0.16.0 源码与契约
实验先注册 20 个同键 DoChan 请求,让加载函数停在一个 gate 上;注册完后打开 gate。这样明确保证它们重叠,不靠 Sleep 猜测调度:
go
for i := range results {
results[i] = g.DoChan("tenant:7:item:9", func() (any, error) {
calls.Add(1)
<-gate
return "value", nil
})
}
close(gate)实际输出:
text
singleflight: requests=20 backend=1 shared=20 after-next=2前 20 次只执行一次函数。它们结束后,同一个 key 再调用一次,计数变成 2。singleflight 合并同一时段的工作,不缓存跨时段的结果。 把它接到缓存时,执行函数内部仍应再次检查缓存,再决定是否回源。
shared=true 表示结果被多个调用者共享,不等于“这个调用者不是第一个”。本例第一个调用的结果也被别人共享,所以 20 个结果都为 true。
超时是等待者的,还是共享任务的?
Do 没有 context 参数,等待过程不能直接根据当前请求取消。DoChan 返回结果 channel,调用者可以在自己的 context 和结果之间 select:
go
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-ch:
return r.Val, r.Err
}实验取消一个等待者,但让共享任务继续:
text
waiter: canceled=true leader=completed这没有自动取消函数。若直接用第一个 HTTP 请求的 context 执行共享数据库查询,第一个用户断开可能让其他仍在等待的请求一起失败。需要明确工作预算:例如共享加载使用受服务生命周期约束的独立超时,等待者各有自己的截止时间。若希望最后一个等待者离开后取消任务,则需额外的引用计数和生命周期设计,singleflight 本身不提供这一完整策略。
DoChan 的 channel 不会被关闭,不要用 for range 等它结束。Forget 允许后续同键请求开启新调用,但不会停止原来的调用,误用可能重新制造并发回源。key 必须包含决定结果的租户、权限范围和参数;否则不同用户可能共享不该共享的结果。
一个后端错误会同时返回给这一波等待者。所有人立刻重试,又会形成下一波。需要错误分类、短暂退避与抖动、总重试预算,有时还要短期缓存可恢复错误的状态;绝不能把错误伪装成 NotFound。
逻辑过期:用旧值换等待时间
逻辑过期把“条目还存在”与“条目仍新鲜”分开。条目可保存值、业务版本、soft deadline 与 hard deadline:新鲜期直接返回;软过期后允许返回旧值,同时由一个受控任务刷新;硬过期后必须重新加载或降级。
正在绘制图示…
查看图源代码
stateDiagram-v2
[*] --> Missing
Missing --> Fresh: 回源成功并保存
Fresh --> Stale: 超过 soft deadline
Stale --> Fresh: 单一刷新成功
Stale --> Stale: 刷新失败但尚未硬过期
Stale --> Unusable: 超过 hard deadline
Unusable --> Fresh: 回源成功
Unusable --> Rejected: 无容量或回源失败
这个模式与 HTTP 的 stale-while-revalidate / stale-if-error 思想相近,但应用缓存要自己实现状态与业务边界,不能只加一个时间字段就算完成。RFC 5861
它适合旧排行榜、非关键展示资料等可接受陈旧的场景。权限撤销、价格确认和不可逆决策不应因为“系统忙”就无限使用旧值。刷新必须有并发上限、超时和失败告警;否则每个读者都启动后台 goroutine,只是把前台洪峰换了个地方。逻辑过期还需要物理 TTL 或容量淘汰,防止从此无人访问的旧对象永久留在内存。
雪崩:打散时间之外,还要限制规模
TTL 加抖动,只针对“集中到期”
一次批量预热把十万个 key 都设为 10 分钟,十分钟后就可能一起消失。给 TTL 加随机扰动,可以把到期分散到一个窗口。例如允许寿命在 8 到 10 分钟之间,就从这个区间选取,而不是统一为 10 分钟。
抖动范围应服从最大陈旧时间。如果业务说最多缓存 60 秒,随手写 60秒+随机0到30秒 就把约束改成了最多 90 秒;可以选择向下抖动,例如 48 到 60 秒。随机种子或取值方式也要避免所有进程重启后重复同一批序列。
TTL 抖动解决不了整个 Redis 集群不可用、全量清缓存、统一版本切换、网络断连和大规模进程重启。这些情况下到期时间再分散,也没有可读的缓存。
多级缓存:用本地副本减小依赖,同时承担失效责任
L1 放在应用进程内,L2 放在 Redis,权威数据在数据库。热点命中 L1 时没有网络往返,L2 故障时也可能有一部分允许使用的旧值。但新实例没有本地缓存;全量发布一起重启会同时失去 L1;各实例的本地副本还需要失效或短 TTL。Redis client-side caching
设计多级缓存时应问:L2 失效后 L1 是否一起失效?失效消息丢失怎样发现?L2 返回的值已经旧了多久?L1 是否又把这份旧值从零开始计时?每层都加十分钟 TTL,不等于整个系统只旧十分钟。
滚动发布、受控预热、限制每台机器的回源并发,能减小冷启动冲击。预热也要设置预算,不能让“为了提高命中率”的后台任务先把数据库压满。
降级:把有限容量留给真正重要的请求
即使做完前面所有优化,缓存故障时也要有总回源上限。可以用信号量限制并发、令牌桶限制速率,并给排队设置长度与时间预算。并发控制约束在途工作,速率控制约束进入速度;慢查询变多时,仅有 QPS 限制仍可能积压大量在途请求。
| 可选动作 | 什么时候可能合适 | 必须写清的业务代价 |
|---|---|---|
| 返回仍在允许陈旧期的旧值 | 展示信息允许滞后 | 返回的数据版本、最大陈旧期 |
| 返回较简单结果 | 推荐、聚合视图可以减少计算 | 哪些字段省略,不能把未知当作真实的零 |
| 快速失败并提示重试 | 关键数据不可陈旧 | 明确可重试状态,避免所有客户端立即同步重试 |
| 限流低优先级工作 | 批处理、刷新可延后 | 饥饿上限与恢复速度 |
| 暂停后台预热/非必要请求 | 核心服务已接近预算 | 后续渐进恢复,避免二次洪峰 |
无限回源不是降级,它只是把故障转交给更昂贵的依赖。无限重试也不是恢复策略,它会让已经拥堵的系统再承受额外负载。
验证方案时,至少换三种输入
| 测试负载 | 应观察什么 | 哪个单一方案通常不够 |
|---|---|---|
| 同一个不存在的 key 反复读 | 负缓存是否命中,错误是否被错误缓存 | 只有 singleflight,波与波之间仍重复查 |
| 不断换不存在的 key | 唯一键增长、过滤误报、总回源上限 | 只有负缓存,复用率很低 |
| 一个真实热 key 同时失效 | 合并比例、等待时长、刷新失败后的下一波 | 只有 TTL 抖动,一个热 key 仍会过期 |
| 整层缓存断连 | DB 并发、限流、降级、恢复时预热速度 | 只有同键合并,不同键仍一起回源 |
| 刷新持续失败 | 陈旧年龄、hard deadline、告警 | 只有逻辑过期,可能无限返回旧值 |
指标也要成对看。高缓存命中率可能来自一直返回陈旧值;singleflight 合并率高可能同时意味着大量用户在等一个卡住的查询;后端 QPS 下降可能是请求被丢弃。把用户端成功率和延迟、数据新鲜度、回源并发与数据库资源放在一起,才知道系统是不是更好。
依据与验证范围
- Redis Bloom 官方文档:概率成员判断与误报边界;正文公式由标准位数组模型推导,数值由本地程序计算。玩具过滤器不实现删除、并发写、持久化或扩容,不应生产使用。
- Go x/sync v0.16.0 singleflight:重复函数调用合并、shared、DoChan channel 与 Forget 的语义,源码已核对。
- RFC 5861:HTTP 使用陈旧内容的有限窗口;本文应用缓存状态机是对应思想的设计示例,并非 RFC 对 Redis 的保证。
- Redis client-side caching、分布式锁模式:多级缓存的失效与锁租约边界。
- 实测:Go 1.27.2、Linux/amd64;
go run、普通测试、race、vet 均通过。20 个重叠调用由 gate 确定性协调;等待者取消后 leader 继续的行为已验证。 - 未测:真实数据库容量、Redis Bloom 误报分布、跨进程合并、逻辑过期生产实现、缓存集群整体故障。本文不给出通用 QPS 或延迟承诺。
完整可运行实验
把下列文件保存到同一目录。依赖固定版本,首次下载需要允许访问 Go 官方模块代理和校验服务。go.sum 的校验记录同时列出,便于复现。
bash
go version
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org go mod download
go run .
go test -count=1 ./...
go test -race -count=1 ./...
go vet ./...go.mod
go
module example.com/stampede
go 1.27.0
require golang.org/x/sync v0.16.0go.sum
text
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=main.go
代码块 · 96 行 · 点击收起 / 展开
go
package main
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"golang.org/x/sync/singleflight"
"math"
"sync/atomic"
)
type Bloom uint64
func positions(s string) (uint64, uint64) {
h := sha256.Sum256([]byte(s))
return binary.LittleEndian.Uint64(h[:8]) % 64, binary.LittleEndian.Uint64(h[8:16]) % 64
}
func (b *Bloom) Add(s string) { a, c := positions(s); *b |= Bloom(1<<a | 1<<c) }
func (b Bloom) Maybe(s string) bool {
a, c := positions(s)
return b&Bloom(1<<a) != 0 && b&Bloom(1<<c) != 0
}
func bloomDemo() (bool, string) {
var b Bloom
for i := 0; i < 12; i++ {
b.Add(fmt.Sprint("member-", i))
}
all := true
for i := 0; i < 12; i++ {
all = all && b.Maybe(fmt.Sprint("member-", i))
}
for i := 0; i < 100000; i++ {
s := fmt.Sprint("absent-", i)
if b.Maybe(s) {
return all, s
}
}
panic("fixed fixture produced no false positive")
}
func mergedWave() (int64, int, int64) {
var g singleflight.Group
var calls atomic.Int64
gate := make(chan struct{})
results := make([]<-chan singleflight.Result, 20)
for i := range results {
results[i] = g.DoChan("tenant:7:item:9", func() (any, error) { calls.Add(1); <-gate; return "value", nil })
}
close(gate)
shared := 0
for _, ch := range results {
r := <-ch
if r.Err != nil || r.Val != "value" {
panic("bad result")
}
if r.Shared {
shared++
}
}
first := calls.Load()
g.Do("tenant:7:item:9", func() (any, error) { calls.Add(1); return "again", nil })
return first, shared, calls.Load()
}
func await(ctx context.Context, ch <-chan singleflight.Result) (any, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-ch:
return r.Val, r.Err
}
}
func cancelledWaiter() (bool, string) {
var g singleflight.Group
gate := make(chan struct{})
leader := g.DoChan("k", func() (any, error) { <-gate; return "completed", nil })
follower := g.DoChan("k", func() (any, error) { panic("must be merged") })
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := await(ctx, follower) // No result can arrive until gate is opened.
close(gate)
r := <-leader
return errors.Is(err, context.Canceled), r.Val.(string)
}
func main() {
all, fp := bloomDemo()
fmt.Printf("bloom: inserted-all-present=%v false-positive=%s\n", all, fp)
n, p := 1000000.0, 0.01
m := math.Ceil(-n * math.Log(p) / (math.Ln2 * math.Ln2))
k := math.Round(m / n * math.Ln2)
fmt.Printf("design: bits=%.0f MiB=%.3f hashes=%.0f\n", m, m/8/(1<<20), k)
first, shared, second := mergedWave()
fmt.Printf("singleflight: requests=20 backend=%d shared=%d after-next=%d\n", first, shared, second)
canceled, value := cancelledWaiter()
fmt.Printf("waiter: canceled=%v leader=%s\n", canceled, value)
}main_test.go
go
package main
import "testing"
func TestBloomFalsePositive(t *testing.T) {
all, fp := bloomDemo()
if !all || fp == "" {
t.Fatal(all, fp)
}
}
func TestSingleflightIsNotCache(t *testing.T) {
first, shared, next := mergedWave()
if first != 1 || shared != 20 || next != 2 {
t.Fatal(first, shared, next)
}
}
func TestWaiterCancellationDoesNotCancelLeader(t *testing.T) {
cancel, value := cancelledWaiter()
if !cancel || value != "completed" {
t.Fatal(cancel, value)
}
}