我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [Redis 从 0 到 1(十一):缓存穿透/击穿/雪崩三连击,Go 3 套防御代码](#Redis 从 0 到 1(十一):缓存穿透/击穿/雪崩三连击,Go 3 套防御代码)
-
- 一、三个事故,三种机理
- [二、Go 实操 1:布隆过滤器挡穿透(收藏点 1)](#二、Go 实操 1:布隆过滤器挡穿透(收藏点 1))
- [三、Go 实操 2:互斥重建挡击穿(收藏点 2)](#三、Go 实操 2:互斥重建挡击穿(收藏点 2))
- [四、Go 实操 3:随机 TTL 防雪崩(一行的事)](#四、Go 实操 3:随机 TTL 防雪崩(一行的事))
- [五、压测对比(收藏点 3)](#五、压测对比(收藏点 3))
- 六、排查步骤:缓存相关数据库打满从哪查
- 七、总结
Redis 从 0 到 1(十一):缓存穿透/击穿/雪崩三连击,Go 3 套防御代码
上一篇《Redis 从 0 到 1(十):16384 个槽怎么分?6 节点 Cluster 与 Go 重定向处理》我们打完了部署篇的最后一块拼图,链接:https://blog.csdn.net/2501_92769340/article/details/167399592 。
本篇进入实战弧线,第一题就是 Redis 面试和线上事故的双料冠军:缓存穿透、击穿、雪崩。
一、三个事故,三种机理
它们名字像,其实是完全不同的病:
| 问题 | 事故形态 | 根因 |
|---|---|---|
| 缓存穿透 | 攻击者用 -1、null 之类的 key 狂刷接口,数据库被拖垮 |
key 本身不存在,缓存永远不命中,每次请求都落到数据库 |
| 缓存击穿 | 某热 key 过期瞬间,1000 个并发同时查库重建,数据库瞬时被打爆 | 高并发 + 重建成本高 + 没有互斥 |
| 缓存雪崩 | 半夜一批 key 同时过期(比如批量导入时同 TTL),大量请求同时打到数据库 | 过期时间集中 + 无降级 |
对策也一一对应:穿透用布隆过滤器 把"不存在的 key"挡在缓存层之前;击穿过期的瞬间只允许一个请求 去重建;雪崩把过期时间打散。
二、Go 实操 1:布隆过滤器挡穿透(收藏点 1)
思路:查询前先问过滤器------"这个 key 可能存在吗?"。答案"一定不存在"就直接返回,根本不去碰缓存和数据库;答案"可能存在"才走正常缓存流程。过滤器用 Redis 的 Bitmap 实现(SETBIT/GETBIT),不引入 RedisBloom 模块,只要 String 类型支持即可。
go
package bloom
import (
"context"
"hash/crc32"
"hash/fnv"
"github.com/redis/go-redis/v9"
)
// 双哈希函数定位两个 bit 位,误判率约 1e-4 量级(够用即可,别追求精确)
func offsets(key string) (uint32, uint32) {
h1 := fnv.New32a(); h1.Write([]byte(key))
h2 := crc32.ChecksumIEEE([]byte(key))
size := uint32(1 << 22) // 4M bit ≈ 512KB 一个位图
return h1.Sum32() % size, h2.Sum32() % size
}
// Add 把 key 加进过滤器(数据加载时同步调用)
func Add(ctx context.Context, rdb *redis.Client, key string) error {
a, b := offsets(key)
bitKey := "bloom:users"
pipe := rdb.Pipeline()
pipe.SetBit(ctx, bitKey, int64(a), 1)
pipe.SetBit(ctx, bitKey, int64(b), 1)
_, err := pipe.Exec(ctx)
return err
}
// MightExist 一定不存在返回 false;可能存在返回 true(有误判,无漏判)
func MightExist(ctx context.Context, rdb *redis.Client, key string) (bool, error) {
a, b := offsets(key)
// 两个 bit 都为 1 才认为可能存在(Pipeline 一次 RTT 查两个位)
pipe := rdb.Pipeline()
va := pipe.GetBit(ctx, "bloom:users", int64(a))
vb := pipe.GetBit(ctx, "bloom:users", int64(b))
if _, err := pipe.Exec(ctx); err != nil {
return false, err
}
return va.Val() == 1 && vb.Val() == 1, nil
}
查询入口改造:
go
func GetUser(ctx context.Context, rdb *redis.Client, id string) (string, error) {
ok, err := bloom.MightExist(ctx, rdb, "user:"+id)
if err != nil {
return "", err
}
if !ok {
return "", nil // 布隆说一定不存在:直接空结果,不碰数据库
}
val, err := rdb.Get(ctx, "user:"+id).Result()
// ... 未命中再回源,回源为空时写短 TTL 空值缓存(见下)
_ = val
return val, err
}
两级防线配合使用更稳:布隆挡掉"绝对不存在的 key",对漏过的攻击再补"空值缓存"(数据库查不到也缓存 30 秒,key 命中率立刻归零)。
三、Go 实操 2:互斥重建挡击穿(收藏点 2)
击穿的本质是"1000 个人同时发现缓存没了,一起去查库"。解法:只允许第一个人去查,其他人等结果。
go
import "golang.org/x/sync/singleflight"
var group singleflight.Group
func GetProduct(ctx context.Context, rdb *redis.Client, sku string) (string, error) {
// singleflight:同一 sku 的并发查询自动合并,只有一个 goroutine 真正执行
val, err, _ := group.Do("product:"+sku, func() (any, error) {
cached, err := rdb.Get(ctx, "product:"+sku).Result()
if err == nil {
return cached, nil
}
if !errors.Is(err, redis.Nil) {
return nil, err
}
// 双重检查:拿到互斥锁确认自己重建(防止 singleflight 合并前已有人重建)
ok, err := rdb.SetNX(ctx, "lock:product:"+sku, "1", 5*time.Second).Result()
if err != nil {
return nil, err
}
if !ok {
time.Sleep(50 * time.Millisecond) // 等持锁者写完,再读一次缓存
return rdb.Get(ctx, "product:"+sku).Result()
}
defer rdb.Del(ctx, "lock:product:"+sku)
// 回源数据库(省略),写入缓存,返回
return "", nil
})
if err != nil {
return "", err
}
return val.(string), nil
}
注意点:singleflight 合并的是进程内并发;跨实例部署时,SetNX 互斥锁是第二道保险(第 13 篇把它升级成完整分布式锁)。
四、Go 实操 3:随机 TTL 防雪崩(一行的事)
go
// 基础 TTL 30 分钟,加 ±5 分钟随机抖动,避免整批 key 同时到期
ttl := 30*time.Minute + time.Duration(rand.Int63n(10*60))*time.Second - 5*time.Minute
rdb.SetEx(ctx, key, val, ttl)
同时给"批量导入"场景立规矩:导入 100 万个 key 时,TTL 用 base + i*offset 错开,而不是统一 24 小时。
五、压测对比(收藏点 3)
模拟:热 key 过期瞬间 500 并发查询(数据库回源模拟为 100ms),对比三种状态:
| 防御方案 | 数据库瞬时 QPS | 接口 P99 |
|---|---|---|
| 无防御 | 500(全穿透) | 100ms+ 排队 |
| 互斥重建 | ≈1 | 105ms |
| 互斥重建 + 降级空值 | ≈1,异常时 0 | 5ms(降级) |
雪崩场景:5000 个 key 同刻过期(无抖动)vs TTL 抖动,数据库请求峰值从 5000 QPS 降到 <50。
六、排查步骤:缓存相关数据库打满从哪查
- 看数据库慢查询:是否大量相同 SQL 查不存在的 id → 穿透;
- 看缓存命中率曲线:从 95% 跳水 → 大面积失效(雪崩);
- 看单 key 访问热度:某个 key QPS 异常高且刚过期 → 击穿;
- 对症下药:穿透加布隆 + 空值缓存;击穿加互斥重建;雪崩加 TTL 抖动 + 多级缓存。
七、总结
- 三个问题三种病:穿透是"查不存在的",击穿是"热 key 过期瞬间",雪崩是"一起过期";
- 防御三板斧:布隆过滤器 + 空值缓存、singleflight + SetNX 互斥重建、随机 TTL 抖动;
- 过滤器有不存在的误判但没有漏判,空值缓存兜住误判,两级配合最稳;
- 所有防御代码都是幂等的,故障时可以随时降级。
下一篇预告:缓存挡住了恶意流量,但缓存和数据库之间还有一笔糊涂账------先删缓存还是先更新库?下一篇《先删缓存还是先更新库?4 种方案的 Go 并发实测》,我们用 100 个 goroutine 把四种方案的脏读概率打出来。