热点数据击穿本质是缓存失效瞬间的并发请求承载问题,需用singleflight.group实现进程内请求合并、逻辑过期替代物理删除、ristretto替代sync.map,并通过原子空值写入或布隆过滤器防御穿透。

热点数据击穿不是“要不要加缓存”的问题,而是“缓存失效瞬间谁来扛住并发请求”的问题——不设防的 redis.Get + db.Query 组合,在 QPS 过万时大概率直接拖垮数据库。
为什么互斥锁在 Go 微服务里容易失效
本地 sync.Mutex 只对单实例有效,集群部署下多个服务副本会同时触发数据库查询;用 Redis 实现分布式锁(如 SET key val NX EX 30)看似可行,但实际有三个硬伤:
- 锁粒度太粗:一个商品 ID 的锁,可能阻塞其他无关商品的请求,降低整体吞吐
- 锁获取失败后轮询重试,若没加退避(如指数等待),反而放大 Redis 压力
-
DEL锁操作不在事务中,若业务逻辑 panic 或网络超时,锁可能永久残留
更稳妥的做法是用 singleflight.Group —— 它在进程内做请求合并,不依赖外部中间件,且天然支持并发归并。同一 key 的所有未命中请求会被压成一次回源,其余协程阻塞等待结果,无锁、无竞态、无残留。
用 “逻辑过期”替代物理删除能绕开大部分雪崩
把缓存值封装成结构体:{"data": "...", "expire_at": 1719781200},读取时只检查 expire_at 是否过期,而不是靠 Redis 的 TTL 自动驱逐。这样做的好处是:
- 缓存 key 永远存在,不会因批量过期引发雪崩
- 后台 goroutine 可异步刷新过期数据,不影响主线程响应
- 即使刷新失败,旧数据仍可继续提供服务(降级可用)
注意:expire_at 必须用绝对时间戳(非相对秒数),避免因系统时间回拨导致误判;写入时用 redis.Client.Set 设置长 TTL(如 24h),仅作兜底,主逻辑完全依赖字段判断。
本地缓存选 ristretto 而不是 sync.Map 的真实原因
sync.Map 不是缓存组件,它只是线程安全的 map;没有容量限制、无淘汰策略、不支持 TTL,直接用于热点数据缓存等于埋 OOM 隐患。而 ristretto 是专为高并发缓存设计的库:
- 默认启用 LFU+LRU 混合淘汰,自动驱逐低频访问条目
- 支持近似内存容量控制(如
ristretto.NewCache(&ristretto.Config{MaxCost: 10 * 1024 * 1024})) -
Cache.Get()返回(value, ok),其中ok == false表示未命中,不是“key 不存在”,需自行回源并调用Set()
别把 ristretto 当成黑盒——它的 Cost 是用户定义的(比如按 value 字节数计算),若不设或设错,容量控制就形同虚设。
空值穿透必须原子写入 Redis,且不能用 nil
用户查一个根本不存在的订单 ID,每次请求都穿透到 DB,这是典型的缓存穿透。错误做法是:
- 存
redis.Set(ctx, key, nil, ttl)→ Redis 报错或被忽略 - 存空字符串
""→ 后续无法区分“真为空”和“查无此 ID” - 先
SET再EXPIRE→ 两步非原子,中间若崩溃,空值将长期滞留
正确姿势只有两种:
- 用原子命令:
redis.Client.Set(ctx, key, "NULL", 60*time.Second),应用层收到"NULL"字符串即跳过后续逻辑 - 前置布隆过滤器:对高频查询字段(如
user_id)建bloomfilter,误判率控制在 1% 内即可大幅削减无效请求
真正难处理的不是技术方案,而是业务语义的边界——比如“用户注销后 ID 是否允许复用”“软删除记录是否算‘存在’”,这些必须在缓存策略落地前与产品对齐,否则再好的锁和过期逻辑也救不了语义混乱。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











