groupcache-源碼分析

總述

項目地址:https://github.com/golang/groupcache

原博客地址:http://www.straka.cn/blog/groupcache-source-code-reading/

題外話,是memcached的作者寫的,細節處還是很精彩的

groupcache已經在dl.Google.com、Blogger、Google Code、Google Fiber、Google生產監視系統等項目中投入使用

相對於go-cache而言,groupcache提供了分佈式的緩存功能,功能複雜度降低(見制約),但是大規模的緩存能力提升。

框架靈活性強,可以靈活定製緩存策略,緩存源數據無關性

還滿足多節點緩存的分佈式一致性hash,以及本地緩存miss後向其他緩存查詢的機制。

框架較爲健壯,細節上對效率有考究,可以應對緩存擊穿、緩存穿透問題以及緩存雪崩問題

Tips:看bradfitz大佬的這個源碼,可以重點關注大段的註釋和TODO,都是很值得思考的部分

制約

只能get、remove,不支持update,只能remove後再get

過期機制爲限制cache隊列長度,不能設置過期時間,只能通過內置lru淘汰過期數據;

經常變更的數據不適合groupcache作爲緩存;

注意一點,groupcache的consistenhash實現只能增加節點,無法減少節點,若要實現該功能,要注意當減少節點時,如果有相同的虛擬節點映射到同一個位置,則要判斷刪除的數量。增加不存在問題,因爲增加的時候後來的虛擬節點可以替換先設置的虛擬節點,不影響使用。

源碼分析:

先說下大體文件結構:

因爲groupcache是一個庫性質的,所以沒有main入口,但是看使用示例也知道,入口是groupcache.go的NewGroup,  所以應該從groupcache.go文件開始分析。

http.go實現了peers之間的http查詢緩存的請求方法和響應服務,

groupcachepb提供了上述節點之間其消息序列化和反序列化協議,基於protobuf,在groupcache.proto中定義。

peers.go抽象了緩存節點,提供了註冊、獲取節點的機制

sinks.go抽象了數據容器, 可以以不同的方法初始化設置,最終以統一的字節切片讀出

byteview.go封裝了字節切片的多種操作方法

singleflight保證了多個相同key的請求下的加鎖等待,避免了熱點緩存擊穿問題, waitGroup實現

lru提供了lru策略的實現,過期方式爲隊列長度超預期則刪除。

consistenthash提供了分佈式一致性hash的抽象,其本身並沒有實現分佈式一致性的hash,而是可以以指定的hash完成分佈式一致性的key的定位和節點的增加,注意這裏沒有實現節點刪除。

後面細說:

groupcache.go:

NewGroup提供了group的新增,內部完成了去重(重複註冊則panic),並提供了兩個位置的hook,一個是初次建立對等節點服務的hook(initPeerServer,其實不算hook,算是提供的初始化定製策略),另一個是新建緩存組的hook(newGroupHook)

// A Group is a cache namespace and associated data loaded spread over a group of 1 or more machines.
type Group struct {
    name string
    getter Getter
    peersOnce sync.Once
    peers PeerPicker
    cacheBytes int64 // limit for sum of mainCache and hotCache size

    mainCache cache

    hotCache cache

    loadGroup flightGroup
    _ int32 // force Stats to be 8-byte aligned on 32-bit platforms
    Stats Stats
}

查看group定義,getter是傳入的回調函數,給出了當本地miss同時緩存節點miss,或者本地miss,但該key屬於本節點維護時該如何獲取數據的函數。見load

裏面有兩個cache,一個是mainCache,其實就是本地的cache,hotCache是當本地miss,向其他緩存請求時本地也會緩存下來,防止反覆向其他對等節點的請求。

flightGroup定義了接口,用以保證對同一個key的併發請求不會引起大量的peers間的請求,用waitGroup實現,具體實現文件在singleflight.

_ int32用來在32bit平臺上強制8字節對齊的(這個較少遇到)

Stats是一些統計,比如緩存請求數,hit數,peer請求數等等

重點是Get和load函數的分析

