Kratos 云盘项目:多级缓存与缓存三大问题

2025-01-15T10:30:00+08:00 | 33分钟阅读 | 更新于 2025-01-15T10:30:00+08:00

@

学习目标

学完本章你应该能够:

  1. 用自己的话讲清楚云盘项目为什么需要缓存——从"文件列表、存储统计"这类读多写少场景出发,量化缓存能扛下的流量。
  2. 画出并解释 L1(本地 hot cache)+ L2(Redis)多级缓存Get/Set 全流程,包括布隆过滤器(防穿透)和熔断器(防雪崩)在链路中的插入位置。
  3. 缓存穿透 / 雪崩 / 击穿三件套成因、本项目对策讲成一个完整的故事,并说清 BloomGuard、TTL 抖动、L1 热缓存各自解决的是哪一个。
  4. 讲透熔断器三状态机(Closed → Open → HalfOpen)在本项目里的状态转换条件与阈值(failThreshold=5openTimeout=30s)。
  5. 面试时能把"写后删缓存的一致性时序、DeleteByPattern 模糊删的 SCAN 改进、singleflight 防击穿"等延伸点讲清楚,并承认当前实现的权衡与不足。

前置知识:Go 基础(sync.RWMutex、接口、time.Duration);Redis 基础(GET/SET/DEL/SCAN);HTTP 读多写少场景直觉;Kratos 分层(biz / data)与 google/wire 依赖注入。

本章你会动手做的事

  1. internal/data/cache/multi_level_cache.go 里跟一遍 Get 的每一层短路逻辑,标出哪些是"fail-open(返回空值)"。
  2. BloomGuard.Allow 的冷启动阈值 bloomWarmupThreshold=100 改成 0,推演一次"重启后空过滤器把所有读都误杀"的事故。
  3. 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.goGet):

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
}

几个工程要点:

  1. L1 永远第一关——命中就返回,连 Redis 都不碰。这是多级缓存性能的根。
  2. 布隆放 L1 之后、Redis 之前——只对"L1 没命中、但 key 可能存在于缓存"的请求放行,拦截掉那些根本不可能有值的 key(防穿透,下节细讲)。
  3. 熔断放 Redis 之前——Redis 已经挂了就别再发请求去雪上加霜,直接 fail-open 返回空,让上游回源。
  4. 回填 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(旁路缓存)模式的两大动作:

  1. 读(Read-Aside):先查缓存,未命中则查 DB,再把结果写回缓存。
  2. 写(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 失败被 _ = 吞掉了。本实现里 invalidateFileListCacheDeleteByPattern 的返回错误直接忽略。这意味着"删缓存失败"只会在 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[可能存在
有误判可能]

原理拆解:

  1. 数据结构:一个长度为 m 的 bit 数组(初始全 0),加 k 个互相独立的哈希函数。本项目用 bloom.NewWithEstimates(1e6, 0.01) 自动根据"预计元素数 100 万、误判率 1%“反算出合适的 mk
  2. 写入(Add):对 key 跑 k 个哈希,得到 k 个下标,把 bit 数组里这些位置都置 1。
  3. 查询(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 = 1e6p = 0.01m ≈ 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 取到值都会回填 L1Get 里的 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 // 成功调用清零失败计数
	}
}

把状态机翻译成人话:

  1. Closed(关闭,正常放行):每次成功 RecordSuccess 会清零 failCount;一旦连续失败累计到 failThreshold=5,切到 Open
  2. Open(打开,快速失败)IsOpen() 返回 true,所有读/写请求直接短路(本项目返回空 / 跳过 Redis)。但 IsOpen() 内部有个巧妙逻辑:如果距离上次失败已超过 openTimeout=30s,它会自动把状态改成 HalfOpen(并清零 successCount),给 Redis 一个恢复的机会。
  3. 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.goDeleteByPattern 在 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-cliKEYS 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 缓存文件元数据,TTL fileMetaCacheTTL = 2 * time.Minute(较短,避免文件被改/删后长时间脏数据)。
  • GetStorageInfo 缓存 5min±抖动,因为存储统计变化慢。

