コンテンツにスキップ

取得したデータのキャッシュ

ハンドラから為替サービスや在庫 API、集計エンドポイントを呼ぶとします。結果は1分ほど 変わらないのに、ページはそれより頻繁に開かれます。このままでは、リクエストのたびに 同じ通信が発生します。

pw.Memo はその戻り値を保存し、次回以降の呼び出しで再利用します。

quote, err := pw.Memo(r.Context(), store, QuoteKey{Pair: pair},
func(ctx context.Context) (Quote, error) { return fetchQuote(ctx, pair) })

この種のキャッシュを置ける場所は2つあり、もう一方は レンダリングキャッシュ です。識別子を受け取って自分でレコードをロードするコンポーネントなら、ロードとマークアップを 1つの @cache で覆えて、設定すべきストアもありません。**当てはまるならまずそちらを見て ください。**アノテーション1つがこのページの内容すべてを置き換えます。

ただし見た目ほど当てはまりません。コンポーネントのローダは同期 external なので失敗を報告 できず、コンポーネントキャッシュには TTL しかなく stale の窓も無効化もなく、エントリは マークアップ向けに見積もられた描画ストアに載ります。pw.Memo を使うのは、ロードが失敗しうる うえ読者がそれを知る必要があるとき、書き込みが期限を待たずにエントリを落とす必要があるとき、 上流の障害を伝播させずに凌ぎたいとき、そしてコンポーネントが届かない場所でその値が要るとき です。

手を出す前に、向いていない相手を知っておいてください。エントリは JSON を一往復します。 ハンドラがマイクロ秒で計算できる値は、やってもいない仕事と引き換えに、キーとエンコードと デコードを手に入れます。効くのはプロセスの外に出ていくとき —— ネットワーク呼び出し、遅い クエリ、集計 —— であって、手元にすでにあるスライスを回すだけの処理ではありません。

上流を1分に1回だけ呼ぶハンドラ

Section titled “上流を1分に1回だけ呼ぶハンドラ”

pwconfig.toml:

[cache]
enabled = true
[[cache.stores]]
name = "rates"
ttl = "1m"
scope = "public"
max_entries = 4096

handlers/quote.go:

package handlers
import (
"context"
"net/http"
"github.com/shibukawa/popcornweb/pw"
)
// Quote は上流が返すものであり、1エントリが持つものです。
type Quote struct {
Pair string `json:"pair"`
Value float64 `json:"value"`
}
// QuoteKey は答えではなく問いです。印を付けたフィールドがキーに入り、
// 付けていないものは入りません。
type QuoteKey struct {
Pair string `cache:"key"`
}
func ShowQuote(w http.ResponseWriter, r *http.Request) {
store, err := pw.MemoStore(r, "rates")
if err != nil {
pw.WriteProblem(w, r, err)
return
}
pair := r.PathValue("pair")
quote, err := pw.Memo(r.Context(), store, QuoteKey{Pair: pair},
func(ctx context.Context) (Quote, error) {
return fetchQuote(ctx, pair)
})
if err != nil {
pw.WriteProblem(w, r, err)
return
}
pw.WriteAPI(w, r, quote)
}
// fetchQuote は上流サービスを呼ぶ、あなた自身の関数です。
func fetchQuote(ctx context.Context, pair string) (Quote, error) {
return Quote{Pair: pair, Value: 1.0}, nil
}

pw generateQuoteKey のキーメソッドを cachekey_pw_gen.go に書き出します。この型を キー型にしているのは上の pw.Memo 呼び出しです。生成は呼び出し箇所から結果の隣の引数を 辿るので、印を付けただけでどこからもキャッシュに渡していない構造体からは何も生成されませ ん。

印はオプトインで、これは Quote に付いている json タグとは逆向きです。キャッシュに渡す 構造体は、たいてい手元にすでにあるエンティティで、フィールドの大半は答えの側です。 そこからキーを組み立てるということは、取得を避けるために引いているはずの値からキーを作る ということになります。だから問いのほうに印を付け、残りは放っておきます。

