使用Go實現健壯的記憶體型快取

charlieroro發表於2022-05-19

使用Go實現健壯的記憶體型快取

本文介紹了快取的常見使用場景、選型以及注意點,比較有價值。
譯自:Implementing robust in-memory cache with Go

記憶體型快取是一種以消費記憶體為代價換取應用效能和彈性的方式,同時也推遲了資料的一致性。在使用記憶體型快取時需要注意並行更新、錯誤快取、故障轉移、後臺更新、過期抖動,以及快取預熱和轉換等問題。

由來

快取是提升效能的最便捷的方式,但快取不是萬能的,在某些場景下,由於事務或一致性的限制,你無法重複使用某個任務的結果。快取失效是電腦科學中最常見的兩大難題之一。

如果將操作限制在不變的資料上,則無需擔心快取失效。此時快取僅用於減少網路開銷。然而,如果需要與可變資料進行同步,則必須關注快取失效的問題。

最簡單的方式是基於TTL來設定快取失效。雖然這種方式看起來遜於基於事件的快取失效方式,但它簡單且可移植性高。由於無法保證事件能夠即時傳遞,因此在最壞的場景中(如事件代理短時間下線或過載),事件甚至還不如TTL精確。

短TTL通常是效能和一致性之間的一種折衷方式。它可以作為一道屏障來降低高流量下到資料來源的負載。

Demo應用

下面看一個簡單的demo應用,它接收帶請求引數的URL,並根據請求引數返回一個JSON物件。由於資料儲存在資料庫中,因此整個互動會比較慢。

下面將使用一個名為plt的工具對應用進行壓測,plt包括引數:

  • cardinality - 生成的唯一的URLs的資料,會影響到快取命中率
  • group - 一次性傳送的URL相似的請求個數,模擬對相同鍵的併發訪問。
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,迴圈傳送10000個不同的URLs,每秒傳送5000個請求,最大併發數為200。每個URL會以100個請求為批次將進行傳送,用以模仿單個資源的併發,下面展示了實時資料:

image

Demo應用通過CACHE環境變數定義了三種操作模式:

  • none:不使用快取,所有請求都會涉及資料庫
  • naive:使用簡單的map,TTL為3分鐘
  • advanced:使用github.com/bool64/cache 庫,實現了很多特性來提升效能和彈性,TTL也是3分鐘。

Demo應用的程式碼位於:github.com/vearutop/cache-story,可以使用make start-deps run命令啟動demo應用。

在不使用快取的條件下,最大可以達到500RPS,在併發請求達到130之後DB開始因為 Too many connections而阻塞,這種結果不是最佳的,雖然並不嚴重,但需要提升效能。

image

使用advanced快取的結果如下,吞吐量提升了60倍,並降低了請求延遲以及DB的壓力:

image
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

位元組 VS 結構體

哪個更佳?

取決於使用場景,位元組快取([]byte)的優勢如下:

  • 資料不可變,在訪問資料時需要進行解碼
  • 由於記憶體碎片較少,使用的記憶體也較少
  • 對垃圾回收友好,因為沒有什麼需要遍歷的
  • 便於線上路上傳輸
  • 允許精確地限制記憶體

位元組快取的最大劣勢是編解碼帶來的開銷,在熱點迴圈中,編解碼導致的開銷可能會非常大。

結構體的優勢:

  • 在訪問資料時無需進行編碼/解碼
  • 更好地表達能力,可以快取那些無法被序列化的內容

結構體快取的劣勢:

  • 由於結構體可以方便地進行修改,因此可能會被無意間修改
  • 結構體的記憶體相對比較稀疏
  • 如果使用了大量長時間存在的結構體,GC可能會花費一定的時間進行遍歷,來確保這些結構體仍在使用中,因此會對GC採集器造成一定的壓力
  • 幾乎無法限制快取例項的總記憶體,動態大小的項與其他所有項一起儲存在堆中。

本文使用了結構體快取。

Native 快取

使用了互斥鎖保護的map。當需要檢索一個鍵的值時,首先檢視快取中是否存在該資料以及有沒有過期,如果不存在,則需要從資料來源構造該資料並將其放到快取中,然後返回給呼叫者。

整個邏輯比較簡單,但某些缺陷可能會導致嚴重的問題。

併發更新

當多個呼叫者同時miss相同的鍵時,它們會嘗試構建資料,這可能會導致死鎖或因為快取踩踏導致資源耗盡。此外如果呼叫者嘗試構建值,則會造成額外的延遲。

如果某些構建失敗,即使快取中可能存在有效的值,此時父呼叫者也會失敗。

image

可以使用低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'
image

上圖展示了使用naive快取的應用,藍色標誌標識重啟並使用advanced快取。可以看到鎖嚴重影響了效能(Incoming Request Latency)和資源使用(DB Operation Rate)。

一種解決方案是阻塞並行構建,這樣每次只能進行一個構建。但如果有大量併發呼叫者請求各種鍵,則可能會導致嚴重的鎖競爭。

