sync.Map不能当L1缓存,因其无TTL、不淘汰、无容量控制、无命中统计、不感知Redis状态;应选bigcache或Ristretto,严格遵循“读时填充+写时失效”流程协同L2。

不能用 sync.Map 当 L1 缓存层——它不是缓存,只是线程安全的 map;真要扛住高并发读,必须用 bigcache 或 Ristretto 做 L1,Redis 做 L2,并严格按「读时填充 + 写时失效」流程协同。
为什么 sync.Map 不能当 L1 缓存
sync.Map 没有 TTL、不淘汰、无容量控制、无命中统计,也不感知 Redis 状态。你手动 Store 一个值,其他 goroutine 可能因内存模型看不到更新;用 time.Now().Unix() 算过期,在容器里时钟漂移后 L1 就永远不清理。
- 只适合极轻量固定场景:如 session ID 映射、预热后配置快照
- 别包装成带
expireAt字段的 struct + 定时 goroutine 清理——GC 会被卡住,且无法响应 key 突增 - 硬塞进去当 L1,等于把一致性逻辑全压给业务代码,线上出问题时根本分不清是 L1 没清、L2 过期了,还是 Redis 根本没连上
怎么选和初始化 L1 缓存(bigcache vs Ristretto)
bigcache 和 Ristretto 都原生支持 TTL、分片锁、淘汰回调和容量控制,但定位不同:
-
bigcache:适合大对象、高吞吐场景,底层用时间轮管理 TTL,天然规避容器时钟漂移;必须显式设MaxEntries和OnRemove回调,否则内存无限增长 -
Ristretto:适合读多写少、热点波动明显的场景,用TinyLFU + Sampled LFU淘汰,支持 cost 控制,命中率更高;对小对象更友好,GC 压力更可控 - 别用
go-cache应对百万级 QPS——它用sync.RWMutex,写频繁时锁争抢严重
L1 和 L2 的读写契约必须守住
多级缓存真正的复杂点不在代码结构,而在时间窗口里的一致性博弈——L1 的 TTL、L2 的 TTL、DB 更新耗时,三者只要差 100ms,就可能暴露脏数据。
- 读流程:
l1.Get(key)→ miss →singleflight.Do(key, func() { l2.Get(key) })→ L2 miss → 查 DB →l2.SetEX(key, val, ttl)→ 再l1.Set(key, val, shortTTL) - 写流程:
l1.Delete(key)→l2.Del(key)→ 更新 DB;绝不能反向,否则中间窗口返回旧 L1 + 新 DB - L1 的 TTL 必须 ≤
redis.PTTL(key)返回的剩余时间,否则必然出现“Redis 已更新,L1 还返旧值”
缓存穿透防护在多级下容易失效的点
单层缓存加空值就完事,但多级里 L1 写空值没意义——下次请求可能直接命中 L2 绕过 L1,导致穿透复现。
- L2 层必须对空结果也写入:
redis.SetEX("user:123:empty", "1", 5*time.Minute) - L1 同步写一个带 60s TTL 的 nil sentinel(不是空字符串),防止重复回源
- Redis 宕机时,错误要识别出来(如
redis: nil pointer evaluating interface {}.Do或 timeout),走降级逻辑(比如返回上次成功缓存值 + 告警),而不是直接穿透 DB
最易被忽略的是 L1/L2 TTL 的动态对齐——没人会实时查 redis.PTTL,但不这么做,L1 就成了定时污染源;还有 singleflight 的 key 构造方式,如果没带上 namespace 或版本前缀,不同业务的 key 可能误合并,引发雪崩式重查。

