fetchQuote 側は自分がキャッシュされていることを知りません。pw.Memo は呼び出しを置き換え るのではなく包みます。あとで外すときも同じで、包みを消すか、enabled = false にすれば、 どの呼び出し箇所も自分の関数へそのまま素通りします。1行も直さずに。

ストアを構築せずに設定に書くのは、それがリクエストより長く生き、運用上のサイズを持つから です。データベースプールを設定に書くのと同じ理由です。pw.MemoStore は名前で1つを解決し、 設定に無い名前はエラーになります。設定されている名前を並べて返すのは、黙って一度も キャッシュしない状態は、動いているキャッシュと見分けがつかないからです。

scope"private""public" を取り、省略すると "private" です。

上の例が public を宣言しているのは意図的です。為替レートは誰が尋ねても同じなので、1つの エントリが全読者に応えます。ただし書いていいのは、結果がキーだけの関数であるときに限られ ます。

それ以外は private のままにしてください。この既定は調整ノブではなく安全境界です。private な エントリのキーには、取得した相手の識別子が前置されます。pw.RequestAuthentication(r).Subject —— セッションログインもパスキーもベアラトークンも、ハンドラが動く前に行き着く同じ ローカルアカウント識別子です。だから2人の読者が1つのエントリに届くことはありません。 実際には読者ごとに違うものを public と宣言すれば、キャッシュはある人のデータを次に 尋ねた人へ手渡します。

この2つの間違いは釣り合いません。共有できる結果を private のままにした代償はヒット率と メモリです。読者ごとの結果を public にした代償は誰かのアカウントの露出で、しかも起きても エラーは出ません。キャッシュから見れば何も間違っていないからです。

匿名リクエストにはその識別子がなく、private なストアが識別子なしで呼ばれたときは何も保存 しません。設計というより退避です。空の識別子で書かれたエントリは private の札を下げた共有 エントリであり、ミスのほうがましだからです。

だいたい2つの形で足ります。どちらを選ぶかは、上流が壊れたときにどうなってほしいかで決まり ます。

ttl だけのストアから始めてください。エントリはその時間だけ新鮮で、ミスは上流を呼び ます。上流が信頼でき、データに自明な鮮度の上限があるなら、これで十分です。

stale も宣言したストアは、TTL を過ぎても答え続け、その裏で再取得を1つだけ走らせます。 管理下にない上流サービスがあり、その障害中もページを表示し続ける必要がある場合に設定してくださ い。stale の窓の中では、再取得が失敗しても窓が閉じるまで手元の値がそのまま残ります。上流の 5分の障害がページを壊すのではなく劣化させる、というわけです。代償は、古いと分かっている データを出すことです。残高には間違った取引で、カタログには正しい取引でしょう。

[[cache.stores]]
name = "inventory"
ttl = "30s"
stale = "5m"

ストアは呼び出し箇所ごとではなく方針ごとに1つ定義してください。無関係なキャッシュが max_entries を共有すると互いを追い出し合います。上限はストアごとです。

同時のミスは上流を1回しか呼ばない

Section titled “同時のミスは上流を1回しか呼ばない”

冷えたキーに10本のリクエストが同時に届いても、上流呼び出しは10回にはなりません。最初の1本 が取得を始め、残りはそれに合流します。これが、キャッシュと、エントリが切れるたびに上流を 落とすキャッシュとの違いです。

その帰結が呼び出し側に1つだけ出ます。そして、いちばん引っかかりやすいのがここです。

// 正しい —— fetch は渡されたコンテキストを使う。
pw.Memo(r.Context(), store, key, func(ctx context.Context) (Quote, error) {
return fetchQuote(ctx, pair)
})
// 誤り —— fetch がリクエストのコンテキストを捕捉してしまっている。
pw.Memo(r.Context(), store, key, func(context.Context) (Quote, error) {
return fetchQuote(r.Context(), pair)
})

