天天看點

Golang package輕量級KV資料緩存——go-cache源碼分析

作者:Moon-Light-Dream

出處:https://www.cnblogs.com/Moon-Light-Dream/

轉載:歡迎轉載,但未經作者同意,必須保留此段聲明;必須在文章中給出原文連接配接;否則必究法律責任

什麼是go-cache

KV存儲引擎有很多,常用的如redis,rocksdb等,如果在實際使用中隻是在記憶體中實作一個簡單的kv緩存,使用上述引擎就太大費周章了。在Golang中可以使用go-cache這個package實作一個輕量級基于記憶體的kv存儲或緩存。GitHub源碼位址是:https://github.com/patrickmn/go-cache 。

go-cache這個包實際上是在記憶體中實作了一個線程安全的map[string]interface{},可以将任何類型的對象作為value,不需要通過網絡序列化或傳輸資料,适用于單機應用。對于每組KV資料可以設定不同的TTL(也可以永久存儲),并可以自動實作過期清理。

在使用時一般都是将go-cache作為資料緩存來使用,而不是持久性的資料存儲。對于停機後快速恢複的場景,go-cache支援将緩存資料儲存到檔案,恢複時從檔案中load資料加載到記憶體。

如何使用go-cache

常用接口分析

對于資料庫的基本操作,無外乎關心的CRUD(增删改查),對應到go-cache中的接口如下:

  • 建立對象:在使用前需要先建立cache對象
    1. func New(defaultExpiration, cleanupInterval time.Duration) *Cache

      :指定預設有效時間和清除間隔,建立cache對象。
      • 如果defaultExpiration<1或是NoExpiration,kv中的資料不會被清理,必須手動調用接口删除。
      • 如果cleanupInterval<1,不會自動觸發清理邏輯,要手動觸發c.DeleteExpired()。
    2. func NewFrom(defaultExpiration, cleanupInterval time.Duration, items map[string]Item) *Cache

      :與上面接口的不同是,入參增加了一個map,可以将已有資料按格式構造好,直接建立cache。
  • C(Create):增加一條資料,go-cache中有幾個接口都能實作新增的功能,但使用場景不同
    1. func (c Cache) Add(k string, x interface{}, d time.Duration) error

      :隻有當key不存在或key對應的value已經過期時,可以增加成功;否則,會傳回error。
    2. func (c Cache) Set(k string, x interface{}, d time.Duration)

      :在cache中增加一條kv記錄。
      • 如果key不存在,增加一個kv記錄;如果key已經存在,用新的value覆寫舊的value。
      • 對于有效時間d,如果是0(DefaultExpiration)使用預設有效時間;如果是-1(NoExpiration),表示沒有過期時間。
    3. func (c Cache) SetDefault(k string, x interface{})

      :與Set用法一樣,隻是這裡的TTL使用預設有效時間。
  • R(Read):隻支援按key進行讀取
    1. func (c Cache) Get(k string) (interface{}, bool)

      :通過key擷取value,如果cache中沒有key,傳回的value為nil,同時傳回一個bool類型的參數表示key是否存在。
    2. func (c Cache) GetWithExpiration(k string) (interface{}, time.Time, bool)

      :與Get接口的差別是,傳回參數中增加了key有效期的資訊,如果是不會過期的key,傳回的是time.Time類型的零值。
  • U(Update):按key進行更新
    1. 直接使用

      Set

      接口,上面提到如果key已經存在會用新的value覆寫舊的value,也可以達到更新的效果。
    2. func (c Cache) Replace(k string, x interface{}, d time.Duration) error

      :如果key存在且為過期,将對應value更新為新的值;否則傳回error。
    3. func (c Cache) Decrement(k string, n int64) error

      :對于cache中value是int, int8, int16, int32, int64, uintptr, uint,uint8, uint32, or uint64, float32,float64這些類型記錄,可以使用該接口,将value值減n。如果key不存在或value不是上述類型,會傳回error。
    4. DecrementXXX

      :對于Decrement接口中提到的各種類型,還有對應的接口來處理,同時這些接口可以得到value變化後的結果。如

      func (c *cache) DecrementInt8(k string, n int8) (int8, error)

      ,從傳回值中可以擷取到value-n後的結果。
    5. func (c Cache) Increment(k string, n int64) error

      :使用方法與

      Decrement

      相同,将key對應的value加n。
    6. IncrementXXX

      :使用方法與

      DecrementXXX

      相同。
  • D(Delete)
    1. func (c Cache) Delete(k string)

      :按照key删除記錄,如果key不存在直接忽略,不會報錯。
    2. func (c Cache) DeleteExpired()

      :在cache中删除所有已經過期的記錄。cache在聲明的時候會指定自動清理的時間間隔,使用者也可以通過這個接口手動觸發。
    3. func (c Cache) Flush()

      :将cache清空,删除所有記錄。
  • 其他接口:
    1. func (c Cache) ItemCount() int

      :傳回cache中的記錄數量。需要注意的是,傳回的數值可能會比實際能擷取到的數值大,對于已經過期但還沒有即使清理的記錄也會被統計。
    2. func (c *cache) OnEvicted(f func(string, interface{}))

      :設定一個回調函數(可選項),當一條記錄從cache中删除(使用者主動delete或cache自助清理過期記錄)時,調用該函數。設定為nil關閉操作。

安裝go-cache包

介紹了go-cache的常用接口,接下來從代碼中看看如何使用。在coding前需要安裝go-cache,指令如下。

go get github.com/patrickmn/go-cache           

一個Demo

