多级缓存模块实现教程

2025-08-01T14:11:02+08:00 | 32分钟阅读 | 更新于 2025-08-01T14:11:02+08:00

@

学习目标

学完本章你应该能够:

  1. 讲清"多级缓存"的分层思路:L1 本地热缓存 + L2 Redis + 布隆过滤器 + 熔断器,并分清两个 Cache 接口——业务层 biz.Cache 只有 5 个方法,数据层 cache.Cache 是 8 个方法的实现,二者由 newBizCache 适配器桥接。
  2. 用一句话区分缓存穿透 / 击穿 / 雪崩,并讲清本项目实际装配multiLevelCache 各自用了什么手段(布隆 + 空值缓存 / L1 短 TTL / TTL 抖动 + 熔断),以及其中隐含的两个商用坑(布隆冷启动误杀、熔断 fail-open 打到 DB)。
  3. 用类比解释布隆过滤器的原理,讲清它"有误判、无漏判"的特性、误判率公式里 mk 的作用,并说明为什么进程内布隆过滤器必须在启动时预热
  4. 画出熔断器的三态状态机(Closed / Open / HalfOpen)与状态转换条件,说明它如何防止故障级联扩散,以及它在缓存层"降级返回空"的真实后果。
  5. 在面试里把"分布式锁为什么要用随机 token + Lua 原子释放"讲清楚,并知道 CacheGuard.FetchWithGuard 里的 SETNX 单飞是参考实现(当前未接入主链路),商用上线应把它接到读路径上。
  6. 说清本项目真正的缓存一致性语义是 Cache Aside(读填 写失效),而不是"写时双写";并讲清楚 Redis 故障时应 fail-open 还是 fail-closed 的取舍。

前置知识

  • Redis 基础命令:Get / Set / SETNX / EVAL(Lua)/ SCAN
  • Go 并发基础:sync.RWMutexsync.Mapsync/atomic、接口与组合。
  • 对 Kratos / Wire 依赖注入有基本了解(本章用 ProviderSet + newBizCache 适配器组装,不影响理解主流程)。

本章你会动手做的事

  • jitterTTL 观察同一基础 TTL 被随机打散到 ±30% 区间。
  • 用一个"一定不存在"的 key 走 BloomGuard.Allow,验证直接返回空、不查 Redis;再重启进程观察"冷启动误杀"现象。
  • 让 Redis 调用连续失败 5 次,观察熔断器打开,再等冷却后进入半开、最终恢复。

一、技术栈与中间件

