Redis缓存策略:缓存穿透、击穿、雪崩
摘要: 本篇讲解Redis缓存三大经典问题的解决方案,包括布隆过滤器防缓存穿透、singleflight防缓存击穿、随机过期时间加多级缓存防缓存雪崩,分享热点key过期瞬间大量请求打到数据库的踩坑经历,对比三种缓存问题的现象、原因与解决方案。
开篇故事
去年双11零点,我们的商品详情页接口直接被打爆了。数据库CPU瞬间飙到100%,连接池满载,整个服务雪崩。事后排查发现,一个热门商品的缓存刚好在零点过期,那一瞬间所有请求全打到数据库上,数据库扛不住,服务连锁崩溃。
缓存用得好能给数据库挡掉90%的流量,用不好反而放大风险。穿透、击穿、雪崩是缓存的三个经典问题,每个都有成熟的应对方案。这篇我把三者的原理和代码实现讲透。
一、缓存穿透与布隆过滤器
缓存穿透是指查询一个根本不存在的数据。缓存里没有,数据库里也没有,每次请求都打到数据库。恶意攻击者可以用大量不存在的ID来压垮数据库。
方案一:缓存空值
最简单的方案,查不到的数据在缓存里存一个空值标记。
packagemainimport("context""encoding/json""fmt""time""github.com/redis/go-redis/v9")// 缓存空值防穿透funcgetUserWithNullCache(ctx context.Context,rdb*redis.Client,idint64)(*User,error){key:=fmt.Sprintf("user:%d",id)// 先查缓存val,err:=rdb.Get(ctx,key).Result()iferr==nil{ifval=="NULL"{returnnil,fmt.Errorf("用户不存在")// 缓存了空值标记}varuser User json.Unmarshal([]byte(val),&user)return&user,nil}// 缓存未命中,查数据库user,err:=queryUserFromDB(id)iferr!=nil{// 数据库也没有,缓存空值防穿透,过期时间设短rdb.Set(ctx,key,"NULL",5*time.Minute)returnnil,fmt.Errorf("用户不存在")}// 数据库有,写入缓存data,_:=json.Marshal(user)rdb.Set(ctx,key,data,30*time.Minute)returnuser,nil}缓存空值的缺点是大量不存在的ID会占满Redis内存。
方案二:布隆过滤器
布隆过滤器是概率型数据结构,能判断一个元素"肯定不存在"或"可能存在"。用它过滤掉不存在的ID,请求根本不会到数据库。
// 布隆过滤器防穿透,需要Redis安装RedisBloom模块funcgetUserWithBloom(ctx context.Context,rdb*redis.Client,idint64)(*User,error){// 第一步:布隆过滤器判断,说不存在就是真不存在exists,_:=rdb.Do(ctx,"BF.EXISTS","user_ids",id).Int()ifexists==0{returnnil,fmt.Errorf("用户不存在")// 直接拦截}// 第二步:可能存在,继续查缓存和数据库key:=fmt.Sprintf("user:%d",id)val,_:=rdb.Get(ctx,key).Result()ifval!=""{varuser User json.Unmarshal([]byte(val),&user)return&user,nil}returnqueryUserFromDB(id)}布隆过滤器的特点要记住。说不存在就是真不存在,说存在可能误判。新增数据要同步加入过滤器,删除数据不建议移除(会影响其他元素的判断)。
二、缓存击穿与singleflight
缓存击穿是指某个热点key过期的瞬间,大量并发请求同时打到数据库。和穿透的区别是,穿透是查不存在的数据,击穿是查存在但缓存刚过期的数据。
方案一:互斥锁
用Redis分布式锁,只让一个请求查数据库,其他请求等待。
// 互斥锁防击穿funcgetUserWithMutex(ctx context.Context,rdb*redis.Client,idint64)(*User,error){key:=fmt.Sprintf("user:%d",id)lockKey:=fmt.Sprintf("lock:%s",key)// 先查缓存val,_:=rdb.Get(ctx,key).Result()ifval!=""{varuser User json.Unmarshal([]byte(val),&user)return&user,nil}// 缓存未命中,尝试获取锁ok,_:=rdb.SetNX(ctx,lockKey,"1",10*time.Second).Result()if!ok{time.Sleep(50*time.Millisecond)// 没拿到锁,等一会儿重试returngetUserWithMutex(ctx,rdb,id)}deferrdb.Del(ctx,lockKey)// 拿到锁,查数据库并写回缓存user,err:=queryUserFromDB(id)iferr!=nil{returnnil,err}data,_:=json.Marshal(user)rdb.Set(ctx,key,data,30*time.Minute)returnuser,nil}方案二:singleflight
Go标准库golang.org/x/sync/singleflight更优雅。同一个key的并发调用只有一个会真正执行,其他等结果。
vargroup singleflight.Group// singleflight防击穿funcgetUserWithSingleflight(ctx context.Context,rdb*redis.Client,idint64)(*User,error){key:=fmt.Sprintf("user:%d",id)// 先查缓存val,err:=rdb.Get(ctx,key).Result()iferr==nil{varuser User json.Unmarshal([]byte(val),&user)return&user,nil}// 同一个key的并发调用只执行一次,其他等结果result,err,_:=group.Do(key,func()(interface{},error){// 双重检查,前一个请求可能已经写好缓存ifval,err:=rdb.Get(ctx,key).Result();err==nil{varuser User json.Unmarshal([]byte(val),&user)return&user,nil}user,err:=queryUserFromDB(id)iferr!=nil{returnnil,err}data,_:=json.Marshal(user)rdb.Set(ctx,key,data,30*time.Minute)returnuser,nil})iferr!=nil{returnnil,err}returnresult.(*User),nil}singleflight比互斥锁的优势在于不需要管理锁的获取释放,不处理死锁和锁过期,Go原生支持。缺点是只防当前进程内的并发,多实例部署时每个实例都会有一个请求打到数据库。
三、缓存雪崩与多级缓存
缓存雪崩是指大量key同时过期,或Redis整体宕机,所有请求全打到数据库。和击穿的区别是规模,击穿是一个热点key,雪崩是一批key。
方案一:随机过期时间
给每个key的过期时间加随机值,避免同时过期。
// 随机过期时间防雪崩funcsetCacheWithRandomTTL(ctx context.Context,rdb*redis.Client,users[]User){pipe:=rdb.Pipeline()for_,u:=rangeusers{key:=fmt.Sprintf("user:%d",u.ID)data,_:=json.Marshal(u)// 基础30分钟 + 0到10分钟随机偏移ttl:=30*time.Minute+time.Duration(rand.Intn(600))*time.Second pipe.Set(ctx,key,data,ttl)}pipe.Exec(ctx)}方案二:多级缓存
本地缓存加Redis两级缓存,Redis挂了本地缓存还能扛一阵。
// 多级缓存:本地缓存 + RedistypeMultiLevelCachestruct{localCache*cache.Cache// 进程内缓存redis*redis.Client// 分布式缓存}func(c*MultiLevelCache)Get(ctx context.Context,idint64)(*User,error){key:=fmt.Sprintf("user:%d",id)// 第一级:本地缓存,最快ifval,ok:=c.localCache.Get(key);ok{returnval.(*User),nil}// 第二级:Redisval,err:=c.redis.Get(ctx,key).Result()iferr==nil{varuser User json.Unmarshal([]byte(val),&user)c.localCache.Set(key,&user,5*time.Minute)// 回填本地return&user,nil}// 第三级:数据库,查到后回填两级缓存user,err:=queryUserFromDB(id)iferr!=nil{returnnil,err}data,_:=json.Marshal(user)ttl:=30*time.Minute+time.Duration(rand.Intn(600))*time.Second c.redis.Set(ctx,key,data,ttl)c.localCache.Set(key,user,5*time.Minute)returnuser,nil}四、独家踩坑:热点key过期瞬间打穿数据库
这个坑和开篇的故事是同一个。我们有个热门商品详情页,缓存放了1小时,定时刷新。问题出在刷新逻辑上,刷新时先删旧缓存再写新缓存,中间有几百毫秒空窗期。
// 问题代码:刷新缓存时的空窗期funcrefreshCacheBad(ctx context.Context,rdb*redis.Client,productIDstring){key:=fmt.Sprintf("product:%s",productID)rdb.Del(ctx,key)// 删旧缓存// 查数据库耗时200ms,这段时间所有请求查不到缓存product,_:=queryProductFromDB(productID)data,_:=json.Marshal(product)rdb.Set(ctx,key,data,time.Hour)// 写新缓存}零点活动前运营手动触发了一次刷新。删缓存和写缓存之间200ms空窗,几万个请求全打到数据库,连接池瞬间打满,服务雪崩。
修复用"逻辑过期"代替物理过期。缓存不设TTL,在value里存过期时间字段。读到过期时间到了,异步刷新缓存,当前请求返回旧数据。
// 逻辑过期方案:缓存不设TTL,过期判断在应用层typeCacheItemstruct{Data[]byte// 实际数据ExpireAt time.Time// 逻辑过期时间}funcgetWithLogicalExpire(ctx context.Context,rdb*redis.Client,productIDstring)([]byte,error){key:=fmt.Sprintf("product:%s",productID)val,err:=rdb.Get(ctx,key).Result()iferr==redis.Nil{returnloadAndCache(ctx,rdb,productID)// 缓存不存在,加载}varitem CacheItem json.Unmarshal([]byte(val),&item)// 逻辑过期没到,直接返回iftime.Now().Before(item.ExpireAt){returnitem.Data,nil}// 过期了但数据还能用,异步刷新,当前请求返回旧数据gofunc(){lockKey:=fmt.Sprintf("lock:%s",key)ifok,_:=rdb.SetNX(context.Background(),lockKey,"1",30*time.Second).Result();ok{loadAndCache(ctx,rdb,productID)rdb.Del(context.Background(),lockKey)}}()returnitem.Data,nil}funcloadAndCache(ctx context.Context,rdb*redis.Client,productIDstring)([]byte,error){product,err:=queryProductFromDB(productID)iferr!=nil{returnnil,err}data,_:=json.Marshal(product)item:=CacheItem{Data:data,ExpireAt:time.Now().Add(time.Hour)}itemData,_:=json.Marshal(item)rdb.Set(ctx,fmt.Sprintf("product:%s",productID),itemData,0)// TTL设0表示永不过期returndata,nil}逻辑过期的核心是缓存永不过期(物理层面),过期判断在应用层。过期后返回旧数据加异步刷新,用户感知不到延迟。代价是短时间内返回旧数据,强一致性场景不适用。
五、对比分析
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| 缓存穿透 | 查不存在的数据,每次打到DB | 数据不存在,缓存无法命中 | 缓存空值、布隆过滤器 |
| 缓存击穿 | 热点key过期瞬间DB压力骤增 | 单个热点key过期,并发涌入 | 互斥锁、singleflight、逻辑过期 |
| 缓存雪崩 | 大量key同时过期或Redis宕机 | 过期时间相同、Redis故障 | 随机TTL、多级缓存、熔断降级 |
三个问题的区别在于规模和触发条件。穿透是数据不存在,击穿是单key过期,雪崩是批量key过期。穿透用布隆过滤器,击穿用singleflight,雪崩用随机TTL加多级缓存。实际项目中三种方案经常组合使用。
总结与预告
缓存策略的核心是减少数据库压力。布隆过滤器拦截不存在的请求,singleflight合并并发请求,随机TTL和逻辑过期避免同时失效。热点key一定要做特殊处理,逻辑过期方案虽然会短暂返回旧数据,但能保证服务不雪崩。
下一篇我们换个数据库,讲MongoDB在Go中的实战操作。