
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中的實戰操作。