この↓記事を見かけたので、キャッシュの方法をまとめる。
www.freecodecamp.org
本記事の半分程度はこの↑記事の内容を参照しているが、調べて追加・修正している部分があるのと、最近 singleflight が気になっていたのでそこは動くようにサンプルコードを書いた。
キャッシュ、大事だよね
API利用が増えたとき、すべてのリクエストがデータベースにアクセスし、同じデータを処理したり、同じ JSON を何度もシリアライズしたりすると、レイテンシが徐々に増加し、スループットが低下する。キャッシュは、既に完了した処理を保存し、将来のリクエストで即座に再利用できるようにすることで、高いパフォーマンスを維持するツール。
以下、freeCodeCampの記事 How to Cache Golang API Responses for High Performance で紹介されていた4つの方法を紹介し、その内容について調べたことを多少補足する。
方法1: ローカルおよびRedisストレージによるHTTPレスポンスのキャッシュ
API レスポンスを生成するプロセスのコストが高くなる場合、最も速い解決策はレスポンス全体を保存すること。Redis を使用すると、複数の API サーバーで同じキャッシュされたレスポンスを共有できる。
Goでは、この2層構成は通常、キャッシュアサイドパターンに従う。
つまり、まずローカルメモリを参照し、必要に応じてRedisにフォールバックし、両方の層で結果が見つからない場合にのみ結果を計算する。
計算が完了すると、値はRedisに保存され、すべてのユーザーとメモリに保存され、次の呼び出しですぐに再利用される。
疑似コード
val, ok := local.Get(key)
if !ok {
val, err = rdb.Get(ctx, key).Result()
if err == redis.Nil {
val = computeResponse()
_ = rdb.Set(ctx, key, val, 60*time.Second).Err()
}
local.Set(key, val, 1)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(val))
出処:How to Cache Golang API Responses for High Performance
上記のコードでは、まずローカルキャッシュからレスポンスを取得しようとする。
キーまたはデータが存在する場合は、即座にレスポンスが返される。
見つからない場合は、第2層としてRedisにクエリを実行する。
Redisからも何も返されない場合は、高負荷な計算が実行され、その結果はRedisに保存される。
これは他のサービスがアクセスできるように60秒の有効期限が設けられている。
その後、ローカルキャッシュに格納され、すぐに再利用できるようになる。
その後、レスポンスはJSON形式でクライアントに書き戻される。
これにより、繰り返しの呼び出しに対する超高速応答と、すべての API サーバーにわたる一貫したキャッシュという、両方のメリットが得られる。
方法2: Redisストレージによるデータベースクエリ結果のキャッシュ
2つ目は、APIの処理の中でもDBに対するクエリのパフォーマンスのボトルネックとなっている場合を前提とし、HTTPレスポンスではなく、DBクエリの結果をRedisにキャッシュする。
次のリクエストが来たら、Redisから結果を取得し、データベースを経由せずに応答を高速化する。
key := fmt.Sprintf("q:UserByID:%d", id)
if b, err := rdb.Get(ctx, key).Bytes(); err == nil {
var u User
_ = json.Unmarshal(b, &u)
return u
}
u, _ := repo.GetUser(ctx, id)
bb, _ := json.Marshal(u)
_ = rdb.Set(ctx, key, bb, 2*time.Minute).Err()
return u
ここでは、ユーザーIDを使用してクエリを一意に識別するキャッシュキーを作成し、Redisからシリアル化された結果の取得を試みる。
キーが存在する場合、バイト列をUser構造体にデシリアライズし、データベースにアクセスすることなく即座に戻り値を返す。
キャッシュミスが発生した場合は、リポジトリを介して実際のデータベースクエリを実行し、UserオブジェクトをJSONにシリアル化し、2分間の有効期限でRedisに保存して結果を返す。
このパターンにより、読み取り負荷の高い API のデータベース負荷と応答時間が大幅に削減されるが、データが変更されたときにエントリをクリアまたは更新することや、結果を適度に最新の状態に保つために有効期限の値を短く設定することを忘れないようにする必要がある。
方法3: ETag と Cache-Control を使用した HTTP キャッシュ
HTTP標準には、クライアントやCDNがレスポンスを再利用するためのツールとしてETagや Cache-Controlのようなヘッダーがある。
これを設定することで、レスポンスに変更があったかどうかをクライアントに通知できる。
リソースに変更がない場合、サーバーは軽量の304(Not Modified)レスポンスのみを送信する。
Go では、レスポンス本文から ETag を計算し、それをクライアントが送信した内容と比較し、ペイロード全体を返すか、304 だけを返すかを決定する。
etag := computeETag(responseBytes)
if match := r.Header.Get("If-None-Match"); match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=60")
w.Write(responseBytes)
上記のコードは、レスポンスコンテンツのフィンガープリントまたはハッシュであるETagを生成し、クライアントがIf-None-Match以前のリクエストで送信したETagと一致するヘッダーがあるかどうかを確認する。
ETagが一致する場合、コンテンツは変更されていないため、サーバーは304 Not Modifiedステータスを返してレスポンス本文を送信せず、帯域幅を節約できる。
ETagが一致しない場合、またはクライアントがキャッシュバージョンを持っていない場合、サーバーは新しいETagと、Cache-Control60秒間のパブリックキャッシュを許可するヘッダーを付加し、レスポンス全体を送信する。
このアプローチにより、帯域幅が削減され、CPU 使用率が低下し、応答を直接キャッシュして提供できる CDN とうまく連携する。
方法4: バックグラウンド更新付きStale-While-Revalidate
APIの高速性を維持するのであれば、多少古いデータを提供しても許容されるケースがある。
株価ダッシュボード、アナリティクスサマリー、フィードエンドポイントなどは、このモデルによく当てはまるだろう。
ユーザーにリクエストごとに最新のデータを待たせる代わりに、キャッシュされた値を即座に提供し、バックグラウンドで自動的に更新することができる。この手法はStale-While-Revalidateと呼ばれる。
Goでは、キャッシュデータだけでなく、データがいつ最新の状態か、いつ古いデータとして提供できるか、そしていつ再計算する必要があるかを定義するタイムスタンプも保存することで、これを実現できる。
疑似コード
entry := getEntry(key) // {data, freshUntil, staleUntil}
switch {
case time.Now().Before(entry.freshUntil):
return entry.data
case time.Now().Before(entry.staleUntil):
go refreshSingleflight(key) // キャッシュをバックグラウンドでリフレッシュ
return entry.data
default:
return refreshSingleflight(key) // キャッシュを同期的にリフレッシュ
}
ここで、コードはキャッシュエントリを取得し、その中にデータと、鮮度と古さの境界を示す2つのタイムスタンプが含まれている。
現在時刻が鮮度しきい値より前であれば、データは完全に新鮮であるとみなされ、即座に返される。
鮮度しきい値は過ぎているものの、staleの期間内であれば、コードはわずかに古いデータを即座に返すと同時に、バックグラウンドのゴルーチンを起動して非同期的に更新することで、次のリクエストで最新の情報を取得できるようにする。
さらに、時間が古さの境界を超えると、データは古すぎて提供できないため、コードはブロックし、同期更新を実行してから返す。
これにより、キャッシュが定期的に更新され、鮮度とパフォーマンスのバランスが保たれながら、レイテンシが低く抑えられる。
ただ、これ↑だけだとsingleflightパッケージの使い方や実際何やってんのかが微妙にわからんので、動くコードを書いてみた。
できるだけコメント内容から処理を追えるようにしてみたので、興味があれば動かしてコメントから処理の流れを追ってみてほしい。
私が書いたサンプルコードでは、refreshSingleflight関数を下記のように定義している。
var sg singleflight.Group
...
func refreshSingleflight(key string) (string, error) {
v, err, _ := sg.Do(key, func() (any, error) {
newData := expensiveCompute(key)
entry := &cacheEntry{
data: newData,
freshUntil: time.Now().Add(freshDuration),
staleUntil: time.Now().Add(staleDuration),
}
setEntry(key, entry)
return newData, nil
})
return v.(string), err
}
Go Playground
singleflightパッケージは、重い処理を1つのgoroutineのみで実行するようにし、同時にリクエストされた他の処理にはキャッシュを返すことで処理の重複を防ぐ。
例えば、上記のサンプルコードのexpensiveCompute関数に数秒かかったとして、その処理の間に5回リクエストが来たとしても、実際にexpensiveComputeを実行するのは最初の1リクエストのみとなる。
Go Playgroundのサンプルコードでは、sg.Doの関数内にコメントを打ち、実行回数がわかるようにしておいた。
まとめ
キャッシュは単一の戦術ではなく、様々なニーズに対応する複数の戦略から成る。
フルレスポンスキャッシュは、トップレベルでの繰り返し処理を排除し、クエリ結果キャッシュは、データベースへの繰り返しロードを防ぐ。
HTTPキャッシュは、プロトコルを活用してデータ転送量を削減する。
Stale-While-Revalidateは、データの古さを長期間残すことなく、速度を優先する妥協策。
実際には、これらのアプローチは階層化されることが多い。Go API では、レスポンスにローカルメモリと Redis を使用し、ホットテーブルにはクエリレベルのキャッシュを適用し、クライアントが不要なダウンロードを回避するために ETag を設定するといったことが考えられる。
参考資料
pkg.go.dev
キャッシュアサイドとその他のキャッシュ戦略についてわかりやすく書かれている。
zenn.dev
developer.mozilla.org
HTTPのCache-Controleで、stale-while-revalidate をレスポンスディレクティブに指定することができる。
developer.mozilla.org