Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Goのin-memory cache packageについて調査してみた

サーバーサイドエンジニアの鬼木です!

今回はGoのin-memory cache packageについて調べてみた記事になります。既存機能の拡張でin-memory cacheを使う必要があり調査したことが背景としてあります。

以下について比較、調査しました。

また今回の拡張にあたってはcacheの有効期限を設定できるかという点が重要であり、その観点についてまとめたものが以下になります。

  1. TTL: 各Itemのcache保存後に有効期限切れするまでの時間の一律設定可否
  2. TTL(item単位): cache保存時に保存するitemごとに有効期限切れするまでの時間の設定可否
  3. size limit: byte単位など、使用するmemoryのsizeでのlimitの設定可否
  4. item length limit: Itemの数によるlimitの設定可否

※(3、4については設定できるものはlimitを超えた場合は古いitemが期限切れ or 削除済みとなり、新しいItemの保存領域を作る)

package TTL TTL(item単位) size limit item length limit
bigcache ×
freecache × ×
golang-lru × × ×
ristretto ×

github.com/allegro/bigcache

全てのItem一律のTTLが設定できるcacheです。 有効期限以外にもcacheのcleanのIntervalなど様々なconfigurationを設定できますがminimumではTTLの設定だけで初期化できるので高機能かつ扱いやすいinterfaceで、有効期限の設定の面でユースケースに一番適していたので今回はこちらを採用しました。

package main

import (
    "fmt"

    "github.com/allegro/bigcache/v3"
)

func main() {
    eviction := 5 * time.Minute
    config := bigcache.DefaultConfig(eviction)
    config.HardMaxCacheSize = 128 //MB
    cache, err := bigcache.NewBigCache(config)
    if err != nil {
        panic(err)
    }

    key := "key"
    val := []byte("val")
    cache.Set(key, val)

    if entry, err := cache.Get(key); err == nil {
        fmt.Println(string(entry))
    }
    cache.Delete(key)
}

github.com/coocood/freecache

ItemごとのTTLが設定できるcacheです。 指定できる設定項目としてはcache全体のsize limitとItemごとのTTLだけですが、その分シンプルで扱いやすくItemごとの有効期限が異なるような複数の用途でin-memory cacheを使用するケースに適しているpackageです。

package main

import (
    "fmt"

    "github.com/coocood/freecache"
)

func main() {
    cacheSize := 100 * 1024 * 1024
    cache := freecache.NewCache(cacheSize)

    key := []byte("key")
    val := []byte("val")
    expire := 60 // expire in 60 seconds
    cache.Set(key, val, expire)

    if got, err := cache.Get(key); err == nil {
        fmt.Printf("%s\n", got)
    }

    cache.Del(key)
}

github.com/hashicorp/golang-lru

有効期限の設定がitemのlengthのみで時間やbyte単位でのlimit設定を持たないcacheです。 interface{}型のSetが可能で、保存するItemのsizeが一定でOOMの心配がない場合に一番シンプルに実装できるpackageです。

package main

import (
    "fmt"

    lru "github.com/hashicorp/golang-lru"
)

func main() {
    length := 128
    l, err := lru.New(length)
    if err != nil {
            panic(err)
    }

    key := "key"
    val := "val"
    l.Add(key, val)
    if v, ok := l.Get(key); ok {
        fmt.Println(v.(string))
    }
    l.Remove(key)
}

github.com/dgraph-io/ristretto

DgraphというGraphQL databaseのcontention-free cacheとして実装されたcacheで、他のcache packageに比べてパフォーマンスが高いことが特徴となっています(READMEにはHit RatiosやThroughputの他のpackageとの比較のベンチマークが載っています)。 ItemのSet時に呼び出し側でCostを引数に指定するやり方が少し特殊で運用難易度が上がる印象がありましたが、正しく運用できれば高いパフォーマンスを発揮できるpackageです。

package main

import (
    "fmt"

    "github.com/dgraph-io/ristretto"
)

func main() {
    cache, err := ristretto.NewCache(&ristretto.Config{
        MaxCost: 1 << 30, // maximum cost of cache (1GB).
    })
    if err != nil {
            panic(err)
    }

    // 1 → Cost
    cache.Set("key", "val", 1)

    // wait for val to pass through buffers
    cache.Wait()

    if val, found := cache.Get("key"); found {
        fmt.Println(val)
    }
    cache.Del("key")
}

おまけ:bigcacheとfreecacheの内部的な仕組みについて

bigcacheはGo1.15以降のバージョンでkey, valueがポインタでないmapではGCはそれらを省略するようになった仕様を利用してmap[uint64]uint32と単一のbyte sliceでデータを管理しています。SetしたItemの内容は全て単一のbyte sliceに保持し、mapにhash化されたkeyとbyte sliceから値を取得するための情報を保持しています。この仕組みによりGC時には単一のpointerであるbyte sliceの方のみを見るのでGCのオーバーヘッドを抑えられています。(参考:https://github.com/allegro/bigcache#how-it-works

freecacheもmapのポインタの数の加速度的な増加を抑えるため、データは256個のシャードに分けて保存しており、各シャードに2つのpointerを持つのでどんなにentryを保存してもmapのpointerの数は512を超えることはなくこちらもGCのオーバーヘッドを抑えています。(参考:https://github.com/coocood/freecache#how-it-is-done

このようにapplication内でリクエストスコープを超えて一定時間以上使用されるmapを使用するケースにおいて、mapに多くのpointerを持たせるとGCのオーバーヘッドが高くなるのでin-memory packageの実装においてその点の考慮が必要なことは個人的に興味深かったポイントでした。

まとめ

in-memory cache packageの内部的な仕組みに興味を持つとGoの仕組みに詳しくなれて面白いなと思いました!

ヤプリではGoの標準packageのソースコードリーディングなども行っており、Goのpackageのソースコードを読むのが好きな方、お待ちしております!