技术 / 中间件所属层用途说明
github.com/redis/go-redis/v9L2Redis 客户端 SDK,作为分布式二级缓存,提供 Get / Set / Del / Exists / Expire / Incr / SetNX / Scan / Eval 等命令
github.com/bits-and-blooms/bloom/v3防穿透bits-and-blooms 布隆过滤器库,用于缓存穿透防护,支持 NewWithEstimates(按预期元素数与误判率生成)、AddString / TestStringMarshalBinary / UnmarshalBinary 序列化
Go 标准库 syncL1sync.RWMutex 保护本地热缓存与布隆过滤器的并发读写;sync.Map 作为 CacheGuard 中的短 TTL 本地热缓存
Go 标准库 time通用TTL 过期判断、熔断器冷却计时、TTL 抖动(基于纳秒做 ±30% 随机打散)
Go 标准库 path/filepathL1本地热缓存 DeleteByPattern 使用 filepath.Match 做通配符匹配
Go 标准库 crypto/rand生成 SETNX 分布式锁的随机 token(16 字节 hex),防止误删他人持有的锁
github.com/google/wire装配依赖注入框架;cache.ProviderSet 提供 NewMultiLevelCachecmd/server/main.gonewBizCache 把它适配成 biz.Cache
github.com/go-kratos/kratos/v3/log观测结构化日志,记录缓存初始化、熔断器降级、布隆过滤器异常等事件
Redis SETNX防击穿「Set if Not eXists」分布式锁原语,用于缓存击穿防护中防止惊群效应(参考实现 CacheGuard
Redis SCAN批量删非阻塞式游标扫描,用于按模式批量删除键(避免 KEYS 阻塞)
Redis EVAL / Lua原子原子执行多步操作,主要用于分布式锁的「判断 token + 删除」原子释放
TTL Jitter(抖动)防雪崩在基础 TTL 上叠加 ±30% 随机偏移,避免大量键同时过期引发雪崩
熔断器模式(Circuit Breaker)防雪崩三态(Closed / Open / HalfOpen)状态机,Redis 故障时快速降级

⚠️ 口径修正:源码里其实有两套 Cache 接口。internal/biz/auth.go 定义的 biz.Cache 只有 Get / Set / Delete / Exists / DeleteByPattern 5 个方法;而 internal/data/cache/cache.gocache.Cache8 个方法(多了 Expire / Incr / Eval)。业务层只依赖前者,multiLevelCache 因为方法更多,结构上同时满足两者,再由 newBizCache 适配注入。下文凡是说"业务层接口"均指 5 方法的 biz.Cache


二、实现思路流程(总体)

多级缓存模块整体遵循「分层缓存 + 三重防护」的设计哲学,实现思路按以下流程展开:

白话:把缓存想成"公司前台 + 档案室 + 保安"的组合。L1 本地缓存是前台手边的小抽屉(最快,但容量小);L2 Redis 是档案室(稍慢,但全公司共享);布隆过滤器是门口的"黑名单本"(查过的人才能进,陌生人直接拦);熔断器是保安(档案室着火就先别放人进去,避免大家挤塌房梁)。

下面这张图先把 multiLevelCache.Get 一次查询的真实决策路径画出来(注意两个菱形里都藏着商用坑):

flowchart TD
    G[Get key] --> L1{L1 本地热缓存命中?}
    L1 -->|命中| Ret[直接返回 零网络开销]
    L1 -->|未命中| BG{布隆过滤器 Allow?}
    BG -->|false 一定不存在| Empty[返回空 防穿透]
    BG -->|true 可能存在| BR{熔断器打开?}
    BR -->|打开| Empty2[降级返回空]
    BR -->|未打开| R2[查 L2 Redis]
    R2 -->|命中| Back[回填 L1 再返回]
    R2 -->|未命中| Src[业务回源 DB 并回填缓存]
  1. 抽象接口先行:业务层(biz)只依赖 5 方法的 Cache 接口,完全不感知底层是纯 Redis 还是多级缓存;数据层 cache.Cache(8 方法)是它的超集实现。

  2. 本地热缓存(L1)internal/data/cache/hot_cache.go 中实现 hotCache,用 sync.RWMutex + map 存储带 TTL 的键值对。TTL 默认 30 秒,用于缓存极热点的 Key,命中后直接返回,避免穿透到 Redis。

  3. Redis 缓存(L2)internal/data/cache/redis_cache.go 中实现 redisCache,通过 go-redis SDK 与 Redis 交互。所有键统一加 cloud_disk:cache: 前缀做命名空间隔离。

  4. 多级缓存编排(实际装配)internal/data/cache/multi_level_cache.gomultiLevelCache 组合 hotCache + redisCache + BloomGuard + CircuitBreaker,是 Wire 真正注入到业务层的实现。Get 按 L1 本地 → 布隆过滤 → 熔断器 → L2 Redis 顺序查询,命中后回填 L1。

  5. 布隆过滤器防穿透(需预热)internal/data/cache/bloom_guard.goBloomGuard进程内内存过滤器,预计 100 万元素、1% 误判率。Set 写缓存时同步 AddGet 时先 Allow 判断,键从未写入则直接返回空。修正点:它是内存态,进程重启后为空,空过滤器对任何 key 都返回 false,会导致冷启动期间所有读都被误杀为"不存在"而穿透到 DB(见 5.2)。internal/data/bloom/bloom.go 另提供了 Redis 持久化版 redisBloomFilter(支持 MarshalBinary 落盘与 Rebuild),但当前未接入主链路、且 restore 从未被调用,只能当作参考实现。

  6. L1 短 TTL 防击穿(实际手段):当前主链路没有分布式锁单飞,multiLevelCache 主要靠 L1 本地缓存(默认 30s TTL)吸收同一 key 在过期瞬间的并发重复回源。SETNX 单飞逻辑在 internal/data/bloom/cache_strategies.goCacheGuard.FetchWithGuard 中,是独立、未被装配的参考实现(见 5.3)。

  7. TTL 抖动 + 熔断器防雪崩jitterTTL 在基础 TTL 上叠加 0.7~1.3 倍随机因子,打散过期时间;CircuitBreaker 连续失败 5 次后打开熔断,30 秒后进入半开探测,2 次成功后恢复。修正点:熔断打开时 Get 返回空(fail-open),调用方会把它当"未命中"继续回源 DB,Redis 宕机时反而可能把 DB 打挂——这不是真正的保护,需要配合 fail-closed 或"只服务 L1 陈旧值"来兜底(见 5.4)。

  8. 缓存读写策略(Cache Aside):读未命中时回源 DB 并回填(Set);写(变更)时调用 Delete 失效缓存(如 storage.go 更新存储后 cache.Delete(user:storage:{id})file.go 写文件后 invalidateFileListCache)。修正点:文章旧版把本项目说成"写时双写 L1+L2+布隆",但真实调用方一律用 Delete 失效,属于经典 Cache Aside(旁路缓存),并非 write-through。

  9. 按模式批量删除DeleteByPattern 在 Redis 侧用 SCAN 游标分批扫描匹配键再 Del,在本地侧用 filepath.Match 通配符匹配删除,避免 KEYS 命令阻塞 Redis。


三、面试常问知识点与难点

1. 缓存穿透、击穿、雪崩三大问题及本项目真实方案

  • 穿透:查询一个数据库中根本不存在的 Key(如恶意攻击),缓存永远不会命中,请求直达 DB。本项目用两层防护:① BloomGuard.Allow 返回 false 的键一定不存在,直接返回空;② 空值缓存(商用补全,见 5.3)——对"查明确实不存在"的结果也缓存一个短 TTL 的空标记,避免同一不存在 key 反复穿透。
  • 击穿:单个热点 Key 在过期瞬间,大量并发请求同时回源 DB。本项目实际装配multiLevelCache 主要靠 L1 本地缓存(默认 30s TTL)在过期瞬间吸收重复回源;SETNX 单飞逻辑在 CacheGuard.FetchWithGuard 中,是参考实现,商用上线应接到读路径上。
  • 雪崩:大量 Key 同时过期或 Redis 宕机,请求全部打到 DB。本项目用 TTL 抖动(±30%)打散过期时间 + 熔断器在 Redis 故障时快速降级。修正点:降级"返回空"默认是 fail-open,会把流量导给 DB,需结合 fail-closed 或陈旧值兜底(见 5.4)。

2. 布隆过滤器原理与误判率

布隆过滤器用一个 m 位的位数组 + k 个哈希函数。插入元素时,对元素做 k 次哈希,将对应 k 个位置置 1;查询时,若 k 个位置全为 1 则「可能存在」(有误判),若有任意一位为 0 则「一定不存在」(无漏判)。误判率公式约为 (1 - e^(-kn/m))^k。本项目 bloom.NewWithEstimates(n=1000000, fp=0.01) 自动计算最优 m 与 k,预期 100 万元素、1% 误判率。

⚠️ 冷启动误杀(务必记牢):空布隆过滤器所有位都是 0,TestString 对任何 key 都返回 false。本项目 BloomGuard 是进程内内存态,进程重启后过滤器为空 → multiLevelCache.Getif !c.guard.Allow(key) 对所有 key 都成立 → 全部返回空 → 缓存形同虚设,直到这些 key 被再次 Set 写回。因此生产必须把布隆过滤器在启动时从持久化副本(DB / Redis 里的 redisBloomFilter 序列化)预热进来,或至少在过滤器为空时退化为"放行"(不拦截),否则缓存会在每次重启后失效一段时间。

3. 缓存一致性:本项目是 Cache Aside(读填 写失效)

修正点(纠旧版口径):旧版把本项目描述成"先写缓存再写 DB"的 write-through 变体,但看真实调用方——UserUsecase.GetStorageInfo 读未命中回源 DB 后 Set 回填;UpdateUsedStorageAtomic / 文件增删改后统一 cache.Delete(...) 失效。这是标准的 Cache Aside(旁路缓存)

  • 读:cache.Get → miss → 查 DB → cache.Set 回填。
  • 写:更新 DB → cache.Delete 失效(下次读自然回源拿到新值)。

这样做的好处是写路径简单、不会出现"双写不一致";代价是失效后到下次读之间有一个"缓存空窗",并发下可能短暂读到旧值(最终一致)。强一致场景才需要"先更新 DB 再删缓存 + 延迟双删"。本项目 L1 TTL 短(30s/5s),不一致窗口很小、能快速自愈。

⚠️ 失效时序坑multiLevelCache.Delete 在熔断器打开时会跳过 Redis(if c.breaker.IsOpen() { return nil }),只清了 L1。这意味着 Redis 故障期间做的写操作,其 L2 缓存没被清掉,Redis 恢复后可能短暂返回陈旧值。商用版应在 Redis 恢复后对这些 key 做一次补偿失效(或依赖 TTL 自然过期)。

4. 热点缓存本地化

极热点 Key(如热门分享链接、首页文件列表)即使在 Redis 也会产生网络瓶颈。hotCache 把这些 Key 缓存在进程内存,命中后零网络开销。CacheGuard 中的 sync.Map 本地缓存进一步用 3~5 秒短 TTL 拦截瞬时并发,避免同一 Key 在极端并发下反复回源。

5. 熔断器设计模式

CircuitBreaker 实现三态状态机:Closed(正常放行)→ 连续失败达阈值(5 次)→ Open(快速失败)→ 等待冷却时间(30s)→ HalfOpen(放行探测请求)→ 探测成功达阈值(2 次)→ Closed。这是经典"断路器"模式,防止故障级联扩散,给下游系统恢复时间。

6. TTL Jitter 打散

若所有缓存 Key 用相同 TTL,会在同一时刻集体过期,引发雪崩。jitterTTL 用当前纳秒时间做随机源,在基础 TTL 上乘以 0.7~1.3 的随机因子,使过期时间均匀分散在 ±30% 区间内,从源头避免集体失效。

⚠️ 口径修正:文件列表缓存 file.go 里实际写的是 ttl := 42*time.Second + time.Duration(time.Now().Nanosecond()%18)*time.Second,区间是 42~60s,并不是 jitterTTL(60s) 的 42~78s。商用版应统一收口到 TTLFileList()(即 jitterTTL(60s)),避免两套抖动算法各自为政。

7. Lua 脚本原子性

Redis 单线程执行 Lua 脚本时,脚本内多条命令不会被其他命令插队。本项目 Cache.Eval 暴露 Lua 执行能力,主要用于分布式锁的「判断 token + 删除」原子操作:若直接 GETDEL,中间可能被其他客户端的命令打断,导致误删他人持有的锁。

8. 缓存淘汰策略

本地 hotCache 用「惰性删除」:Get 时检查过期才删,不主动扫描。Redis 侧依赖 Redis 自身的淘汰策略(如 allkeys-lru)。hotCache 目前未做 LRU 淘汰、靠短 TTL(30s)让条目自然过期,适合「少量极热点 Key」;热点 Key 多则需引入 LRU/LFU(见 四.7)。

9. SCAN 与 KEYS 的差异

KEYS pattern 会阻塞 Redis 主线程扫描全库,生产环境禁用。SCAN cursor MATCH pattern COUNT n 用游标分批返回,每次只扫描少量键,不阻塞。本项目 DeleteByPatternSCAN 循环直到 cursor == 0,兼顾功能与性能。

10. 分布式锁的 SETNX 与 token 校验

SetNX(key, token, ttl) 仅在 key 不存在时设置成功,天然适合做锁。释放锁时不能直接 Del,否则可能误删别人的锁(如持锁协程阻塞超时后锁自动过期,另一协程拿到锁,此时前者醒来直接删会删错)。正确做法是 SetNX 时写入随机 token,释放时用 Lua 脚本「判断 token 相等才删」。

注:这套机制位于 internal/data/bloom/cache_strategies.goCacheGuard,是独立的参考实现,当前主链路 multiLevelCache 并未使用它。商用上线建议把 FetchWithGuard 接到文件列表等热点读路径上,真正堵住击穿。

11. key 设计与序列化(商用补全)

  • key 命名空间redisCache 给所有键加 cloud_disk:cache: 前缀,避免与其它 Redis 业务冲突。但业务层自己拼的 key 缺乏统一域名段——user:storage:%dfiles:list:%d:...file:meta:%dblacklist:token:{jti} 混在一起。修正点:商用应统一成 cloud-disk:{domain}:{id}(如 cloud-disk:user:storage:%dcloud-disk:file:meta:%d),由配置化的全局前缀 + 域名段组成,既防冲突又便于按域 SCAN 批量管理。
  • 序列化:所有值都是 JSON 字符串(文件列表、存储信息)。JSON 可读、易调试,但大对象体积大、CPU 开销高;热点大对象可改 Protobuf / MessagePack,并给结构加版本号字段以便灰度兼容。
  • 热 key 分片:单个超热 key 可加分片后缀(如 cloud-disk:user:storage:%d:{shard})打散到不同 Redis 槽位,缓解单 slot 热点。

12. Redis 故障降级:fail-open 还是 fail-closed(商用补全)

multiLevelCache.Get 在布隆拦截 / 熔断打开时返回 ("", nil)——这是 fail-open:把"拿不到缓存"当成"缓存未命中",调用方继续回源 DB。

  • fail-open 的好处:Redis 抖动时服务不中断,自动退化到 DB。
  • fail-open 的风险:Redis 宕机时,所有读都打到 DB,等于把缓存层该扛的流量全转嫁给 DB,很可能引发 DB 雪崩——这反而违背了加缓存的初衷。

商用取舍:L1 本地缓存是最后一道防线,应始终先服务 L1(已做);L2 miss 且 Redis 不可用时,要么 fail-closed 直接返回错误(让上游限流/排队,保护 DB),要么返回 L1 里的陈旧值(牺牲一点一致性换可用性),而不是无脑回源 DB。 本项目当前默认 fail-open,需要在熔断器分支补一条"保护 DB"的策略(见 5.4)。


四、亿级流量优化思路

针对缓存模块在亿级流量场景的优化方向:

  1. 热点 Key 探测与本地缓存:在网关或 SDK 层统计 Key 访问频次,识别 Top-N 热点 Key,主动推送到所有实例的 hotCache。可引入「滑动窗口计数 + 阈值触发」的热点发现机制,避免单实例 Redis 网卡打满。
  2. 布隆过滤器预热与重建:进程启动时从持久化副本把过滤器 restore 进来(修正冷启动误杀);当实际元素数超过预期容量时误判率会急剧上升,应周期性 Rebuild。亿级场景按业务域拆分多个布隆过滤器(ExistingUserBloom / ExistingFileBloom 已做此拆分),并监控填充率触发扩容。
  3. Redis 集群分片:单实例 Redis 有内存与吞吐上限。亿级流量应部署 Redis Cluster,按 Key 哈希分片到多个节点。go-redis*redis.ClusterClient 兼容 *redis.Client 大部分 API,迁移成本低。注意 SCANEVAL 在集群模式下需用 hash tag 保证同 slot。
  4. 熔断降级与多级回退:Redis 故障时除了降级到 DB,还应设计「DB 慢查询熔断 → 返回兜底默认值 / 静态降级页」的多级回退链路;熔断打开期间优先服务 L1 陈旧值,而非穿透到 DB。
  5. 多级缓存命中率监控:分别统计 L1 本地、L2 Redis、DB 回源的命中率与延迟,上报到 Prometheus。命中率低于阈值(如 L1 < 5%、L2 < 80%)时告警,触发缓存预热或容量调整。
  6. 预加载与预热策略:业务低峰期定时把热点数据批量加载到 Redis;用户登录时预加载其常用文件列表到本地缓存;大促前主动刷新即将到期的热点 Key,避免活动开始瞬间集体回源。
  7. 本地缓存容量治理:亿级场景下 hotCachemap 无上限会 OOM。应引入 LRU + 容量上限(如 hashicorp/golang-lru),或用 bigcache / freecache 等高性能本地缓存库,控制内存占用并提升 GC 效率。
  8. 读写分离与从库分担读:Redis 主从架构下,读请求可分发到从库。go-redis 支持自动路由只读命令到从库(NewFailoverClusterClient),成倍提升读吞吐。注意布隆过滤器的本地内存副本需与 Redis 持久化副本保持同步(启动时 restore)。

五、详细实现流程与代码解析

5.1 多级缓存架构设计(双接口 + multiLevelCache 为实际装配)

实现思路

业务层只依赖 5 方法biz.Cache 接口;数据层 cache.Cache8 方法的超集实现(多了 Expire / Incr / Eval)。Wire 用 cache.ProviderSet 构造出 multiLevelCache,再由 cmd/server/main.gonewBizCache 适配器以 biz.Cache 类型注入到 UserUsecase / FileUsecase / TokenManager 等。

下面这张图把"业务层看到什么、数据层实际组装了什么"一次讲清:

flowchart LR
    Biz[业务层 biz] --> I((biz.Cache 5方法))
    I -. newBizCache 适配器 .-> C[cache.Cache 8方法]
    C --> M[multiLevelCache]
    M --> L1[hotCache
L1 本地 30s] M --> BL[BloomGuard
防穿透 需预热] M --> CB[CircuitBreaker
防雪崩] M --> R2[redisCache
L2 Redis 前缀隔离]

关键代码

业务层接口(internal/biz/auth.go仅 5 方法):

// Cache 是 biz 层使用的缓存层接口
// 避免直接依赖 data/cache 包
type Cache interface {
	Get(ctx context.Context, key string) (string, error)
	Set(ctx context.Context, key string, value string, ttl time.Duration) error
	Delete(ctx context.Context, keys ...string) error
	Exists(ctx context.Context, key string) (bool, error)
	DeleteByPattern(ctx context.Context, pattern string) error
}

数据层接口(internal/data/cache/cache.go8 方法超集):

// Cache 定义缓存层的接口(数据层实现,含 Incr/Expire/Eval)。
type Cache interface {
	Get(ctx context.Context, key string) (string, error)
	Set(ctx context.Context, key string, value string, ttl time.Duration) error
	Delete(ctx context.Context, keys ...string) error
	Exists(ctx context.Context, key string) (bool, error)
	Expire(ctx context.Context, key string, ttl time.Duration) error
	Incr(ctx context.Context, key string) (int64, error)
	Eval(ctx context.Context, script string, keys []string, args ...interface{}) (interface{}, error)
	DeleteByPattern(ctx context.Context, pattern string) error
}

适配器与装配(cmd/server/main.go + cmd/server/wire_gen.go):

// newBizCache 把数据层的 cache.Cache(8 方法)适配成业务层 biz.Cache(5 方法)。
// multiLevelCache 同时满足两者,这里只是做一次类型收窄注入。
func newBizCache(c cache.Cache) biz.Cache {
	return c
}

// wire_gen.go 中实际装配:
//   cacheCache := cache.NewMultiLevelCache(client)
//   bizCache   := newBizCache(cacheCache)
// 随后 bizCache 通过 SetCache 注入到各 Usecase / TokenManager。

多级缓存编排(internal/data/cache/multi_level_cache.go 节选,Get 为真实决策路径):

func (c *multiLevelCache) Get(ctx context.Context, key string) (string, error) {
	// L1:本地热缓存,命中则零网络开销返回
	if val, ok := c.local.Get(key); ok {
		return val, nil
	}

	// 布隆过滤器拦截:该键从未被写入缓存,直接返回空值,避免 Redis 穿透。
	// 修正点:进程重启后 BloomGuard 为空,会对所有 key 返回 false,
	// 导致冷启动期间缓存全部失效——生产需启动时预热过滤器(见 5.2)。
	if !c.guard.Allow(key) {
		return "", nil
	}

	// 熔断器打开时降级返回空值。
	// 修正点:这是 fail-open,调用方会当"未命中"继续回源 DB;
	// Redis 宕机时反而可能把 DB 打挂,需配合 fail-closed / 陈旧值兜底(见 5.4)。
	if c.breaker.IsOpen() {
		return "", nil
	}

	val, err := c.redis.Get(ctx, key)
	if err != nil {
		c.breaker.RecordFailure()
		return "", err
	}
	c.breaker.RecordSuccess()

	if val != "" {
		// 回填本地热缓存,下次命中可直接返回
		c.local.Set(key, val, defaultLocalTTL)
	}
	return val, nil
}

Redis 缓存实现(internal/data/cache/redis_cache.go 节选):

// redisCache 使用 Redis 作为后端实现 Cache 接口。
type redisCache struct {
	rdb    *redis.Client
	prefix string // 用于命名空间隔离的键前缀
}

func NewRedisCache(rdb *redis.Client) Cache {
	return &redisCache{
		rdb:    rdb,
		prefix: "cloud_disk:cache:", // 所有键统一加前缀,避免与其它模块冲突
	}
}

func (c *redisCache) prefixed(key string) string {
	return c.prefix + key
}

func (c *redisCache) Get(ctx context.Context, key string) (string, error) {
	val, err := c.rdb.Get(ctx, c.prefixed(key)).Result()
	if err != nil {
		// 修正点:原接口注释写"返回 redis.Nil",实际实现把未命中归一为 ("", nil),
		// 让上层统一按"空值=未命中"处理,不向上抛 redis.Nil。
		if err == redis.Nil {
			return "", nil
		}
		return "", err
	}
	return val, nil
}

本地热缓存(internal/data/cache/hot_cache.go 节选):

// hotCache 是一个带 TTL 的线程安全本地内存缓存(惰性删除)。
type hotCache struct {
	mu    sync.RWMutex
	items map[string]*hotCacheItem
}

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
}

