Redis 从 0 到 1(十一):缓存穿透/击穿/雪崩三连击,Go 3 套防御代码

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • [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。

六、排查步骤:缓存相关数据库打满从哪查

  1. 看数据库慢查询:是否大量相同 SQL 查不存在的 id → 穿透;
  2. 看缓存命中率曲线:从 95% 跳水 → 大面积失效(雪崩);
  3. 看单 key 访问热度:某个 key QPS 异常高且刚过期 → 击穿;
  4. 对症下药:穿透加布隆 + 空值缓存;击穿加互斥重建;雪崩加 TTL 抖动 + 多级缓存。

七、总结

  1. 三个问题三种病:穿透是"查不存在的",击穿是"热 key 过期瞬间",雪崩是"一起过期";
  2. 防御三板斧:布隆过滤器 + 空值缓存、singleflight + SetNX 互斥重建、随机 TTL 抖动;
  3. 过滤器有不存在的误判但没有漏判,空值缓存兜住误判,两级配合最稳;
  4. 所有防御代码都是幂等的,故障时可以随时降级。

下一篇预告:缓存挡住了恶意流量,但缓存和数据库之间还有一笔糊涂账------先删缓存还是先更新库?下一篇《先删缓存还是先更新库?4 种方案的 Go 并发实测》,我们用 100 个 goroutine 把四种方案的脏读概率打出来。

相关推荐
Chenyu_3102 小时前
NUMA 基础:从 CPU、内存到 Node 的整体理解
linux·服务器·网络·缓存
土豆.exe11 小时前
nf_tables 的 verdict 被读了两次:一个十年未闭合的位域错配
java·数据库·网络安全·缓存
hweiyu0015 小时前
Redis命令:LMOVE
redis·缓存
bug菌15 小时前
一个周末就想把短链服务推到 10 万 QPS,真能靠加 Redis 做到吗?
java·redis·go
tachibana216 小时前
Go 语言里的 Context 是什么?
开发语言·后端·golang·go·语言
Linux实战笔记16 小时前
sync.Mutex还是Channel?Go并发场景选型对比与goroutine泄漏实战
go·channel·sync.mutex·goroutine泄漏·race detector
黎相思17 小时前
LRU Cache
c++·缓存
程序员Sunday18 小时前
Redis 缓存穿透、击穿、雪崩怎么区分?从回源压力找原因
数据库·redis·缓存
guo_wen_qiang18 小时前
redis实现秒杀功能-超卖&库存遗留问题的解决
数据库·redis·缓存