缓存击穿是热点key过期瞬间大量并发请求穿透缓存打到数据库,本质是业务层未保障“查db+写缓存”原子性;需用set nx原子命令抢锁、配合超时控制与singleflight去重,而非依赖本地锁或del+set等非原子操作。

击穿不是Redis问题,是业务没控制好原子性
缓存击穿只发生在单个热点 key(比如 item:123)过期瞬间,大量并发请求同时发现缓存 miss,全部打到数据库。这不是 Redis 不够快,而是 Go 层没把「查 DB + 写缓存」这个动作变成原子操作。本地锁、sync.Mutex、甚至先 GET 再 SET 的三步写法,在多实例部署下完全无效。
用 SET 原子命令抢写,别手写锁逻辑
Go-Redis v9 提供了 SetArgs,直接用 SET key val EX 300 NX 语义封装,比自己拼 redis.Set 更安全可靠:
-
rdb.Set(ctx, key, placeholder, redis.SetArgs{Mode: redis.SetNX, Expire: 5 * time.Second})—— 抢锁成功才去查 DB;失败就轮询GET或 sleep 后重试 - 锁超时必须 ≥ DB 查询耗时(建议 ≥ 5s),但别超过 30s,否则故障时锁残留会拖垮后续请求
- 别用
DEL+SET手写锁:存在竞态窗口;也别用GETSET,它无法判断旧值是否属于自己 - 占位符值建议带唯一标识(如 trace ID),避免误删别人持有的锁
配合 singleflight.Group 去重,但注意空值处理
singleflight.Group 是轻量级请求合并工具,适合和缓存搭配使用,但它不解决锁释放或超时传播,需手动兜底:
- key 必须是 string,推荐
fmt.Sprintf("user:%d", id),别直接传int - 真实加载逻辑(含 DB 查询 + 写缓存)必须全部包进
Do的func() (interface{}, error)里,不能在外面做副作用操作 -
Do内部仍要判errors.Is(err, redis.Nil),否则会把空结果当有效值缓存;别写成err == redis.Nil(它是变量,不是常量) - 全局复用一个
singleflight.Group实例即可,不用每次 new
Gin 中集成时,别在 handler 里用 context.Background()
所有 Redis 操作必须带超时 context,尤其在 Gin 的 HTTP handler 里:
- 用
ctx, cancel := context.WithTimeout(c.Request.Context(), 300*time.Millisecond),继承 request 生命周期 - 写操作(
Set、Del、分布式锁)同样要超时;缺超时会导致锁假死、资源长期被占 - 别在
init()里提前声明全局ctx = context.Background()——它不是“默认上下文”,是“无约束上下文” - 连接池初始化时,
PoolSize建议设为预估并发请求数的 2–4 倍(如 300 QPS → 60–120),并务必调rdb.Ping(ctx).Err()验证连通性
真正难的不是写几行代码,而是把「原子性」「超时控制」「错误分类」这三个点在每个路径上都对齐。漏掉任意一个,击穿就会在某个凌晨三点准时发生。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











