不能直接用sync.Map当L1缓存,因其无TTL、不淘汰、无容量控制、无命中统计,仅适合session ID映射等极轻量固定场景;应选bigcache(高吞吐/内存可控)或go-cache(需统计/回调)并严格遵循读时填充、写时失效流程。

为什么不能直接用 sync.Map 当 L1 缓存
它不是缓存,只是线程安全的哈希表。没 TTL、不淘汰、无容量限制、无命中统计——你手动 Store 一个值,其他 goroutine 可能因内存模型看不到更新;用 time.Now().Unix() 算过期,在容器里时钟漂移后,L1 就永远不清理。
只适合极轻量固定场景:
- session ID 映射
- 预热后不变的配置快照
- key 数量极少、生命周期明确且无需时效控制的数据
别包装成带 expireAt 字段的 struct + 定时 goroutine 清理:GC 会被卡住,且无法响应突发 key 增长。
bigcache 和 go-cache 怎么选
两者都比 sync.Map 更接近缓存语义,但行为差异直接影响线上稳定性:
-
bigcache.NewBigCache必须传maxEntries和OnRemove回调:前者控内存上限,后者用于手动释放对象(它不 GC) -
go-cache.New(5*time.Minute, 10*time.Minute)第一个参数是默认 TTL,第二个是清理间隔;不设清理间隔,过期 key 会一直占内存 -
bigcache底层分片 + 时间轮,读写无锁;go-cache用sync.RWMutex,高并发写频繁时易争抢
实操建议:
- QPS > 5k、热 key 密度高、需内存限制 → 选 bigcache
- 需命中统计、回调通知、简单 TTL 控制 → 选 github.com/patrickmn/go-cache
读流程必须严格按 l1.Get → l2.Get → DB 顺序走
顺序反了就不是多级缓存,而是降级缓存。本地缓存本该拦截 90%+ 热请求,如果先查 Redis,等于把所有请求都压到网络层,L1 彻底失效。
立即学习“go语言免费学习笔记(深入)”;
典型路径:
- l1.Load("user:123") → 命中直接返回
- 未命中 → singleflight.Do("user:123", func() { l2.Get("user:profile:123") })
- L2 命中 → 反序列化 + l1.Store(TTL ≤ L2 剩余 TTL)→ 返回
- L2 未命中 → 查 DB → l2.SetEX → l1.Set(带 short TTL)
关键约束:
- l1.TTL 必须 ≤ redis.PTTL(key) 返回的剩余时间,否则必然返回旧值
- 同一 key 并发读必须用 singleflight.Group 合并,否则 N 个 goroutine 同时穿透 DB
Redis 宕机时怎么防雪崩
不能全量穿透 DB。得区分「空结果」和「连接异常」,并设置不同降级策略:
- 遇到
redis.Nil:L2 写空标记,如redis.SetEX("user:123:empty", "1", 5*time.Minute);L1 同步写一个带 60s TTL 的 nil sentinel - 遇到连接超时或
redis: nil pointer evaluating interface {}.Do:启用降级逻辑,比如返回上次成功缓存值 + 告警,而不是直接查 DB - L2 层必须对空结果也写入,否则 L1 写空值没意义——下次请求可能直接命中 L2 绕过 L1,穿透复现
最易被忽略的是:L1 和 L2 的 TTL 对齐不是静态配置,而是要动态获取 redis.PTTL 做校验;写操作删 L1 和 L2 时,顺序不能颠倒,否则窗口期会返回旧 L1 + 新 DB。



