5.2 布隆过滤器实现(穿透防护 + 冷启动修正)

实现思路

项目提供了两套布隆过滤器实现:

  1. 轻量版 BloomGuardbloom_guard.go):纯内存,预计 100 万元素、1% 误判率,随进程生命周期存在,集成在 multiLevelCache 中作为默认防穿透组件。
  2. 持久化版 redisBloomFilterbloom/bloom.go):内存中维护过滤器,通过 MarshalBinary 把二进制状态序列化到 Redis(48 小时 TTL),并提供了 restore / Rebuild修正点:这套持久化版当前未被主链路使用,restore 也从未被调用,ExistingUserBloom / ExistingFileBloom 只是未被引用的工厂函数。 它真正的价值是给 BloomGuard 提供"启动预热源"。

Allow 返回 false 的键一定不存在,直接跳过 Redis 查询;返回 true 的键可能存在(1% 误判),需进一步查 Redis。Add 在缓存写入成功后调用,把键标记进过滤器。

类比:布隆过滤器像小区快递柜门口的"住户名单灯板"。每入住一户,就在灯板上按几个固定编号按亮几盏灯。查某人是不是住户时,只看他那几个编号的灯——只要有一盏不亮,就一定不是住户(无漏判);但如果几盏都亮,可能是别人刚好也按亮了同样的编号(误判)。所以它说"你可能是住户"时,你得再开门确认一下。

