sync.Map 不适合作为 L1 缓存,因其无 TTL、无容量控制、无自动淘汰、无命中统计,且不保证内存可见性;真要上线应选用 bigcache 或 go-cache,并严格遵循「读时填充 + 写时失效」协同流程。

sync.Map 不适合直接当 L1 缓存用,硬套会导致过期失效不可控、命中率统计缺失、并发写不一致——真要上线,得换 bigcache 或 go-cache。
为什么不能用 sync.Map 当 L1 缓存
它只是线程安全的 map,不是缓存组件。没 TTL、没容量限制、没淘汰策略、没命中统计,连“过期”都得自己手写定时器去扫——容器里时钟漂移后,time.Now().Unix() 算出来的过期时间就全乱了。
- 常见错误:把
sync.Map包一层 struct 加个expireAt字段,再起 goroutine 定时清理 → GC 会被卡住,key 突增时清理跟不上 - 真实场景踩坑:L1 写入后其他 goroutine 读不到新值,因为
sync.Map.Load/Store不保证内存可见性顺序;或者删 key 后 L2 还在,L1 却没清,查出来是旧数据 - 只适合极轻量固定场景:比如 session ID 到 user ID 的映射、启动时加载的静态配置快照
选对 L1 库:优先 bigcache 而不是 go-cache
bigcache 更适合高并发读写密集型服务,go-cache 更适合中小规模、需要简单 TTL + 回调的场景。
-
bigcache:基于分片 + ring buffer 实现,无 GC 压力,支持自动淘汰(LRU + TTL),但不支持自定义淘汰回调;初始化必须指定最大条目数或字节上限 -
go-cache:基于map+sync.RWMutex,支持onEvicted回调、手动Increment、GetWithExpiration,但大量 key 下锁竞争明显 - 别用
github.com/patrickmn/go-cache的默认构造 —— 它的清理 goroutine 默认每分钟跑一次,高频更新下过期延迟可达 60 秒;建议显式传cache.New(5*time.Minute, 10*time.Minute)控制清理间隔
L1 和 L2 的读写契约必须写死
多级缓存失效不是“删两遍”,协同也不是“写两份”。核心是明确谁负责什么:L1 只加速读,不保强一致;L2 是跨实例共享的权威源;DB 是最终事实。
立即学习“go语言免费学习笔记(深入)”;
- 读流程必须走
singleflight.Do(key, func() { l2.Get(key) }):避免 L1 miss 后 N 个协程同时打 DB - L1 写操作只能是
Delete,绝不Set:写 DB 后只删 L1 + L2,让下次读自然回源填充,防止 L1/L2 值不一致窗口 - L1 设置短 TTL(比如 10s),L2 设置长 TTL(比如 10m):既防穿透,又避免 L1 成为一致性黑洞
- 别在 L1 Set 时传入和 L2 一样的 TTL —— L1 淘汰太快会频繁穿透,太慢又拖慢一致性收敛
Redis 作为 L2 时要注意连接与序列化
Go 里最常用的是 github.com/go-redis/redis/v9,但它默认不带连接池参数校验,容易线上跑满连接数。
- 初始化 client 时必须设
PoolSize(建议 10~50,视 QPS 调整),否则默认 10 个连接在高并发下立刻打满 - 避免用
json.Marshal直接塞进 Redis:小对象还好,大结构体序列化开销大;可考虑gob或msgpack,但要注意跨语言兼容性 - Redis key 命名要有层级前缀,比如
user:profile:<id></id>,别裸写<id></id>—— 方便后续 scan 清理、监控按前缀聚合 - 别依赖
SET key val EX 3600的原子性做业务逻辑:Redis 网络抖动时命令可能发出去但没收到响应,得配合本地重试或幂等 key 设计
真正难的不是搭起两级缓存,而是让 L1 的局部性、L2 的共享性、DB 的最终一致性在并发写和网络异常下不互相撕扯。很多问题不会在压测里暴露,而是在凌晨三点 Redis 故障切换后,L1 里一堆过期却没清掉的脏数据开始集体返祖。


















