多くの構成図では、キャッシュはサービスとデータベースの間にある 1 つの箱です。その箱は、キャッシュが役に立つか害になるかを決める問いを隠してしまいます。誰がデータを入れるのか、誰がデータベースに書き込むのか、その間に読み手は何を見るのか、という問いです。
この問いへの答え方が異なる戦略が 4 つあります。キャッシュアサイド、リードスルー、ライトスルー、ライトバックです。このガイドでは、同じ例を使ってそれぞれを説明し、比較し、本番で起きる障害を取り上げ、再利用できる図で締めくくります。
どのキャッシュも答える 2 つの問い
キャッシュ戦略は、2 つの問いへの答えの組み合わせです。
- 読み取り時にキャッシュへデータを載せるのは誰か。 アプリケーションのコードか、データベースへのアクセス方法を知っているキャッシュ層です。
- データベースに書き込むのは誰で、いつか。 アプリケーション、同じリクエスト内のキャッシュ層、あるいはあとからキャッシュ層です。
| 戦略 | 読み取り:キャッシュを埋めるのは誰か | 書き込み:データベースに書くのは誰か |
|---|---|---|
| キャッシュアサイド | ミスのあと、アプリケーションが埋める | アプリケーションが書き、その後キーを無効化する |
| リードスルー | ミスのあと、キャッシュ層が埋める | 戦略としては定めない。通常は次の 2 つのどちらかと組み合わせる |
| ライトスルー | 通常はリードスルーかキャッシュアサイドと組み合わせる | キャッシュ層が同じリクエスト内で書く |
| ライトバック(ライトビハインド) | 通常はリードスルーと組み合わせる | キャッシュ層があとで、多くはまとめて書く |
全体を通して使う例は、Postgres の products テーブルと Redis キャッシュを持つ商品 API です。商品ページは product:42 を読み、管理ツールが価格を更新します。
キャッシュアサイド(遅延読み込み)
アプリケーションが両方のストアとやり取りします。まずキャッシュに問い合わせ、ミスしたらデータベースを読み、その結果を有効期限(TTL)付きでキャッシュに入れます。AWS の ElastiCache のドキュメントでは、同じパターンを遅延読み込み(lazy loading)として説明しています。
def get_product(product_id):
key = f"product:{product_id}"
cached = redis.get(key)
if cached is not None:
return json.loads(cached) # hit
row = db.fetch_one("SELECT * FROM products WHERE id = %s", product_id)
if row is not None:
redis.set(key, json.dumps(row), ex=300) # fill, with a 300 s TTL
return row
def update_price(product_id, price):
db.execute("UPDATE products SET price = %s WHERE id = %s", price, product_id)
redis.delete(f"product:{product_id}") # invalidate, don't overwrite
書き込み時は、新しい値をキャッシュに書くのではなく、キーを削除します。次の読み取りで最新の行が読み込まれます。Microsoft のキャッシュアサイドパターンも同じ書き込みルールを説明しています。
得られるもの:誰かが要求したデータだけがキャッシュされ、キャッシュが落ちても読み取りはデータベースに向かって動き続けます。遅くなるだけです。支払うもの:ミスのたびに 3 回の往復(キャッシュ、データベース、キャッシュ)がかかり、競合状態もあります。ミスした読み手が古い行を読み、書き手が行を更新してキーを削除し、その後で読み手が古い値をキャッシュに書き戻すことがあります。その値は TTL が切れるまで古いままです。キャッシュアサイドに TTL が欠かせない理由の 1 つです。
リードスルー
アプリケーションはキャッシュだけを呼び出します。ミスすると、キャッシュ層が設定済みのローダーを通してデータベースから値を読み込み、保存して返します。読み取りの結果はキャッシュアサイドと同じですが、読み込みのコードは呼び出し側ごとではなく 1 か所にまとまります。
素の Redis サーバーは、自分からデータベースを読むことはしません。リードスルーを実現するのは、それができる層です。ローダー関数を持つキャッシュライブラリ、プロキシ、あるいは特定のデータベース向けに作られたマネージドキャッシュです。Amazon DynamoDB Accelerator(DAX)がその例で、キャッシュミスのときは読み取りを DynamoDB に渡し、結果を返してキャッシュします。
トレードオフ:キャッシュ層が読み取り経路に入ります。キャッシュ層が落ちると、クライアントにデータベースへのフォールバックがない限り、読み取りも失敗します。
ライトスルー
アプリケーションはキャッシュ層に書き込み、キャッシュ層は同じ操作の中でデータベースに書き込みます。アプリケーションが成功を受け取るのは、両方の書き込みが成功したときだけです。DAX はまさにこれを文書化しています。データはまず DynamoDB のテーブルに、次にキャッシュに書き込まれ、両方の書き込みが成功した場合にだけ呼び出しが成功します。
得られるもの:キャッシュ経由で書いたデータはキャッシュ内で最新なので、書き込み直後の読み取りでも見えます。支払うもの:書き込みのたびに 2 つのストアを待つことになり、キャッシュは書かれても読まれないデータで埋まっていきます。期限切れになるよう TTL を付けましょう。新しく空のキャッシュノードには、データが書き直されるまで何もありません。ライトスルーが通常リードスルーや遅延読み込みと組み合わされるのはそのためです。
ライトバック(ライトビハインド)
アプリケーションはキャッシュに書き込み、すぐに成功を受け取ります。キャッシュ層はあとから、多くはまとめてデータベースに書き込み、同じキーへの繰り返しの更新は 1 回のデータベース書き込みにまとめられます。
得られるもの:最も低い書き込みレイテンシと、ピーク時のデータベース書き込みの大幅な削減です。支払うもの:まだ書き出していないデータは、バッファが複製されているか耐久性のあるログに裏付けられていない限り、キャッシュノードが落ちると失われます。データベースもキャッシュより遅れるので、レポート、ほかのサービス、バックアップは古いデータを見ることになります。ライトバックは、数秒分を失っても許容できる閲覧数カウンターのようなデータに向いています。
比較
| キャッシュアサイド | リードスルー | ライトスルー | ライトバック | |
|---|---|---|---|---|
| アプリのコードがデータベースを知っている | はい | いいえ | いいえ | いいえ |
| 書き込みレイテンシ | データベースへの書き込みとキャッシュの削除 | 書き込み戦略による | キャッシュとデータベース | キャッシュのみ |
| 古い読み取り | 無効化か TTL まで | 無効化か TTL まで | キャッシュ経由の書き込みならまれ | データベースがキャッシュより遅れる |
| キャッシュノードを失ったとき | 読み取りが遅くなる | フォールバックがなければ読み取りが失敗する | 書き直されるまでデータが欠ける | 書き出していない書き込みが失われる |
| 典型的な用途 | Redis での標準的な選択 | ライブラリかマネージドキャッシュ | 書き込み直後の鮮度 | 書き込みの多いカウンターとピーク |
TTL、追い出し、3 つの典型的な障害
TTL は、値がどこまで古くなりうるかの上限を決めます。データの種類ごとに、プロダクトが許容できる古さに合わせて選びましょう。商品説明なら数分、在庫数ならおそらく数秒です。期限切れのキーを読むと、存在しないかのように振る舞います。
追い出し(エビクション)は別の仕組みで、メモリが足りなくなったときにキャッシュが何を取り除くかを決めます。Redis では、maxmemory が上限を設定し、maxmemory-policy が何を取り除くかを選びます。redis.conf のデフォルトは noeviction で、メモリがいっぱいになると、データを追加するコマンドにエラーを返します。純粋なキャッシュには、Redis のドキュメントが適切なデフォルトとして allkeys-lru を勧めています。マネージドサービスは独自のデフォルトを設定しているので、自分の環境を確認しましょう。ヒット率は INFO stats から keyspace_hits / (keyspace_hits + keyspace_misses) として計測します。
ほぼすべてのキャッシュで、次の 3 つの障害が起きます。
- スタンピード。 人気のキーが期限切れになり、数百のリクエストが同時にミスして、すべてがデータベースに問い合わせます。値の再構築は 1 つのリクエストだけに任せ、ほかのリクエストは少し待たせるか古い値を返します。Redis のロックでこれを実現できます。
SET lock:product:42 <token> NX PX 5000は 1 つの呼び出し元に対してだけ成功します。Facebook の memcache の論文は同じ問題をリースで解決し、この問題が起きやすいキーでは、データベースへのクエリのピークが毎秒 1 万 7000 件から 1300 件に減ったと報告しています。 - ペネトレーション。 存在しない ID へのリクエストは必ずミスし、必ずデータベースに届きます。「見つからない」という結果を短い TTL でキャッシュするか、先に有効な ID のブルームフィルターを確認しましょう。
- アバランチ(雪崩)。 たとえば一括ロードやキャッシュの再起動のあとで多数のキーが同時に期限切れになり、データベースが全負荷を一度に受けます。TTL にランダムなゆらぎを加え(たとえば 300 秒に最大 60 秒を足す)、キャッシュは段階的に温めましょう。
図
4 行とも同じ 3 つの参加者なので、違いはエッジにあります。誰が、いつデータベースと通信するかです。
ダイアグラムの内容
- Product API
- Redis
- Postgres
- Cache layer
- 1 GET (miss)
- 2 SELECT
- 3 SET, TTL 300 s
- 1 read
- 2 on miss: SELECT
- 1 write
- 2 UPDATE, same request
- 1 write, OK at once
- 2 flush later, batched
図の読み方は次のとおりです。
- 1 行目、キャッシュアサイド。 商品 API は Redis を読み(1)、ミスしたら自分で Postgres を読み(2)、TTL 付きで Redis を埋めます(3)。Postgres へのエッジはキャッシュを迂回します。2 つの呼び出しはどちらもアプリケーションが担います。
- 2 行目、リードスルー。 API はキャッシュ層とだけ通信し、キャッシュ層がミスのときに Postgres から読み込みます。
- 3 行目、ライトスルー。 書き込みは同じリクエストの中で、キャッシュ層を経由して Postgres に届きます。
- 4 行目、ライトバック。 API はすぐに成功を受け取ります。破線のエッジは、あとからまとめて行う書き出しです。
構成図でのキャッシュの描き方
- 戦略と TTL を書く。 キャッシュのノードかエッジに「Cache-aside, TTL 300 s」とあれば、レビュアーの最初の質問に答えられます。
- 誰がデータベースと通信するかを描く。 キャッシュアサイドでは、サービスはキャッシュだけでなくデータベースにもエッジを持ちます。データベースの手前にインラインで描いたキャッシュはリードスルーを連想させ、読み手もそう受け取ります。
- 無効化の経路を描く。 データベースに直接書き込むバッチジョブや管理ツールも含めて、誰がキーを削除するのかを示します。キャッシュへの経路を持たない書き手こそが、古いデータの発生源です。
- 障害時の挙動を書く。 「キャッシュ停止時:読み取りは Postgres へ」と書いてあるほうが、2 つ目のキャッシュのアイコンよりも当番のエンジニアに多くを伝えます。
- 数値をキャンバスに載せる。 目標ヒット率、メモリ上限、追い出しポリシーは、キャッシュの横の小さなメモに収まります。
- 非同期のものはすべて破線にする。 ライトバックの書き出しや無効化イベントは、リクエストの一部ではありません。
選び方
まずは TTL 付きのキャッシュアサイドから始めましょう。どのキャッシュ、どのデータベースでも使え、キャッシュが落ちても読み取りが遅くなるだけで済みます。ライブラリやマネージドキャッシュがローダーを提供するなら、リードスルーに移行します。書き込み直後の読み取りで新しい値が見える必要があり、書き込みが追加のレイテンシを許容できるなら、ライトスルーを加えます。ライトバックは、書き出していない書き込みを失うか再実行しても構わない場合にだけ使いましょう。
キャッシュの周辺も含めた全体像は、Redis が URL 短縮サービスの読み取り経路を担うシステム設計図の例を、どのサービスがどのデータを所有するかを示す方法はマイクロサービスアーキテクチャ図のルールを参照してください。
Archboard で作る
- Google または GitHub でサインインし、図を作成します。
- 戦略ごとに基本パックの テキスト で見出しを付け、3 つのノードを横に並べます。商品 API 用に C4 モデルパックの コンテナ、キャッシュ用にデータパックの Redis、データベース用にデータパックの Postgres です。2〜4 行目では、キャッシュに「Cache layer」とラベルを付けます。
- 各行を番号付きのエッジラベルで結びます。キャッシュアサイドなら「1 GET (miss)」「2 SELECT」「3 SET, TTL 300 s」、ほかの行では読み取りまたは書き込みの手順です。キャッシュアサイドの API から Postgres へのエッジは、キャッシュの上を通します。
- ライトバックの書き出しのエッジを破線にします。
- 各行の下に、基本パックの テキスト で、書き込みのルールとキャッシュが落ちたときに何が起きるかをメモします。
この例で使うダイアグラムノードは 12 個で、Free プランの範囲に収まります。あらゆるアーキテクチャ図に共通する基本的な手順は、ソフトウェアアーキテクチャ図の描き方を参照してください。
よくある質問
キャッシュアサイドと遅延読み込みは同じですか? はい。どちらも、ミスのあとでだけアプリケーションがデータをキャッシュに載せることを表します。AWS のドキュメントは「lazy loading」と呼び、ほかの多くの資料は「cache-aside」と呼びます。
データが変わったら、キャッシュのキーは更新すべきですか、削除すべきですか? キャッシュアサイドでは削除しましょう。それぞれが新しい値をキャッシュに書く 2 つの書き手は、誤った順番で終わって古い値を残すことがあります。削除してから新しく読み込めばこれを避けられ、上で説明したまれな競合状態は TTL が抑えます。
Redis だけでリードスルーやライトスルーはできますか? できません。Redis サーバーは、クライアントが送ったものを保存するだけです。リードスルーとライトスルーには、データベースを知っている層が必要です。キャッシュライブラリ、プロキシ、あるいは特定のデータベース向けのマネージドキャッシュです。
TTL はどう決めればよいですか? そのデータの種類について、プロダクトが許容できる最も長い古さにします。そのうえで、一緒に読み込まれたキーが一緒に期限切れにならないよう、ゆらぎを加えます。
ライトバックは安全ですか? 書き出していない書き込みを失うか再実行しても構わない場合、あるいは書き込みバッファが複製されているか耐久性のあるログに裏付けられている場合に限ります。決済や注文のようなデータには避けましょう。