缓存雪崩、击穿、穿透的根因在于业务代码中对 ttl 扰动、原子写入、空值序列化、预热节流等细节的缺失,而非 redis 本身问题;应通过随机过期时间、set nx 原子操作、序列化“假 nil”、懒加载预热等手段精准防控。

加随机过期时间,别让一堆 key 同一秒失效
缓存雪崩不是 Redis 崩了,是大量热 key(比如首页商品列表、用户配置)在 EXPIRE 时间点高度重合,导致瞬间全量 miss,请求直冲数据库。Go 客户端(如 github.com/redis/go-redis/v9)不会帮你打散时间,必须自己加扰动。
- 正确做法:设置 TTL 时叠加随机秒数,例如
time.Minute*10 + time.Second*time.Duration(rand.Int63n(600))(±10 分钟) - 错误写法:先
SET再单独调EXPIRE—— 网络失败会导致 key 永不过期,或只设成功一半 - 别用
rand.Seed(time.Now().UnixNano())在全局 init 里调一次,goroutine 并发下容易撞种子,应每次生成新rand.New(rand.NewSource(time.Now().UnixNano()))或用math/rand的 global rand(Go 1.20+ 默认安全) - 如果用了 Redis Cluster,各节点系统时间偏差超过几秒就可能让实际过期不一致,务必开 NTP 校时
用 SET 原子命令防击穿,别手写 SETNX + GET + DEL 三步锁
单个热点 key 过期瞬间,多个 goroutine 同时发现缓存 miss,一起查 DB 写缓存,就是击穿。这不是并发太高,是没控制好原子性。
- 最简方案:用
SET key val EX 300 NX原子抢写 —— 抢到的去查 DB 并写最终值;没抢到的直接GET轮询,直到命中或超时 - 别在业务逻辑里
if !exists { db.Get(); cache.Set() },中间有竞态窗口,高并发下照样打穿 - 锁过期时间必须比 DB 查询耗时长(建议 ≥ 5s),但也不能太长,否则故障时锁残留拖垮后续请求
- 不要用
sync.Mutex或本地 map 做锁 —— 多实例部署下完全无效
空值缓存要序列化“假 nil”,别传 Go 的 nil
穿透常来自恶意 ID(如 id=-1、id=abc)或已删除资源,Redis 查不到,每次都要走 DB。核心不是拦请求,而是让缓存对“不存在”也返回明确响应。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
- 写缓存时,DB 返回空,就塞一个可反序列化的占位符,比如
"null"字符串、或自定义 structtype NullUser struct{ IsNull bool } -
client.Get(ctx, key).Result()返回redis.Nil是客户端错误类型,不是业务空值;真正业务空值是返回""或0或解码后字段为零值 - 读取后必须显式判断:比如 json.Unmarshal 后检查是否等于
"null",再决定是否查 DB,否则会把空字符串当真实数据返回 - 空值 TTL 要短(如 2 分钟),避免长期污染缓存,也别设成永久 —— 万一误判,恢复成本高
预热和降级要分场景,别一启动就全量刷热 key
预热不是银弹。盲目在服务启动时批量 GET + SET 热 key,可能触发 DB 尖峰,尤其当热 key 关联复杂 JOIN 或未加索引时。
- 低峰期懒加载更稳:首次请求触发回源 + 写缓存,配合随机 TTL,自然形成错峰
- 真要预热,优先选关键路径的前 N 个 key(如首页 top10 商品),且加限速(每秒 ≤ 50 个)和失败重试退避
- DB 不可用时,缓存空值本身就能降级 —— 只要你的业务能接受“短暂显示旧数据或空状态”,就别硬扛着查 DB
- 别依赖布隆过滤器防穿透:用户 ID 类动态 key 空间不可控,重建成本高、误判率难收敛,反而增加运维负担
雪崩、击穿、穿透听着像三座山,其实每座都只卡在一个具体动作上:TTL 没扰动、SET 不原子、空值不序列化、预热不节流。问题不在 Redis,而在你写那几行 cache.Set 和 db.Query 之间漏掉的上下文控制。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