:灯板如果刚装上、一个人都没登记(进程重启),那每盏灯都是灭的——按上面的规则,“只要有一盏不亮就不是住户”,结果所有合法住户也都会被拦在门外。这就是冷启动误杀,必须在开门前先把已入住名单(从 DB / Redis 副本)灌进灯板。

flowchart LR
    K[键 user:123] --> H1[哈希函数 1]
    K --> H2[哈希函数 2]
    K --> H3[哈希函数 k]
    H1 --> B[(位数组 m 位)]
    H2 --> B
    H3 --> B
    B -->|查询时 k 位全为 1| Maybe[可能存在 有误判率]
    B -->|任一位为 0| No[一定不存在 无漏判]

关键代码

轻量内存版(internal/data/cache/bloom_guard.go):

const (
	bloomExpectedElements = 1000000
	bloomFalsePositiveRate = 0.01
)

// BloomGuard 使用布隆过滤器防止缓存穿透。
type BloomGuard struct {
	mu     sync.RWMutex
	filter *bloom.BloomFilter
}

func NewBloomGuard() *BloomGuard {
	return &BloomGuard{
		// 修正点:空过滤器对所有 key 都返回 false,
		// 生产要在 NewMultiLevelCache 之后调用 WarmFrom(ctx, keys) 预热。
		filter: bloom.NewWithEstimates(bloomExpectedElements, bloomFalsePositiveRate),
	}
}

