動手實現一個localcache - 欣賞優秀的開源設計

asong發表於2021-12-24

前言

哈嘍,大家好,我是asong。上篇文章:動手實現一個localcache - 設計篇 介紹了設計一個本地快取要思考的點,有讀者朋友反饋可以借鑑bigcache的儲存設計,可以減少GC壓力,這個是我之前沒有考慮到的,這種開源的優秀設計值得我們學習,所以在動手之前我閱讀了幾個優質的本地快取庫,總結了一下各個開源庫的優秀設計,本文我們就一起來看一下。

高效的併發訪問

本地快取的簡單實現可以使用map[string]interface{} + sync.RWMutex的組合,使用sync.RWMutex對讀進行了優化,但是當併發量上來以後,還是變成了序列讀,等待鎖的goroutine就會block住。為了解決這個問題我們可以進行分桶,每個桶使用一把鎖,減少競爭。分桶也可以理解為分片,每一個快取物件都根據他的keyhash(key),然後在進行分片:hash(key)%N,N就是要分片的數量;理想情況下,每個請求都平均落在各自分片上,基本無鎖競爭。

分片的實現主要考慮兩個點:

  • hash演算法的選擇,雜湊演算法的選擇要具有如下幾個特點:

    • 雜湊結果離散率高,也就是隨機性高
    • 避免產生多餘的記憶體分配,避免垃圾回收造成的壓力
    • 雜湊演算法運算效率高
  • 分片的數量選擇,分片並不是越多越好,根據經驗,我們的分片數可以選擇N2次冪,分片時為了提高效率還可以使用位運算代替取餘。

開源的本地快取庫中 bigcachego-cachefreecache都實現了分片功能,bigcachehash選擇的是fnv64a演算法、go-cachehash選擇的是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也是一個重要的思考點。freecacnebigcache都號稱避免高額GC的庫,bigcache做到避免高額GC的設計是基於Go語言垃圾回收時對map的特殊處理;在Go1.5以後,如果map物件中的key和value不包含指標,那麼垃圾回收器就會無視他,針對這個點們的keyvalue都不使用指標,就可以避免gcbigcache使用雜湊值作為key,然後把快取資料序列化後放到一個預先分配好的位元組陣列中,使用offset作為value,使用預先分配好的切片只會給GC增加了一個額外物件,由於位元組切片除了自身物件並不包含其他指標資料,所以GC對於整個物件的標記時間是O(1)的。具體原理還是需要看原始碼來加深理解,推薦看原作者的文章:https://dev.to/douglasmakey/h...;作者在BigCache的基礎上自己寫了一個簡單版本的cache,然後通過程式碼來說明上面原理,更通俗易懂。

freecache中的做法是自己實現了一個ringbuffer結構,通過減少指標的數量以零GC開銷實現map,keyvalue都儲存在ringbuffer中,使用索引查詢物件。freecache與傳統的雜湊表實現不一樣,實現上有一個slot的概念,畫了一個總結性的圖,就不細看原始碼了:

推薦文章

總結

一個高效的本地快取中,併發訪問、減少GC這兩個點是最重要的,在動手之前,看了這幾個庫中的優雅設計,直接推翻了我之前寫好的程式碼,真是沒有十全十美的設計,無論怎麼設計都會在一些點上有犧牲,這是無法避免的,軟體開發的道路上仍然道阻且長。自己實現的程式碼還在縫縫補補當中,後面完善了後發出來,全靠大家幫忙CR了。

好啦,本文到這裡就結束了,我是asong,我們下期見。

**歡迎關注公眾號:【Golang夢工廠】

相關文章