目錄
思考
- 如何控制緩存過(guò)期時(shí)間
- 如何控制緩存內(nèi)存大小
- 如何提高內(nèi)存的利用率
1.前言
緩存在業(yè)務(wù)中是不可避免的一個(gè)模塊须教,大體上分成兩類(lèi)
- 本地緩存烁涌,如前端的localstorage
- 分布式緩存,如redise/memcache
同時(shí)墓贿,通常我們?yōu)榱吮苊鈽I(yè)務(wù)層直接操作redis,會(huì)引入一個(gè)緩存模塊术吝,即再次封裝淋样,最典型的例子就是go-cache
2.go-cache走讀
2.1API概覽與快速上手
go-cache的API設(shè)計(jì)主要分成兩部分
- 單個(gè)操作:Set/Get
- 針對(duì)數(shù)字的加減: Increment
func Test_go_cache(t *testing.T) {
c := cache.New(time.Minute, 10*time.Minute)
c.Set("name", "zjb", cache.DefaultExpiration)
res, ok := c.Get("name")
if !ok {
t.Fatal("not found")
}
name := res.(string)
t.Log(name)
}
2.2主要數(shù)據(jù)結(jié)構(gòu)
New方法返回Cache結(jié)構(gòu)體
func New(defaultExpiration, cleanupInterval time.Duration) *Cache {
items := make(map[string]Item)
return newCacheWithJanitor(defaultExpiration, cleanupInterval, items)
}
type Item struct {
Object interface{}
Expiration int64 // 過(guò)期時(shí)間:設(shè)置時(shí)間+緩存時(shí)長(zhǎng)
}
我們可以大致猜到這個(gè)Item就是實(shí)際存儲(chǔ)KV的本地緩存胳螟,實(shí)際上就是如此,后續(xù)的Set/get等就是并發(fā)操作map接校;同時(shí)我們也意識(shí)到map屬于hash一類(lèi)的數(shù)據(jù)結(jié)構(gòu)猛频,內(nèi)存利用率并不高(但從這角度講,替換成數(shù)組是否更好?)
那問(wèn)題來(lái)了伦乔,為什么我們不直接使用map還要套一層娃呢厉亏?因?yàn)閙ap并發(fā)會(huì)沖突,擴(kuò)展性也差
type Cache struct {
*cache
// If this is confusing, see the comment at the bottom of New()
}
type cache struct {
defaultExpiration time.Duration
items map[string]Item
mu sync.RWMutex
onEvicted func(string, interface{})
janitor *janitor
}
type janitor struct {
Interval time.Duration // 多長(zhǎng)時(shí)間掃描一次緩存
stop chan bool // 是否需要停止
}
- onEvicted 是刪除Key時(shí)執(zhí)行的回調(diào)函數(shù)
- mu 內(nèi)部使用鎖來(lái)處理并發(fā)
- defaultxxxx 默認(rèn)的超時(shí)時(shí)間New函數(shù)傳入
- janitor 負(fù)責(zé)定時(shí)清空緩存
2.3清空緩存操作
newCacheWithJanitor方法初始化janitor
C := &Cache{c}
if ci > 0 {
runJanitor(c, ci)
runtime.SetFinalizer(C, stopJanitor)
}
內(nèi)部啟動(dòng)一個(gè)協(xié)程烈和,監(jiān)聽(tīng)計(jì)時(shí)器和信號(hào)通道(cache的刪除操作是 for range 遍歷全部)
func runJanitor(c *cache, ci time.Duration) {
j := &janitor{
Interval: ci,
stop: make(chan bool),
}
c.janitor = j
go j.Run(c)
}
func (j *janitor) Run(c *cache) {
ticker := time.NewTicker(j.Interval)
for {
select {
case <-ticker.C:
c.DeleteExpired()
case <-j.stop:
ticker.Stop()
return
}
}
}
那么問(wèn)題來(lái)了這個(gè)終止的信號(hào)量由誰(shuí)輸入爱只?runtime.SetFinalizer
runtime.SetFinalizer 是Go提供對(duì)象被GC回收時(shí)的一個(gè)注冊(cè)函數(shù),可以在對(duì)象被回收的時(shí)候回掉函數(shù)
2.4擴(kuò)展思考
- go-cache是鎖住全部map招刹,鎖的粒度是否可以更刑袷浴?
- 使用sync.Map代替map?
- SetFinalizer函數(shù)使用有什么需要注意點(diǎn)疯暑?
其他緩存庫(kù)參考:
- freecache
- groupcache
- bigcache
3.嘗試自己實(shí)現(xiàn)一個(gè)高可用的緩存
假設(shè)我們自己的redis緩存API設(shè)計(jì)如下
type Cache interface {
Get(ctx context.Context, key string) (any, error)
Set(ctx context.Context, key string, value any, t time.Duration) error
Delete(ctx context.Context, key string) error
}
實(shí)現(xiàn)結(jié)構(gòu)體為
type RedisCache struct {
t time.Duration
cmd redis.Cmdable
}
set和get就不貼出了训柴,具體就是操作cmd
3.1緩存的過(guò)期機(jī)制如何設(shè)計(jì)
緩存如何處理過(guò)期Key?
- 定期刪除:學(xué)go-cache開(kāi)一個(gè)協(xié)程定時(shí)輪詢,time.Ticker時(shí)間一到就檢測(cè)一遍妇拯。那么時(shí)間間隔多久合適幻馁,或者直接讓用戶傳入?遍歷數(shù)量是全部還是100個(gè)越锈,1000個(gè)仗嗦?(時(shí)間不精準(zhǔn),對(duì)象過(guò)期只有等到下一個(gè)甚至更久的循環(huán)才能被刪除)
- 定時(shí)刪除:每個(gè)key開(kāi)一個(gè)協(xié)程盯著(time.Afterfunc)甘凭?雖然可行但很離譜,這些協(xié)程大部分時(shí)間在阻塞稀拐,浪費(fèi)資源(定時(shí)器本身開(kāi)銷(xiāo)就很大,時(shí)間重置要先取消原本定時(shí)器丹弱,再重啟)
- 懶惰刪除:學(xué)mysql懶刪除德撬,即用戶調(diào)用Get訪問(wèn)key的時(shí)候檢查過(guò)期,通常會(huì)與定期刪除一起使用躲胳,比如redis(對(duì)象過(guò)期后究竟什么時(shí)候刪除不確定蜓洪,內(nèi)存浪費(fèi))
- 延遲隊(duì)列:把對(duì)象扔到一個(gè)延遲隊(duì)列里面,當(dāng)從隊(duì)列里取出時(shí)表示已經(jīng)過(guò)期(需要額外的內(nèi)存開(kāi)銷(xiāo)泛鸟,而且過(guò)期時(shí)間被重置時(shí)需要調(diào)整在隊(duì)列中的位置蝠咆,計(jì)算的時(shí)間復(fù)雜度增加)
3.1.1 參考redis的過(guò)期刪除策略
redis是追求高性能的中間件,因此不會(huì)選擇使用延遲隊(duì)列和定時(shí)刪除的方式北滥,選擇懶加載和定期的方式
同時(shí)redis的定期刪除會(huì)在每一個(gè)循環(huán)中遍歷DB,如果當(dāng)次定期沒(méi)有遍歷全部DB闸翅,那么下一次循環(huán)從下一個(gè)DB開(kāi)始遍歷再芋,對(duì)于每個(gè)DB:
- 如果DB的key都沒(méi)設(shè)置過(guò)期時(shí)間就遍歷下一個(gè)DB
- 設(shè)置了就抽一批調(diào)查,默認(rèn)25個(gè)坚冀,檢查過(guò)期济赎,執(zhí)行刪除
- 每遍歷16個(gè)key就檢測(cè)執(zhí)行時(shí)間,如果超過(guò)閾值就中斷本次循環(huán)
- 如果這批過(guò)期的key比例超過(guò)一個(gè)閾值,就繼續(xù)抽取下一批(比例太低就直接去遍歷下一個(gè)DB)
redis通過(guò)hz和dynamic-hz的值來(lái)控制定期刪除的頻率
特變注意redis還有一個(gè)主從分布的問(wèn)題司训,3.2之前若key過(guò)期主庫(kù)會(huì)執(zhí)行刪除操作构捡,從庫(kù)不會(huì),因此仍可以從從庫(kù)中獲取key
補(bǔ)充一點(diǎn)redis的持久化文件RDB在生成時(shí)會(huì)忽略已經(jīng)過(guò)期的key壳猜,AOF是無(wú)論是定期刪除還是懶刪除都會(huì)記錄DEL命令
3.1.2 過(guò)期時(shí)間如何確定和優(yōu)化
最完美的情況就是容量無(wú)限大勾徽,設(shè)置永不過(guò)期,但實(shí)際怎么可能统扳,基本都是通過(guò)緩存命中率來(lái)確定過(guò)期時(shí)間
- 從緩存命中率角度出發(fā)(命中緩存的次數(shù)/過(guò)期時(shí)間)喘帚,可以選擇調(diào)大過(guò)期時(shí)間,但代價(jià)就是緩存了更多key咒钟,占用更多內(nèi)存吹由,類(lèi)似的業(yè)務(wù)就是登錄的狀態(tài)(緩存命中率越高,就越需要更多的緩存容量朱嘴,越長(zhǎng)的過(guò)期時(shí)間)
- 減少過(guò)期時(shí)間倾鲫,這是根據(jù)業(yè)務(wù)特征出發(fā)的,如果有些業(yè)務(wù)根本用不了一定的時(shí)間萍嬉,可以選擇降低過(guò)期時(shí)間乌昔,比如當(dāng)某個(gè)數(shù)據(jù)被查出來(lái)后,大概率在三十分鐘內(nèi)再次使用這個(gè)對(duì)象帚湘,就可以減少過(guò)期時(shí)間到30分鐘
問(wèn)題又來(lái)了如何確定緩存命中率玫荣,這個(gè)實(shí)際要看用戶體驗(yàn)確定,或者公司規(guī)定的平均響應(yīng)時(shí)間來(lái)推算大诸,比如要求平均響應(yīng)時(shí)間是300ms捅厂,實(shí)際命中緩存時(shí)間100ms,沒(méi)命中1000ms资柔,假設(shè)命中率=p 則p需要滿足
命中的時(shí)間p+沒(méi)命中的時(shí)間(1-p)=300
100 x p + 1000 x (1-p) = 300
p=0.78
- 從數(shù)據(jù)特征出發(fā)焙贷,熱點(diǎn)數(shù)據(jù)越熱,過(guò)期時(shí)間越長(zhǎng),這也就是動(dòng)態(tài)確定過(guò)期時(shí)間贿堰,比如根據(jù)請(qǐng)求特征辙芍、計(jì)算時(shí)間、重要性羹与、優(yōu)先級(jí)等
3.1.3 擴(kuò)展-緩存的預(yù)加載和超短過(guò)期時(shí)間
簡(jiǎn)單來(lái)說(shuō)就是預(yù)料用戶的行為--空間換響應(yīng)時(shí)間故硅,在訪問(wèn)a數(shù)據(jù)時(shí),大概率會(huì)訪問(wèn)B纵搁,獲取A數(shù)據(jù)時(shí)提前緩存B數(shù)據(jù)吃衅,此時(shí)這個(gè)用戶不管看不看B數(shù)據(jù),別人都用不上腾誉,所以可以把B數(shù)據(jù)的過(guò)期時(shí)間設(shè)置很短徘层,典型的就是B站的視頻下方的視頻推薦列表峻呕,朋友圈的九宮格圖片,點(diǎn)開(kāi)第一張后半預(yù)加載第二張等趣效,總結(jié)就是預(yù)期用戶行為
3.2緩存的內(nèi)存如何控制
為了防止內(nèi)存無(wú)止盡的擴(kuò)展通常需要我們做緩存控制瘦癌,go-cache里沒(méi)實(shí)現(xiàn)改功能
- 控制鍵值對(duì)數(shù)量
- 控制內(nèi)存大小
思路都是在set時(shí)檢測(cè)是否超過(guò)閾值,在刪除時(shí)恢復(fù)當(dāng)前余量
3.2.1擴(kuò)展閱讀-淘汰策略
- LRU最近最少策略跷敬,緩存容量不足時(shí)從所有key挑一個(gè)最近最長(zhǎng)未使用的key淘汰(經(jīng)典的實(shí)現(xiàn)方式是額外使用一個(gè)隊(duì)列讯私,最近使用過(guò)的放到隊(duì)首,隊(duì)尾就是長(zhǎng)時(shí)間未使用過(guò)的)
- LFU最不經(jīng)常使用干花,根據(jù)使用頻率刪除(可以在隊(duì)列實(shí)現(xiàn)的基礎(chǔ)上妄帘,增加一個(gè)表示訪問(wèn)次數(shù)的字段,每次訪問(wèn)+1)
redis支持多種淘汰算法池凄,可以根據(jù)maxmemory設(shè)置內(nèi)存用量和maxmemory_policy設(shè)置淘汰算法
- 還有從業(yè)務(wù)角度出發(fā)抡驼,區(qū)分VIP用戶和普通用戶的數(shù)據(jù),優(yōu)先淘汰普通用戶
- 從數(shù)據(jù)角度出發(fā)肿仑,可以選擇先淘汰大對(duì)象或小對(duì)象致盟,熱點(diǎn)數(shù)據(jù)或冷點(diǎn)數(shù)據(jù)
3.3緩存模式
緩存常與數(shù)據(jù)庫(kù)DB一起使用(最經(jīng)典的場(chǎng)景就是redis查不到,再去DB里面查)尤慰,這也涉及到我們常用的一些緩存模式
3.3.1 Cache Aside
就是Cache和DB的更新策略全部由開(kāi)發(fā)者自己決定(把緩存看作是一個(gè)獨(dú)立的數(shù)據(jù)源)馏锡,即上面的業(yè)務(wù)層(寫(xiě)在業(yè)務(wù)代碼里)查cache查不到,去db里查再去更新cache
cache-aside常在并發(fā)場(chǎng)景下的數(shù)據(jù)不一致問(wèn)題
擴(kuò)展閱讀
3.3.2 讀穿透read-through/寫(xiě)穿透write-through
cache可以做決策
read-through:
- 業(yè)務(wù)代碼只需要從cache中讀取數(shù)據(jù)伟端,cache會(huì)在緩存不命中的時(shí)候讀取數(shù)據(jù)(我們可以主動(dòng)給結(jié)構(gòu)體設(shè)置一個(gè)接收讀DB的方法成員)
- 寫(xiě)數(shù)據(jù)的時(shí)候杯道,業(yè)務(wù)代碼需要自己寫(xiě)DB和cache
write-through反之,當(dāng)然這兩者也不能解決并發(fā)情況下责蝠,對(duì)同一數(shù)據(jù)使用的數(shù)據(jù)一致性問(wèn)題
3.3.3 write back
- 寫(xiě)操作直接寫(xiě)緩存党巾,不會(huì)更新DB,讀也是一樣
-
緩存過(guò)期了再寫(xiě)回?cái)?shù)據(jù)庫(kù)
(使用redis的Setnx回寫(xiě)命令能解決)
3.3.4 refresh ahead
即在DB和Redis中間再加一層肴敛,監(jiān)聽(tīng)DB變更自動(dòng)更新cache這種模式也有緩存一致性問(wèn)題署海,出現(xiàn)在緩存未命中時(shí)(使用redis的Setnx回寫(xiě)命令能解決)
3.3.5刪除緩存
顧名思義,在寫(xiě)操作時(shí)医男,直接寫(xiě)數(shù)據(jù)庫(kù)刪除緩存砸狞,類(lèi)似的就是延遲雙刪(在第一次刪除后設(shè)置一個(gè)定時(shí)器再次刪除,為防止刪除時(shí)再來(lái)一個(gè)寫(xiě)操作镀梭,實(shí)際就是二次檢查)
3.3.6 緩存一致性問(wèn)題
實(shí)際就是并發(fā)下的數(shù)據(jù)一致性問(wèn)題(操作部分失敗也會(huì)造成不一致我問(wèn)題)趾代,
業(yè)務(wù)層面:常用的解決思路就是二次檢查double-check:在獲取數(shù)據(jù)時(shí)加鎖做一次檢查,在執(zhí)行操作前再加鎖做一次檢查
使用消息隊(duì)列丰辣,確保同一時(shí)刻只有一個(gè)線程在更新數(shù)據(jù)
引入數(shù)據(jù)版本號(hào)撒强,每一次更新版本號(hào)+1,低版本數(shù)據(jù)不能覆蓋高版本數(shù)據(jù)笙什,缺點(diǎn)就是難以維護(hù)
多級(jí)緩存:本地+redis+mysql結(jié)合
分布式鎖方案
3.4緩存異常
3.4.1緩存穿透
請(qǐng)求的數(shù)據(jù)根本不在(DB和Cache都沒(méi)有)飘哨,在cache-aside模式下就會(huì)造成多次請(qǐng)求全部打到數(shù)據(jù)庫(kù)上(典型如黑客非法攻擊)
那么我們是否可以在數(shù)據(jù)庫(kù)都沒(méi)有的情況下,往緩存里回寫(xiě)一個(gè)特殊值琐凭,標(biāo)記數(shù)據(jù)不存在芽隆,那么下次查詢過(guò)來(lái)不就防止了(但是如果攻擊者每次都用不同key就會(huì)造成資源浪費(fèi))
3.4.2 緩存擊穿
緩存沒(méi)有對(duì)應(yīng)的key,但是在某個(gè)時(shí)間該key并發(fā)訪問(wèn)量巨大统屈,請(qǐng)求就會(huì)都落在數(shù)據(jù)庫(kù)上
3.4.3 緩存雪崩
同一時(shí)刻大量key過(guò)期
3.4.4 解決思路-singleflight
- 穿透:同一ip的大量請(qǐng)求落在數(shù)據(jù)庫(kù)上
- 擊穿:不同ip的大量請(qǐng)求落在數(shù)據(jù)庫(kù)上
- 雪崩:大量key同時(shí)過(guò)期導(dǎo)致大量請(qǐng)求落在數(shù)據(jù)庫(kù)上
綜上胚吁,解決思路就是如何在緩存出問(wèn)題時(shí)如何讓請(qǐng)求不落在數(shù)據(jù)庫(kù)上?或者說(shuō)我們?cè)谝?guī)定時(shí)間內(nèi)愁憔,多個(gè)協(xié)程訪問(wèn)同一個(gè)key時(shí)腕扶,只允許一個(gè)通過(guò),
singleflight就是上面的思路吨掌,擊穿都能解決半抱,但是穿透效果不好,因?yàn)閿?shù)據(jù)庫(kù)里面也是沒(méi)有膜宋,那么我們可以使用布隆過(guò)濾器或者bit array窿侈,cache未命中時(shí)再問(wèn)一下這些數(shù)據(jù)結(jié)構(gòu)
最后別忘了最關(guān)鍵的限流