func (g *BloomGuard) Allow(key string) bool {
	g.mu.RLock()
	defer g.mu.RUnlock()
	return g.filter.TestString(key)
}

func (g *BloomGuard) Add(key string) {
	g.mu.Lock()
	defer g.mu.Unlock()
	g.filter.AddString(key)
}

// 修正点(商用补全):启动时从全量已有 key 预热过滤器,避免冷启动误杀。
// 真实 key 集合可从 DB / Redis 副本拉取后批量 Add。
func (g *BloomGuard) WarmFrom(keys []string) {
	g.mu.Lock()
	defer g.mu.Unlock()
	for _, k := range keys {
		g.filter.AddString(k)
	}
}

Redis 持久化版(internal/data/bloom/bloom.go 节选,参考实现,需接入才生效):

// redisBloomFilter 内存过滤器 + Redis 持久化,支持跨重启恢复。
type redisBloomFilter struct {
	rdb      *redis.Client
	filter   *bloom.BloomFilter
	redisKey string // "cloud_disk:bloom:" + name
	n        uint
	fp       float64
}

// persist 把过滤器状态序列化到 Redis(48h TTL)。
func (b *redisBloomFilter) persist(ctx context.Context) error {
	b.mu.RLock()
	data, err := b.filter.MarshalBinary()
	b.mu.RUnlock()
	if err != nil {
		return err
	}
	return b.rdb.Set(ctx, b.redisKey, data, 48*time.Hour).Err()
}

// restore 从 Redis 恢复过滤器状态。
// 修正点:当前无人调用——应在 NewMultiLevelCache 装配后主动 restore,
// 再喂给 BloomGuard.WarmFrom,才能真正解决冷启动问题。
func (b *redisBloomFilter) restore(ctx context.Context) error {
	data, err := b.rdb.Get(ctx, b.redisKey).Bytes()
	if err != nil {
		if errors.Is(err, redis.Nil) {
			return nil
		}
		return err
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.filter.UnmarshalBinary(data)
}

5.3 缓存击穿防护(L1 短 TTL 为实际手段 + SETNX 单飞为参考 + 空值缓存补全)

实现思路

实际装配路径multiLevelCache.Get)对击穿的防护主要靠 L1 本地缓存的短 TTL(默认 30s):热点 key 过期瞬间,并发请求里先到的几个会回源,但同一进程内后续请求在 30s 内直接命中 L1,不会全部打到 Redis/DB。问题是它跨进程无效——多实例部署时每个实例各自回源一次。

参考实现internal/data/bloom/cache_strategies.goCacheGuard.FetchWithGuard)用 Redis SETNX 抢占分布式锁,保证「全局只有一个协程回源」,其余短暂等待后读本地热缓存。修正点:该实现独立存在、未被 Wire 装配,商用上线应把它接到文件列表等热点读路径上,才能真正跨进程防击穿。

空值缓存(穿透/击穿通用补全):对"DB 明确查不到"的 key(如不存在的用户/文件),也写入一个带短 TTL 的空标记(NULL 字符串或特殊前缀),避免同一不存在 key 被反复打到 DB。它和布隆过滤器互补:布隆负责"大概率不存在"的快速拦截,空值缓存负责"确认不存在"后的短期兜底,且不受冷启动影响。

白话:缓存击穿的"惊群效应"就像一群人同时发现自动贩卖机卡货,一窝蜂去拍。正确做法是:第一个人拿到"维修权"(SETNX 抢锁)进去补货,其余人乖乖在门口等 50ms,等补货的人出来顺手把货塞进旁边的"临时取货架"(本地热缓存),门外的人再一拿就有。绝不让所有人同时冲进去补货。

sequenceDiagram
    participant C as 并发请求
    participant L as SETNX 分布式锁
    participant DB as 数据库
    participant LC as 本地热缓存
    C->>L: 抢锁 rdb.SetNX
    alt 抢到锁
        L-->>DB: 仅一个协程回源
        DB-->>LC: 写缓存 + 本地热缓存
    else 未抢到锁
        L-->>LC: 等待 50ms 后读本地热缓存
    end

关键代码

缓存击穿防护核心(internal/data/bloom/cache_strategies.go 节选,参考实现):