更好的方式是對每個鍵的構建單獨加鎖,這樣某個呼叫者就可以獲取鎖並執行構建,其他呼叫者則等待構建好的值即可。

image

後臺更新

當快取過期時,需要一個新的值,構建新值可能會比較慢。如果同步進行,則可以減慢尾部延遲(99%以上)。可以提前構建那些被高度需要的快取項(甚至在資料過期前)。如果可以容忍老資料,也可以繼續使用這些資料。

這種場景下,可以使用老的/即將過期的資料提供服務,並在後臺進行更新。需要注意的是,如果構建依賴父上下文,則在使用完老資料之後可能會取消上下文(如滿足父HTTP請求),如果我們使用這類上下文來訪問資料,則會得到一個context canceled錯誤。

解決方案是將上下文與父上下文進行分離,並忽略父上下文的取消行為。

另外一種策略是主動構建那些即將過期的快取項,而無需父請求,但這樣可能會因為一直淘汰那些無人關心的快取項而導致資源浪費。

同步過期

假設啟動了一個使用TTL快取的例項,由於此時快取是空的,所有請求都會導致快取miss並建立值。這樣會導致資料來源負載突增,每個儲存的快取項的過期時間都非常接近。一旦超過TTL,大部分快取項幾乎會同步過期,這樣會導致一個新的負載突增,更新後的值也會有一個非常接近的過期時間,以此往復。

這種問題常見於熱點快取項,最終這些快取項會同步更新,但需要花費一段時間。

對這種問題的解決辦法是在過期時間上加抖動。

如果過期抖動為10%,意味著,過期時間為0.95 * TTL1.05 * TTL。雖然這種抖動幅度比較小,但也可以幫助降低同步過期帶來的問題。

下面例子中,使用高cardinality 和高concurrency模擬這種情況。它會在短時間內請求大量表項,以此構造過期峰值。

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'
image

從上圖可以看出,使用naive快取無法避免同步過期問題,藍色識別符號表示重啟服務並使用帶10%抖動的advanced快取,可以看到降低了峰值,且整體服務更加穩定。

快取錯誤

當構建值失敗,最簡單的方式就是將錯誤返回給呼叫者即可,但這種方式可能會導致嚴重的問題。

例如,當服務正常工作時可以藉助快取處理10K的RPS,但突然出現快取構建失敗(可能由於短時間內資料庫過載、網路問題或如錯誤校驗等邏輯錯誤),此時所有的10K RPS都會命中資料來源(因為此時沒有快取)。

對於高負載系統,使用較短的TTL來快取錯誤至關重要。

故障轉移模式

有時使用過期的資料要好於直接返回錯誤,特別是當這些資料剛剛過期,這類資料有很大概率等於後續更新的資料。

故障轉移以精確性來換取彈性,通常是分散式系統中的一種折衷方式。

快取傳輸

快取有相關的資料時效果最好。

當啟動一個新的例項時,快取是空的。由於產生有用的資料需要花費一定的時間,因此這段時間內,快取效率會大大降低。

有一些方式可以解決"冷"快取帶來的問題。如可以通過遍歷資料來預熱那些可能有用的資料。

例如可以從資料庫表中拉取最近使用的內容,並將其儲存到快取中。這種方式比較複雜,且並不一定能夠生效。

此外還可以通過定製程式碼來決定使用哪些資料並在快取中重構這些表項。但這樣可能會對資料庫造成一定的壓力。

還可以通過共享快取例項(如redis或memcached)來規避這種問題,但這也帶來了另一種問題,通過網路讀取資料要遠慢於從本地快取讀取資料。此外,網路頻寬也可能成為效能瓶頸,網路資料的編解碼也增加了延遲和資源損耗。

最簡單的辦法是將快取從活動的例項傳輸到新啟動的例項中。

活動例項快取的資料具有高度相關性,因為這些資料是響應真實使用者請求時產生的。

傳輸快取並不需要重構資料,因此不會濫用資料來源。

在生產系統中,通常會並行多個應用例項。在部署過程中,這些例項會被順序重啟,因此總有一個例項是活動的,且具有高質量的快取。

Go有一個內建的二進位制系列化格式encoding/gob,它可以幫助以最小的代價來傳輸資料,缺點是這種方式使用了反射,且需要暴露欄位。

使用快取傳輸的另一個注意事項是不同版本的應用可能有不相容的資料結構,為了解決這種問題,需要為快取的結構新增指紋,並在不一致時停止傳輸。

下面是一個簡單的實現

// 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或其他合適的協議來傳輸快取資料,本例中使用了HTTP,程式碼為/debug/transfer-cache。注意,快取可能會包含不應該對外暴露的敏感資訊。

在本例中,可以藉助於單個啟用了不同埠的應用程式例項來執行傳輸:

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"}
image

上圖中藍色標識標識應用重啟,最後兩條為快取傳輸。可以看到效能不受影響,而在沒有快取傳輸的情況下,會受到嚴重的預熱懲罰。

一個不那麼明顯的好處是,可以將快取資料傳輸到本地開發機器,用於重現和除錯生產環境的問題。

