前言
哈嘍,大家好,我是asong
。上篇文章:動手實現一個localcache - 設計篇 介紹了設計一個本地快取要思考的點,有讀者朋友反饋可以借鑑bigcache的儲存設計,可以減少GC壓力,這個是我之前沒有考慮到的,這種開源的優秀設計值得我們學習,所以在動手之前我閱讀了幾個優質的本地快取庫,總結了一下各個開源庫的優秀設計,本文我們就一起來看一下。
高效的併發訪問
本地快取的簡單實現可以使用map[string]interface{}
+ sync.RWMutex
的組合,使用sync.RWMutex
對讀進行了優化,但是當併發量上來以後,還是變成了序列讀,等待鎖的goroutine
就會block
住。為了解決這個問題我們可以進行分桶,每個桶使用一把鎖,減少競爭。分桶也可以理解為分片,每一個快取物件都根據他的key
做hash(key)
,然後在進行分片:hash(key)%N
,N就是要分片的數量;理想情況下,每個請求都平均落在各自分片上,基本無鎖競爭。
分片的實現主要考慮兩個點:
hash
演算法的選擇,雜湊演算法的選擇要具有如下幾個特點:- 雜湊結果離散率高,也就是隨機性高
- 避免產生多餘的記憶體分配,避免垃圾回收造成的壓力
- 雜湊演算法運算效率高
- 分片的數量選擇,分片並不是越多越好,根據經驗,我們的分片數可以選擇
N
的2
次冪,分片時為了提高效率還可以使用位運算代替取餘。
開源的本地快取庫中 bigcache
、go-cache
、freecache
都實現了分片功能,bigcache
的hash
選擇的是fnv64a
演算法、go-cache
的hash
選擇的是djb2演算法、freechache
選擇的是xxhash
演算法。這三種演算法都是非加密雜湊演算法,具體選哪個演算法更好呢,需要綜合考慮上面那三點,先對比一下執行效率,相同的字串情況下,對比benchmark
:
func BenchmarkFnv64a(b *testing.B) {
b.ResetTimer()
for i:=0; i < b.N; i++{
fnv64aSum64("test")
}
b.StopTimer()
}
func BenchmarkXxxHash(b *testing.B) {
b.ResetTimer()
for i:=0; i < b.N; i++{
hashFunc([]byte("test"))
}
b.StopTimer()
}
func BenchmarkDjb2(b *testing.B) {
b.ResetTimer()
max := big.NewInt(0).SetUint64(uint64(math.MaxUint32))
rnd, err := rand.Int(rand.Reader, max)
var seed uint32
if err != nil {
b.Logf("occur err %s", err.Error())
seed = insecurerand.Uint32()
}else {
seed = uint32(rnd.Uint64())
}
for i:=0; i < b.N; i++{
djb33(seed,"test")
}
b.StopTimer()
}
執行結果:
goos: darwin
goarch: amd64
pkg: github.com/go-localcache
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
BenchmarkFnv64a-16 360577890 3.387 ns/op 0 B/op 0 allocs/op
BenchmarkXxxHash-16 331682492 3.613 ns/op 0 B/op 0 allocs/op
BenchmarkDjb2-16 334889512 3.530 ns/op 0 B/op 0 allocs/op
通過對比結果我們可以觀察出來Fnv64a
演算法的執行效率還是很高,接下來我們在對比一下隨機性,先隨機生成100000
個字串,都不相同;
func init() {
insecurerand.Seed(time.Now().UnixNano())
for i := 0; i < 100000; i++{
randString[i] = RandStringRunes(insecurerand.Intn(10))
}
}
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func RandStringRunes(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[insecurerand.Intn(len(letterRunes))]
}
return string(b)
}
然後我們跑單元測試統計衝突數:
func TestFnv64a(t *testing.T) {
m := make(map[uint64]struct{})
conflictCount :=0
for i := 0; i < len(randString); i++ {
res := fnv64aSum64(randString[i])
if _,ok := m[res]; ok{
conflictCount++
}else {
m[res] = struct{}{}
}
}
fmt.Printf("Fnv64a conflict count is %d", conflictCount)
}
func TestXxxHash(t *testing.T) {
m := make(map[uint64]struct{})
conflictCount :=0
for i:=0; i < len(randString); i++{
res := hashFunc([]byte(randString[i]))
if _,ok := m[res]; ok{
conflictCount++
}else {
m[res] = struct{}{}
}
}
fmt.Printf("Xxxhash conflict count is %d", conflictCount)
}
func TestDjb2(t *testing.T) {
max := big.NewInt(0).SetUint64(uint64(math.MaxUint32))
rnd, err := rand.Int(rand.Reader, max)
conflictCount := 0
m := make(map[uint32]struct{})
var seed uint32
if err != nil {
t.Logf("occur err %s", err.Error())
seed = insecurerand.Uint32()
}else {
seed = uint32(rnd.Uint64())
}
for i:=0; i < len(randString); i++{
res := djb33(seed,randString[i])
if _,ok := m[res]; ok{
conflictCount++
}else {
m[res] = struct{}{}
}
}
fmt.Printf("Djb2 conflict count is %d", conflictCount)
}
執行結果:
Fnv64a conflict count is 27651--- PASS: TestFnv64a (0.01s)
Xxxhash conflict count is 27692--- PASS: TestXxxHash (0.01s)
Djb2 conflict count is 39621--- PASS: TestDjb2 (0.01s)
綜合對比下,使用fnv64a
演算法會更好一些。
減少GC
Go
語言是帶垃圾回收器的,GC
的過程也是很耗時的,所以要真的要做到高效能,如何避免GC
也是一個重要的思考點。freecacne
、bigcache
都號稱避免高額GC
的庫,bigcache
做到避免高額GC
的設計是基於Go
語言垃圾回收時對map
的特殊處理;在Go1.5
以後,如果map物件中的key和value不包含指標,那麼垃圾回收器就會無視他,針對這個點們的key
、value
都不使用指標,就可以避免gc
。bigcache
使用雜湊值作為key
,然後把快取資料序列化後放到一個預先分配好的位元組陣列中,使用offset
作為value
,使用預先分配好的切片只會給GC增加了一個額外物件,由於位元組切片除了自身物件並不包含其他指標資料,所以GC對於整個物件的標記時間是O(1)的。具體原理還是需要看原始碼來加深理解,推薦看原作者的文章:https://dev.to/douglasmakey/h...;作者在BigCache的基礎上自己寫了一個簡單版本的cache,然後通過程式碼來說明上面原理,更通俗易懂。
freecache
中的做法是自己實現了一個ringbuffer
結構,通過減少指標的數量以零GC開銷實現map,key
、value
都儲存在ringbuffer
中,使用索引查詢物件。freecache
與傳統的雜湊表實現不一樣,實現上有一個slot
的概念,畫了一個總結性的圖,就不細看原始碼了:
推薦文章
- https://colobu.com/2019/11/18...
- https://dev.to/douglasmakey/h...
- https://studygolang.com/artic...
- https://blog.csdn.net/chizhen...
總結
一個高效的本地快取中,併發訪問、減少GC
這兩個點是最重要的,在動手之前,看了這幾個庫中的優雅設計,直接推翻了我之前寫好的程式碼,真是沒有十全十美的設計,無論怎麼設計都會在一些點上有犧牲,這是無法避免的,軟體開發的道路上仍然道阻且長。自己實現的程式碼還在縫縫補補當中,後面完善了後發出來,全靠大家幫忙CR
了。
好啦,本文到這裡就結束了,我是asong
,我們下期見。
**歡迎關注公眾號:【Golang夢工廠】