ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Redis缓存策略:穿透、击穿与雪崩

Redis缓存策略:穿透、击穿与雪崩 Redis缓存策略:缓存穿透、击穿、雪崩摘要: 本篇讲解Redis缓存三大经典问题的解决方案包括布隆过滤器防缓存穿透、singleflight防缓存击穿、随机过期时间加多级缓存防缓存雪崩分享热点key过期瞬间大量请求打到数据库的踩坑经历对比三种缓存问题的现象、原因与解决方案。开篇故事去年双11零点我们的商品详情页接口直接被打爆了。数据库CPU瞬间飙到100%连接池满载整个服务雪崩。事后排查发现一个热门商品的缓存刚好在零点过期那一瞬间所有请求全打到数据库上数据库扛不住服务连锁崩溃。缓存用得好能给数据库挡掉90%的流量用不好反而放大风险。穿透、击穿、雪崩是缓存的三个经典问题每个都有成熟的应对方案。这篇我把三者的原理和代码实现讲透。一、缓存穿透与布隆过滤器缓存穿透是指查询一个根本不存在的数据。缓存里没有数据库里也没有每次请求都打到数据库。恶意攻击者可以用大量不存在的ID来压垮数据库。方案一:缓存空值最简单的方案查不到的数据在缓存里存一个空值标记。packagemainimport(contextencoding/jsonfmttimegithub.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()iferrnil{ifvalNULL{returnnil,fmt.Errorf(用户不存在)// 缓存了空值标记}varuser User json.Unmarshal([]byte(val),user)returnuser,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()ifexists0{returnnil,fmt.Errorf(用户不存在)// 直接拦截}// 第二步:可能存在继续查缓存和数据库key:fmt.Sprintf(user:%d,id)val,_:rdb.Get(ctx,key).Result()ifval!{varuser User json.Unmarshal([]byte(val),user)returnuser,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)returnuser,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}方案二:singleflightGo标准库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()iferrnil{varuser User json.Unmarshal([]byte(val),user)returnuser,nil}// 同一个key的并发调用只执行一次其他等结果result,err,_:group.Do(key,func()(interface{},error){// 双重检查前一个请求可能已经写好缓存ifval,err:rdb.Get(ctx,key).Result();errnil{varuser User json.Unmarshal([]byte(val),user)returnuser,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.Minutetime.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()iferrnil{varuser User json.Unmarshal([]byte(val),user)c.localCache.Set(key,user,5*time.Minute)// 回填本地returnuser,nil}// 第三级:数据库查到后回填两级缓存user,err:queryUserFromDB(id)iferr!nil{returnnil,err}data,_:json.Marshal(user)ttl:30*time.Minutetime.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()iferrredis.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中的实战操作。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表