快取使用的一些經驗

語言: CN / TW / HK

在一個大的專案中, 使用了全快取模型, 即, 所有資料都會經過cache.

簡單分層: 應用->記憶體快取->redis快取->資料庫

是一個典型的 多讀寫少 的場景, 並且資料量, 請求量非常大.

總結了一些使用經驗, 供參考

1. 更新快取的Design Pattern: 使用Cache aside

簡潔優雅

關於快取更新, 可以閱讀這篇文章: CoolShell: 快取更新的套路

為什麼選擇 Cache Aside Pattern , 因為這個模式足夠簡單, 出現不一致的概率非常低, 對於大多數專案來說夠用了.

而其他幾種模式, 複雜度會高很多.

2. 併發很高時, 需要防快取擊穿

當併發很高的時候, 一個熱點 key 失效, 會觸發回資料庫重查的邏輯, 此時會有大量請求落到資料庫

需要做防快取擊穿的處理.

一般各種語言的庫, 都有考慮到這一點, 例如 go-redis/cache

如果是golang並且自定義了cache, 可以使用 singleflight , 其他語言也可以找類似機制的庫.

這個庫很輕量

# define
type Cache struct {
	name              string
	keyPrefix         string
	codec             *cache.Cache
	cli               *redis.Client
	defaultExpiration time.Duration
	G                 singleflight.Group
}

# usage
// if missing, call retrieveFunc
data, err, _ := c.G.Do(key.Key(), func() (interface{}, error) {
		return retrieveFunc(key)
	})

3. 快取空值, 需要防快取穿透

如果一個key不存在, 在快取中查不到, 在資料庫中也查不到, 那麼這個key的請求每次都會穿透到資料庫

此時, 可以引入 bloomfilter 或者 cuckoofilter ;

但是, 更簡單的做法是, 快取空值; 當成一個普通的key處理(快取失效/資料一致性處理等)

4. 總是設定過期時間, 並且帶隨機數避免快取雪崩

大部分場景下, 給每一個快取 key 設定 TTL 是一個很好的習慣. 可以避免無用資料佔用資源, 及時淘汰掉使用較少的資料.

但是, 設定 TTL 的時候, 建議加上一個範圍內容的隨機數, 避免快取在同一時間失效, 造成快取雪崩.

TTL = 900s + randint(0,10)

5. key 中使用namespace+version字首

key = {namespace}:{version}:{type}:{uniqueKey}

在實際應用部署中, 由於可能跟其他應用共用一套快取, 所以建議快取的 key 加入字首, 防止衝突(如果衝突, 非常難以debug)

另外, 需要加入一個 version , 在版本釋出必要時變更, 以棄用快取中已有的資料

  1. 由於不斷迭代開發, 同一個key對應的value可能會變更, 例如value對應的資料結構新增了一個欄位, 那麼此時快取中存量的快取資料是沒有這個欄位的, 可能會造成一些bug.
  2. 還有另外一個需要特別小心的是, 升級快取第三方庫的時候, 某些版本可能是breaking change, 例如改變了壓縮演算法, 此時存量資料將無法正確被獲取. 一個例子: Can’t upgrade from v7 to v8 directly?

6. 快取結構體, 使用msgpack替代json

MessagePack: It’s like JSON.but fast and small

優點:

缺點:

  • 在redis等服務端debug獲取時不是明文, 不是很利於除錯

所以, 快取資料量比較大, 並且對效能有要求的, 可以使用msgpack

7. value比較大, 可以考慮啟用壓縮

如果 value 比較大, 那麼在放入快取前, 可以進行一次壓縮, 獲取後再解壓

當然, 這個會產生額外的資源消耗(CPU), 以及會多一些耗時.

但是, 這個有利於減少網路傳輸中的包大小. 如果 讀取 是非常高頻的話, 那麼代價還是值得的.

可以參考 go-redis/cache , 當值超過一定大小時使用 s2 compression 進行壓縮

8. 批量操作, 使用pipeline

以redis為例, 批量操作

mget/mhget
pipeline

可以根據 key-value 特徵, 批量 key 的數量等, 簡單壓測下效能, 決定使用哪種方式. 正常情況下, key 數量較大的時候, pipeline 效能最好.

甚至, 程式碼實現可以根據 key 的數量, 自行決定使用 mget 還是 pipeline

9. 記憶體快取 vs Redis

大部分情況, 專案中會混用兩種快取.

如果對資料一致性要求比較高, 可以全部使用 Redis.

但是, 其實每一次 Redis 操作代價大於記憶體操作

某些資料, 例如模型, 主鍵之類的, 一旦確定, 是不會變更的.

此時, 可以考慮使用 記憶體快取 替代.

如果是golang, 推薦使用 go-cache . 沒有其他實現那麼強大, 但是勝在不需要序列化/反序列化.

10. 多級快取 and client-side-cache

如果使用的 Redis6, 並且程式的driver支援, 那麼可以直接利用 client-side-caching 特性獲取最大的效能. 這個對程式透明, 無需在額外的邏輯處理.

但是, 當前(2022)有很多時候, 部署基建還是老版本Redis, 很多語言的driver也還沒有支援, 可能複用不了

那麼, 此時如果使用了 記憶體->redis 兩級快取, 如何確保資料一致性.

可以做的額外操作:

  1. 實現類似redis6 client-side-cache機制, 通過釋出訂閱等方式實現
  2. 可以使用一個 sorted-set 儲存 5 分鐘內變更的 key , 記憶體快取TTL設定 5 分鐘; 每次先獲取變更 key 列表, 本地快取進行時間戳對比(這個方案對於批量key操作效能提升很大, 相當於把 N 次redis操作, 變成 本地快取+ 1 次 changedkeylist獲取+M次redis操作)

11. 配置建議: 同時支援standalone和sentinel配置

讓運維根據實際應用場景, 自行切換使用.

成本不高的話, 也可以支援下 redis-cluster 配置

注意, 開啟 pool 以獲取更好的效能

另外, 也需要關注下如何開啟prometheus/otel相關的配置, 以便某些情況下, 監測相關的指標

12. 設定開關, 支援withCache/withoutCache除錯

引入快取後, 在進行問題除錯的時候非常不變.

建議加入相關的除錯標誌, 例如 ?force=true

  • 加上, 全鏈路資料獲取走資料庫
  • 沒加, 全鏈路走快取

此時, 可以通過對比兩次請求的差異, 確定是否是快取問題

甚至, 可以加入 ?debug=true 以獲取各個環節的上下文資訊, 快速除錯