🔩 ねじき教室 Go と Web の教室

キャッシュ

読了目安 約4分

同じ結果を毎回作らない。オンメモリキャッシュの実装と、「いつ消すか」「再起動で消える」という 2 つの難問を扱います。

この章の目次

N+1 の解消もインデックスも、クエリ 1 回を安くする話でした。 この章は、クエリの回数そのものを減らします。 最初の 1 回の結果を覚えておき、2 回目からはそれを返す仕組みをキャッシュと呼びます。

競プロのメモ化と同じ発想で、クエリの結果を map に覚えて DB への往復を消します。

一番簡単な形は起動時に全部読むこと

一番簡単な形は、起動時にテーブルを丸ごと読んで map に載せることです。 以後そのテーブルへのクエリは、すべて map 参照(およそ O(1)、往復なし)になります。

成立するのは、競技中にほとんど更新されないデータに限ります。 そういう前提データをマスタデータと呼び、カテゴリ一覧のような読まれる一方のテーブルが典型です。 更新されるテーブルを載せると、古い値を返してベンチマーカーの整合性チェックに落ちます。

map と sync.RWMutex で作る

ハンドラは複数の goroutine から同時に呼ばれ、素の map は同時に読み書きするとプログラムごと落ちます(goroutine と並行性)。 読み込みが圧倒的に多い用途なので、読み込み同士は同時に通す sync.RWMutex で守ります。

次のプログラムは、DB への問い合わせの代わりに 10 ミリ秒かかる関数を置いて、キャッシュの効果を体感するものです。

Go
package main

import (
	"fmt"
	"sync"
	"time"
)

// NameCache はユーザー ID から名前を引くオンメモリキャッシュです。
type NameCache struct {
	mu    sync.RWMutex
	names map[int]string
}

func (c *NameCache) Get(id int) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	name, ok := c.names[id]
	return name, ok
}

func (c *NameCache) Set(id int, name string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.names[id] = name
}

// slowFetch は DB への問い合わせの代役です。1 回 10 ミリ秒かかります。
func slowFetch(id int) string {
	time.Sleep(10 * time.Millisecond)
	return fmt.Sprintf("user-%d", id)
}

func main() {
	cache := &NameCache{names: map[int]string{}}

	start := time.Now()
	for i := 0; i < 100; i++ {
		id := 1 // 100 回とも同じユーザーを見ている
		name, ok := cache.Get(id)
		if !ok {
			name = slowFetch(id)
			cache.Set(id, name)
		}
		_ = name
	}
	fmt.Println("キャッシュあり:", time.Since(start))

	start = time.Now()
	for i := 0; i < 100; i++ {
		_ = slowFetch(1) // 毎回問い合わせる
	}
	fmt.Println("キャッシュなし:", time.Since(start))
}

問い合わせが走るのは最初の 1 回だけで、残り 99 回は map 参照で済んでいます。

試してみようid := 1id := i に変えて実行してみてください。 100 回とも別のユーザーになってキャッシュにヒットせず、「キャッシュあり」も 1 秒かかります。 キャッシュが効くのは「同じものが繰り返し読まれる」ときだけです。

実際のハンドラに組み込むと、この形になります。

Go
func userName(db *sql.DB, id int) (string, error) {
	if name, ok := cache.Get(id); ok {
		return name, nil
	}
	var name string
	err := db.QueryRow("SELECT name FROM users WHERE id = ?", id).Scan(&name)
	if err != nil {
		return "", err
	}
	cache.Set(id, name)
	return name, nil
}

難問その 1、いつ消すか

ユーザーが名前を変えたのに map に古い名前が残っていれば、整合性チェックで落ちます。 対処は 2 つです。

  • 更新した場所で消す(または上書きする):DB を UPDATE したハンドラで、その場で cache.Set も呼ぶ。1 箇所でも漏れると不整合になるので、更新箇所が少ないデータほど安全
  • TTL(Time To Live、値の有効期限):一定時間で勝手に捨てる。期限までは古い値を返すので、「多少古い値でもよい」とレギュレーションに明記されたデータ(集計ランキングなど)に限る

難問その 2、再起動で消える

オンメモリキャッシュはプロセスが終わると消えます。 問題になる場面は 2 つです。

  • 初期化処理:ベンチ開始時にデータを初期状態へ戻す処理で、多くの回では /initialize というエンドポイント。map に前回のデータが残っていると、存在しないはずのデータを返して落ちる。初期化処理の中でキャッシュも空にする(マスタデータなら読み直す)ところまでが実装
  • 再起動試験:DB に書かずに map にだけ書いたデータは、再起動で本当に消えて失格に直結する。正本は常に DB、map は消えても作り直せるコピー、という線を越えない

どのデータから狙うか

キャッシュに向くのは「読み込みが多く、更新が少ないデータ」です。 pt-query-digest の順位表で Calls が多い SELECT のうち、対象テーブルへの UPDATE や INSERT がほとんどないものが狙い目です。

次章では、Go のコードそのものの遅さを計測します。