不能只用 redis 或 sync.map:前者放大网络延迟,后者无法跨实例共享且无自动失效机制;多级缓存通过本地热数据加速、redis兜底、db最终一致实现高性能与可用性平衡。

为什么不能只用 redis 或只用 sync.Map
单靠 redis 会放大网络延迟,高并发下 RT 明显上扬;只用本地内存(比如 sync.Map)又没法跨实例共享、无法主动失效,一更新就全集群不一致。多级缓存不是叠 Buff,是让热数据走最快路径、冷数据兜底查持久层。
典型场景:用户资料接口每秒几千 QPS,其中 80% 请求集中在 Top 100 用户,这些 ID 就该优先落在本地内存里,而不是每次都打到 Redis。
-
sync.Map适合读多写少、key 稳定的热数据,但没 TTL,得自己配定时清理或基于访问频次淘汰 -
redis是共享层,必须带 TTL,且 key 命名要带业务前缀(比如user:profile:<var>id</var>),避免冲突 - 两级之间没有强一致性要求,但要有“失效传播”机制——本地缓存过期时间应 ≤ Redis 的 TTL,否则会出现 Redis 已更新但本地还返回旧值
github.com/patrickmn/go-cache 比 sync.Map 更适合做一级缓存
sync.Map 是底层原语,没过期、没回调、没统计,硬套容易漏掉清理逻辑。而 go-cache 虽然是内存型,但支持自动过期、可注册 onEvicted 回调(比如记录淘汰日志),还能限制容量防止 OOM。
注意它不是线程安全的全局单例——每个 cache 实例独立管理生命周期,建议按业务域拆分(如 userCache、orderCache),别全塞进一个实例里。
立即学习“go语言免费学习笔记(深入)”;
- 初始化时设
defaultExpiration = 5 * time.Second,cleanupInterval = 10 * time.Second,避免过期扫描太勤影响吞吐 - 写入用
Set(key, value, cache.DefaultExpiration),别传cache.NoExpiration,否则等于放弃一级缓存的时效性控制 - 读取顺序固定:先
Get本地 cache → 命中直接返回;未命中再查 Redis → Redis 有则写回本地并返回;都无则查 DB,再双写(DB + Redis + 本地)
Redis 查询失败时怎么避免缓存击穿和雪崩
当 Redis 宕机或超时,如果所有请求都穿透到 DB,瞬间就把库打挂。必须有降级策略,而且得区分「空结果」和「查询异常」。
- 对空结果(比如
redis.Nil),仍要往本地 cache 写一个空对象(如nilUser结构体),并设较短 TTL(比如 60 秒),防止重复穿透 - 对 Redis 连接错误(
redis: nil pointer evaluating interface {}.Do或timeout),跳过写本地 cache,直接查 DB 并只写本地(不写 Redis),等 Redis 恢复后再逐步回填 - 加一层简单熔断:连续 5 次 Redis 调用失败,接下来 30 秒内所有 Redis 操作直接跳过,只走本地 + DB
双写不一致时,用「删除 + 延迟重建」比「更新缓存」更可靠
更新 DB 后立刻 Set 本地缓存和 SET Redis,看着快,但任意一环失败(比如 Redis 写成功但本地写失败),状态就错位了。实际线上更稳的做法是删缓存,等下次读来触发重建。
但直接 Delete 有风险:刚删完,多个并发读同时发现本地 & Redis 都没值,全去查 DB,造成瞬时压力。所以要在删除后加个短暂延迟(比如 100ms)再允许重建,或者用 singleflight 包做请求合并。
- 更新 DB 成功后,调用
userCache.Delete(key)和redisClient.Del(ctx, "user:profile:"+id) - 不要在更新逻辑里写新值到任何缓存;把「重建」完全交给读路径的懒加载逻辑
- 如果业务对一致性极其敏感(如余额),可在删除后发个消息到队列,由消费者异步补全两级缓存,但这个链路要单独监控失败率
最麻烦的其实是时间窗口:本地缓存 TTL、Redis TTL、DB 更新时机、删除动作执行顺序——这四个变量只要两个没对齐,就会出现肉眼可见的脏读。上线前一定用真实流量压测过期/删除组合场景。