func (g *Group) Get(ctx Context, key string, dest Sink) error {
    g.peersOnce.Do(g.initPeers)   //首次運行,初始化對等節點

    g.Stats.Gets.Add(1)    //設置stats
    if dest == nil {              //必須指定數據載體
        return errors.New("groupcache: nil dest Sink")
    }
    value, cacheHit := g.lookupCache(key)   //本地緩存查找

    if cacheHit {   //本地緩存命中
        g.Stats.CacheHits.Add(1)  
        return setSinkView(dest, value)
    }

    destPopulated := false   //由於load中會對併發的請求做處理,只有最先到的請求會直接向對等節點請求,執行loadGroup.Do的傳入參數二的閉包函數,而該函數調用了getLocally設置了dest,其他的只會等待,所以需要這個標誌,對沒有直接請求的另外執行setSinkView完成值的傳遞。

    value, destPopulated, err := g.load(ctx, key, dest)
    if err != nil {
        return err
    }
    if destPopulated {
        return nil
    }
    return setSinkView(dest, value)
}

 

// load loads key either by invoking the getter locally or by sending it to another machine.
func (g *Group) load(ctx Context, key string, dest Sink) (value ByteView, destPopulated bool, err error) {
    g.Stats.Loads.Add(1)
    viewi, err := g.loadGroup.Do(key, func() (interface{}, error) {

        //注意這裏又調用了lookupCache, 這個很巧妙,brilliant!,防止了略有先後的兩個請求可能導致兩次重複對peer的訪問,比如後來的請求在第一個請求還沒有發起對等節點查詢的時候發起

       //等第一個請求還沒完成loadGroup.Do(key,func)中的func()中對緩存的設置 getFromPeer,第二個請求完成了本地緩存的檢索,則第二個緩存會重複進入load,並且此時可能loadGroup.Do已經返回,

      //waitGroup已經結束,會再一次向對等節點發起請求
        if value, cacheHit := g.lookupCache(key); cacheHit {
            g.Stats.CacheHits.Add(1)
            return value, nil
        }
        g.Stats.LoadsDeduped.Add(1)
        var value ByteView
        var err error
        if peer, ok := g.peers.PickPeer(key); ok {   //根據分佈式一致性hash查找對應節點, ok爲true表明不是本機
            value, err = g.getFromPeer(ctx, peer, key) //向對應節點請求數據
            if err == nil {
                g.Stats.PeerLoads.Add(1)
                return value, nil
            }
            g.Stats.PeerErrors.Add(1)
        }
        value, err = g.getLocally(ctx, key, dest)  //peer未找到該節點,或者該key屬於本節點管轄,應該請求向數據源獲取數據,調用group初始化的getter回調函數
        if err != nil {
            g.Stats.LocalLoadErrs.Add(1)
            return nil, err
        }
        g.Stats.LocalLoads.Add(1)
        destPopulated = true // only one caller of load gets this return value
        g.populateCache(key, value, &g.mainCache)  // 再次從本地cache中加載
        return value, nil
    })
    if err == nil {
        value = viewi.(ByteView)
    }
    return
}

這兩個函數之後的內容就好分析了很多,只單獨說下populateCache,該函數將新增的(無論從peer獲取,或從數據源getter獲取)key\value存入對應的緩存,peer→hotCache, 數據源獲取→mainCache,

並在該函數內實現了對cache的維護,當兩個cache超過總的大小限制,則根據lru刪除多餘的內容,並保證hotCache大小小於mainCache/8

接下來看http.go和Peer.go部分:

type HTTPPool struct {
    Context func(*http.Request) Context

    Transport func(Context) http.RoundTripper
    // this peer's base URL, e.g. "https://example.net:8000"
    self string
    opts HTTPPoolOptions
    mu sync.Mutex // guards peers and httpGetters
    peers *consistenthash.Map
    httpGetters map[string]*httpGetter // keyed by e.g. "http://10.0.0.2:8008"
}

其中 Context可根據情況定製,主要用於服務端,將接收到的http請求轉換成指定的context類型,便於進一步處理

Transport可根據情況定製,主要用於客戶端,將一個向對等節點發起的請求context,轉換成指定的Http訪問方式,其中 RoundTripper爲實現了

RoundTrip(*Request) (*Response, error)的接口,故而,HTTPPool可以實現訪問方式的靈活定製

self爲該節點本身的URL,爲監聽服務的地址

opts爲HTTP池的默認選項

type HTTPPoolOptions struct {
    // http服務地址前綴,默認爲 "/_groupcache/".
    BasePath string

    // 分佈式一致性hash中虛擬節點數量,默認 50.

    Replicas int
    // 分佈式一致性hash的hash算法,默認 crc32.ChecksumIEEE.
    HashFn consistenthash.Hash
}

