Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Goで多層キャッシュ実装

こんにちは、メディア事業本部所属のちまきん(@__timakin__)です。こちらはGunosy Advent Calendar 2018、3日目の記事です。なお、昨日(本日)の記事は@aibouさんのInfrastructure as Codeの心構えでした。

僕は普段サーバーサイドのエンジニアとして主にGoを書いているので、その周辺の話を書こうと思います。

多層キャッシュについて

今回書くのは、多層キャッシュの実装パターンについてです。

DB負荷を減らすためにキャッシュを前段に置くと思いますが、その際にローカルのインメモリキャッシュと、マネージドなRedisやmemcachedなどを多層で挟み込むような実装を行う場面があるかと思います。多層キャッシュが正式な用語かは不明ですが、海外のブログとかを見ると Multi-Layered Cache Implementation とか出てきたりするので、多層キャッシュとしました。

多層キャッシュを考慮したデータアクセスの順序は

  1. インスタンス毎のローカルキャッシュ
  2. 共用のリモートキャッシュ
  3. DB直接参照

となりますが、それぞれexpireするタイミングが違うので、fallbackを考慮した書き方をするのが若干面倒です。

では、実際問題多層キャッシュはどのようにして実装することができるのでしょうか。

実装パターン

例えば記事一覧を取得するようなケースを考えてみましょう

1. 素直なif文

素直にif文で多段キャッシュする場合はこんな感じでしょうか。コードは都合上かなり簡略化したのと、実際の実装とは少し変えてあります。 多層化されたキャッシュの場合、そのレイヤーの数だけif文のネストが多くなり、非常に見通の悪いコードとなるかと思います。仮にremoteとlocalのキャッシュが同じ機構を使っていたら、実装を揃えられるのでまだ浅くなりますが、全く違うパッケージを利用している場合はこのようにごちゃごちゃした形式になります。

var articles []*entity.Article
articles, err := localCacheClient.GetArticles(ctx)
if err != nil {
    if err != constraint.ErrNotFound {
        return nil, err
    }
    
    articles, err := remoteCacheClient.GetArticles(ctx)
    if err != nil{
        if err != constraint.ErrNotFound {
           return nil, err
        }

        articles, err = dbClient.GetArticles(ctx)
        if err != nil {
            return nil, err
        }

        err = remoteCacheClient.SetArticles(ctx, articles)
        if err != nil {
            // ログ表示等
        }
    }
        
    err = localCacheClient.SetArticles(ctx, articles)
    if err != nil {
        // ログ表示等
    }
}

2. Callback

次に、Callbackでキャッシュが存在しなかった時のfallbackを表現するパターンです。 素直にデータが取得できたときはそのまま返せば良いのですが、もしデータが特定のレイヤーになかった場合は、その補填処理をCallbackとして定義してあげるやり方です。

Callback関数がネストするのは、引数が大きくなって行けば行くほど横に伸びてしまうのよGoだとそこまでCallbackを書く機会というのも多くはないので、視覚的に若干の違和感があるくらいでしょうか。

ただ、Setの処理を隠蔽することでかなり素直なif文よりも見通しが良くなると思います。

articles, err := localCacheClient.GetOrSetArticles(ctx, func(ctx context.Context) ([]*entity.Article, error) {
    return localCacheClient.GetOrSetArticles(ctx, func(ctx context.Context) ([]*entity.Article, error) {
        func(ctx context.Context) ([]*entity.Article, error) {
            return dbClient.GetArticles(ctx)
        }
    })
})
if err != nil {
    return nil, err
}

3. MiddleWare Iterator

もう少しネストを浅くした状態で多段キャッシュを実装するためには、キャッシュクライアントの配列を内包した、イテレーターを実装すれば良さそうです。

本当は個別の構造体によった実装ではなく、もっと広範に使える形式にしたり、イテレーション実行時の処理も詳細に書きたいところですが、一旦コンセプトだけ説明すると次のような実装になります。

何らかのリソースを参照する構造体に対して、DBクライアントと同時に、キャッシュクライアントのイテレーターを持たせることで、fallback時のネストを浅く見せることができます。内部実装はかなり複雑になりますが、ネストを解消しつつ多層キャッシュへのアクセスを実装する手段として、キャッシュMiddlewareのイテレーターの定義は有効だと思います。

もっと具体的な実装を見たいという場合は、mercari/datastoreとかが綺麗なイテレーションを実装しているので、そちらをご覧ください。

// Redisやmemcacheなど、キャッシュクライアントを実装する際のインターフェース
type ArticleCacheMiddleware interface {
    GetMulti(ctx context.Context, ids []int64) (as []*entity.Article, error) 
}

// キャッシュクライアントを持ったイテレーター。取得失敗時は次のレイヤのキャッシュを使う
type ArticleCacheIterator struct {
    ms []*ArticleCacheMiddleware
}

func (ci *CacheIterator) GetMulti(ctx context.Context, ids []int64, fb func()) (as []*entity.Article, error) {
    if len(ci.ms) == 0 {
        fb()
        return
    }
    current := ci.ms[0]
    ci.ms = ci.ms[1:]
    return current.GetMulti(ctx, ids)
}

type articleRepository struct {
    ci *CacheIterator
    dbClient *sql.DB
}

func (repo *articleRepository) GetArticleByIDs(ctx context.Context, ids []int64) ([]*entity.Article, error) {
    return repo.ci.GetMulti(ctx, ids, func() {
        // キャッシュから取得できなかった時のfallback処理: dbClientを使った直接のDB参照
    })
}

終わりに

ということで、サクッと多層キャッシュへのアクセスの実装方法を書きました。

サービス規模が大きくなればこうした多層化も視野に入れることになると思いますので、参考になれば幸いです。