Skip to content

缓存三大问题 ​

缓存正常时,数据库只接住少量未命中的请求;缓存突然不好用了,数据库却可能被全部流量同时击中。“穿透、击穿、雪崩”是三种常见表现,真正要保护的是回源能力。

本文用请求是否存在、集中在哪些键、为什么同时失效来区分三者,再讲布隆过滤器、空值缓存、互斥锁、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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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/>只能判为可能存在"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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: 无容量或回源失败
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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.0

go.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)
	}
}

保留各书来源与许可 · 示例运行条件以正文为准