使用Go實現(xiàn)健壯的內(nèi)存型緩存的方法
使用Go實現(xiàn)健壯的內(nèi)存型緩存
本文介紹了緩存的常見使用場景、選型以及注意點,比較有價值。
譯自:Implementing robust in-memory cache with Go
內(nèi)存型緩存是一種以消費內(nèi)存為代價換取應用性能和彈性的方式,同時也推遲了數(shù)據(jù)的一致性。在使用內(nèi)存型緩存時需要注意并行更新、錯誤緩存、故障轉(zhuǎn)移、后臺更新、過期抖動,以及緩存預熱和轉(zhuǎn)換等問題。
由來
緩存是提升性能的最便捷的方式,但緩存不是萬能的,在某些場景下,由于事務或一致性的限制,你無法重復使用某個任務的結(jié)果。緩存失效是計算機科學中最常見的兩大難題之一。
如果將操作限制在不變的數(shù)據(jù)上,則無需擔心緩存失效。此時緩存僅用于減少網(wǎng)絡開銷。然而,如果需要與可變數(shù)據(jù)進行同步,則必須關(guān)注緩存失效的問題。
最簡單的方式是基于TTL來設置緩存失效。雖然這種方式看起來遜于基于事件的緩存失效方式,但它簡單且可移植性高。由于無法保證事件能夠即時傳遞,因此在最壞的場景中(如事件代理短時間下線或過載),事件甚至還不如TTL精確。
短TTL通常是性能和一致性之間的一種折衷方式。它可以作為一道屏障來降低高流量下到數(shù)據(jù)源的負載。
Demo應用
下面看一個簡單的demo應用,它接收帶請求參數(shù)的URL,并根據(jù)請求參數(shù)返回一個JSON對象。由于數(shù)據(jù)存儲在數(shù)據(jù)庫中,因此整個交互會比較慢。
下面將使用一個名為plt的工具對應用進行壓測,plt包括參數(shù):
cardinality- 生成的唯一的URLs的數(shù)據(jù),會影響到緩存命中率group- 一次性發(fā)送的URL相似的請求個數(shù),模擬對相同鍵的并發(fā)訪問。
go run ./cmd/cplt --cardinality 10000 --group 100 --live-ui --duration 10h --rate-limit 5000 curl --concurrency 200 -X 'GET' 'http://127.0.0.1:8008/hello?name=World&locale=ru-RU' -H 'accept: application/json'
上述命令會啟動一個client,循環(huán)發(fā)送10000個不同的URLs,每秒發(fā)送5000個請求,最大并發(fā)數(shù)為200。每個URL會以100個請求為批次將進行發(fā)送,用以模仿單個資源的并發(fā),下面展示了實時數(shù)據(jù):

Demo應用通過CACHE環(huán)境變量定義了三種操作模式:
none:不使用緩存,所有請求都會涉及數(shù)據(jù)庫naive:使用簡單的map,TTL為3分鐘advanced:使用github.com/bool64/cache 庫,實現(xiàn)了很多特性來提升性能和彈性,TTL也是3分鐘。
Demo應用的代碼位于:github.com/vearutop/cache-story,可以使用make start-deps run命令啟動demo應用。
在不使用緩存的條件下,最大可以達到500RPS,在并發(fā)請求達到130之后DB開始因為 Too many connections而阻塞,這種結(jié)果不是最佳的,雖然并不嚴重,但需要提升性能。

使用advanced緩存的結(jié)果如下,吞吐量提升了60倍,并降低了請求延遲以及DB的壓力:

go run ./cmd/cplt --cardinality 10000 --group 100 --live-ui --duration 10h curl --concurrency 100 -X 'GET' 'http://127.0.0.1:8008/hello?name=World&locale=ru-RU' -H 'accept: application/json'
Requests per second: 25064.03 Successful requests: 15692019 Time spent: 10m26.078s Request latency percentiles: 99%: 28.22ms 95%: 13.87ms 90%: 9.77ms 50%: 2.29ms
字節(jié) VS 結(jié)構(gòu)體
哪個更佳?
取決于使用場景,字節(jié)緩存([]byte)的優(yōu)勢如下:
- 數(shù)據(jù)不可變,在訪問數(shù)據(jù)時需要進行解碼
- 由于內(nèi)存碎片較少,使用的內(nèi)存也較少
- 對垃圾回收友好,因為沒有什么需要遍歷的
- 便于在線路上傳輸
- 允許精確地限制內(nèi)存
字節(jié)緩存的最大劣勢是編解碼帶來的開銷,在熱點循環(huán)中,編解碼導致的開銷可能會非常大。
結(jié)構(gòu)體的優(yōu)勢:
- 在訪問數(shù)據(jù)時無需進行編碼/解碼
- 更好地表達能力,可以緩存那些無法被序列化的內(nèi)容
結(jié)構(gòu)體緩存的劣勢:
- 由于結(jié)構(gòu)體可以方便地進行修改,因此可能會被無意間修改
- 結(jié)構(gòu)體的內(nèi)存相對比較稀疏
- 如果使用了大量長時間存在的結(jié)構(gòu)體,GC可能會花費一定的時間進行遍歷,來確保這些結(jié)構(gòu)體仍在使用中,因此會對GC采集器造成一定的壓力
- 幾乎無法限制緩存實例的總內(nèi)存,動態(tài)大小的項與其他所有項一起存儲在堆中。
本文使用了結(jié)構(gòu)體緩存。
Native 緩存
使用了互斥鎖保護的map。當需要檢索一個鍵的值時,首先查看緩存中是否存在該數(shù)據(jù)以及有沒有過期,如果不存在,則需要從數(shù)據(jù)源構(gòu)造該數(shù)據(jù)并將其放到緩存中,然后返回給調(diào)用者。
整個邏輯比較簡單,但某些缺陷可能會導致嚴重的問題。
并發(fā)更新
當多個調(diào)用者同時miss相同的鍵時,它們會嘗試構(gòu)建數(shù)據(jù),這可能會導致死鎖或因為緩存踩踏導致資源耗盡。此外如果調(diào)用者嘗試構(gòu)建值,則會造成額外的延遲。
如果某些構(gòu)建失敗,即使緩存中可能存在有效的值,此時父調(diào)用者也會失敗。

可以使用低cardinality和高group來模擬上述問題:
go run ./cmd/cplt --cardinality 100 --group 1000 --live-ui --duration 10h --rate-limit 5000 curl --concurrency 150 -X 'GET' 'http://127.0.0.1:8008/hello?name=World&locale=ru-RU' -H 'accept: application/json'

上圖展示了使用naive緩存的應用,藍色標志標識重啟并使用advanced緩存??梢钥吹芥i嚴重影響了性能(Incoming Request Latency)和資源使用(DB Operation Rate)。
一種解決方案是阻塞并行構(gòu)建,這樣每次只能進行一個構(gòu)建。但如果有大量并發(fā)調(diào)用者請求各種鍵,則可能會導致嚴重的鎖競爭。
更好的方式是對每個鍵的構(gòu)建單獨加鎖,這樣某個調(diào)用者就可以獲取鎖并執(zhí)行構(gòu)建,其他調(diào)用者則等待構(gòu)建好的值即可。

