Go 言語、ElastiCache の、その前に ~分散キャッシュは groupcache で~


 はい。ということで五七五ではじまりました、テックタッチ Advent Calendar 2019 10日目です。本記事を担当する @mxxxxkxxxx です。よろしくお願いします。

 9日目は @smith-30 による Nature Remo で快適生活 でした。読むと Nature Remo 欲しくなりますね。しかしサイバーマンデーは終わってしまった。なんとかすでにおすし🍣ですね。とはいえ通常の売価も高くないので買いです!ちなみに私はワンレンではありません

 ということで今回はサーバサイドのキャッシュのお話をしたいと思います。

サーバサイドのキャッシュ、とりあえず ElastiCache 入れとけ!ってなってませんか?

 キャッシュに KVS を使うことはよくあると思います。例えば ElastiCache とか。
 しかしローカルキャッシュなどアプリケーションサーバのリソースを活用せずにいきなりドーンと ElastiCache はちょっともったいないですよね。お金もかかるし。アプリケーションサーバのメモリは割と余裕があることが多いですし、活用しない手はないです。

 そんなあなたのために!golang では groupcache というものがあります。

groupcache とは

 groupcache は golang 向けのシンプルなキャッシュ用 KVS ライブラリです。google 社内でもよく使われているとのこと。
 キャッシュはローカルのメモリに乗り、LRU で参照されていないデータから消えていきます。一方で Expiration がないので、キャッシュが有効期限切れで消えることはありません。memcached のように複数ノード上で分散キャッシュもできます。

有効期限切れでキャッシュが消えてくれないの?

 消えてほしいですよね。しかし本家の実装だと無理筋なので Expiration がほしい人は fork して自分で書くしかありません。

 twitter も fork してますね。
 https://github.com/twitter/groupcache

 試しに fork して簡易的な Expiration の実装をしてみました。
 https://github.com/mxxxxkxxxx/groupcache

 修正点はこれな。
 https://github.com/mxxxxkxxxx/groupcache/commit/fdba4ea69a1680fef060d5c58ba7390d5bbdf33a

 本記事では上の fork で話を進めていきます。

導入

 groupcache の導入については既に Qiita に記事を書いた方がいらっしゃるのですが、五年以上前なので改めて。

 https://qiita.com/yosisa/items/4980559f328fb31c3a8d

Web API を利用したサンプルにしたのでまず Gin をインストールします。


$ go get -u github.com/gin-gonic/gin

次に fork して Expiration を実装した groupcache をインストール。


$ go get -u github.com/mxxxxkxxxx/groupcache

実装

こんな感じで。詳細はコメントに書きました。


package main

import (
    "errors"
    "fmt"
    "github.com/gin-gonic/gin"
    "github.com/mxxxxkxxxx/groupcache"
    "net/http"
    "os"
)

const cacheTTL = 60 // sec

func main() {
    /*
     * groupcache
     */

    //  自身の URL 設定
    addr := getEnv("GROUPCACHE_ADDR", "localhost:18080")
    url := "http://" + addr

    // Peer の URL 設定(複数)
    peers := groupcache.NewHTTPPool(url)
    if len(os.Args) > 1 {
        peers.Set(os.Args[1:]...)
    } else {
        peers.Set(url)
    }
    go func() {
        err := http.ListenAndServe(addr, peers)
        if err != nil {
            fmt.Println(err)
            os.Exit(1)
        }
    }()

    // キャッシュのグループ設定
    group := groupcache.NewGroup(
        // グループ名
        "default",
        // キャッシュサイズ ()キャッシュがこのサイズを超えると長く参照されていないものから削除される(LRU))
        1024 * 1024 * 10,
        // キャッシュがないときに実行される getter
        // 例えば DB の値をキャッシュしたいときはここで DB から値を取得してキャッシュにセットする
        groupcache.GetterFunc(func (_ groupcache.Context, key string, dest groupcache.Sink) error{
            v, err := getData(key)
            if err != nil {
                return err
            }

            // キャッシュにセット
            dest.SetString(v, cacheTTL)

            return nil
        }),
    )

    /*
     * gin
     */

    r := gin.Default()
    r.GET("/ping1", func(c *gin.Context) {
        var value string
        group.Get(nil, "ping1", groupcache.StringSink(&value))
        c.JSON(200, gin.H{
            "message": value,
        })
    })
    r.GET("/ping2", func(c *gin.Context) {
        var value string
        group.Get(nil, "ping2", groupcache.StringSink(&value))
        c.JSON(200, gin.H{
            "message": value,
        })
    })
    r.GET("/stats", func(c *gin.Context) {
        c.JSON(200, group.Stats)
    })
    r.GET("/cache-stats/local", func(c *gin.Context) {
        c.JSON(200, group.CacheStats(groupcache.MainCache))
    })
    r.GET("/cache-stats/remote", func(c *gin.Context) {
        c.JSON(200, group.CacheStats(groupcache.HotCache))
    })
    host := getEnv("HTTP_HOST", "localhost")
    port := getEnv("HTTP_PORT", "8080")
    r.Run(host + ":" + port)
}