读路径 fail-open vs 写路径 fail-closed——一个贯穿全篇的设计哲学。 本项目对"读"和"写"的容错态度是故意相反的:

  • 读路径 fail-open:Redis 挂了 / 布隆拦截 / 熔断打开,读都"返回空值、视为未命中",让上游去回源 DB。读失败绝不应该把整个请求搞成 5xx,缓存本来就是"加速层",它不在了业务还能跑(只是慢)。
  • 写路径 fail-closed(严格)Set/Delete 到 Redis 失败时返回 errRecordFailure 并向上抛),因为"写缓存失败"意味着"下次读会拿到旧数据或穿透",属于数据一致性问题,应当被感知。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 种类有限(文件列表/元数据/存储统计),实际不会爆,但作为面试复盘要能指出:生产级本地缓存应当加上容量上限 + 主动淘汰(如 ristrettogo-cache 或简单 LRU)。这也反过来解释了为什么本项目把 L1 的 TTL 压到 defaultLocalTTL = 30s 这么短:即使没有主动淘汰,较短的 TTL 也能让"写过但不再读"的 key 更快过期,变相缓解内存无限膨胀的压力。


十二、面试延伸与改进点

Q14. 如果要让这套缓存更稳,你还会加什么?

答: 把前面埋的伏笔收一下,作为面试的"加分项":

  1. singleflight 防击穿:在回源 DB 那一层用 golang.org/x/sync/singleflight,对同一 key 的并发回源只放一个 goroutine 去查,其余共享结果。本项目靠 L1 兜底,但重启瞬间 L1 为空时仍可叠加 singleflight。
  2. Redis Lua 原子删 / 分布式锁:如果需要"先查后删"的原子性(如删缓存同时要保证回源时别的请求不写脏),可用 Eval 执行 Lua 脚本(本项目 Cache 接口已预留 Eval 方法,注释写明"用于分布式锁")。
  3. 本地缓存与 Redis 一致性权衡:L1 是每实例独享,实例越多、L1 脏数据窗口越分散。可选方案:用 Redis 的 pub/sub 在"删缓存"时广播通知所有实例清本地 L1(主动失效),把失效延迟从"等 TTL"降到"秒级广播"。但引入 pub/sub 又增加复杂度,需要权衡。
  4. 布隆过滤器持久化 / 预热:当前 BloomGuard 进程内、重启即空,靠 warmupThreshold=100 放行。生产可换 RedisBloom 或启动时从 DB 预热,让重启也立刻有穿透防护。
  5. 熔断器改成"时间窗口滑动计数":当前是"连续失败"语义,可改为"最近 N 秒失败率超阈值才熔断",更贴合真实故障特征。

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 多级缓存 Get 的链路顺序是什么?其中哪几层是 fail-open(返回空值)的?为什么这么设计?
  2. 布隆过滤器的"一定不存在"和"可能存在"分别由什么保证?bloomFalsePositiveRate=0.01bloomWarmupThreshold=100 各自解决什么问题?
  3. 缓存穿透、雪崩、击穿的定义和成因有什么区别?本项目分别用哪三个机制应对(BloomGuard / TTL 抖动+熔断 / L1 热缓存)?
  4. 熔断器从 Closed 到 Open 到 HalfOpen 再回到 Closed 的条件各是什么?failThreshold=5openTimeout=30ssuccessThreshold=2 分别作用在哪一跳?
  5. 缓存一致性里为什么"先更新 DB 再删缓存"?本项目对"删缓存失败"的态度是什么?DeleteByPattern 用的是 KEYS 还是 SCAN?为什么?

动手练习(建议真做一遍)

  1. 在本地起 Redis,给 BloomGuardbloomWarmupThreshold 改成 0,重启服务后用一个"从没写入过"的 key 调 Get,观察是否立刻被拦截(模拟冷启动误杀),再改回 100 验证放行。
  2. redis-cli --scan --pattern 'cloud_disk:cache:files:list:*' 观察 invalidateFileListCache 执行后匹配键是否被清空,并对比 KEYS 命令在大数据量下的阻塞差异。
  3. 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 + 延迟双删是大多数场景的最佳平衡;强一致需要放弃缓存或加分布式锁,成本高。
面试官
BloomFilter 为什么不能用于"删除"操作?如果误判为存在,会发生什么?
候选人
Bloom Filter 的误判只有一种方向:可能误报(false positive),绝不会漏报(false negative)

为什么不能删除
① 布隆过滤器用 bit array 表示集合,没有反向索引
② 删一个元素需要知道哪些 bit 是该元素设置的,但这些 bit 可能被其他元素也设置了
③ 误删会导致其他已存在元素被错误标记为"不存在"

误判后果
当 BloomFilter 返回"可能存在"时,我们仍要去 Redis 查询——如果 Redis 也没有,说明是 false positive,此时应把结果写入缓存(空值缓存),避免每次都被拦截但实际数据不存在的情况。

面试加分点:提到误判率公式 p = (1 - e^(-kn/m)) ^ k,其中 k 是 hash 函数个数,m 是 bit 数组长度,n 是元素数量。增大 m 或减小 k 可以降低误判率。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!