面试必问三件套:缓存穿透、缓存雪崩、缓存击穿。
但实际生产中踩过坑才知道,这三个问题不只是面试题,是真的会让服务挂掉的。
先搞清楚概念
| 问题 | 原因 | 后果 |
|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 请求全打到数据库 |
| 缓存雪崩 | 大量缓存同时失效 | 瞬间压垮数据库 |
| 缓存击穿 | 热点key突然过期 | 并发请求打穿数据库 |
一张图理解:
vbnet
正常情况:
请求 → Redis命中 → 返回
缓存穿透:
请求(不存在的key) → Redis未命中 → DB未命中 → 每次都查DB
缓存雪崩:
大量请求 → Redis大面积失效 → 全部打到DB → DB崩溃
缓存击穿:
大量并发请求(同一个热点key) → key刚好过期 → 全部打到DB
缓存穿透
什么是穿透
查询一个根本不存在的数据。缓存里没有,数据库里也没有。
vbnet
请求: GET /user/999999999
Redis: 没有这个key
MySQL: SELECT * FROM users WHERE id = 999999999 → 空
返回: null
下次请求: 还是走MySQL
恶意攻击者可以用大量不存在的ID疯狂请求,每个请求都打到数据库。
解决方案一:缓存空值
go
func GetUser(ctx context.Context, userID int64) (*User, error) {
key := fmt.Sprintf("user:%d", userID)
// 1. 查缓存
val, err := rdb.Get(ctx, key).Result()
if err == nil {
if val == "" {
return nil, nil // 空值,说明DB也没有
}
var user User
json.Unmarshal([]byte(val), &user)
return &user, nil
}
// 2. 查数据库
user, err := db.GetUser(userID)
if err != nil {
return nil, err
}
// 3. 写缓存
if user == nil {
// 缓存空值,设置较短过期时间
rdb.Set(ctx, key, "", 5*time.Minute)
return nil, nil
}
data, _ := json.Marshal(user)
rdb.Set(ctx, key, data, 30*time.Minute)
return user, nil
}
缺点:如果攻击者用随机key,空值缓存会占用大量内存。
解决方案二:布隆过滤器
布隆过滤器可以判断一个元素一定不存在 或可能存在。
go
import "github.com/bits-and-blooms/bloom/v3"
// 初始化布隆过滤器,存放所有存在的用户ID
var userBloom *bloom.BloomFilter
func InitBloom() {
userBloom = bloom.NewWithEstimates(10000000, 0.01) // 1000万数据,1%误判率
// 加载所有用户ID
userIDs, _ := db.GetAllUserIDs()
for _, id := range userIDs {
userBloom.AddString(fmt.Sprintf("%d", id))
}
}
func GetUser(ctx context.Context, userID int64) (*User, error) {
// 先过布隆过滤器
if !userBloom.TestString(fmt.Sprintf("%d", userID)) {
return nil, nil // 一定不存在
}
// 可能存在,走正常逻辑
// ...
}
Redis也有布隆过滤器模块:
bash
# 安装RedisBloom模块后
BF.ADD users 123
BF.EXISTS users 123 # 返回1
BF.EXISTS users 999 # 返回0
go
// Go代码
func CheckUserExists(ctx context.Context, userID int64) bool {
exists, _ := rdb.Do(ctx, "BF.EXISTS", "users", userID).Bool()
return exists
}
解决方案三:参数校验
最简单但最有效:
go
func GetUser(ctx context.Context, userID int64) (*User, error) {
// 参数校验
if userID <= 0 || userID > 10000000000 {
return nil, errors.New("invalid user id")
}
// 正常逻辑...
}
缓存雪崩
什么是雪崩
大量缓存在同一时间失效,请求全部打到数据库。
常见原因:
- 缓存设置了相同的过期时间
- Redis服务宕机
解决方案一:过期时间加随机值
go
func SetUserCache(ctx context.Context, userID int64, user *User) error {
key := fmt.Sprintf("user:%d", userID)
data, _ := json.Marshal(user)
// 基础过期时间 + 随机时间
baseExpire := 30 * time.Minute
randomExpire := time.Duration(rand.Intn(300)) * time.Second // 0~5分钟随机
expire := baseExpire + randomExpire
return rdb.Set(ctx, key, data, expire).Err()
}
这样缓存过期时间分散开,不会同时失效。
解决方案二:多级缓存
scss
请求 → 本地缓存(L1) → Redis(L2) → 数据库
go
import "github.com/patrickmn/go-cache"
var localCache = cache.New(5*time.Minute, 10*time.Minute)
func GetUser(ctx context.Context, userID int64) (*User, error) {
key := fmt.Sprintf("user:%d", userID)
// 1. 查本地缓存
if val, found := localCache.Get(key); found {
return val.(*User), nil
}
// 2. 查Redis
val, err := rdb.Get(ctx, key).Result()
if err == nil {
var user User
json.Unmarshal([]byte(val), &user)
localCache.Set(key, &user, cache.DefaultExpiration) // 写入本地缓存
return &user, nil
}
// 3. 查数据库
user, err := db.GetUser(userID)
if err != nil {
return nil, err
}
// 4. 写缓存
data, _ := json.Marshal(user)
rdb.Set(ctx, key, data, 30*time.Minute)
localCache.Set(key, user, cache.DefaultExpiration)
return user, nil
}
即使Redis挂了,本地缓存还能顶一会。
解决方案三:Redis高可用
- Redis Sentinel:哨兵模式,自动故障转移
- Redis Cluster:集群模式,数据分片+高可用
go
// Sentinel模式连接
rdb := redis.NewFailoverClient(&redis.FailoverOptions{
MasterName: "mymaster",
SentinelAddrs: []string{"sentinel1:26379", "sentinel2:26379", "sentinel3:26379"},
})
解决方案四:熔断降级
Redis不可用时,走降级逻辑:
go
import "github.com/sony/gobreaker"
var cb *gobreaker.CircuitBreaker
func init() {
cb = gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "redis",
MaxRequests: 3, // 半开状态允许的请求数
Interval: 10 * time.Second,
Timeout: 30 * time.Second, // 熔断后多久尝试恢复
ReadyToTrip: func(counts gobreaker.Counts) bool {
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return counts.Requests >= 3 && failureRatio >= 0.6
},
})
}
func GetUser(ctx context.Context, userID int64) (*User, error) {
result, err := cb.Execute(func() (interface{}, error) {
// 正常的Redis查询逻辑
return getFromRedis(ctx, userID)
})
if err != nil {
// 熔断了,走降级逻辑
return getFromDB(ctx, userID)
}
return result.(*User), nil
}
缓存击穿
什么是击穿
某个热点key突然过期,大量并发请求同时打到数据库。
和雪崩的区别:雪崩是大面积失效,击穿是单个热点key失效。
makefile
热点key: "hot_product:123"
过期瞬间:
请求1 → Redis Miss → 查DB
请求2 → Redis Miss → 查DB
请求3 → Redis Miss → 查DB
...
1000个请求同时查DB
解决方案一:互斥锁
只让一个请求去查数据库,其他请求等待。
go
func GetHotProduct(ctx context.Context, productID int64) (*Product, error) {
key := fmt.Sprintf("product:%d", productID)
lockKey := fmt.Sprintf("lock:product:%d", productID)
// 1. 查缓存
val, err := rdb.Get(ctx, key).Result()
if err == nil {
var product Product
json.Unmarshal([]byte(val), &product)
return &product, nil
}
// 2. 获取分布式锁
locked, err := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
if err != nil {
return nil, err
}
if !locked {
// 没拿到锁,等待后重试
time.Sleep(50 * time.Millisecond)
return GetHotProduct(ctx, productID) // 递归重试
}
defer rdb.Del(ctx, lockKey) // 释放锁
// 3. 双重检查(可能别人已经写入缓存了)
val, err = rdb.Get(ctx, key).Result()
if err == nil {
var product Product
json.Unmarshal([]byte(val), &product)
return &product, nil
}
// 4. 查数据库
product, err := db.GetProduct(productID)
if err != nil {
return nil, err
}
// 5. 写缓存
data, _ := json.Marshal(product)
rdb.Set(ctx, key, data, 30*time.Minute)
return product, nil
}
解决方案二:逻辑过期
不设置真正的过期时间,而是在value里存逻辑过期时间。
go
type CacheValue struct {
Data json.RawMessage `json:"data"`
ExpireTime int64 `json:"expire_time"` // 逻辑过期时间戳
}
func GetHotProduct(ctx context.Context, productID int64) (*Product, error) {
key := fmt.Sprintf("product:%d", productID)
val, err := rdb.Get(ctx, key).Result()
if err != nil {
// 缓存不存在,需要预热
return nil, errors.New("cache not found, need warm up")
}
var cv CacheValue
json.Unmarshal([]byte(val), &cv)
var product Product
json.Unmarshal(cv.Data, &product)
// 检查是否逻辑过期
if time.Now().Unix() > cv.ExpireTime {
// 过期了,异步刷新
go refreshCache(ctx, productID)
}
// 返回旧数据(不阻塞)
return &product, nil
}
func refreshCache(ctx context.Context, productID int64) {
key := fmt.Sprintf("product:%d", productID)
lockKey := fmt.Sprintf("refresh:product:%d", productID)
// 获取刷新锁
locked, _ := rdb.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if !locked {
return // 已经有人在刷新了
}
defer rdb.Del(ctx, lockKey)
// 查数据库
product, _ := db.GetProduct(productID)
// 写缓存
cv := CacheValue{
ExpireTime: time.Now().Add(30 * time.Minute).Unix(),
}
cv.Data, _ = json.Marshal(product)
data, _ := json.Marshal(cv)
rdb.Set(ctx, key, data, 0) // 不设置过期时间
}
特点:用户永远不会等待,总是返回数据(可能是旧的)。
解决方案三:热点数据永不过期
对于真正的热点数据,不设置过期时间,通过后台任务定时更新。
go
// 后台任务定时刷新热点数据
func RefreshHotData() {
ticker := time.NewTicker(5 * time.Minute)
for range ticker.C {
hotProductIDs := getHotProductIDs()
for _, id := range hotProductIDs {
product, _ := db.GetProduct(id)
key := fmt.Sprintf("product:%d", id)
data, _ := json.Marshal(product)
rdb.Set(context.Background(), key, data, 0)
}
}
}
实战:热点Key问题
除了击穿,热点Key还有另一个问题:单个Redis节点压力过大。
发现热点Key
bash
# Redis 4.0+
redis-cli --hotkeys
# 或者用monitor命令(生产慎用,性能影响大)
redis-cli monitor | head -n 10000 | awk '{print $4}' | sort | uniq -c | sort -rn | head -20
解决方案:本地缓存+多副本
go
// 热点key多副本,分散请求
func GetHotProduct(ctx context.Context, productID int64) (*Product, error) {
// 先查本地缓存
localKey := fmt.Sprintf("product:%d", productID)
if val, found := localCache.Get(localKey); found {
return val.(*Product), nil
}
// 随机选一个副本查询
replica := rand.Intn(3)
key := fmt.Sprintf("product:%d:r%d", productID, replica)
val, err := rdb.Get(ctx, key).Result()
if err == nil {
var product Product
json.Unmarshal([]byte(val), &product)
localCache.Set(localKey, &product, 30*time.Second)
return &product, nil
}
// 查数据库...
}
// 写入时写多个副本
func SetHotProduct(ctx context.Context, productID int64, product *Product) error {
data, _ := json.Marshal(product)
pipe := rdb.Pipeline()
for i := 0; i < 3; i++ {
key := fmt.Sprintf("product:%d:r%d", productID, i)
pipe.Set(ctx, key, data, 30*time.Minute)
}
_, err := pipe.Exec(ctx)
return err
}
总结
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 穿透 | 查不存在的数据 | 缓存空值、布隆过滤器、参数校验 |
| 雪崩 | 大量缓存同时失效 | 过期时间随机化、多级缓存、Redis高可用、熔断降级 |
| 击穿 | 热点key过期 | 互斥锁、逻辑过期、永不过期+后台刷新 |
实际生产中,这三个问题往往要组合解决:
- 所有缓存都加随机过期时间(防雪崩)
- 查询前做参数校验(防穿透)
- 缓存空值(防穿透)
- 热点数据用互斥锁或逻辑过期(防击穿)
- Redis做好高可用(防雪崩)
- 加上熔断降级兜底
有问题评论区聊。