再往後看兩個主要函數

func (p *HTTPPool) ServeHTTP(w http.ResponseWriter, r *http.Request) { //實現了http服務的具體方法,這裏簡略說下

    if !strings.HasPrefix(r.URL.Path, p.opts.BasePath)     // 判斷URL前綴是否合法

    parts := strings.SplitN(r.URL.Path[len(p.opts.BasePath):], "/", 2)  // 分割URL,並從中提取group和key值,示例請求URL爲:https://example.net:8000/_groupcache/groupname/key

    group := GetGroup(groupName)  // 根據url中提取的groupname 獲取group
    if p.Context != nil {
        ctx = p.Context(r)   // 如Context不爲空,說明需要使用定製的context
    }

   err := group.Get(ctx, key, AllocatingByteSliceSink(&value))  // 獲取指定key對應的緩存
    body, err := proto.Marshal(&pb.GetResponse{Value: value})  //序列化響應內容

    w.Header().Set("Content-Type", "application/x-protobuf")  // 設置http頭
    w.Write(body)  //設置http  body
}

其次是

func (h *httpGetter) Get(context Context, in *pb.GetRequest, out *pb.GetResponse) error {  // 該方法根據需要向對等節點查詢緩存
    u := fmt.Sprintf( "%v%v/%v",h.baseURL ...... )  // 生成請求url
    req, err := http.NewRequest("GET", u, nil)  // 新建Get請求
    tr := http.DefaultTransport //獲取transport方法 
    res, err := tr.RoundTrip(req) // 執行請求
    b := bufferPool.Get().(*bytes.Buffer) // 這裏用到了go 提供的 sync.Pool,對字節緩衝數組進行復用,避免了反覆申請(緩存期爲兩次gc之間)
    b.Reset()  //字節緩衝重置
    _, err = io.Copy(b, res.Body) //字節緩衝填充 
    err = proto.Unmarshal(b.Bytes(), out)  //反序列化字節數組
}

其餘的,通過實現PickPeer函數實現了接口PeerPicker,此函數用於根據key得到對應的對等節點peer,返回的peer的表現形式爲實現了

ProtoGetter接口的對象,而該接口提供了Get方法,然後於getFromPeer中被調用,用以獲取peer中對應key的value值

如此一來,peer.go中的最重要的兩個接口PeerPicker\ProtoGetter也都介紹了,peer.go中就剩下兩個Regist函數,註冊一個可以根據groupName獲取PeerPicker的函數,

還有一個getPeers就是根據groupName獲取PeerPicker的函數,可以繼續其他文件分析。

sinks.go 和 byteview.go:

byteview很好理解,就是定義了一個新的結構體類型

type ByteView struct {
    // If b is non-nil, b is used, else s is used. //最重要的就是這個註釋,當b是nil 的時候,s作爲存儲內容的容器
    b []byte
    s string
}

其餘的都是針對該類型實現的方法,read write等基本方法

重頭戲是sink,

type Sink interface {
    // SetString sets the value to s.
    SetString(s string) error

    // SetBytes sets the value to the contents of v. The caller retains ownership of v.
    SetBytes(v []byte) error
    // SetProto sets the value to the encoded version of m. The caller retains ownership of m.
    SetProto(m proto.Message) error
    // view returns a frozen view of the bytes for caching.
    view() (ByteView, error)
}

註釋貌似說的很清楚,但是一看後面的實現,各種byte string sink byteview還是會有點凌亂,別急,不慌

Sink是一個接口,把它叫數據容器接口吧,這裏面的方法分兩部分,除了view,其餘的設置方法都是將該接口對應的數據容器類型的內容用對應的類型進行設置,比如SetString就是用string進行初始化該sink

後面定義了實現該接口的多種類型

stringSink、byteViewSink、protoSink、allocBytesSink、truncBytesSink,顧名思義,不同的類型代表了其內部的存儲方式的不同,

其每一種接口都實現了對應的初始化函數,比如以stringSink爲例

// StringSink returns a Sink that populates the provided string pointer.
func StringSink(sp *string) Sink {
    return &stringSink{sp: sp}
}

其實就是把該類型內部用於存儲的數據容器進行初始化,以便後續SetXXX的時候可以正常設置不panic,

其中byteViewSink必須用non-nil的字節slice初始化,而allocBytesSink可以傳入nil自行分配內存