// FetchWithGuard 走完整防护链路:熔断 → 布隆 → 本地热缓存 → Redis → 加锁回源。
// 修正点:这是参考实现,主链路 multiLevelCache 未调用它;
// 商用应把它接到 ListFiles 等热点读上,真正启用 SETNX 单飞。
func (g *CacheGuard) FetchWithGuard(ctx context.Context, cacheKey, bloomKey string, ttl time.Duration, fetch FetchFunc) (string, bool, error) {
	if g.circuitOpen.Load() {
		// 熔断打开:降级直接查 DB(修正点:这里仍是 fail-open,需配合 fail-closed 兜底)
		val, err := fetch(ctx)
		if err == nil {
			g.cache.Set(ctx, cacheKey, val, jitterTTL(ttl/10))
		}
		return val, false, err
	}

	if bloomKey != "" && g.bloom != nil {
		exists, _ := g.bloom.Test(ctx, bloomKey)
		if !exists {
			return "", false, nil // 一定不存在,直接返回空
		}
	}

	// 第 2 层:本地热缓存(sync.Map,5s TTL)
	if cached, ok := g.local.Load(cacheKey); ok {
		entry := cached.(*localCacheEntry)
		if time.Now().Before(entry.expireAt) {
			return entry.value, true, nil
		}
	}

	// 第 3 层:Redis 缓存
	val, err := g.cache.Get(ctx, cacheKey)
	if err == nil && val != "" {
		g.local.Store(cacheKey, &localCacheEntry{value: val, expireAt: time.Now().Add(3 * time.Second)})
		return val, true, nil
	}

	// 第 4 层:SETNX 抢锁,防止惊群
	lockKey := "lock:" + cacheKey
	lockToken := generateLockToken()
	locked, _ := g.rdb.SetNX(ctx, lockKey, lockToken, 3*time.Second).Result()

	if locked {
		defer g.rdb.Del(ctx, lockKey) // 修正点:生产应改用 Lua 校验 lockToken 再删,见 三.10
		// 双重检查:抢锁后再查一次缓存
		if v, e := g.cache.Get(ctx, cacheKey); e == nil && v != "" {
			return v, true, nil
		}
		val, err = fetch(ctx)
		if err != nil {
			return "", false, err
		}
		g.cache.Set(ctx, cacheKey, val, jitterTTL(ttl))
		g.local.Store(cacheKey, &localCacheEntry{value: val, expireAt: time.Now().Add(5 * time.Second)})
		return val, false, nil
	}

	// 第 5 层:未抢到锁,短暂等待后重试本地缓存 / 直接回源
	time.Sleep(50 * time.Millisecond)
	if cached, ok := g.local.Load(cacheKey); ok {
		if time.Now().Before(cached.(*localCacheEntry).expireAt) {
			return cached.(*localCacheEntry).value, true, nil
		}
	}
	val, err = fetch(ctx)
	g.cache.Set(ctx, cacheKey, val, jitterTTL(ttl))
	return val, false, nil
}

空值缓存补全(商用)——在 GetStorageInfo 这类读路径上,对"确认不存在"的结果也做短期缓存:

const nullCacheMarker = "__NULL__"

// GetStorageInfo 带缓存返回用户存储统计;对"用户不存在"也缓存空标记防穿透。
func (uc *UserUsecase) GetStorageInfo(ctx context.Context, userID uint64) (*StorageInfo, error) {
	if uc.cache != nil {
		if val, err := uc.cache.Get(ctx, fmt.Sprintf(cacheKeyUserStorage, userID)); err == nil && val != "" {
			if val == nullCacheMarker {
				return nil, biz.ErrUserNotFound // 命中空值缓存,直接返回
			}
			var info StorageInfo
			if json.Unmarshal([]byte(val), &info) == nil {
				return &info, nil
			}
		}
	}
	total, used, err := uc.repo.GetUserStorage(ctx, userID)
	if err != nil {
		if errors.IsNotFound(err) {
			// 修正点:确认不存在,缓存短 TTL 空标记,避免反复穿透
			if uc.cache != nil {
				_ = uc.cache.Set(ctx, fmt.Sprintf(cacheKeyUserStorage, userID), nullCacheMarker, 60*time.Second)
			}
			return nil, biz.ErrUserNotFound
		}
		return nil, err
	}
	// ... 组装 info 并回填(见 5.5)...
}

5.4 缓存雪崩防护(TTL jitter + 熔断器 + fail-open 修正)

实现思路

雪崩有两个诱因:大量 Key 同时过期、Redis 宕机。本项目用两道防线:

  1. TTL 抖动jitterTTL 在基础 TTL 上乘以 0.71.3 的随机因子(用当前纳秒做随机源),使过期时间分散在 ±30% 区间,避免集体失效。**修正点:文件列表实际写的是 4260s 的手写抖动,应统一收口到 jitterTTL / TTLFileList()(42~78s),避免两套算法。**
  2. 熔断器CircuitBreaker 三态状态机。Redis 连续失败 5 次后打开熔断,30 秒后进入半开放行探测请求,2 次成功后恢复 Closed。修正点:熔断打开时 Get 返回 ("", nil) 是 fail-open,调用方继续回源 DB——Redis 宕机时反而可能把 DB 打挂。 商用应改为:L1 命中照常返回;L1 未命中且 Redis 不可用时,要么 fail-closed 直接返回错误(让上游限流保护 DB),要么返回 L1 陈旧值,而不是无脑穿透到 DB。

白话:熔断器就像家里的"漏电保护开关"。平时合着(Closed),电流正常;一旦连续跳闸太多次(连续失败达阈值),开关直接弹开(Open),切断后续请求,避免全家电路烧掉;等过一会儿(冷却时间)开关半合(HalfOpen)试探一下,如果用电正常就彻底合上(Closed),还不行就继续弹开。它保护的是"下游别被你一波流打死"——但注意,本项目现在只是"跳闸后让大家改走楼梯(DB)",楼梯(DB)同样会被踩塌,需要再加一道"楼梯限流"。

stateDiagram-v2
    [*] --> Closed
    Closed --> Open: 连续失败达阈值 5 次
    Open --> HalfOpen: 冷却 30s 后
    HalfOpen --> Closed: 探测成功 2 次
    HalfOpen --> Open: 探测失败
    Closed --> Closed: 成功则重置失败计数

关键代码

TTL 抖动算法(internal/data/bloom/cache_strategies.go 节选):

// jitterTTL 在基础 TTL 上叠加 ±30% 随机抖动,防止缓存雪崩。
func jitterTTL(base time.Duration) time.Duration {
	if base <= 0 {
		base = 60 * time.Second
	}
	ns := time.Now().Nanosecond()
	factor := 0.7 + float64(ns%1000)/1000.0*0.6 // 0.7 ~ 1.3
	return time.Duration(float64(base) * factor)
}

// 不同数据类型的 TTL 推荐(均带 ±30% 抖动)。
func TTLFileList() time.Duration   { return jitterTTL(60 * time.Second) }
func TTLUserInfo() time.Duration   { return jitterTTL(300 * time.Second) }
func TTLShare() time.Duration      { return jitterTTL(60 * time.Second) }
func TTLRecycleList() time.Duration { return jitterTTL(120 * time.Second) }

熔断器实现(internal/data/cache/circuit_breaker.go 节选):

