キャッシュ
読了目安 約4分
同じ結果を毎回作らない。オンメモリキャッシュの実装と、「いつ消すか」「再起動で消える」という 2 つの難問を扱います。
この章の目次
N+1 の解消もインデックスも、クエリ 1 回を安くする話でした。 この章は、クエリの回数そのものを減らします。 最初の 1 回の結果を覚えておき、2 回目からはそれを返す仕組みをキャッシュと呼びます。
競プロのメモ化と同じ発想で、クエリの結果を map に覚えて DB への往復を消します。
一番簡単な形は起動時に全部読むこと
一番簡単な形は、起動時にテーブルを丸ごと読んで map に載せることです。 以後そのテーブルへのクエリは、すべて map 参照(およそ O(1)、往復なし)になります。
成立するのは、競技中にほとんど更新されないデータに限ります。 そういう前提データをマスタデータと呼び、カテゴリ一覧のような読まれる一方のテーブルが典型です。 更新されるテーブルを載せると、古い値を返してベンチマーカーの整合性チェックに落ちます。
map と sync.RWMutex で作る
ハンドラは複数の goroutine から同時に呼ばれ、素の map は同時に読み書きするとプログラムごと落ちます(goroutine と並行性)。
読み込みが圧倒的に多い用途なので、読み込み同士は同時に通す sync.RWMutex で守ります。
次のプログラムは、DB への問い合わせの代わりに 10 ミリ秒かかる関数を置いて、キャッシュの効果を体感するものです。
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 := 1 を id := i に変えて実行してみてください。
100 回とも別のユーザーになってキャッシュにヒットせず、「キャッシュあり」も 1 秒かかります。
キャッシュが効くのは「同じものが繰り返し読まれる」ときだけです。
実際のハンドラに組み込むと、この形になります。
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 のコードそのものの遅さを計測します。