后臺更新
當緩存過期時,需要一個新的值,構(gòu)建新值可能會比較慢。如果同步進行,則可以減慢尾部延遲(99%以上)??梢蕴崆皹?gòu)建那些被高度需要的緩存項(甚至在數(shù)據(jù)過期前)。如果可以容忍老數(shù)據(jù),也可以繼續(xù)使用這些數(shù)據(jù)。
這種場景下,可以使用老的/即將過期的數(shù)據(jù)提供服務,并在后臺進行更新。需要注意的是,如果構(gòu)建依賴父上下文,則在使用完老數(shù)據(jù)之后可能會取消上下文(如滿足父HTTP請求),如果我們使用這類上下文來訪問數(shù)據(jù),則會得到一個context canceled錯誤。
解決方案是將上下文與父上下文進行分離,并忽略父上下文的取消行為。
另外一種策略是主動構(gòu)建那些即將過期的緩存項,而無需父請求,但這樣可能會因為一直淘汰那些無人關(guān)心的緩存項而導致資源浪費。
同步過期
假設啟動了一個使用TTL緩存的實例,由于此時緩存是空的,所有請求都會導致緩存miss并創(chuàng)建值。這樣會導致數(shù)據(jù)源負載突增,每個保存的緩存項的過期時間都非常接近。一旦超過TTL,大部分緩存項幾乎會同步過期,這樣會導致一個新的負載突增,更新后的值也會有一個非常接近的過期時間,以此往復。
這種問題常見于熱點緩存項,最終這些緩存項會同步更新,但需要花費一段時間。
對這種問題的解決辦法是在過期時間上加抖動。
如果過期抖動為10%,意味著,過期時間為0.95 * TTL 到1.05 * TTL。雖然這種抖動幅度比較小,但也可以幫助降低同步過期帶來的問題。
下面例子中,使用高cardinality 和高concurrency模擬這種情況。它會在短時間內(nèi)請求大量表項,以此構(gòu)造過期峰值。
go run ./cmd/cplt --cardinality 10000 --group 1 --live-ui --duration 10h --rate-limit 5000 curl --concurrency 200 -X 'GET' 'http://127.0.0.1:8008/hello?name=World&locale=ru-RU' -H 'accept: application/json'