如何在golang中使用上述接口實作kv資料庫的增删改查,接下來看一個demo。其他更多接口的用法和更詳細的說明,可以參考GoDoc。

import (
    "fmt"
    "time"
    
    "github.com/patrickmn/go-cache" // 使用前先import包
)

func main() {
    // 建立一個cache對象,預設ttl 5分鐘,每10分鐘對過期資料進行一次清理
    c := cache.New(5*time.Minute, 10*time.Minute)

    // Set一個KV,key是"foo",value是"bar"
    // TTL是預設值(上面建立對象的入參,也可以設定不同的值)5分鐘
    c.Set("foo", "bar", cache.DefaultExpiration)

    // Set了一個沒有TTL的KV,隻有調用delete接口指定key時才會删除
    c.Set("baz", 42, cache.NoExpiration)

    // 從cache中擷取key對應的value
    foo, found := c.Get("foo")
    if found {
        fmt.Println(foo)
    }

    // 如果想提高性能,存儲指針類型的值
    c.Set("foo", &MyStruct, cache.DefaultExpiration)
    if x, found := c.Get("foo"); found {
        foo := x.(*MyStruct)
            // ...
    }
}           

源碼分析

1. 常量:内部定義的兩個常量`NoExpiration`和`DefaultExpiration`,可以作為上面接口中的入參,`NoExpiration`表示沒有設定有效時間,`DefaultExpiration`表示使用New()或NewFrom()建立cache對象時傳入的預設有效時間。           
const (
    NoExpiration time.Duration = -1
    DefaultExpiration time.Duration = 0
)           
2.  Item:cache中存儲的value類型,Object是真正的值,Expiration表示過期時間。可以使用Item的```Expired()```接口确定是否到期,實作方式是過比較目前時間和Item設定的到期時間來判斷是否過期。           
type Item struct {
    Object     interface{}
    Expiration int64
}

func (item Item) Expired() bool {
    if item.Expiration == 0 {
        return false
    }
    return time.Now().UnixNano() > item.Expiration
}           
3. cache:go-cache的核心資料結構,其中定義了每條記錄的預設過期時間,底層的存儲結構等資訊。           
type cache struct {
    defaultExpiration time.Duration              // 預設過期時間
    items             map[string]Item            // 底層存儲結構,使用map實作 
    mu                sync.RWMutex               // map本身非線程安全,操作時需要加鎖
    onEvicted         func(string, interface{})  // 回調函數,當記錄被删除時觸發相應操作
    janitor           *janitor                   // 用于定時輪詢失效的key
}           
4. janitor:用于定時輪詢失效的key,其中定義了輪詢的周期和一個無緩存的channel,用來接收結束資訊。           
type janitor struct {
    Interval time.Duration // 定時輪詢周期
    stop     chan bool     // 用來接收結束資訊
}

func (j *janitor) Run(c *cache) {
    ticker := time.NewTicker(j.Interval) // 建立一個timeTicker定時觸發
    for {
        select {
        case <-ticker.C:
            c.DeleteExpired()            // 調用DeleteExpired接口處理删除過期記錄
        case <-j.stop:
            ticker.Stop()
            return
        }
    }
}           

對于janitor的處理,這裡使用的技巧值得學習 ,下面這段代碼是在New() cache對象時,會同時開啟一個goroutine跑janitor,在run之後可以看到做了

runtime.SetFinalizer

的處理,這樣處理了可能存在的記憶體洩漏問題。

func stopJanitor(c *Cache) {
    c.janitor.stop <- true
}

func newCacheWithJanitor(de time.Duration, ci time.Duration, m map[string]Item) *Cache {
    c := newCache(de, m)
    // This trick ensures that the janitor goroutine (which--granted it
    // was enabled--is running DeleteExpired on c forever) does not keep
    // the returned C object from being garbage collected. When it is
    // garbage collected, the finalizer stops the janitor goroutine, after
    // which c can be collected.
    C := &Cache{c}
    if ci > 0 {
        runJanitor(c, ci)
        runtime.SetFinalizer(C, stopJanitor)
    }
    return C
}           

可能的洩漏場景如下,使用者建立了一個cache對象,在使用後置為nil,在使用者看來在gc的時候會被回收,但是因為有goroutine在引用,在gc的時候不會被回收,是以導緻了記憶體洩漏。

c := cache.New()
    // do some operation
    c = nil           

解決方案可以增加Close接口,在使用後調用Close接口,通過channel傳遞資訊結束goroutine,但如果使用者在使用後忘了調用Close接口,還是會造成記憶體洩漏。

另外一種解決方法是使用

runtime.SetFinalizer

,不需要使用者顯式關閉, gc在檢查C這個對象沒有引用之後, gc會執行關聯的SetFinalizer函數,主動終止goroutine,并取消對象C與SetFinalizer函數的關聯關系。這樣下次gc時,對象C沒有任何引用,就可以被gc回收了。

總結

  1. go-cache的源碼代碼裡很小,代碼結構和處理邏輯都比較簡單,可以作為golang新手閱讀的很好的素材。
  2. 對于單機輕量級的記憶體緩存如果僅從功能實作角度考慮,go-cache是一個不錯的選擇,使用簡單。
  3. 但在實際使用中需要注意:
    • go-cache沒有對記憶體使用大小或存儲數量進行限制,可能會造成記憶體峰值較高;
    • go-cache中存儲的value盡量使用指針類型,相比于存儲對象,不僅在性能上會提高,在記憶體占用上也會有優勢。由于golang的gc機制,map在擴容後原來占用的記憶體不會立刻釋放,是以如果value存儲的是對象會造成占用大量記憶體無法釋放。