/*
 * functions
 */

var data = map[string]string{
    "ping1": "this is ping1",
    "ping2": "this is ping2",
}
func getData(key string) (string, error) {
    if v, exists := data[key]; exists {
        return v, nil
    }

    return "", errors.New("not found: key=" + key)
}

func getEnv(key string, defaultValue string) string {
    value := os.Getenv(key)
    if len(value) > 0 {
        return value
    }

    return defaultValue
}

実行してみます。


[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. 

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /ping1                    --> main.main.func3 (3 handlers)
[GIN-debug] GET    /ping2                    --> main.main.func4 (3 handlers)
[GIN-debug] GET    /stats                    --> main.main.func5 (3 handlers) 
[GIN-debug] GET    /cache-stats/local        --> main.main.func6 (3 handlers)
[GIN-debug] GET    /cache-stats/remote       --> main.main.func7 (3 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8080

Expiration

動いたので curl して統計値を見てみます。


$ curl http://127.0.0.1:8080/stats 
{"Gets":0,"CacheHits":0,"PeerLoads":0,"PeerErrors":0,"Loads":0,"LoadsDeduped":0,"LocalLoads":0,"LocalLoadErrs":0,"ServerRequests":0} 

CacheHits は 0 なのでキャッシュはない状態ですね。
次にキャッシュが作られるエンドポイントを叩いてみます。


$ curl http://127.0.0.1:8080/ping1 
{"message":"this is ping1"} 

また統計値を見てみましょう。


$ curl http://127.0.0.1:8080/stats 
{"Gets":1,"CacheHits":0,"PeerLoads":0,"PeerErrors":0,"Loads":1,"LoadsDeduped":1,"LocalLoads":1,"LocalLoadErrs":0,"ServerRequests":0} 

まだキャッシュヒットはありません。キャッシュはつくられたかな?ということでもう一回。


$ curl http://127.0.0.1:8080/ping1 
{"message":"this is ping1"} 

$ curl http://127.0.0.1:8080/stats 
{"Gets":2,"CacheHits":1,"PeerLoads":0,"PeerErrors":0,"Loads":1,"LoadsDeduped":1,"LocalLoads":1,"LocalLoadErrs":0,"ServerRequests":0} 

キャッシュにヒットしました!

今回60秒でキャッシュが消えるようにしているので、それを確認してみます。


$ curl http://127.0.0.1:8080/cache-stats/local 
{"Bytes":18,"Items":1,"Gets":3,"Hits":1,"Evictions":0,"Expired":0} 

これはローカルのキャッシュの統計値です。
有効期限切れのキャッシュがあれば Expired がインクリメントされます。


$ curl http://127.0.0.1:8080/stats
{"Gets":2,"CacheHits":1,"PeerLoads":0,"PeerErrors":0,"Loads":1,"LoadsDeduped":1,"LocalLoads":1,"LocalLoadErrs":0,"ServerRequests":0} 

全体の統計値をもう一度確認。


$ curl http://127.0.0.1:8080/ping1 
{"message":"this is ping1"} 

$ curl http://127.0.0.1:8080/cache-stats/local 
{"Bytes":18,"Items":1,"Gets":5,"Hits":1,"Evictions":0,"Expired":1} 

$ curl http://127.0.0.1:8080/stats 
{"Gets":3,"CacheHits":1,"PeerLoads":0,"PeerErrors":0,"Loads":2,"LoadsDeduped":2,"LocalLoads":2,"LocalLoadErrs":0,"ServerRequests":0} 
  • /cache-stats/local の Expired が増えた
  • /stats の Gets が増えた
  • /stats の CacheHits は変わらず

ということで、無事有効期限切れでキャッシュが消えてくれたようです。

分散キャッシュ

今度はキャッシュが分散されているか見てみましょう。
次の通り2つ立ち上げます。

node1 (http://127.0.0.1:8080/)


$ GROUPCACHE_ADDR=127.0.0.1:18080 HTTP_PORT=8080 go run main.go http://127.0.0.1:18080 http://127.0.0.1:18081 

node2 (http://127.0.0.1:8081/)


$ GROUPCACHE_ADDR=127.0.0.1:18081 HTTP_PORT=8081 go run main.go http://127.0.0.1:18080 http://127.0.0.1:18081 

go run main.go の引数に指定している http://127.0.0.1:18080 http://127.0.0.1:18081 がキャッシュのノードたちです。
自分も含みます(ノードたちの一部なので)。
ノード間の通信は HTTP となります。
当然ながら Gin とは別のポートを使うこととなります。

まずは各ノードの統計値を確認。

node1

$ curl http://127.0.0.1:8080/stats 
{"Gets":0,"CacheHits":0,"PeerLoads":0,"PeerErrors":0,"Loads":0,"LoadsDeduped":0,"LocalLoads":0,"LocalLoadErrs":0,"ServerRequests":0} 

$ curl http://127.0.0.1:8080/cache-stats/local 
{"Bytes":0,"Items":0,"Gets":0,"Hits":0,"Evictions":0,"Expired":0} 
node2

$ curl http://127.0.0.1:8081/stats                                                                                                                                                       
{"Gets":0,"CacheHits":0,"PeerLoads":0,"PeerErrors":0,"Loads":0,"LoadsDeduped":0,"LocalLoads":0,"LocalLoadErrs":0,"ServerRequests":0} 

$ curl http://127.0.0.1:8081/cache-stats/local                                                                                                                                           
{"Bytes":0,"Items":0,"Gets":0,"Hits":0,"Evictions":0,"Expired":0} 

はい空っぽです。
では node1 に curl して統計値を見てみます。


$ curl http://127.0.0.1:8080/ping1
{"message":"this is ping1"} 
node1

$ curl http://127.0.0.1:8080/stats 
{"Gets":1,"CacheHits":0,"PeerLoads":0,"PeerErrors":0,"Loads":1,"LoadsDeduped":1,"LocalLoads":1,"LocalLoadErrs":0,"ServerRequests":0} 

$ curl http://127.0.0.1:8080/cache-stats/local 
{"Bytes":18,"Items":1,"Gets":2,"Hits":0,"Evictions":0,"Expired":0} 
node2

$ curl http://127.0.0.1:8081/stats
{"Gets":0,"CacheHits":0,"PeerLoads":0,"PeerErrors":0,"Loads":0,"LoadsDeduped":0,"LocalLoads":0,"LocalLoadErrs":0,"ServerRequests":0} 

$ curl http://127.0.0.1:8081/cache-stats/local 
{"Bytes":0,"Items":0,"Gets":0,"Hits":0,"Evictions":0,"Expired":0} 

node2 は空っぽのままですね。
次に node2 に curl して統計値を見ましょう。


$ curl http://127.0.0.1:8081/ping1 
{"message":"this is ping1"} 
node1

$ curl http://127.0.0.1:8080/stats
{"Gets":2,"CacheHits":1,"PeerLoads":0,"PeerErrors":0,"Loads":1,"LoadsDeduped":1,"LocalLoads":1,"LocalLoadErrs":0,"ServerRequests":1} 

$ curl http://127.0.0.1:8080/cache-stats/local 
{"Bytes":18,"Items":1,"Gets":3,"Hits":1,"Evictions":0,"Expired":0} 
node2

$ curl http://127.0.0.1:8081/stats
{"Gets":1,"CacheHits":0,"PeerLoads":1,"PeerErrors":0,"Loads":1,"LoadsDeduped":1,"LocalLoads":0,"LocalLoadErrs":0,"ServerRequests":0} 

$ curl http://127.0.0.1:8081/cache-stats/local 
{"Bytes":0,"Items":0,"Gets":2,"Hits":0,"Evictions":0,"Expired":0} 
  • node2 の PeerLoads がインクリメントされた
  • node1 の CacheHits がインクリメントされた

ということで、無事 node2 から node1 のキャッシュをひけたようです。

最後に

 以上、ElastiCache はじめ Redis/Memcached のような KVS を利用しないキャッシュの仕組みを紹介させていただきました。
 当然用途によって何が最適かは異なる訳で、例えばリアルタイムランキングを実装するとしたら Redis の Sorted Set を採用すべきです。
 長短考慮しながら最適な技術を採用していけたらいいですね。

 明日11日目のテックタッチ Advent Calendar 2019 は @taisa831 の「Goのtestingパッケージの基本を理解する」をお届けします。おたのしみに!