前言
快取,設計的初衷是為了減少繁重的IO操作,增加系統并發能力,不管是 CPU多級快取,page cache,還是我們業務中熟悉的 redis 快取,本質都是將有限的熱點資料存盤在一個存取更快的存盤介質中,
計算機本身的快取設計就是 CPU 采取多級快取,那對我們服務來說,我們是不是也可以采用這種多級快取的方式來組織我們的快取資料,同時 redis 的存取都會經過網路IO,那我們能不能把熱點資料直接存在本行程內,由行程自己快取一份最近最熱的這批資料呢?
這就引出了我們今天探討的:local cache,本地快取,也叫行程快取,
本文帶你一起探討下 go-zero 中行程快取的設計,Let’s go!
快速入門
作為一個行程存盤設計,當然是 crud 都有的:
- 我們先初始化
local cache
// 先初始化 local cache
cache, err = collection.NewCache(time.Minute, collection.WithLimit(10))
if err != nil {
log.Fatal(err)
}
其中引數的含義:
expire:key統一的過期時間CacheOption:cache設定,比如key的上限設定等
- 基礎操作快取
// 1. add/update 增加/修改都是該API
cache.Set("first", "first element")
// 2. get 獲取key下的value
value, ok := cache.Get("first")
// 3. del 洗掉一個key
cache.Del("first")
Set(key, value)設定快取value, ok := Get(key)讀取快取Del(key)洗掉快取
- 高級操作
cache.Take("first", func() (interface{}, error) {
// 模擬邏輯寫入local cache
time.Sleep(time.Millisecond * 100)
return "first element", nil
})
前面的 Set(key, value) 是單純將 <key, value> 加入快取;Take(key, setFunc) 則是在 key 對于的 value 不存在時,執行傳入的 fetch 方法,將具體讀取邏輯交給開發者實作,并自動將結果放到快取里,
到這里核心使用代碼基本就講完了,其實看起來還是挺簡單的,也可以到 https://github.com/tal-tech/go-zero/blob/master/core/collection/cache_test.go 去看 test 中的使用,
解決方案

首先快取實質是一個存盤有限熱點資料的介質,面臨以下的這些問題:
- 有限容量
- 熱點資料統計
- 多執行緒存取
下面來說說這3個方面我們的設計實踐,
有限容量
有限就意味著滿了要淘汰,這個就涉及到淘汰策略,cache 中使用的是:LRU(最近最少使用),
那淘汰怎么發生呢? 有幾個選擇:
- 開一個定時器,不斷回圈所有key,等到了預設過期時間,執行回呼函式(這里是洗掉map中過的key)
- 惰性洗掉,訪問時判斷該鍵是否被洗掉,缺點是:如果未訪問的話,會加重空間浪費,
而 cache 中采取的是第一種 主動洗掉,但是,主動洗掉中遇到最大的問題是:
不斷回圈,空消耗CPU資源,即使在額外的協程中這么做,也是沒有必要的,
cache 中采取的是時間輪記錄額外過期通知,等過期 channel 中有通知時,然后觸發洗掉回呼,
有關 時間輪 更多的設計文章:https://go-zero.dev/cn/timing-wheel.html
熱點資料統計
對于快取來說,我們需要知道這個快取在使用額外空間和代碼的情況下是否有價值,以及我們想知道需不需要進一步優化過期時間或者快取大小,所有這些我們就很依賴統計能力了, go-zero 中 sqlc 和 mongoc 也同樣提供了統計能力,所以我們在 cache 中也加入的快取,為開發者提供本地快取監控的特性,在接入 ELK 時開發者可以更直觀的監測到快取的分布情況,
而設計其實也很簡單,就是:Get() 命中,就在統計 count 上加1即可,
func (c *Cache) Get(key string) (interface{}, bool) {
value, ok := c.doGet(key)
if ok {
// 命中hit+1
c.stats.IncrementHit()
} else {
// 未命中miss+1
c.stats.IncrementMiss()
}
return value, ok
}
多執行緒存取
當多個協程并發存取的時候,對于快取來說,涉及的問題以下幾個:
- 寫-寫沖突
LRU中元素的移動程序沖突- 并發執行寫入快取時,造成流量沖擊或者無效流量
這種情況下,寫沖突好解決,最簡單的方法就是 加鎖 :
// Set(key, value)
func (c *Cache) Set(key string, value interface{}) {
// 加鎖,然后將 <key, value> 作為鍵值對寫入 cache 中的 map
c.lock.Lock()
_, ok := c.data[key]
c.data[key] = value
// lru add key
c.lruCache.add(key)
c.lock.Unlock()
...
}
// 還有一個在操作 LRU 的地方時:Get()
func (c *Cache) doGet(key string) (interface{}, bool) {
c.lock.Lock()
defer c.lock.Unlock()
// 當key存在時,則調整 LRU item 中的位置,這個程序也是加鎖的
value, ok := c.data[key]
if ok {
c.lruCache.add(key)
}
return value, ok
}
而并發執行寫入邏輯,這個邏輯主要是開發者自己傳入的,而這個程序:
func (c *Cache) Take(key string, fetch func() (interface{}, error)) (interface{}, error) {
// 1. 先獲取 doGet() 中的值
if val, ok := c.doGet(key); ok {
c.stats.IncrementHit()
return val, nil
}
var fresh bool
// 2. 多協程中通過 sharedCalls 去獲取,一個協程獲取多個協程共享結果
val, err := c.barrier.Do(key, func() (interface{}, error) {
// double check,防止多次讀取
if val, ok := c.doGet(key); ok {
return val, nil
}
...
// 重點是執行了傳入的快取設定函式
val, err := fetch()
...
c.Set(key, val)
})
if err != nil {
return nil, err
}
...
return val, nil
}
而 sharedCalls 通過共享回傳結果,節省了多次執行函式,減少了協程競爭,
總結
本篇文章講解了本地快取設計實踐,從使用到設計思路,你也可以根據你的業務動態修改 快取的過期策略,加入你想要的統計指標,實作自己的本地快取,
甚至可以將本地快取和 redis 結合,給服務提供多級快取,這個就留到我們下一篇文章:快取在服務中的多級設計,
關于 go-zero 更多的設計和實作文章,可以關注『微服務實踐』公眾號,
專案地址
https://github.com/tal-tech/go-zero
歡迎使用 go-zero 并 star 支持我們!
微信交流群
關注『微服務實踐』公眾號并點擊 進群 獲取社區群二維碼,
go-zero 系列文章見『微服務實踐』公眾號
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/285529.html
標籤:Go
下一篇:Go 函式詳解
