sync.map 不适合作为 l1 缓存,因其无 ttl、无容量控制、无自动淘汰、无命中统计,仅适用于 session id 映射等极轻量固定场景;真需本地缓存应选 bigcache 或 go-cache。

不能把 sync.Map 当 L1 缓存用,它不是缓存,只是线程安全的哈希表;真要落地多级缓存,必须明确 L1 只加速、L2 保一致、DB 是最终源——否则上线后查不到脏数据在哪一层,连日志都难定位。
为什么 sync.Map 不该被当 L1 缓存用
它没 TTL、不淘汰、无容量控制、无命中统计,更不会自动同步到 Redis。你手动 Store 一个值,其他 goroutine 可能因内存模型看不到更新;你用 time.Now().Unix() 算过期,在容器里时钟漂移后,L1 就永远不清理。
- 只适合极轻量场景:比如 session ID 映射、预热后固定配置快照
- 别包装成带
expireAt字段的 struct + 定时 goroutine 清理——GC 会被卡住,且无法应对 key 突增 - 真要本地缓存,选
bigcache(压测吞吐高 2–3 倍)或github.com/patrickmn/go-cache(支持回调、统计、TTL)
bigcache 和 go-cache 的关键参数差异
两者都比 sync.Map 更接近缓存语义,但行为差异直接影响线上稳定性:
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
-
bigcache.NewBigCache必须传maxEntries和OnRemove回调:前者控内存上限,后者用于手动释放对象(它不 GC) -
go-cache.New(5*time.Minute, 10*time.Minute)第一个参数是默认 TTL,第二个是清理间隔;不设清理间隔,过期 key 会一直占内存 -
bigcache底层分片 + 时间轮,读写无锁;go-cache用sync.RWMutex,高并发下读多写少时性能尚可,但写频繁会争抢
L1/L2 协同必须守住的读写契约
不是“写两份”,而是靠流程约束一致性。错位 100ms 就可能返回旧值——这不是 bug,是时间窗口博弈。
- 读流程:
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错误要识别出来,走降级逻辑(比如返回上次成功缓存值 + 告警),而不是直接穿透 DB
真正难的不是代码怎么写,而是三个 TTL 怎么对齐:L1 的存活窗口、L2 的剩余过期时间、DB 更新耗时——业务能容忍多久的脏数据,决定了这三个值的手动取舍,没有自动解法。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