鎖競爭和底層效能

基本每種快取實現都會使用鍵值對映來支援併發訪問(通常是讀)。

大多數場景下可以忽略底層效能帶來的影響。例如,如果使用記憶體型快取來處理HTTP API,使用最簡單的map+mutex就足夠了,這是因為IO操作所需的時間要遠大於記憶體操作。記住這一點很重要,以免過早地進行優化以及增加不合理的複雜性。

如果依賴記憶體型快取的應用是CPU密集型的,此時鎖競爭可能會影響到整體效能。

為了避免併發讀寫下的資料衝突,可能會引入鎖競爭。在使用單個互斥鎖的情況下,這種同步可能會限制同一時間內只能進行一個操作,這也意味著多核CPU可能無法發揮作用。

對於以讀為主的負載,標準的sync.Map 就可以滿足效能要求,但對於以寫為主的負載,則會降低其效能。有一種比sync.Map效能更高的方式github.com/puzpuzpuz/xsync.Map,它使用了 Cache-Line Hash Table (CLHT)資料結構。

另一種常見的方式是通過map分片的方式(fastcache, bigcache, bool64/cache)來降低鎖競爭,這種方式基於鍵將值分散到不同的桶中,在易用性和效能之間做了折衷。

記憶體管理

記憶體是一個有限的資源,因此快取不能無限增長。

過期的元素需要從快取中淘汰,這個步驟可以同步執行,也可以在後臺執行。使用後臺回收方式不會阻塞應用本身,且如果將後臺回收程式配置為延遲迴收的方式時,在需要故障轉移時就可以使用過期的資料。

如果上述淘汰過期資料的方式無法滿足記憶體回收的要求,可以考慮使用其他淘汰策略。在選擇淘汰策略時需要平衡CPU/記憶體使用和命中/丟失率。總之,淘汰的目的是為了在可接受的效能預算內優化命中/丟失率,這也是評估一個淘汰策略時需要注意的指標。

下面是常見的選擇淘汰策略的原則:

  • 最近最少頻率使用(LFU),需要在每次訪問時維護計數器
  • 最近最少使用(LRU),需要在每次訪問時更新元素的時間戳或順序
  • 先進先出(FIFO),一旦建立快取就可以使用快取中的資料,比較輕量
  • 隨機元素,效能最佳,不需要任何排序,但精確性最低

上述給出瞭如何選項一個淘汰策略,下一個問題是"何時以及應該淘汰多少元素?"。

對於[]byte快取來說,該問題比較容易解決,因為大多數實現中都精確提供了控制記憶體的方式。

但對於結構體快取來說就比較棘手了。在應用執行過程中,很難可靠地確定特定結構體對堆記憶體的影響,GC可能會獲取到這些記憶體資訊,但應用本身則無法獲取。下面兩種獲取結構體記憶體的指標精確度不高,但可用:

  • 快取中的元素個數
  • 應用使用的總記憶體

由於這些指標並不與使用的快取記憶體成線性比例,因此不能據此計算需要淘汰的元素。一種比較合適的方式是在觸發淘汰時,淘汰一部分元素(如佔使用記憶體10%的元素)。

快取資料的堆影響很大程度上與對映實現有關。可以從下面的效能測試中看到,相比於二進位制序列化(未壓縮)的資料,map[string]struct{...}佔用的記憶體是前者的4倍。

基準測試

下面是儲存1M小結構體(struct { int, bool, string })的基準測試,驗證包括10%的讀操作以及0.1%的寫操作。位元組快取通過編解碼結構體來驗證。

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可以提供最佳的記憶體使用(fastcache使用動態申請的方式來分配記憶體)

對於CPU密集型的應用,可以使用xsync.Map

從上述測試可以看出,位元組快取並不一定意味著高效地利用記憶體,如bigcachefreecache

開發者友好

程式並不會總是按照我們期望的方式允許,複雜的邏輯會導致很多非預期的問題,也很難去定位。不幸的是,快取使得程式的狀況變得更糟,這也是為什麼讓快取更友好變得如此重要。

快取可能成為多種問題的誘發因素,因此應該儘快安全地清理相關快取。為此,可以考慮對所有快取的元素進行校驗,在高載情況下,失效不一定意味著“刪除”,一旦一次性刪除所有快取,資料來源可能會因為過載而失敗。更優雅的方式是為所有元素設定過期時間,並在後臺進行更新,更新過程中使用老資料提供服務。

如果有人正在調查特定的資料來源問題,快取項可能會因為過期而誤導使用者。可以禁用特定請求的快取,這樣就可以排除快取帶來的不精確性。可以通過特定的請求頭以及並在中介軟體上下文中實現。注意這類控制並不適用於外部使用者(會導致DOS攻擊)。

總結

本文比較了位元組快取和結構體快取的優劣勢,介紹了快取穿透、快取錯誤、快取預熱、快取傳輸、故障轉移、快取淘汰等問題,並對一些常見的快取庫進行了基準測試。

相關文章