const (
	defaultFailThreshold    = 5
	defaultSuccessThreshold = 2
	defaultOpenTimeout      = 30 * time.Second
)

type CircuitBreaker struct {
	mu              sync.RWMutex
	state           State
	failCount       int32
	successCount    int32
	failThreshold   int32
	successThreshold int32
	openTimeout     time.Duration
	lastFailureTime time.Time
}

// IsOpen 判断熔断器是否打开;若冷却时间已过,自动从 Open 转 HalfOpen。
// 修正点:每次调用都取写锁,高并发下有一定锁竞争;
// 且转为 HalfOpen 时只重置 successCount 不重置 failCount,属可优化的小瑕疵。
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
}

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
		}
	}
}

fail-open 修正(商用)——把 multiLevelCache.Get 的熔断分支从"穿透到 DB"改为"保护 DB":

// 修正点:熔断打开时不再无脑穿透到 DB。
// 先尝试 L1(已在函数最前面处理);L1 未命中则 fail-closed 返回错误,
// 由上游限流/排队保护 DB,或调用方可选择返回陈旧默认值。
if c.breaker.IsOpen() {
	// 可选:返回特定 sentinel error,让上层决定是排队重试还是降级响应
	return "", ErrCacheUnavailable
}

5.5 缓存读写策略与一致性(Cache Aside:读填 写失效)

实现思路

修正点(纠旧版口径):本项目真实语义是 Cache Aside(旁路缓存),不是"写时双写"。

  • :先查 L1 本地,未命中查 L2 Redis,仍未命中由业务层回源 DB 并 Set 回填缓存。
  • 写(变更):更新 DB 后调用 Delete 失效缓存——storage.go 更新存储统计后 cache.Delete(user:storage:{id})file.go 增删改文件后 invalidateFileListCacheDeleteByPattern(files:list:{uid}:*) 清文件列表。
  • 自增Incr 时先删 L1 再操作 L2(本地缓存是字符串,无法原子自增,删掉避免读到旧值)。
  • TTLExpire 仅作用于 Redis(本地缓存靠 Set 时的 TTL 控制)。
flowchart TD
    R[读请求] --> C{缓存命中?}
    C -->|是| H[返回 并命中 L1]
    C -->|否| D[查 DB]
    D --> W[回填缓存 Set]
    W --> H
    WReq[写请求] --> DBw[更新 DB]
    DBw --> INV[Delete 失效缓存]
    INV --> OK[下次读回源拿新值]

multiLevelCacheGet / Set / Delete / Exists / Expire / Incr / Eval 都遵循「先本地后 Redis + 熔断器降级 + 状态上报」的统一模式。

关键代码

读写策略核心方法(internal/data/cache/multi_level_cache.go 节选):

// Set 同时写入本地热缓存、Redis 和布隆过滤器(仅用于"读路径回填"与"主动缓存")。
func (c *multiLevelCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
	c.local.Set(key, value, defaultLocalTTL)
	c.guard.Add(key)
	if c.breaker.IsOpen() {
		return nil // 修正点:熔断时只写 L1,跳过 Redis;恢复后需补偿失效
	}
	if err := c.redis.Set(ctx, key, value, ttl); err != nil {
		c.breaker.RecordFailure()
		return err
	}
	c.breaker.RecordSuccess()
	return nil
}

// Delete 同时清除本地热缓存和 Redis 缓存(写路径失效用)。
func (c *multiLevelCache) Delete(ctx context.Context, keys ...string) error {
	c.local.Delete(keys...)
	if c.breaker.IsOpen() {
		// 修正点:熔断打开时只清了 L1,L2 未清;Redis 恢复后可能返回陈旧值,
		// 商用需在恢复后补一次失效或依赖 TTL 自然过期。
		return nil
	}
	if err := c.redis.Delete(ctx, keys...); err != nil {
		c.breaker.RecordFailure()
		return err
	}
	c.breaker.RecordSuccess()
	return nil
}

// Incr 原子自增:先删本地缓存,避免本地旧字符串值与 Redis 自增后的新值不一致。
func (c *multiLevelCache) Incr(ctx context.Context, key string) (int64, error) {
	c.local.Delete(key)
	if c.breaker.IsOpen() {
		return 0, nil
	}
	val, err := c.redis.Incr(ctx, key)
	if err != nil {
		c.breaker.RecordFailure()
		return 0, err
	}
	c.breaker.RecordSuccess()
	return val, nil
}

业务侧调用示例(internal/biz/storage.go 写后失效 + internal/biz/user.go 读填):

// 写路径:原子更新存储后失效缓存(Cache Aside 的"写失效")
func (uc *UserUsecase) UpdateUsedStorageAtomic(ctx context.Context, userID uint64, delta int64) error {
	if err := uc.repo.UpdateUsedStorageAtomic(ctx, userID, delta); err != nil {
		return err
	}
	if uc.cache != nil {
		_ = uc.cache.Delete(ctx, fmt.Sprintf(cacheKeyUserStorage, userID))
	}
	return nil
}

// 读路径:缓存优先 → 未命中查库 → 回填(Cache Aside 的"读填")
// 修正点:源码 key 为 user:storage:%d,商用建议统一为 cloud-disk:user:storage:%d
const (
	cacheKeyUserStorage = "user:storage:%d"
	cacheTTLUserStorage = 300 * time.Second
)

func (uc *UserUsecase) GetStorageInfo(ctx context.Context, userID uint64) (*StorageInfo, error) {
	if uc.cache != nil {
		if val, err := uc.cache.Get(ctx, fmt.Sprintf(cacheKeyUserStorage, userID)); err == nil && val != "" {
			var info StorageInfo
			if json.Unmarshal([]byte(val), &info) == nil {
				return &info, nil
			}
		}
	}
	total, used, err := uc.repo.GetUserStorage(ctx, userID)
	if err != nil {
		return nil, err
	}
	info := &StorageInfo{ /* 组装 Total/Used/Available/Percent */ }
	if uc.cache != nil {
		if b, err := json.Marshal(info); err == nil {
			_ = uc.cache.Set(ctx, fmt.Sprintf(cacheKeyUserStorage, userID), string(b), cacheTTLUserStorage)
		}
	}
	return info, nil
}

5.6 按模式批量删除缓存(DeleteByPattern)

实现思路

业务场景中常需批量删除一类缓存(如用户文件列表变更后删除 files:list:{uid}:*)。直接用 KEYS pattern 会阻塞 Redis 主线程,生产环境禁用。本项目分两步:

  1. Redis 侧:用 SCAN cursor MATCH pattern COUNT 100 游标分批扫描匹配键,每批 100 个,扫到即 Del,循环直到 cursor == 0SCAN 非阻塞,不影响 Redis 服务其他请求。
  2. 本地侧:遍历 hotCache.items,用 filepath.Match(pattern, key) 做 shell 风格通配符匹配,匹配中的删除。