共有された取得は、待ち手の誰からも意図的に切り離されたコンテキストで走ります。タブを閉じた 読者が待つのをやめても、残り9人が必要としている仕事は止まらない。渡ってくるのはその 切り離されたコンテキストです。代わりにリクエストのコンテキストを捕捉すると、共有の呼び出し をたまたま最初にミスした読者に固定したことになります。その人が消えたとき、全員の取得が 失敗します。切り離されたコンテキストは値 —— 読者の識別子、トレース —— を保ち、失うのは 期限だけです。だからストアは fetch_timeout を自前で持っています。

TTL だけでは、書いた本人がすでに間違いだと知っている窓がそのまま開いたままになります。 エントリを古くしたのが自分のハンドラなら、そう伝えてください。

pw.MemoInvalidate(ctx, store, QuoteKey{Pair: pair})

キー1つでは表せない場合のために、粗い形が2つあります。 pw.MemoInvalidateScope(store, subject) はある読者の持ち物をすべて落とします。サインアウト やアカウント変更が欲しいのはこれです。pw.MemoInvalidateTag はタグが名指すものをすべて 落とします。1回の書き込みが複数のキーのエントリを同時に無効にする場合向けで、キー型は CacheKey の隣に pw.CacheTagger を実装してタグを宣言します。

暗黙に無効化されるものはありません。どの読み取りとどの書き込みが矛盾するかをフレームワーク は知らず、当て推量は何もしないより悪いからです。

キーが運んでいない依存は、古い答えではなく間違った答えです。 オプトインの印付けが開けた ままにしている穴で、生成が肩代わりできない唯一のものでもあります。生成が保証するのは宣言 したキーの綴りであって、その完全性ではありません。ID にしか印が付いていないのに、取得側が テナントやロケールやフィーチャーフラグをコンテキストから読んでいれば、あるテナントのデータ が別のテナントへ、それも無期限に配られます。1テナントだけを動かすテストは全部通ります。 取得が依存を1つ増やしたら、その隣のフィールドに cache:"key" を1つ増やしてください。

違う問いが2つあるならキー型も2ついります。 エントリを分けているのは、生成がメソッドに 書き込むキー型自身の名前です。だから QuoteKey{Pair: "x"} と、同じ文字列を持つ OrderKey は衝突しません。1つのエンティティを2通りに引くなら、構造体を2つ作ります。1つの構造体に 2組の印を付けるのではありません。

印を付けたフィールドを並べ替えるとキャッシュは空になります。 キーは宣言順に組み立てられ るので、印の付いたフィールドを別のフィールドの向こうへ動かすと、その型が作るキーがすべて 変わります。古いエントリは間違いになるのではなく冷えるだけで、放っておけば期限で消えます。 バグとして調べ始める前に知っておく価値があります。

書き込みをキャッシュしない。トランザクションの中の読み取りもキャッシュしない。 前者は 言われれば当たり前ですが、後者はそうでもありません。トランザクション内の読み取りはその トランザクションの中でだけ真であり、保存するということは、周りのロールバックが消すはず だった値を公開するということです。

pw.MemoHas は構造上レースします。 答えるのは「いまエントリが新鮮か」であって、 次の行が動くまでにそれは切れ得ます。診断か、重い処理を始めるかどうかの判断に使ってくだ さい。続く pw.Memo がヒットする前提のガードには使えません。

エントリは再起動をまたがず、レプリカ間でも共有されません。 ストアはプロセス内です。 ロードバランサの後ろに3インスタンスあれば、独立したキャッシュが3つあり、それぞれがキーごと に1回ずつ上流を呼びます。max_entries は台数分ではなく1プロセス分で見積もってください。

アプリケーション設定一覧 に全キーと既定値が、 レンダリングキャッシュ に遅いページのもう半分が あります。