AIGO微服务电商项目全栈拆解(08)多级缓存设计与实现
写在前面
这篇文章我们来讨论基于本地和Redis实现的二级缓存公共组件,它的定位很明确:在一次 cache-aside 读请求中,先查当前进程的 L1,再查 Redis L2,最后才调用业务 loader。组件同时处理了 TTL、并发回源、空结果、批量漏键、主动失效和指标,但没有把自己包装成一个完整的分布式缓存一致性系统。
先看整体读路径
L1 是进程内 GoFrame gcache LRU,L2 是 Redis;L1 命中不会访问 Redis;L2 命中会按 Redis 的实际剩余 TTL 补填 L1;两级都未命中时,单键 GetOrLoad 通过 singleflight 合并回源。源数据写成功后由调用方显式 Delete,其他实例没有 Pub/Sub 推送,只能等自己的短 L1 TTL 到期后重新读取 Redis。
一、组件的边界:缓存旁路,而不是缓存即事实
Cache 持有三个核心对象:
l1:当前进程中的gcache.Cache,默认容量为 10,000;l2:由调用方传入的 GoFrame Redis client 适配器;group:singleflight.Group,只负责同一个完整 key 的并发回源合并。
公共 API 对业务暴露的是 Get、Set、Delete,以及泛型辅助函数 GetOrLoad、GetOrLoadMany 和 GetOrLoadManyWithCount。组件采用 cache-aside:源数据仍然由业务 loader 或 DAO 负责,缓存只保存查询结果。
1. namespace 是 key 的一部分
组件会把业务 key 组装为:
1 | namespace:key |
namespace 不能为空;业务 key 会先去掉首尾空格,空 key 直接报错。这个约束不是 Redis 的通用规则,而是该组件自己的 key 拼接约定,因此每个 bounded domain 应使用稳定、互不冲突的 namespace。
Config 里的 TTL 没有全局默认值。正缓存每次 Set 或 GetOrLoad 都必须传入 L2 TTL,L1 的过期时间由组件根据 L2 实际剩余时间计算,而不是再维护一份独立配置。
2. L2 存 wireEntry,L1 存原值
Redis 中保存的是 JSON 形式的 wireEntry:
1 | type wireEntry struct { |
Found=true 时才有 Value;Found=false 表示“源数据确认不存在”,也就是负缓存。L1 中保存的是 cacheEntry,正值路径会保留原始 Go 值的引用语义。
从 L2 读取时,组件先反序列化 wireEntry,再把 Value 反序列化到调用方提供的目标指针,并用解码后的值填充 L1。类型不匹配时会清掉这条 L1,再回到 L2 或 loader,而不是把错误类型继续向业务层传播。
二、一次单键读取:L1 → L2 → loader
Get 的状态只有三个:Miss、Hit、NegativeHit。一次读取可以按下面的顺序理解:
- 用完整 key 查 L1;正值命中返回
Hit,负值命中返回NegativeHit。 - L1 未命中后查 Redis;Redis 没有 key 返回
Miss。 - Redis 有正值时解码,返回
Hit,并尝试根据 L2 实际剩余 TTL 补填 L1。 - Redis 有
Found=false时返回NegativeHit,同样按剩余 TTL 的一半尝试补填 L1。 - 只有
GetOrLoad判断为 miss,才进入 singleflight 并调用 loader。
代码摘要可以压缩成下面这样,重点是 found 和 err 的语义:
1 | product, found, err := cache.GetOrLoad( |
loader 返回 found=false 不是异常,而是可以被缓存一小段时间的“确实不存在”。loader 自己返回 error,或者缓存数据无法解码,则不会被当成正常 miss 静默吞掉。
L1 为什么只用 L2 实际 TTL 的一半?
写入正缓存时,组件先把 JSON 写入 L2,再调用 GetExpire 读取 Redis 返回的实际剩余时间,最后执行:
1 | l1TTL := remaining / 2 |
从 L2 命中补填 L1 时也走同样的逻辑;如果算出的 L1 TTL 小于 1 毫秒,则不写入 L1。这样做有两个直接效果:
- L1 不会比 L2 活得更久,进程内旧值会更早让位给 Redis;
- Redis TTL 因网络、实现或写入耗时发生变化时,L1 仍以实际剩余时间为基准,而不是盲用调用方传入的原始 TTL。
“一半”不是一致性协议,也不能替代写后失效。它只是让 L1 成为更短的本地加速层:L1 过期后重新读 L2,L2 过期后才真正回源。
三、singleflight:把同一个 key 的并发回源合并
GetOrLoad 第一次查缓存未命中后,以完整的 namespace:key 作为 singleflight key。进入 group.Do 后还会再检查一次 L1:前一个 goroutine 可能已经完成 loader 并写入了 L1,后进入的 goroutine 不需要再次访问源数据。
因此,同一进程、同一完整 key 的并发 miss,loader 只执行一次,其他调用方共享结果。组件还单独记录 singleflight 的 shared 次数,用于观察请求是否确实发生了合并。
这里要注意范围:当前代码只在单键 GetOrLoad 中使用 singleflight。GetOrLoadMany 是一次批量扫描和一次 batch loader 调用,并没有为每个批量 key 再建立一套并发 singleflight 机制,不能把它描述成“批量请求也自动全局合并”。
Redis 故障时会怎样?
ErrL2Unavailable 是组件专门标记 Redis 操作失败的错误。GetOrLoad 对这类错误采取 fail-open:把它当成缓存不可用,继续执行 loader;如果 loader 成功但 Redis 写入或 TTL 读取仍失败,结果可以返回给业务,但不会因此成功填充 L1。
这不是所有错误都忽略:JSON 解码错误、参数错误和 loader 错误仍然返回。直接调用 Cache.Get 或 Cache.Set 也会看到 L2 错误;只有 GetOrLoad 和批量辅助函数对 ErrL2Unavailable 做了回源兜底。
四、负缓存:10 秒止住不存在数据的反复回源
当单键 loader 返回 found=false 时,组件不使用调用方传入的正缓存 TTL,而是写入固定的 negativeL2TTL = 10 * time.Second。批量场景中,loader map 没有返回的漏键也按同样规则负缓存。
所以一次“商品不存在”的结果大致是:
1 | L2:Found=false,TTL 固定 10 秒 |
负缓存解决的是缓存穿透的一小部分:重复查询同一个确认不存在的 key 时,不会每次都直接打到数据库。它不是布隆过滤器,也没有声明所有非法 ID 都能被预判;它只缓存 loader 已经确认的 not found。
五、批量读取:去重、只加载漏键、为漏键继续缓存
GetOrLoadMany 使用默认的 DefaultBatchCount=50;需要更大或更小的处理上限时,可以调用 GetOrLoadManyWithCount 显式传入 count。
它的顺序很重要:先按完整 key 去重,再应用 count。比如输入 a, a, b, c,count 为 2 时,实际选中的是 a, b,而不是先截断成 a, a 再去重。
批量函数随后逐个查询两级缓存:
- 正缓存命中直接进入结果 map;
- 负缓存命中直接跳过;
- 两级都 miss 的 key 才进入
missingKeys; - loader 只收到
missingKeys,并且调用一次; - loader 返回 map 中未出现的请求 key 会写入负缓存;
- loader 返回了请求之外的额外 key,会被忽略;
- 最终结果 map 只包含找到的值。
这套接口把“请求 key 集合”和“数据库真正需要查询的 key 集合”分开了。loader 不需要重新判断哪些 key 已经命中缓存,也不需要为不存在的记录手工创建另一套标记。
六、Delete 失效策略与多实例收敛
Delete(ctx, keys...) 会构造完整 key,先尝试删除 Redis L2,再清理当前进程 L1。即使 L2 删除失败,代码仍然会执行 L1 删除,并把 L2 错误返回给调用方。因此它不是“删除失败就保留本地缓存”,而是优先降低当前进程继续读旧值的概率,同时把跨实例问题暴露出来。
七、指标:能看到命中、回源和合并是否发生
pkg/cache 建立了名为 micro-mall.dev/pkg/cache 的 meter,当前代码定义了三类观测:
| 指标 | 作用 |
|---|---|
micro_mall_cache_requests_total |
按 cache.namespace、cache.layer、cache.outcome 统计 L1/L2/loader 等结果 |
micro_mall_cache_loader_duration_ms |
记录单键 loader 和 batch loader 的执行时延,单位为毫秒 |
micro_mall_cache_singleflight_shared_total |
统计有多少次 GetOrLoad 共享了 singleflight 结果 |
这些指标能回答“缓存是否命中”“Redis 是否出错”“loader 是否变慢”“并发 miss 是否被合并”。但当前组件没有在代码中提供命中率计算、自动告警、热 key 淘汰策略或跨实例失效广播。
八、Product服务的接入:商品详情和批量列表
1. 单个商品详情
app/product/internal/service/v2/product.go 定义:
1 | const ( |
GetProduct 用 product:<id> 作为 key,通过 GetOrLoad 读取。loader 查询 pms_product 的指定 ID,并要求 publish_status 为上架状态;查不到时返回 found=false,最终服务层把它转换为商品不存在的业务错误。
这说明缓存的是“当前商品详情查询结果”,不是任意商品表快照:未上架商品不会因为数据库中存在记录就被当作可读商品缓存。
2. 批量商品列表
ListProducts 将输入商品 ID 转成 product:<id>,然后调用 GetOrLoadManyWithCount,count 传入 len(keys),TTL 仍是一小时:
真实调用是 cache.GetOrLoadManyWithCount(ctx, utility.Cache, keys, len(keys), productCacheTTL, loader);loader 只接收 missingKeys,再把这些 key 还原成商品 ID,执行 WhereIn 查询和上架状态过滤,并按 product:<id> 建 map。这里不把完整 DAO 代码贴进文章,避免把缓存组件的重点淹没在查询细节中。
上面只保留调用形态,loader 的真实实现是把漏掉的 key 还原成 ID,用 WhereIn 批量查 pms_product,并过滤上架状态,再按 product:<id> 建 map。缓存组件负责去重、漏键分流和负缓存,Product 服务负责 ID 转换、DAO 查询和返回顺序。
如果 loader 没有返回某个请求 key,该 key 会被 pkg/cache 负缓存;Product 最后遍历原始 ProductIds,只把结果 map 中找到的商品追加到返回列表。因此,数据库中不存在或未上架的商品不会进入结果列表。
3. Product 的缓存实例配置
app/product/utility/cache.go 使用 namespace product、L1 容量 10,000,并把 g.Redis() 交给 mmcache.New。这部分是已实际被 Product 调用的实例。
但在本次核对的 Product 和相关 utility 文件里没有找到写路径上的 Delete 调用,文章只能下结论到“读缓存已经接入”,不能扩展成“商品更新后的缓存失效闭环已经完成”。
九、Q&A
Q1:为什么 L1 TTL 取 L2 实际剩余时间的一半?
因为 L1 是进程内副本,必须比共享的 Redis L2 更早过期。代码不是拿传入的原始 TTL 直接设置 L1,而是写入/读到 L2 后调用 GetExpire,用实际剩余时间除以二。这样 L1 到期后会重新检查 L2,减少本地旧值长期遮蔽 Redis 新值的窗口;但它仍不是强一致保证,写成功后的主动 Delete 不能省略。
Q2:Redis 故障时为什么还能返回数据?
GetOrLoad 只把 ErrL2Unavailable 当作缓存不可用并继续 loader。loader 成功时可以把源数据返回给业务;如果 Redis 写回或 TTL 读取也失败,则不填充 L1,避免把一次没有成功持久化到 L2 的结果误当成已缓存。loader 错误和解码错误不属于这个 fail-open 范围,仍然会返回。
Q3:批量 loader 为什么只拿 miss 的 key?
因为批量 API 已经先逐个完成 L1/L2 判断。把命中的 key 再交给 DAO 会浪费查询,并且让 loader 无法区分“需要回源”和“已经有缓存”。代码通过 missingKeys 明确分界,loader 只查询漏键;返回 map 中没出现的漏键再被负缓存,下一次批量请求也不会重复打源。
Q4:singleflight 是否解决了多实例击穿?
没有。它挂在每个 Cache 实例内部,只能合并同一进程中相同完整 key 的并发 loader。多实例之间没有共享 singleflight;它们依靠 Redis 作为共享 L2,以及 L1 短 TTL 和主动 Delete 尽量降低重复回源和旧值窗口。当前实现也没有分布式锁或 Pub/Sub 失效广播。
Q5:调用 Delete 后是否所有实例立即拿到新值?
不保证。Delete 会清除调用实例的 L1 和共享 L2,但不会远程清掉其他实例的 L1。没有 Pub/Sub 时,其他实例要等自己的 L1 TTL 到期,之后从 Redis 读取;如果 L2 删除失败,Redis 侧旧值也可能仍在。这个组件提供的是最终收敛路径,不是即时全局失效协议。
结语:这套缓存真正解决了什么
- L1 GoFrame LRU 加 L2 Redis,读路径固定为 L1 → L2 → loader;
- L1 使用 L2 实际剩余 TTL 的一半,缩短进程内旧值窗口;
- 单键回源用 singleflight 合并同进程并发,Redis 故障按可识别错误 fail-open;
- not found 用 10 秒负缓存,批量接口先去重、只回源漏键,并继续缓存漏键结果;
- Delete 同时清理两层,但多实例只做到没有 Pub/Sub 时的 TTL 收敛;
- 指标覆盖请求结果、loader 时延和 singleflight 共享次数。
1.AIGO微服务电商项目全栈拆解(07)管理后台的系统设计
2.AIGO微服务电商项目全栈拆解(16)用户积分与会员等级:从动态账户到账本建模
3.AIGO微服务电商项目全栈拆解(13)设计模式全景:从代码里数出用了哪些模式
4.AIGO微服务电商项目全栈拆解(17)管理后台的技术实现:Gin、直连多库、任务与物流
5.AIGO微服务电商项目全栈拆解(14)为将来可替换而设计:第三方封装的横向总结
6.AIGO微服务电商项目全栈拆解(10)支付抽象与支付状态闭环
7.AIGO微服务电商项目全栈拆解(12)用 MQ 异步化解耦非核心业务
8.AIGO微服务电商项目全栈拆解(15)购买后投放系统:责任链、快照与并发收敛