学习目标
学完本章你应该能够:
- 用自己的话讲清楚云盘项目为什么需要缓存——从"文件列表、存储统计"这类读多写少场景出发,量化缓存能扛下的流量。
- 画出并解释 L1(本地 hot cache)+ L2(Redis)多级缓存的
Get/Set全流程,包括布隆过滤器(防穿透)和熔断器(防雪崩)在链路中的插入位置。 - 把缓存穿透 / 雪崩 / 击穿三件套成因、本项目对策讲成一个完整的故事,并说清 BloomGuard、TTL 抖动、L1 热缓存各自解决的是哪一个。
- 讲透熔断器三状态机(Closed → Open → HalfOpen)在本项目里的状态转换条件与阈值(
failThreshold=5、openTimeout=30s)。 - 面试时能把"写后删缓存的一致性时序、DeleteByPattern 模糊删的 SCAN 改进、singleflight 防击穿"等延伸点讲清楚,并承认当前实现的权衡与不足。
前置知识:Go 基础(sync.RWMutex、接口、time.Duration);Redis 基础(GET/SET/DEL/SCAN);HTTP 读多写少场景直觉;Kratos 分层(biz / data)与 google/wire 依赖注入。
本章你会动手做的事:
- 在
internal/data/cache/multi_level_cache.go里跟一遍Get的每一层短路逻辑,标出哪些是"fail-open(返回空值)"。 - 把
BloomGuard.Allow的冷启动阈值bloomWarmupThreshold=100改成 0,推演一次"重启后空过滤器把所有读都误杀"的事故。 - 用
redis-cli观察files:list:%d:%s:*这类前缀键,验证DeleteByPattern走的是SCAN而非KEYS。
一、为什么云盘需要缓存:从"读多写少"说起
Q1. 云盘项目里,到底哪些请求是真的需要缓存的?
答: 先建立直觉。
类比:你开了一家网红奶茶店,菜单(文件列表)一整天被无数顾客反复翻看,但真正改菜单(上传/删除文件)的动作一天没几次。如果每次有人看菜单你都跑后厨现做一份给他看,后厨(数据库)早累垮了。正确做法是:把菜单 photocopy 几份贴在门口(缓存),大家看复印件就行,只有你改菜单时才重印。
对应到工程里,云盘有两个非常典型的读多写少场景:
- 文件列表查询(
ListFiles):用户每次打开网盘目录都在翻页浏览,但真正上传/删除/移动文件的频率低得多。 - 存储统计(
GetStorageInfo):网盘首页永远在展示"已用 / 总空间 / 百分比",而用户实际存满或扩容的写操作极少。
这两个场景的共同点是:数据相对稳定、读 QPS 远高于写 QPS、对实时性的容忍度是"秒级"而非"微秒级"。一旦把这些读请求全部打到 MySQL,连接池和磁盘 IO 很快成为瓶颈。缓存的价值就是把 90% 以上的读挡在数据库之前。
可以用一个极简的成本模型判断"该不该缓存":假设一次 DB 查询成本 1,一次缓存查询成本 ε(ε ≪ 1);缓存命中率 h 下,引入缓存后的平均读成本 ≈ (1-h)*1 + h*ε。只有当 h 足够高(接近 1)时,平均成本才显著低于 1。而 h 高的前提正是"读多写少 + 数据复用率高"——文件列表每刷新一次首页都被反复读、存储统计每个页面都展示,天然命中率高。反过来,如果写频率接近读频率,缓存会被频繁失效/更新,有效命中率 h 上不去,平均成本反而可能高于直接读 DB(还多付了写缓存的开销)。所以**“读多写少"不是口号,是缓存成立的数学前提**。
下面是本项目中真正落了缓存的键,以及它们的"该不该缓存"判断:
| 缓存键 | 来源 | 是否缓存 | 原因 |
|---|---|---|---|
files:list:%d:%s:* | 文件列表第一页 | 缓存 | 读多写少,TTL 短,写后失效 |
file:meta:%d | 文件元数据 | 缓存 | 元数据读多,TTL 2min |
cloud-disk:user:storage:%d | 存储统计 | 缓存 | 首页高频读,TTL 5min±抖动 |
| 用户密码 / 令牌 | 登录鉴权 | 不缓存明文 | 安全敏感,绝不落缓存 |
⚠️ 坑:把"写多的数据"也塞进缓存是反向优化。比如秒传计数、实时在线人数,如果写频率接近读频率,缓存层的"写放大”(每次写都要同步删/写双层缓存 + 布隆)反而拖累性能。本项目只缓存读多写少、且允许秒级延迟的数据。
二、多级缓存架构:L1 本地 + L2 共享
Q2. 为什么不直接上 Redis,而要搞"L1 + L2"两级?
答: 先类比。
类比:公司前台有一排"常用电话便签"(L1,就在手边,拿起来就看),和一本"全公司通讯录"(L2,放在档案室,要去查)。绝大多数来电都是那几个常用号码,你根本不用去档案室;只有查陌生号码才去翻通讯录。L1 解决"快",L2 解决"全"和"共享"。
工程上的取舍更具体:
- L1(进程内 hot cache):数据就在本进程内存里,读延迟是纳秒~微秒级,没有网络往返,扛得住极高 QPS。代价是每个实例各有一份,容量有限、实例间不共享、重启即丢。
- L2(Redis):所有实例共享同一份缓存,容量大、可持久化。代价是每次访问都有一次网络往返(毫秒级)和序列化开销。
把延迟量级摆出来会更直观:一次本进程内 map 读(L1)大约 10~100 纳秒;一次 Redis GET 在本机或同机房大约 0.2~1 毫秒(跨机房或走公网可能 530 毫秒);一次 **MySQL 查询(含连接池取连、SQL 解析、磁盘 IO)通常 110 毫秒起步,慢查询可达百毫秒**。也就是说,L1 比 Redis 快约 1~2 个数量级,Redis 比 MySQL 又快约 1 个数量级。多级缓存的精髓就是:用最快的 L1 兜住绝大多数热点读,用共享的 L2 兜住次热点,DB 只在 L1/L2 都未命中时才被触碰——绝大多数请求停在 L1,少数走到 L2,极少数才到 DB,形成"金字塔"式的流量收敛。
只用 Redis 的话,一次 GET 至少一次 TCP + 一次内核拷贝 + 一次序列化;在高并发读下,Redis 本身也会成为热点。加一层 L1,把"热点中的热点"留在进程内,Redis 的压力直接降一两个数量级。
本项目里这两层在 multi_level_cache.go 中由一个 multiLevelCache 结构体组合:
// multiLevelCache 实现了本地热缓存 → Redis → DB 的多级缓存策略,
// 并集成布隆过滤器(防穿透)和熔断器(防雪崩)。
type multiLevelCache struct {
local *hotCache // L1:进程内带 TTL 的内存缓存(sync.RWMutex 保护)
redis *redisCache // L2:Redis 共享缓存,键统一加前缀 cloud_disk:cache:
guard *BloomGuard // 布隆过滤器,拦截"从未写入过缓存"的 key(防穿透)
breaker *CircuitBreaker // 熔断器,Redis 故障时降级(防雪崩)
}
// NewMultiLevelCache 创建一个多级缓存实例。
func NewMultiLevelCache(rdb *redis.Client) Cache {
return &multiLevelCache{
local: newHotCache(),
redis: NewRedisCache(rdb).(*redisCache),
guard: NewBloomGuard(),
breaker: NewCircuitBreaker(),
}
}
⚠️ 坑:别被"无锁"误导。任务描述里常把本地缓存说成"无锁低延迟",但本项目的
hotCache用的是sync.RWMutex保护的map[string]*hotCacheItem(见hot_cache.go)。它是线程安全的,但并发写时仍有锁竞争。所谓"低延迟"是相对于 Redis 网络往返而言,不是真的零开销。面试时如实说"带读写锁的线程安全 map"更专业。
L1 的实现非常朴素——一个带过期时间的 map:
// hotCacheItem 表示本地热缓存中的一个条目。
type hotCacheItem struct {
value string
expire time.Time
}
// Get 从本地缓存获取值(惰性过期:读时发现过期就顺手删掉)。
func (c *hotCache) Get(key string) (string, bool) {
c.mu.RLock()
item, ok := c.items[key]
c.mu.RUnlock()
if !ok {
return "", false
}
if time.Now().After(item.expire) {
c.Delete(key) // 惰性删除
return "", false
}
return item.value, true
}
三、Get 读取流程:四道关卡
Q3. 一次 Get 到底经过了哪些层?每层的"短路"逻辑是什么?
答: 这一问是面试核心。Get 的顺序是 L1 → 布隆 → 熔断 → Redis → 回填 L1,每一层都可能"短路"返回,避免继续往下走。
这张图把整条读取链路画出来,注意每一处"直接返回空值"的 fail-open 点:
flowchart TD
A[请求 Get key] --> B{L1 本地热缓存
是否命中}
B -- 命中 --> Z[直接返回 value
零网络开销]
B -- 未命中 --> C{BloomGuard
Allow key?}
C -- 不通过
从未写入过 --> Z0[返回空字符串
视为缓存未命中]
C -- 通过 --> D{熔断器
IsOpen 打开?}
D -- 打开 --> Z0
D -- 关闭 --> E[查询 Redis
rdb.Get]
E -- err 异常 --> F[RecordFailure
返回空 fail-open]
E -- 命中非空 --> G[回填 L1
RecordSuccess]
G --> H[返回 value]
E -- 命中为空 --> H逐层解释,并贴真实代码(multi_level_cache.go 的 Get):
func (c *multiLevelCache) Get(ctx context.Context, key string) (string, error) {
// 第 1 关:L1 本地热缓存
if val, ok := c.local.Get(key); ok {
return val, nil
}
// 第 2 关:布隆过滤器拦截——该 key 从未写入过缓存,直接返回空
if !c.guard.Allow(key) {
return "", nil
}
// 第 3 关:熔断器打开时降级返回空,避免把 Redis 故障传导给上游
if c.breaker.IsOpen() {
return "", nil
}
// 第 4 关:查 Redis
val, err := c.redis.Get(ctx, key)
if err != nil {
// 读路径 fail-open:Redis 异常时降级为空值(视为未命中),由上游回源
c.breaker.RecordFailure()
return "", nil
}
c.breaker.RecordSuccess()
if val != "" {
// 回填本地热缓存,默认 TTL = defaultLocalTTL
c.local.Set(key, val, defaultLocalTTL)
}
return val, nil
}
几个工程要点:
- L1 永远第一关——命中就返回,连 Redis 都不碰。这是多级缓存性能的根。
- 布隆放 L1 之后、Redis 之前——只对"L1 没命中、但 key 可能存在于缓存"的请求放行,拦截掉那些根本不可能有值的 key(防穿透,下节细讲)。
- 熔断放 Redis 之前——Redis 已经挂了就别再发请求去雪上加霜,直接 fail-open 返回空,让上游回源。
- 回填 L1:从 Redis 取到非空值后,顺手写回 L1,下次同 key 直接命中 L1。
defaultLocalTTL = 30 * time.Second是回填时的固定本地 TTL(注意它和业务 TTL 是两回事)。
还有一点值得单独强调:这四道关卡的先后顺序不能随意调换。为什么是 L1 → 布隆 → 熔断 → Redis,而不是别的顺序?
- 布隆必须放在 L1 之后。L1 命中意味着值就在进程里,根本不需要"判断 key 是否存在",直接返回即可;只有 L1 没命中、才需要决定"要不要去查 Redis",布隆的价值正在于此。把布隆放 L1 之前,会让每个 L1 命中的请求也白跑一次
TestString,纯属浪费。 - 熔断必须放在 Redis 之前。它的唯一作用是"Redis 已经不可达时别再发请求",所以必须在真正发起 Redis 调用之前拦截。放在 Redis 之后就失去意义了。
- 布隆和熔断的相对顺序则相反也问题不大——两者都是"放行 / 拦截"的短路判定,谁先谁后只是少跑一次布隆或多跑一次熔断的区别,本项目选"先布隆后熔断"是为了让"从未写入过"的穿透请求连熔断计数都不触发(穿透不是 Redis 故障,不该污染熔断器的失败计数)。
⚠️ 坑:
Get返回""和"缓存未命中"是同一个语义。本实现里val != ""才回填 L1,这意味着如果业务上某个 value 本身合法地等于空字符串,会被误判为"未命中"而反复回源。云盘缓存的都是 JSON 序列化后的结构体,空字符串几乎不会出现,但这是个设计上的隐含假设,面试时可主动点出。
四、Set 写入流程:三层齐写
Q4. 写入时为什么要把 L1、Redis、布隆"一起写"?
答: Set 的语义是"这个值现在存在了",所以要让所有"判断它存不存在"的组件都同步知道。
这张图是 Set 流程:
flowchart TD
A[请求 Set key value] --> B[写 L1 本地热缓存
TTL=defaultLocalTTL=30s]
B --> C[布隆过滤器 Add key
标记该 key 可能存在]
C --> D{熔断器 IsOpen?}
D -- 打开 --> Z[直接返回 nil
跳过 Redis 写]
D -- 关闭 --> E[写 Redis
带业务 TTL]
E -- err --> F[RecordFailure
返回 err]
E -- 成功 --> G[RecordSuccess
返回 nil]真实代码:
// Set 同时写入本地热缓存、Redis 和布隆过滤器。
func (c *multiLevelCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
c.local.Set(key, value, defaultLocalTTL) // L1 用固定本地 TTL
c.guard.Add(key) // 布隆:标记该 key 可能存在
if c.breaker.IsOpen() {
return nil // 熔断打开,跳过 Redis 写
}
if err := c.redis.Set(ctx, key, value, ttl); err != nil {
c.breaker.RecordFailure()
return err
}
c.breaker.RecordSuccess()
return nil
}
关键点:
- L1 用
defaultLocalTTL = 30s,而不是业务ttl。原因:L1 只是"热"缓存,容量小、重启即丢,用较短的固定 TTL 既能挡热点又不会让脏数据赖在本地太久。业务ttl(比如存储统计的 5min)只给 Redis。 guard.Add(key)必须在 Set 时调用。布隆过滤器只"加"不"删"(原理决定,见 Q7),所以只有在写缓存成功时才标记。这样Allow(key)对"曾经写入过"的 key 才返回可能存在,从而放行去查 Redis;对"从没写入过"的 key 直接拦截。- 熔断打开时 Set 仍写 L1 + 布隆,但跳过 Redis——因为此刻 Redis 不可达,强行写只会报错。L1 先留着,等熔断恢复后再由读路径把 Redis 补上。
五、Cache-Aside 旁路缓存:读时回源、写时删缓存
Q5. 什么是 Cache-Aside?本项目是"更新缓存"还是"删缓存"?
答: 先类比。
类比:图书馆书架上的"热门书索引卡"(缓存)和书库(数据库)是两套东西。Cache-Aside 的意思是:读者自己先看索引卡,没有再去书库找,找到后自己补一张索引卡;而当管理员把某本书下架(写)时,他不是去改索引卡,而是直接把索引卡撕掉——下次谁来看索引卡发现没了,自然去书库拿最新版,再补一张新卡。
这就是 Cache-Aside(旁路缓存)模式的两大动作:
- 读(Read-Aside):先查缓存,未命中则查 DB,再把结果写回缓存。
- 写(Write-Aside):先更新 DB,再让缓存失效(删除),而不是去更新缓存里的值。
本项目用的是"删缓存",不是"更新缓存"。最典型的就是 invalidateFileListCache——文件列表变更后,按前缀把缓存整体删掉,而不是重新计算一份新列表写回去:
// invalidateFileListCache 清除用户目录的文件列表缓存
// 在文件操作(上传、删除、移动、复制、重命名、创建文件夹)后调用
func (uc *FileUsecase) invalidateFileListCache(ctx context.Context, userID uint64, parentID *uint64) {
if uc.cache == nil {
return
}
parentStr := "root"
if parentID != nil {
parentStr = strconv.FormatUint(*parentID, 10)
}
// 缓存键格式:files:list:{userID}:{parentID}:{sortBy}:{sortOrder}
pattern := fmt.Sprintf("files:list:%d:%s:*", userID, parentStr)
_ = uc.cache.DeleteByPattern(ctx, pattern)
}
为什么是"删"而不是"更新"?因为列表缓存的 key 里带了 sortBy/sortOrder,同一目录可能有多种排序组合的键。重新计算并写回每一种组合成本很高,而直接按前缀 files:list:%d:%s:* 删掉全部组合,下次查询时自然回源重算并重新缓存,简单且不会漏。
文件元数据也是同样的"删"策略:
// InvalidateFileMetaCache 在文件被修改/删除/移动时调用
func (uc *FileUsecase) InvalidateFileMetaCache(ctx context.Context, fileID uint64) {
if uc.cache == nil {
return
}
cacheKey := fmt.Sprintf("file:meta:%d", fileID)
_ = uc.cache.Delete(ctx, cacheKey)
}
⚠️ 坑:
DeleteByPattern失败被_ =吞掉了。本实现里invalidateFileListCache对DeleteByPattern的返回错误直接忽略。这意味着"删缓存失败"只会在 Redis 层记一条日志(见multi_level_cache.go内部),不会让写操作失败。这是故意的取舍:缓存失效失败不应阻断主流程,宁可容忍一次短暂脏读,也比让用户上传失败要好。但这也带来一致性窗口——下一节专门讲。
六、缓存穿透与布隆过滤器
Q6. 什么是缓存穿透?本项目的 BloomGuard 怎么拦?
答: 缓存穿透的定义是:查询一个"数据库里根本不存在"的 key。因为 DB 没有、缓存也没有,这个请求会每次都穿透缓存打到 DB。如果是黑客用大量不存在的 key 恶意刷接口(如遍历 file:meta:99999999),DB 会直接被打挂。
注意穿透和"缓存未命中"的区别:未命中是"key 存在但缓存刚好过期",回源一次拿到值后回填就好了;穿透是"key 永远不存在",回源永远拿不到,却每次都回源。
本项目的对策是 BloomGuard——在查 Redis 之前先用布隆过滤器问一句:“这个 key 可能存在于缓存吗?” 如果过滤器说"一定不存在",就直接返回空,根本不会去查 Redis,更不会打到 DB。
// BloomGuard 使用布隆过滤器防止缓存穿透。
// 只有在过滤器中标记过的键才允许访问 Redis,否则直接返回空。
type BloomGuard struct {
mu sync.RWMutex
filter *bloom.BloomFilter
added uint64 // 已写入过滤器的 key 数量(用于冷启动判断)
}
func NewBloomGuard() *BloomGuard {
return &BloomGuard{
filter: bloom.NewWithEstimates(bloomExpectedElements, bloomFalsePositiveRate),
}
}
三个关键常量:
const (
bloomExpectedElements = 1000000 // 预计存放的键数量,即 1e6
bloomFalsePositiveRate = 0.01 // 可接受误判率 1%
bloomWarmupThreshold = 100 // 冷启动阈值:写入键数低于此值直接放行
)
Allow 的逻辑是"冷启动放行 + 之后严格拦截":
func (g *BloomGuard) Allow(key string) bool {
g.mu.RLock()
defer g.mu.RUnlock()
if g.added < bloomWarmupThreshold {
return true // 冷启动阶段(写入不足 100 个),直接放行,避免空过滤器误杀
}
return g.filter.TestString(key)
}
Q7. 布隆过滤器的原理是什么?为什么能"一定不存在"却"可能误判"?
答: 先看原理图。
flowchart LR
K[待加入的 key] --> H1[哈希函数 1]
K --> H2[哈希函数 2]
K --> H3[哈希函数 k]
H1 --> B[(bit 数组
m 个比特位)]
H2 --> B
H3 --> B
B --> Q{查询时
k 个位
是否全为 1}
Q -- 有任一位为 0 --> S[一定不存在
直接拦截]
Q -- 全部为 1 --> R[可能存在
有误判可能]原理拆解:
- 数据结构:一个长度为
m的 bit 数组(初始全 0),加k个互相独立的哈希函数。本项目用bloom.NewWithEstimates(1e6, 0.01)自动根据"预计元素数 100 万、误判率 1%“反算出合适的m和k。 - 写入(Add):对 key 跑 k 个哈希,得到 k 个下标,把 bit 数组里这些位置都置 1。
- 查询(Test/Allow):同样跑 k 个哈希,看这 k 个位置是否全为 1:
- 只要有一个位是 0 → 这个 key 一定没被加入过(因为加入时必然会把这 k 位都置 1)。这是布隆过滤器的"铁律”,所以能 100% 拦截穿透。
- 全为 1 → 可能存在。因为可能是别的 key 碰巧把这 k 个位都置 1 了,这就是误判(false positive)。误判率由
bloomFalsePositiveRate = 0.01控制。
几个必须记住的特性:
- 不可删除:删除某个 key 不能简单把它对应的 k 位清 0,因为那些位可能也被别的 key 共享置 1,清掉会误杀别人。所以布隆过滤器是"只加不删"的。本项目里 key 一旦
Add就永远在过滤器里——这对"防穿透"完全够用,因为我们只需要知道"是否可能写过"。 - 误判代价:误判只是"让一个本不存在的 key 放行去查了一次 Redis/DB",不会返回错误结果,只是少挡一次,可接受。
- 冷启动问题:进程刚启动时过滤器是空的,任何 key 的 k 位都是 0,会把所有请求都误判为"一定不存在"而拦截——相当于缓存被全面"封杀"。本项目的
bloomWarmupThreshold = 100就是为这个设计的:写入不足 100 个 key 前,Allow直接放行(return true),等预热够了再进入严格拦截模式。
顺带算一笔账,理解 bloom.NewWithEstimates(1e6, 0.01) 的空间代价。布隆过滤器所需 bit 数有个经典公式:m = -n * ln(p) / (ln2)^2,代入 n = 1e6、p = 0.01:m ≈ 1e6 * 4.605 / 0.480 ≈ 9.6e6 bit,约 1.2 MB 内存就能装下 100 万个 key、且误判率仅 1% 的过滤器。哈希函数个数 k = -ln(p)/ln2 ≈ 6.6,即约 7 个哈希。也就是说,用 1.2MB 内存,就给整个缓存层加了一道"100% 拦截不存在 key"的闸门——性价比极高,这正是布隆过滤器在防穿透场景不可替代的原因。代价是它"只加不删",key 一旦写入就永久占着那几个 bit,所以预计元素数 n 要估准:估小了误判率飙升,估大了浪费内存。
⚠️ 坑:布隆过滤器是进程内内存结构,重启即清零。本项目
BloomGuard没有持久化,重启后过滤器为空,靠warmupThreshold=100放行渡过冷启动。但代价是:重启后的前 100 次写入期间,穿透防护是"半关闭"状态。生产若要求重启也立刻防穿透,需要把布隆过滤器持久化到 Redis(如 RedisBloom 模块)或启动时从 DB 预热。面试时这是个很好的延伸点。
七、缓存雪崩与 TTL 抖动
Q8. 缓存雪崩是什么?本项目用哪两招防?
答: 缓存雪崩:大量 key 在同一时刻集中过期,或者 Redis 整体挂掉,导致瞬间所有请求都回源到 DB,DB 被一波流打挂,进而上游连锁失败。
和穿透的区别:穿透是"查不存在的 key",雪崩是"大量存在的 key 同时失效 / 底层存储挂了"。雪崩更可怕,因为它平时好好的,某个时间点突然崩。
本项目上两道闸:
第一道闸:TTL 加随机抖动,避免同时过期。
存储统计的缓存 TTL 不是固定 5 分钟,而是 5 分钟 + 0~29 秒随机:
const (
cacheKeyUserStorage = "cloud-disk:user:storage:%d"
cacheTTLUserStorage = 300 * time.Second // 5 分钟基础 TTL
)
// 回写到缓存(TTL 带抖动,防止大量用户同时过期导致缓存雪崩)
if uc.cache != nil {
if b, err := json.Marshal(info); err == nil {
ttl := cacheTTLUserStorage + time.Duration(time.Now().Nanosecond()%30)*time.Second
_ = uc.cache.Set(ctx, fmt.Sprintf(cacheKeyUserStorage, userID), string(b), ttl)
}
}
文件列表第一页的 TTL 同样带抖动(基础 42 秒 + 0~17 秒):
// 缓存第一页结果(TTL 带抖动:42s ± 17s)
if cursor == "" && cacheKey != "" && uc.cache != nil {
if data, err := json.Marshal(result); err == nil {
ttl := 42*time.Second + time.Duration(time.Now().Nanosecond()%18)*time.Second
uc.cache.Set(ctx, cacheKey, string(data), ttl)
}
}
这里用 time.Now().Nanosecond()%30 取纳秒的低位数作为随机量,让每个 key 的过期时间错开,避免"整点集体失效"。
关于抖动幅度,有个工程直觉:抖动窗口应和基准 TTL 成正比,但一般不超过基准的 10%~30%。本项目存储统计基准 300s、抖动 0~29s(约 ±5%10%),文件列表基准 42s、抖动 017s(约 ±20%~40%),都是"让过期时间分散、又不至于让部分 key 过早失效导致频繁回源"的折中。如果抖动窗口远大于基准 TTL,相当于大部分 key 的 TTL 被大幅缩水,缓存命中率反而下降,雪崩没来、“缓存击穿/频繁回源"先到了。
可以反过来想如果没有抖动会怎样:假设 10 万用户都在整点后首次访问存储统计,缓存全部以 300s TTL 写入,那么 300s 后这 10 万个 key 同一秒集体过期,瞬间 10 万请求回源 MySQL——这就是典型雪崩。加了 0~29s 抖动后,这 10 万请求分散在 30 秒窗口内逐步过期、逐步回源,峰值被削平一个数量级。L1 本地缓存还会再吸收掉每个实例内的重复读,进一步压低回源量。
第二道闸:熔断器在 Redis 故障时降级(fail-open)。
如果 Redis 真的挂了,那是"存储挂掉"型雪崩。本项目的读路径是 fail-open:Redis 报错时 Get 返回空值(视为未命中),由上游去回源 DB,而不是把 Redis 的错误抛给上游造成大面积 5xx。更进一步,breaker.IsOpen() 为真时连 Redis 请求都不发,直接返回空,保护 Redis 不被"已经挂了还拼命重试"的流量淹没。
val, err := c.redis.Get(ctx, key)
if err != nil {
// 读路径 fail-open:Redis 异常时降级为空值(视为缓存未命中),
// 由上游回源数据库,避免缓存故障直接传导为业务错误。
c.breaker.RecordFailure()
return "", nil
}
⚠️ 坑:
Nanosecond()%30不是密码学随机,且同一纳秒内多次调用值相同。抖动的目的是"打散过期时间点”,不需要随机性多强,所以time.Now().Nanosecond()完全够用。但如果一个批量任务在极短时间内(同一纳秒窗口)给大量 key 设置 TTL,它们抖动值可能相同。生产中高并发下纳秒级也会分散,影响可忽略;若追求更均匀,可用math/rand配合 seed。
八、缓存击穿与 L1 热缓存
Q9. 缓存击穿(热点 key)是什么?本项目怎么扛?
答: 缓存击穿:某一个极度热点 key(如某大 V 的公开文件元数据)在失效的瞬间,恰好涌入海量并发请求,这些请求全部"同时未命中"、全部回源 DB,把 DB 打穿。它和雪崩的区别是——雪崩是"很多 key 一起失效",击穿是"单个热点 key 失效"。
本项目的天然防线是 L1 本地热缓存:
- 热点 key 在失效前已经被大量读请求反复命中 L1,且每次从 Redis 取到值都会回填 L1(
Get里的c.local.Set(key, val, defaultLocalTTL))。 - L1 的 TTL 是
defaultLocalTTL = 30s,和 Redis 的业务 TTL(如 2min)不同步。即使 Redis 里这个 key 因为某种原因瞬间消失,只要 L1 还有(30s 内被反复读会在),绝大多数请求在 L1 就被吸收了,只有极少数穿透到 Redis/DB。
flowchart TD
A[海量并发请求
同一个热点 key] --> B{L1 本地热缓存
命中?}
B -- 命中 99% --> Z[直接返回
不触碰 Redis/DB]
B -- 未命中 极少 --> C[放行到 Redis/DB]
C --> D[回源成功回填 L1]
D --> Z换句话说,L1 把"单热点 key 失效瞬间的高并发"在大多数情况下在进程内就消化掉了,这是对击穿最朴素也最有效的缓解。
面试延伸:singleflight 进一步防击穿。 当 L1 也刚好失效(比如本实例刚重启、或 30s 窗口内没被读),仍可能有少量并发打到 Redis/DB。更严谨的做法是用 golang.org/x/sync/singleflight:对同一 key 的并发回源,只放一个 goroutine 去查 DB,其余 goroutine 共享这次结果。本项目目前没有显式用 singleflight,L1 是主要防线;可补一句:“若要彻底挡住击穿,可在回源 DB 那一层包一层 singleflight。”
把"用 singleflight 包裹回源"落到伪代码,思路非常清晰——把原来"未命中就直接查 DB"改成"未命中时用一个以 key 为维度的 singleflight 组来查 DB":
// 用一个包级 singleflight.Group 收敛并发回源
var fileMetaGroup singleflight.Group
func (uc *FileUsecase) getCachedFile(ctx context.Context, fileID uint64) (*File, error) {
cacheKey := fmt.Sprintf("file:meta:%d", fileID)
if uc.cache != nil {
if cached, err := uc.cache.Get(ctx, cacheKey); err == nil && cached != "" {
var f File
if json.Unmarshal([]byte(cached), &f) == nil {
return &f, nil // L1/Redis 命中,直接返回
}
}
}
// 缓存未命中:用 singleflight 保证同一 fileID 同时只有一个 goroutine 回源 DB
v, err, _ := fileMetaGroup.Do(cacheKey, func() (interface{}, error) {
return uc.fileRepo.FindByID(ctx, fileID)
})
if err != nil {
return nil, err
}
file := v.(*File)
if uc.cache != nil {
if data, err := json.Marshal(file); err == nil {
_ = uc.cache.Set(ctx, cacheKey, string(data), fileMetaCacheTTL)
}
}
return file, nil
}
关键点:singleflight.Group.Do(key, fn) 对同一个 key 的并发调用,只有第一个会真正执行 fn(回源 DB),其余调用阻塞等待并共享同一个结果和 error。这样即使 100 个请求同时发现 file:meta:123 失效,DB 也只被查一次。注意它保护的是"单实例内"的并发;若要做到集群级(多个实例同时击穿),还得叠加 Redis 分布式锁(SET NX)或 Redis 层的 singleflight 中间件。
⚠️ 坑:L1 是每实例独享的,击穿防护是"实例级"而非"集群级"。如果某个热点 key 在 100 个实例上同时 Redis 失效,每个实例都会各自放几个请求去回源——总量 = 实例数 × 每实例漏过的请求。实例越多,漏过去的回源越多。singleflight 只防单实例内的并发,集群级防击穿还得靠 Redis 层的分布式锁或 singleflight。
九、熔断器三状态机
Q10. 熔断器在本项目里是怎么从"关"到"开"再到"半开"的?
答: 熔断器(Circuit Breaker)是防雪崩的"大闸":当 Redis 连续失败达到阈值,就"拉开闸"让后续请求快速失败(本项目是 fail-open 返回空),给 Redis 喘息时间;过一阵子再"半开"放一个探测请求,探测成功就彻底恢复。
三状态机如下图:
stateDiagram-v2
[*] --> Closed
Closed --> Open: 连续 failThreshold=5 次失败
Open --> HalfOpen: openTimeout=30s 后
放探测请求
HalfOpen --> Closed: 连续 successThreshold=2 次成功
HalfOpen --> Open: 探测请求失败
Closed --> Closed: 成功调用
清零 failCount本项目的三个阈值:
const (
defaultFailThreshold = 5 // 连续失败 5 次触发熔断
defaultSuccessThreshold = 2 // 半开状态连续成功 2 次恢复
defaultOpenTimeout = 30 * time.Second // 熔断打开 30s 后进入半开
)
状态转换代码:
// IsOpen 判断熔断器是否处于打开状态。
// 打开状态超过 openTimeout 后,自动切到 HalfOpen 放探测。
func (cb *CircuitBreaker) IsOpen() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
if cb.state == StateOpen && time.Since(cb.lastFailureTime) > cb.openTimeout {
cb.state = StateHalfOpen
cb.successCount = 0
}
return cb.state == StateOpen
}
// RecordFailure 记录一次失败。
func (cb *CircuitBreaker) RecordFailure() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.failCount++
cb.lastFailureTime = time.Now()
switch cb.state {
case StateHalfOpen:
cb.state = StateOpen // 半开探测失败 → 重新拉开
case StateClosed:
if cb.failCount >= cb.failThreshold {
cb.state = StateOpen // 连续 5 次失败 → 拉开
}
}
}
// RecordSuccess 记录一次成功。
func (cb *CircuitBreaker) RecordSuccess() {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case StateHalfOpen:
cb.successCount++
if cb.successCount >= cb.successThreshold {
cb.state = StateClosed // 半开连续 2 次成功 → 恢复
cb.failCount = 0
cb.successCount = 0
}
case StateClosed:
cb.failCount = 0 // 成功调用清零失败计数
}
}
把状态机翻译成人话:
- Closed(关闭,正常放行):每次成功
RecordSuccess会清零failCount;一旦连续失败累计到failThreshold=5,切到 Open。 - Open(打开,快速失败):
IsOpen()返回 true,所有读/写请求直接短路(本项目返回空 / 跳过 Redis)。但IsOpen()内部有个巧妙逻辑:如果距离上次失败已超过openTimeout=30s,它会自动把状态改成 HalfOpen(并清零successCount),给 Redis 一个恢复的机会。 - HalfOpen(半开,放探测):此时放行请求去试 Redis。若探测成功,
successCount累计到successThreshold=2就彻底恢复成 Closed;若探测失败,立刻退回 Open,再等 30s。
⚠️ 坑:本项目熔断器的计数不是"时间窗口滑动"而是"连续计数"。
failThreshold=5是"连续 5 次失败",中间只要插入一次成功(RecordSuccess在 Closed 态会清零failCount),计数就归零重来。这比"1 秒内失败 5 次"宽松——偶发 1~2 次 Redis 抖动不会触发熔断。面试时要说清这是"连续失败"语义,不是"窗口内失败",否则会被追问。
⚠️ 坑:
IsOpen()持有写锁并可能改状态。注意IsOpen()用的是cb.mu.Lock()(写锁)而非读锁,因为它可能把Open改成HalfOpen。这意味着高并发下每次Get都要抢这把写锁,有轻微竞争。对云盘读场景影响很小,但若是超高并发纯读,熔断器的锁可能成为热点。
十、缓存一致性与失效时序
Q11. 写后删缓存,到底先更新 DB 还是先删缓存?本项目怎么处理删失败?
答: 这是缓存一致性最经典的题。Cache-Aside 的标准顺序是先更新 DB,再删缓存。为什么不是反过来?
这张时序图展示标准顺序和本项目的"删失败容忍":
sequenceDiagram
participant Req as 写请求
participant DB as MySQL
participant C as MultiLevelCache
Req->>DB: 1. 先更新数据库
DB-->>Req: 写入成功
Req->>C: 2. 再删缓存 DeleteByPattern
alt 删成功
C-->>Req: 继续
else 删失败
Note over Req,C: 仅记日志
不阻断主流程
容忍短暂脏读
end先更新 DB 再删缓存,理由:
- 如果先删缓存再更新 DB:删完缓存、还没更新 DB 的空档里,另一个读请求发现缓存没了,会去读 DB 拿到旧值并写回缓存,导致缓存里是旧数据,长期脏读。
- 反过来先更新 DB 再删缓存:即使删缓存的瞬间有读请求,最多是读到一个旧值(删之前那一瞬),删完后立刻一致;而"删缓存"这一步即使失败,也只是多一段短暂的脏读窗口,不会永久不一致(等 TTL 过期自然恢复)。所以本项目选"先 DB 后删缓存",且删失败只
log容忍。
本项目里删缓存失败的处理很明确——multi_level_cache.go 的 DeleteByPattern 在 Redis 层出错时 RecordFailure 并返回 err,但 biz 层调用处用 _ = 忽略:
func (c *multiLevelCache) DeleteByPattern(ctx context.Context, pattern string) error {
c.local.DeleteByPattern(pattern) // 本地无条件清
if c.breaker.IsOpen() {
return nil
}
if err := c.redis.DeleteByPattern(ctx, pattern); err != nil {
c.breaker.RecordFailure()
return err // 返回错误,但上层用 _ = 吞掉
}
c.breaker.RecordSuccess()
return nil
}
Q12. DeleteByPattern 模糊删用的是 KEYS 还是 SCAN?
答: 这是个高频坑。redis-cli 的 KEYS pattern 会全量遍历整个 keyspace 并阻塞 Redis,数据量大时直接让 Redis 卡死,生产环境禁用。本项目用的是 SCAN 游标迭代 + 分批 DEL,不会阻塞:
// DeleteByPattern removes all keys matching the given pattern.
// Pattern uses Redis SCAN with MATCH syntax (e.g., "files:list:123:*").
func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error {
fullPattern := c.prefixed(pattern) // 自动加前缀 cloud_disk:cache:
var cursor uint64
for {
keys, nextCursor, err := c.rdb.Scan(ctx, cursor, fullPattern, 100).Result()
if err != nil {
return err
}
if len(keys) > 0 {
if err := c.rdb.Del(ctx, keys...).Err(); err != nil {
return err
}
}
cursor = nextCursor
if cursor == 0 {
break // 游标回到 0,遍历完成
}
}
return nil
}
SCAN 每次只返回一小批(这里 count=100)并带回一个游标,循环直到游标归零。它不阻塞 Redis,是线上安全的模糊删方案。注意它仍会删掉所有匹配前缀的键(包括不同 sortBy/sortOrder 的列表组合),这正是 invalidateFileListCache 想要的——把某目录的所有列表缓存一键清空。
⚠️ 坑:
SCAN不是原子的,且可能重复/遗漏。在SCAN遍历期间如果有新键写入或旧键过期,可能多删或漏删。对"失效缓存"场景可容忍(漏掉的等 TTL 自然过期)。另外DeleteByPattern对本地 L1 用的是filepath.Match全量遍历 map,数据量小无所谓,但 L1 键极多时也是 O(n)。面试可提"用 Redis 的 Lua 脚本把 SCAN+DEL 合并成原子操作"作为改进点(尽管 SCAN 本身无法放进单条 Lua,实践中常用UNLINK异步删 + 应用层 SCAN 组合)。
面试加分:延迟双删(Delayed Double Delete)。 本项目"先更新 DB 再删缓存"在绝大多数情况下足够,但仍存在一个极小概率的竞态:删缓存之后、DB 更新完成之前,若恰好有一个读请求发生,它会读到旧值并写回缓存,造成短暂脏数据。更严谨的做法是在"更新 DB 后删一次缓存"的基础上,延迟几百毫秒再删第二次(延迟双删),把那段竞态窗口里可能被写回的旧值再清掉一次。代价是引入一个延迟任务(如消息队列或 time.AfterFunc),复杂度上升,所以本项目用"TTL 自然过期"兜底而非上延迟双删——这也是一个明确的工程权衡,面试时主动说出来比被追问更能加分。
十一、什么该缓存、什么不该
Q13. 站在云盘项目,总结一下缓存的边界?
答: 一张对比图收口:
flowchart TD
P[缓存决策] --> A[该缓存]
P --> B[不该缓存]
A --> A1[文件列表第一页
读多写少]
A --> A2[文件元数据 file:meta
读多 TTL 2min]
A --> A3[存储统计 storage
首页高频读 TTL 5min]
B --> B1[密码 / 令牌明文
安全敏感]
B --> B2[秒传计数 / 实时在线
写频率接近读]
B --> B3[强一致性金额类
不允许秒级延迟]本项目的落地原则,用一句话概括:凡是"读多写少、允许秒级延迟、非安全敏感"的数据就缓存;写操作后必须按 key / 前缀失效(Cache-Aside 的删缓存)。
具体落点回顾:
ListFiles只缓存第一页(cursor == ""时),翻页结果不缓存——因为翻页结果用游标分页,缓存过期页会导致数据错位,所以代码里明确if cursor == ""才走缓存。getCachedFile缓存文件元数据,TTLfileMetaCacheTTL = 2 * time.Minute(较短,避免文件被改/删后长时间脏数据)。GetStorageInfo缓存 5min±抖动,因为存储统计变化慢。
读路径 fail-open vs 写路径 fail-closed——一个贯穿全篇的设计哲学。 本项目对"读"和"写"的容错态度是故意相反的:
- 读路径 fail-open:Redis 挂了 / 布隆拦截 / 熔断打开,读都"返回空值、视为未命中",让上游去回源 DB。读失败绝不应该把整个请求搞成 5xx,缓存本来就是"加速层",它不在了业务还能跑(只是慢)。
- 写路径 fail-closed(严格):
Set/Delete到 Redis 失败时返回err(RecordFailure并向上抛),因为"写缓存失败"意味着"下次读会拿到旧数据或穿透",属于数据一致性问题,应当被感知。Set里 Redis 写失败会return err,调用方(biz层)虽然多数用_ =吞掉,但 Redis 层已经RecordFailure计入熔断,机制上是 fail-closed 的。
这种"读宽松、写严格"的二分法,是缓存系统最稳妥的默认姿态,面试时能从哲学层面讲出来,比背八股高一个段位。
⚠️ 坑:本地 L1 没有容量上限(真实代码隐患)。回头看
hotCache,它只是一个map[string]*hotCacheItem+ 惰性过期(读时发现有过期才Delete),没有任何 maxSize、没有 LRU/LFU 淘汰。如果系统里 distinct key 极多、且 30s 内不断有新 key 写入,map 会持续膨胀直到 OOM——因为惰性删除只在"被读到"时触发,那些"写过但再也没被读"的 key 会一直赖在内存里。本项目因为缓存的 key 种类有限(文件列表/元数据/存储统计),实际不会爆,但作为面试复盘要能指出:生产级本地缓存应当加上容量上限 + 主动淘汰(如ristretto、go-cache或简单 LRU)。这也反过来解释了为什么本项目把 L1 的 TTL 压到defaultLocalTTL = 30s这么短:即使没有主动淘汰,较短的 TTL 也能让"写过但不再读"的 key 更快过期,变相缓解内存无限膨胀的压力。
十二、面试延伸与改进点
Q14. 如果要让这套缓存更稳,你还会加什么?
答: 把前面埋的伏笔收一下,作为面试的"加分项":
- singleflight 防击穿:在回源 DB 那一层用
golang.org/x/sync/singleflight,对同一 key 的并发回源只放一个 goroutine 去查,其余共享结果。本项目靠 L1 兜底,但重启瞬间 L1 为空时仍可叠加 singleflight。 - Redis Lua 原子删 / 分布式锁:如果需要"先查后删"的原子性(如删缓存同时要保证回源时别的请求不写脏),可用
Eval执行 Lua 脚本(本项目Cache接口已预留Eval方法,注释写明"用于分布式锁")。 - 本地缓存与 Redis 一致性权衡:L1 是每实例独享,实例越多、L1 脏数据窗口越分散。可选方案:用 Redis 的 pub/sub 在"删缓存"时广播通知所有实例清本地 L1(主动失效),把失效延迟从"等 TTL"降到"秒级广播"。但引入 pub/sub 又增加复杂度,需要权衡。
- 布隆过滤器持久化 / 预热:当前
BloomGuard进程内、重启即空,靠warmupThreshold=100放行。生产可换 RedisBloom 或启动时从 DB 预热,让重启也立刻有穿透防护。 - 熔断器改成"时间窗口滑动计数":当前是"连续失败"语义,可改为"最近 N 秒失败率超阈值才熔断",更贴合真实故障特征。
自测题与动手练习
自测题(合上书能答出来,才算懂):
- 多级缓存
Get的链路顺序是什么?其中哪几层是 fail-open(返回空值)的?为什么这么设计? - 布隆过滤器的"一定不存在"和"可能存在"分别由什么保证?
bloomFalsePositiveRate=0.01和bloomWarmupThreshold=100各自解决什么问题? - 缓存穿透、雪崩、击穿的定义和成因有什么区别?本项目分别用哪三个机制应对(BloomGuard / TTL 抖动+熔断 / L1 热缓存)?
- 熔断器从 Closed 到 Open 到 HalfOpen 再回到 Closed 的条件各是什么?
failThreshold=5、openTimeout=30s、successThreshold=2分别作用在哪一跳? - 缓存一致性里为什么"先更新 DB 再删缓存"?本项目对"删缓存失败"的态度是什么?
DeleteByPattern用的是KEYS还是SCAN?为什么?
动手练习(建议真做一遍):
- 在本地起 Redis,给
BloomGuard的bloomWarmupThreshold改成 0,重启服务后用一个"从没写入过"的 key 调Get,观察是否立刻被拦截(模拟冷启动误杀),再改回 100 验证放行。 - 用
redis-cli --scan --pattern 'cloud_disk:cache:files:list:*'观察invalidateFileListCache执行后匹配键是否被清空,并对比KEYS命令在大数据量下的阻塞差异。 - 给
GetStorageInfo的回源 DB 那一层包一层singleflight.Group,写个并发压测(如go test -race+ 100 并发)验证同一 key 的 DB 查询次数从 N 降到 1。
本章小结
- 云盘缓存的本质是用空间换时间:针对"文件列表、存储统计"这类读多写少、允许秒级延迟的数据,用 L1 本地热缓存(快、独享)+ L2 Redis(共享、容量大)挡在 DB 前。
- 多级缓存读取链路是 L1 → 布隆 → 熔断 → Redis → 回填 L1;写入是 L1 + 布隆 + Redis 三层齐写。读路径 fail-open、写路径 fail-closed,是"缓存故障不要拖垮业务"的核心原则。
- 缓存三件套的对策要一一对应:穿透靠 BloomGuard 在查 Redis 前拦截不存在的 key;雪崩靠 TTL 抖动(
Nanosecond()%30)+ 熔断器 fail-open 降级;击穿靠 L1 热缓存吸收大部分读,再用 singleflight 补强。 - 缓存一致性走 Cache-Aside(写后删缓存,而非更新),本项目删缓存失败仅记日志、容忍短暂脏读;模糊删用
SCAN而非阻塞的KEYS。这些"故意的取舍"本身就是面试里最能体现工程权衡的加分点。 - 下一篇可以接着看分布式锁与 Redis Lua 原子操作,它在"缓存击穿的 singleflight 跨实例版"和"秒传去重的并发安全"里会用到,正好承接本章的延伸点。
- 三层架构原则:L1 本地缓存(快+独享)+ L2 Redis(共享+持久)+ Bloom 前置过滤,每层解决不同问题。
- Fail-open vs Fail-closed:读路径 fail-open(宁可返回旧数据也不能让系统崩),写路径 fail-closed(缓存更新失败必须记录告警)。
- 一致性取舍:Cache-Aside + 延迟双删是大多数场景的最佳平衡;强一致需要放弃缓存或加分布式锁,成本高。
为什么不能删除:
① 布隆过滤器用 bit array 表示集合,没有反向索引
② 删一个元素需要知道哪些 bit 是该元素设置的,但这些 bit 可能被其他元素也设置了
③ 误删会导致其他已存在元素被错误标记为"不存在"
误判后果:
当 BloomFilter 返回"可能存在"时,我们仍要去 Redis 查询——如果 Redis 也没有,说明是 false positive,此时应把结果写入缓存(空值缓存),避免每次都被拦截但实际数据不存在的情况。
面试加分点:提到误判率公式
p = (1 - e^(-kn/m)) ^ k,其中 k 是 hash 函数个数,m 是 bit 数组长度,n 是元素数量。增大 m 或减小 k 可以降低误判率。