multiLevelCache.DeleteByPattern 先清本地再清 Redis,两端都带熔断器降级保护。

关键代码

Redis 侧按模式删除(internal/data/cache/redis_cache.go 节选):

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
		}
	}
	return nil
}

多级缓存编排按模式删除(internal/data/cache/multi_level_cache.go 节选):

func (c *multiLevelCache) DeleteByPattern(ctx context.Context, pattern string) error {
	c.local.DeleteByPattern(pattern) // 本地用 filepath.Match 通配
	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
}

5.7 key 设计与序列化(商用补全)

实现思路

key 命名空间redisCache 已加全局前缀 cloud_disk:cache:,但业务层自拼 key 缺统一域名段(user:storage:%d / files:list:... / file:meta:%d / blacklist:token:)。商用应统一为 cloud-disk:{domain}:{id},由配置化全局前缀 + 域名段组成,既防跨模块冲突,又便于按域 SCAN

序列化:所有值均为 JSON 字符串。JSON 易调试,但大对象体积与 CPU 开销高;热点大对象建议 Protobuf / MessagePack,并给结构加 Version 字段以便灰度兼容。

热 key 分片:单个超热 key 加分片后缀(cloud-disk:user:storage:%d:{shard})打散到不同 Redis 槽位,缓解单 slot 热点。

flowchart LR
    B[业务 key] --> P[全局前缀 cloud_disk:cache:]
    P --> D[域名段 cloud-disk:user: / :file:]
    D --> K[具体 id]
    K --> R[(Redis key 全貌)]
    V[值] --> J[JSON 字符串]
    J --> S[大对象建议 Protobuf/版本号]

关键代码(商用推荐的 key 规范)

// 修正点:统一 key 规范,避免命名冲突、便于按域管理。
// 全局前缀在 redisCache 里已是 cloud_disk:cache:,这里补域名段。
const (
	keyUserStorage = "cloud-disk:user:storage:%d"   // 原 user:storage:%d
	keyFileList    = "cloud-disk:file:list:%d:%s:%s:%s"
	keyFileMeta    = "cloud-disk:file:meta:%d"
	keyBlacklist   = "cloud-disk:token:blacklist:%s" // 原 blacklist:token:%s
)

// 超热 key 分片示例
func shardKey(userID uint64, shard int) string {
	return fmt.Sprintf("cloud-disk:user:storage:%d:s%d", userID, shard)
}

自测题与动手练习

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

  1. 本项目有几个 Cache 接口?业务层 biz.Cache 有几个方法,数据层 cache.Cache 又有几个?二者靠什么桥接?为什么业务层只依赖小接口?
  2. 缓存穿透、击穿、雪崩各是什么意思?本项目实际装配multiLevelCache 分别用了什么手段?布隆过滤器为什么必须在启动时预热(冷启动误杀)?
  3. CacheGuard.FetchWithGuard 里的 SETNX 单飞当前有没有被主链路使用?它解决的是三大问题里的哪一个?商用上线该怎么处理它?
  4. 本项目缓存一致性到底是 write-through 还是 Cache Aside?写出"读填"和"写失效"在源码里的对应调用(GetStorageInfo / UpdateUsedStorageAtomic)。
  5. 熔断器的三态是什么?Closed → Open、Open → HalfOpen、HalfOpen → Closed 各自触发条件是什么?熔断打开时 Get 返回空是 fail-open 还是 fail-closed,对 DB 有什么风险?
  6. 分布式锁用 SetNX 抢到后,为什么释放时不能直接 Del,而要用随机 token + Lua 脚本原子校验?本项目 CacheGuard 的释放现在用的是哪种(有没有用 Lua)?
  7. 文件列表缓存的 TTL 抖动在源码里实际是多大区间?和 jitterTTL(60s) 的标准区间有何差异?为什么要统一?
  8. key 设计上本项目有哪些可改进点?为什么建议统一成 cloud-disk:{domain}:{id}

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

  1. jitterTTL(60 * time.Second) 连跑 10 次打印实际 TTL,观察是否都落在 42s~78s 区间内且各不相同;再对比 file.go 里手写的 42s + Nanosecond()%18*1s,看区间差异。
  2. 构造一个从未写入过的 key 调用 BloomGuard.Allow,确认返回 false;然后重启进程再次调用,观察是否仍返回 false(冷启动误杀),再手动 WarmFrom 一批 key 后验证恢复正常。
  3. 在本地起 Redis,故意让 redisCache.Get 持续报错 5 次以上,观察 CircuitBreaker 从 Closed 变 Open,再等 30s 看是否进入 HalfOpen 并最终恢复 Closed;思考此时如果把 Get 的熔断分支改成 fail-closed,对 DB 的影响有何不同。
  4. 用 Redis + Lua 脚本实现"判断 token 相等才删锁",故意让持锁协程 sleep 超过锁 TTL,验证另一个协程拿到锁后,前者醒来不会误删新锁。

本章小结

  • 双接口 + 适配器:业务层 biz.Cache(5 方法)只定义边界,数据层 cache.Cache(8 方法)是超集实现,由 newBizCache 适配后注入;实际装配到业务层的是 multiLevelCache(L1 本地 + L2 Redis + BloomGuard + CircuitBreaker)。
  • 三大问题:穿透用布隆过滤器拦截"一定不存在"的 key(必须启动时预热,否则冷启动误杀)+ 空值缓存兜底;击穿在主链路靠 L1 短 TTL,SETNX 单飞在 CacheGuard 里是参考实现,商用应接到热点读路径;雪崩用 TTL 抖动打散过期 + 熔断快速降级(熔断默认 fail-open,需配合 fail-closed / 陈旧值保护 DB)。
  • 一致性语义:本项目是 Cache Aside(读填 写失效),不是 write-through;写路径统一 Delete 失效,L1 TTL 短让不一致快速自愈。
  • 布隆过滤器:m 位位数组 + k 个哈希,无漏判、有误判;Allow=false 直接返回空,Add 在写缓存成功后调用;进程内版本重启即空,必须预热或接 redisBloomFilterrestore
  • 分布式锁SetNX 抢锁 + 随机 token,释放用 Lua 脚本"校验 token 才删",避免误删他人持有的锁。
  • key 与序列化:全局前缀 cloud_disk:cache: 已做,但业务 key 缺统一域名段,建议 cloud-disk:{domain}:{id};值统一 JSON,大对象可换 Protobuf 并加版本号;超热 key 用分片打散。

下一篇可延伸到"亿级流量下的热点 Key 探测、Redis Cluster 分片与多级回退链路",把本模块的防护能力放到更大规模下检验。

About Me

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

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

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

目标

学AI,加油!加油!