此外這兩種byteSink還提供了setView函數,以滿足一個內部接口,函數setSinkView函數的內部接口viewSetter,

func (s *byteViewSink) setView(v ByteView) error {
    *s.dst = v
    return nil
}

func setSinkView(s Sink, v ByteView) error {
    // A viewSetter is a Sink that can also receive its value from a ByteView. This is a fast path to minimize copies when the
    // item was already cached locally in memory (where it's cached as a ByteView)
    type viewSetter interface {
        setView(v ByteView) error
    }
    if vs, ok := s.(viewSetter); ok {
        return vs.setView(v)
    }
    if v.b != nil {
        return s.SetBytes(v.b)
    }
    return s.SetString(v.s)
}

可以看到通過這個內部接口可以進行sink類型的區分,如果該sink類型可以成功轉換成viewSetter接口,則說明其支持直接用byteView進行內容填充,則可以避免寫入和讀出的兩次內存拷貝。Smart!

這裏提醒下golang 初學者,使用sink的地方,比如groupcache.go中的Getter接口,其方法

Get(ctx Context, key string, dest Sink),初學者會好奇,爲啥sink傳入的不是指針,那麼如何能在函數作用域內修改其值而傳遞出來,其實Sink是interface,interface就包含了兩個指針,

一個對象指針,一個運行時類型指針,參考https://stackoverflow.com/questions/44370277/type-is-pointer-to-interface-not-interface-confusion

所以傳入的對象是可以被修改的,而且,golang 中是不提倡使用 *interface類型的

lru.go:

實現了lru策略的緩存,注意,沒有鎖機制,所以不是線程安全 的,需要根據應用場景,在調用層進行併發讀寫控制, 本項目中應該是考慮緩存的應用場景不涉及key對應的value的update,而key和value作爲整體放在entry

類型中,最壞的情況是cache miss, 所以沒有用鎖,但值得思考的是,list沒有加鎖的情況下進行移動和節點增刪,會導致怎樣的問題, TODO 需要分析彙編碼,maybe crash

type Cache struct {
    // MaxEntries is the maximum number of cache entries before an item is evicted. Zero means no limit.
    MaxEntries int

    // OnEvicted optionally specifies a callback function to be executed when an entry is purged from the cache.
    OnEvicted func(key Key, value interface{})
    ll *list.List
    cache map[interface{}]*list.Element
}

其他的可以簡單過一下了:

singleflight.go 核心兩個結構體,其用Group管理着同一個group中遇到的對相同key的訪問,該訪問存於Group.m中,用mu加鎖保護讀寫,當同時有併發的請求,Group.m中會查詢到該key, 於是不執行請求,進入

call.wg.Wait(), 直到最先到達的那個完成call.wg.Done(),如此減少了併發key請求下對peer網絡的壓力。

type call struct {
    wg sync.WaitGroup
    val interface{}
    err error
}

type Group struct {
    mu sync.Mutex // protects m
    m map[string]*call // lazily initialized
}

groupcachepb就不說了,參見protobuf相關的內容

最後就剩一個consistenthash.go,要理解這個當然要先去看下分佈式一致性hash的原理,再看代碼會輕鬆很多

type Map struct {
    hash Hash   //hash 函數,[]byte--->uint32的hash
    replicas int   //虛擬節點數
    keys []int // Sorted    //排好序的所有groupname及其虛擬節點的hash值
    hashMap map[int]string   //保存所有節點的hash對groupname的反向映射
}

核心就兩個函數,一個是節點以及虛擬節點的創建

func (m *Map) Add(keys ...string) {
    for _, key := range keys {
        for i := 0; i < m.replicas; i++ {
            hash := int(m.hash([]byte(strconv.Itoa(i) + key)))
            m.keys = append(m.keys, hash)
            m.hashMap[hash] = key
        }
    }
    sort.Ints(m.keys)
}

另外是Get函數將指定key映射到指定group的過程

    hash := int(m.hash([]byte(key)))

    // Binary search for appropriate replica.
    idx := sort.Search(len(m.keys), func(i int) bool { return m.keys[i] >= hash })

重點是groupname的映射和key的映射是使用相同的hash算法。

原博客:http://www.straka.cn/blog/groupcache-source-code-reading/

參考:

https://studygolang.com/articles/6209

https://www.jianshu.com/p/f69f3a3a9a78

https://www.jianshu.com/p/fc39399a27f1

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章