從上圖可以看出,使用naive緩存無法避免同步過期問題,藍色標識符表示重啟服務并使用帶10%抖動的advanced緩存,可以看到降低了峰值,且整體服務更加穩(wěn)定。
緩存錯誤
當構(gòu)建值失敗,最簡單的方式就是將錯誤返回給調(diào)用者即可,但這種方式可能會導致嚴重的問題。
例如,當服務正常工作時可以借助緩存處理10K的RPS,但突然出現(xiàn)緩存構(gòu)建失敗(可能由于短時間內(nèi)數(shù)據(jù)庫過載、網(wǎng)絡問題或如錯誤校驗等邏輯錯誤),此時所有的10K RPS都會命中數(shù)據(jù)源(因為此時沒有緩存)。
對于高負載系統(tǒng),使用較短的TTL來緩存錯誤至關(guān)重要。
故障轉(zhuǎn)移模式
有時使用過期的數(shù)據(jù)要好于直接返回錯誤,特別是當這些數(shù)據(jù)剛剛過期,這類數(shù)據(jù)有很大概率等于后續(xù)更新的數(shù)據(jù)。
故障轉(zhuǎn)移以精確性來換取彈性,通常是分布式系統(tǒng)中的一種折衷方式。
緩存?zhèn)鬏?/h3>
緩存有相關(guān)的數(shù)據(jù)時效果最好。
當啟動一個新的實例時,緩存是空的。由于產(chǎn)生有用的數(shù)據(jù)需要花費一定的時間,因此這段時間內(nèi),緩存效率會大大降低。
有一些方式可以解決"冷"緩存帶來的問題。如可以通過遍歷數(shù)據(jù)來預熱那些可能有用的數(shù)據(jù)。
例如可以從數(shù)據(jù)庫表中拉取最近使用的內(nèi)容,并將其保存到緩存中。這種方式比較復雜,且并不一定能夠生效。
此外還可以通過定制代碼來決定使用哪些數(shù)據(jù)并在緩存中重構(gòu)這些表項。但這樣可能會對數(shù)據(jù)庫造成一定的壓力。
還可以通過共享緩存實例(如redis或memcached)來規(guī)避這種問題,但這也帶來了另一種問題,通過網(wǎng)絡讀取數(shù)據(jù)要遠慢于從本地緩存讀取數(shù)據(jù)。此外,網(wǎng)絡帶寬也可能成為性能瓶頸,網(wǎng)絡數(shù)據(jù)的編解碼也增加了延遲和資源損耗。
最簡單的辦法是將緩存從活動的實例傳輸?shù)叫聠拥膶嵗小?/p>
活動實例緩存的數(shù)據(jù)具有高度相關(guān)性,因為這些數(shù)據(jù)是響應真實用戶請求時產(chǎn)生的。
傳輸緩存并不需要重構(gòu)數(shù)據(jù),因此不會濫用數(shù)據(jù)源。
在生產(chǎn)系統(tǒng)中,通常會并行多個應用實例。在部署過程中,這些實例會被順序重啟,因此總有一個實例是活動的,且具有高質(zhì)量的緩存。
Go有一個內(nèi)置的二進制系列化格式encoding/gob,它可以幫助以最小的代價來傳輸數(shù)據(jù),缺點是這種方式使用了反射,且需要暴露字段。
使用緩存?zhèn)鬏數(shù)牧硪粋€注意事項是不同版本的應用可能有不兼容的數(shù)據(jù)結(jié)構(gòu),為了解決這種問題,需要為緩存的結(jié)構(gòu)添加指紋,并在不一致時停止傳輸。
下面是一個簡單的實現(xiàn):
// RecursiveTypeHash hashes type of value recursively to ensure structural match.
func recursiveTypeHash(t reflect.Type, h hash.Hash64, met map[reflect.Type]bool) {
for {
if t.Kind() != reflect.Ptr {
break
}
t = t.Elem()
}
if met[t] {
return
}
met[t] = true
switch t.Kind() {
case reflect.Struct:
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
// Skip unexported field.
if f.Name != "" && (f.Name[0:1] == strings.ToLower(f.Name[0:1])) {
continue
}
if !f.Anonymous {
_, _ = h.Write([]byte(f.Name))
}
recursiveTypeHash(f.Type, h, met)
}
case reflect.Slice, reflect.Array:
recursiveTypeHash(t.Elem(), h, met)
case reflect.Map:
recursiveTypeHash(t.Key(), h, met)
recursiveTypeHash(t.Elem(), h, met)
default:
_, _ = h.Write([]byte(t.String()))
}
}可以通過HTTP或其他合適的協(xié)議來傳輸緩存數(shù)據(jù),本例中使用了HTTP,代碼為/debug/transfer-cache。注意,緩存可能會包含不應該對外暴露的敏感信息。
在本例中,可以借助于單個啟用了不同端口的應用程序?qū)嵗齺韴?zhí)行傳輸:
CACHE_TRANSFER_URL=http://127.0.0.1:8008/debug/transfer-cache HTTP_LISTEN_ADDR=127.0.0.1:8009 go run main.go
2022-05-09T02:33:42.871+0200 INFO cache/http.go:282 cache restored {"processed": 10000, "elapsed": "12.963942ms", "speed": "39.564084 MB/s", "bytes": 537846}
2022-05-09T02:33:42.874+0200 INFO brick/http.go:66 starting server, Swagger UI at http://127.0.0.1:8009/docs
2022-05-09T02:34:01.162+0200 INFO cache/http.go:175 cache dump finished {"processed": 10000, "elapsed": "12.654621ms", "bytes": 537846, "speed": "40.530944 MB/s", "name": "greetings", "trace.id": "31aeeb8e9e622b3cd3e1aa29fa3334af", "transaction.id": "a0e8d90542325ab4"}
上圖中藍色標識標識應用重啟,最后兩條為緩存?zhèn)鬏?。可以看到性能不受影響,而在沒有緩存?zhèn)鬏數(shù)那闆r下,會受到嚴重的預熱懲罰。
一個不那么明顯的好處是,可以將緩存數(shù)據(jù)傳輸?shù)奖镜亻_發(fā)機器,用于重現(xiàn)和調(diào)試生產(chǎn)環(huán)境的問題。
鎖競爭和底層性能
基本每種緩存實現(xiàn)都會使用鍵值映射來支持并發(fā)訪問(通常是讀)。
大多數(shù)場景下可以忽略底層性能帶來的影響。例如,如果使用內(nèi)存型緩存來處理HTTP API,使用最簡單的map+mutex就足夠了,這是因為IO操作所需的時間要遠大于內(nèi)存操作。記住這一點很重要,以免過早地進行優(yōu)化以及增加不合理的復雜性。
如果依賴內(nèi)存型緩存的應用是CPU密集型的,此時鎖競爭可能會影響到整體性能。
為了避免并發(fā)讀寫下的數(shù)據(jù)沖突,可能會引入鎖競爭。在使用單個互斥鎖的情況下,這種同步可能會限制同一時間內(nèi)只能進行一個操作,這也意味著多核CPU可能無法發(fā)揮作用。
對于以讀為主的負載,標準的sync.Map 就可以滿足性能要求,但對于以寫為主的負載,則會降低其性能。有一種比sync.Map性能更高的方式github.com/puzpuzpuz/xsync.Map,它使用了 Cache-Line Hash Table (CLHT)數(shù)據(jù)結(jié)構(gòu)。
另一種常見的方式是通過map分片的方式(fastcache, bigcache, bool64/cache)來降低鎖競爭,這種方式基于鍵將值分散到不同的桶中,在易用性和性能之間做了折衷。
內(nèi)存管理
內(nèi)存是一個有限的資源,因此緩存不能無限增長。
過期的元素需要從緩存中淘汰,這個步驟可以同步執(zhí)行,也可以在后臺執(zhí)行。使用后臺回收方式不會阻塞應用本身,且如果將后臺回收進程配置為延遲回收的方式時,在需要故障轉(zhuǎn)移時就可以使用過期的數(shù)據(jù)。
如果上述淘汰過期數(shù)據(jù)的方式無法滿足內(nèi)存回收的要求,可以考慮使用其他淘汰策略。在選擇淘汰策略時需要平衡CPU/內(nèi)存使用和命中/丟失率??傊蕴哪康氖菫榱嗽诳山邮艿男阅茴A算內(nèi)優(yōu)化命中/丟失率,這也是評估一個淘汰策略時需要注意的指標。
下面是常見的選擇淘汰策略的原則:
- 最近最少頻率使用(LFU),需要在每次訪問時維護計數(shù)器
- 最近最少使用(LRU),需要在每次訪問時更新元素的時間戳或順序
- 先進先出(FIFO),一旦創(chuàng)建緩存就可以使用緩存中的數(shù)據(jù),比較輕量
- 隨機元素,性能最佳,不需要任何排序,但精確性最低
上述給出了如何選項一個淘汰策略,下一個問題是"何時以及應該淘汰多少元素?"。
對于[]byte緩存來說,該問題比較容易解決,因為大多數(shù)實現(xiàn)中都精確提供了控制內(nèi)存的方式。
但對于結(jié)構(gòu)體緩存來說就比較棘手了。在應用執(zhí)行過程中,很難可靠地確定特定結(jié)構(gòu)體對堆內(nèi)存的影響,GC可能會獲取到這些內(nèi)存信息,但應用本身則無法獲取。下面兩種獲取結(jié)構(gòu)體內(nèi)存的指標精確度不高,但可用:
- 緩存中的元素個數(shù)
- 應用使用的總內(nèi)存
由于這些指標并不與使用的緩存內(nèi)存成線性比例,因此不能據(jù)此計算需要淘汰的元素。一種比較合適的方式是在觸發(fā)淘汰時,淘汰一部分元素(如占使用內(nèi)存10%的元素)。
緩存數(shù)據(jù)的堆影響很大程度上與映射實現(xiàn)有關(guān)。可以從下面的性能測試中看到,相比于二進制序列化(未壓縮)的數(shù)據(jù),map[string]struct{...}占用的內(nèi)存是前者的4倍。
基準測試
下面是保存1M小結(jié)構(gòu)體(struct { int, bool, string })的基準測試,驗證包括10%的讀操作以及0.1%的寫操作。字節(jié)緩存通過編解碼結(jié)構(gòu)體來驗證。
goos: darwin goarch: amd64 cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
name MB/inuse time/op (10%) time/op (0.1%) sync.Map 192 ± 0% 142ns ± 4% 29.8ns ±10% // Great for read-heavy workloads. shardedMap 196 ± 0% 53.3ns ± 3% 28.4ns ±11% mutexMap 182 ± 0% 226ns ± 3% 207ns ± 1% rwMutexMap 182 ± 0% 233ns ± 2% 67.8ns ± 2% // RWMutex perf degrades with more writes. shardedMapOf 181 ± 0% 50.3ns ± 3% 27.3ns ±13% ristretto 346 ± 0% 167ns ± 8% 54.1ns ± 4% // Failed to keep full working set, ~7-15% of the items are evicted. xsync.Map 380 ± 0% 31.4ns ± 9% 22.0ns ±14% // Fastest, but a bit hungry for memory. patrickmn 184 ± 0% 373ns ± 1% 72.6ns ± 5% bigcache 340 ± 0% 75.8ns ± 8% 72.9ns ± 3% // Byte cache. freecache 333 ± 0% 98.1ns ± 0% 77.8ns ± 2% // Byte cache. fastcache 44.9 ± 0% 60.6ns ± 8% 64.1ns ± 5% // A true champion for memory usage, while having decent performance.
如果實際場景支持序列化,那么fastcache可以提供最佳的內(nèi)存使用(fastcache使用動態(tài)申請的方式來分配內(nèi)存)
對于CPU密集型的應用,可以使用xsync.Map。
從上述測試可以看出,字節(jié)緩存并不一定意味著高效地利用內(nèi)存,如bigcache和freecache。
開發(fā)者友好
程序并不會總是按照我們期望的方式允許,復雜的邏輯會導致很多非預期的問題,也很難去定位。不幸的是,緩存使得程序的狀況變得更糟,這也是為什么讓緩存更友好變得如此重要。
緩存可能成為多種問題的誘發(fā)因素,因此應該盡快安全地清理相關(guān)緩存。為此,可以考慮對所有緩存的元素進行校驗,在高載情況下,失效不一定意味著“刪除”,一旦一次性刪除所有緩存,數(shù)據(jù)源可能會因為過載而失敗。更優(yōu)雅的方式是為所有元素設置過期時間,并在后臺進行更新,更新過程中使用老數(shù)據(jù)提供服務。
如果有人正在調(diào)查特定的數(shù)據(jù)源問題,緩存項可能會因為過期而誤導用戶??梢越锰囟ㄕ埱蟮木彺?,這樣就可以排除緩存帶來的不精確性。可以通過特定的請求頭以及并在中間件上下文中實現(xiàn)。注意這類控制并不適用于外部用戶(會導致DOS攻擊)。
總結(jié)
本文比較了字節(jié)緩存和結(jié)構(gòu)體緩存的優(yōu)劣勢,介紹了緩存穿透、緩存錯誤、緩存預熱、緩存?zhèn)鬏敗⒐收限D(zhuǎn)移、緩存淘汰等問題,并對一些常見的緩存庫進行了基準測試。
到此這篇關(guān)于使用Go實現(xiàn)健壯的內(nèi)存型緩存的文章就介紹到這了,更多相關(guān)go內(nèi)存型緩存內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Go 庫bytes.Buffer和strings.Builder使用及性能對比
這篇文章主要為大家介紹了Go 庫bytes.Buffer和strings.Builder使用及性能對比,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-12-12
詳解Go如何實現(xiàn)協(xié)程并發(fā)執(zhí)行
線程是通過本地隊列,全局隊列或者偷其它線程的方式來獲取協(xié)程的,目前看來,線程運行完一個協(xié)程后再從隊列中獲取下一個協(xié)程執(zhí)行,還只是順序執(zhí)行協(xié)程的,而多個線程一起這么運行也能達到并發(fā)的效果,接下來就給給大家詳細介紹一下Go如何實現(xiàn)協(xié)程并發(fā)執(zhí)行2023-08-08

