キャッシュする前に考えるべきこと

キャッシングには意味があります。キャッシングに踏み切る前にこれを読んで、正しいキャッシングの決断をしてください。

私はAmazonで複数のリージョンにまたがる何千ものEC2インスタンスを運用していた経験から、重要な教訓を学びました。このブログでは、キャッシングにまつわる私の教訓について説明します。

私は、毎秒数百万トランザクション(TPS)を処理しなければならないパーソナライズされた広告挿入サービスの構築を手伝いました。このサービスの目標は、視聴者一人ひとりにパーソナライズされた体験を提供することでしたが、パーソナライズを行うには厳しい予算が必要でした。あまり時間がかかりすぎると、顧客の動画がバッファリングされ、エンドユーザー体験が損なわれてしまいます。また、この作業負荷は非常に苛酷で、予測不可能なものでした。サッカーの試合がいつ面白くなり、多くのファンがチャンネルを合わせるようになるかはわかりません。この問題を解決するために最初に考えたのは、キャッシュを追加することでした!現在、キャッシング会社のエンジニア・リーダーとして、キャッシュを追加することに対する私の反応は、あなたを困惑させるかもしれません……。

Are you sure you want to add a cache?

信じてほしいのですが、Amazonのスケールサービスに携わっていたとき、私自身の直感として、パフォーマンスを向上させるための道具箱の中の事実上の道具としてキャッシュを使うことがよくありました。何度も何度も、私のチーム(そして私がデザインレビューを行う他のチーム)は同じ落とし穴を経験しました。

スローダウン(キャッシュの計測とサイズ調整)

何よりもまず、キャッシュを追加するには時間がかかります。急ぐことはできません。キャッシュを追加するには、私たちのチームはAmazon ElastiCacheクラスタのプロビジョニング、サイズ調整、ベンチマーク、計測を行う必要がありました。この計画は、キャッシュをミックスに統合する前の、スプリント以上の作業でした。

インストゥルメンテーションは、チームがキャッシュを急ぐときに見落とされがちです。もし間違った実装をすれば、キャッシュは高速化の助けにすらなりません。実際、キャッシュがかえってスピードを落としたり、エラー率を上げたりする場合もあります。インスツルメンテーションがなければ、そのことに気づくこともなく、キャッシュがスピードを落とす代わりにスピードアップさせているのだと思い込んで作業を続けることになるのです。

同様に、キャッシュのサイジングとベンチマークも重要です。過剰にプロビジョニングすれば、無駄遣いになります。プロビジョニングが不足すれば、多くの画面がバッファリングされ、キャッシュは遅くなり、エラーが発生しやすくなります。

さて、Momento Cacheがほんの数秒でキャッシュを準備し、サイズを調整する必要がなく、開発者がスプリントやインスツルメンテーションの時間を節約できることを紹介します。しかし…キャッシュが簡単になったからといって、やみくもにどこにでもキャッシュを追加すればいいというわけではありません!もっと熟慮が必要です。

キャッシュが追加するモダリティを認識する

私たちはお客様のためにキャッシュミス率(CMR)の改善に多くの時間を費やしていますが、CMRが本当に低いことに対するマイナス面もあります。DynamoDBの論文では、キャッシュヒット率(CHR)が99.75%という話が出ていますが、これはCMRが0.25%という驚くべき低さです。つまり、CHRが0.75%下がって99%になると、CMRは0.25%から1%になり、額面上は比較的小さな増加に見えるかもしれませ。しかし、これは実際には4倍の急上昇です。その結果、バックエンドデータベースへの負荷が突然4倍になるのです。この二峰性の挙動は破滅的なものになりかねません。コールドキャッシュは、コールドスタート時にシステムを大混乱に陥れる可能性があります(実際の例としては、このRobloxの障害の事後報告をご覧ください)。

陳腐化を回避するための明確な計画

システムにキャッシュを追加するとすぐに、システムの一貫性セマンティクスが損なわれる可能性が高くなります。気づかないうちに古いデータを取得してしまう可能性があるのです。これはまた、デバッグが本当に難しい問題です。開発者は、データベースの内容から得られるレスポンスが腑に落ちないために、髪をかきむしっているのをよく見かけます。そして最終的に、レスポンスのごく一部が、キャッシュされていないと信じていた古いキャッシュ値によって動かされていることに気づくのです。

計画を立てる際に考慮すべきいくつかの重要な質問:

1.キャッシュの値はどのように更新するのですか?

2.TTL(Time To Live)を使うのか、それともキャッシュされたデータをいつ更新するかを決定する別のコンポーネントが必要なのか?

3.データを読み書きする人が更新プロセスを開始するのでしょうか、それともデータを更新するバックグラウンド・プロセスがあるのでしょうか?バックグラウンド・プロセスがある場合、そのプロセスが停止したらどうなりますか?

4.新しい値をフェッチできない場合、古い値を返すのか?いつまで古い値を返すのか?

5.アイテムがキャッシュに存在しない場合、どのように対処するのか。サクセスを返す前にアイテムをキャッシュに入れるのか、サクセスを返した後にアイテムをキャッシュに入れるのか。

6.アイテムがキャッシュになく、ダウンストリームがエラーを返した場合、どのようにエラーに対処しますか?エラー・レスポンスをキャッシュしますか?

徐々にソリューションは複雑になっていきます。その代償として、削減しようとしていたコストは、かえってこのスパゲッティ・コードを維持しなければならないことによるコスト増を招くことになるのです。このパターンを何度も見てきたため、私は常に、キャッシュ内のすべてのアイテムに明示的なTTLを設定することを主張しています。

場所、場所、場所: どこに何をキャッシュするか?

ローカルでのキャッシュ

私たちはしばしば、パフォーマンスやスケーラビリティの問題を単純に隠す方法としてキャッシュを使います。しかし、この方法には2つの大きな欠点があります。第一に、遅かれ早かれパフォーマンスとスケーラビリティが再び問題となります。例えば、ローカル・キャッシュは小規模なウェブ・サーバ群ではとてもうまく機能しますが、ウェブ・サーバの数が増えるにつれて、バックエンドは新しいタイプの負荷に悩まされ始めます。このような状況では、ウェブサーバーフリートサイズを2倍にした場合の影響について真剣に考え始める必要があります。

そしてこれは、ひいては2つ目の大きな欠点につながります。キャッシュはコードを複雑にします。最も単純なケースでは、値を保存し、必要なときにその値を取り戻します。依存関係がないので、キャッシュから古いデータを取り出すのは非常に簡単です。ローカル・キャッシュの場合、より多くの場所に古くなった値が存在するリスクがあります。毒薬を取り除くには、ウェブサーバー全体をバウンスする必要があるかもしれません。

ローカルキャッシュはコールドスタートを増やします。新しいウェブサーバーを追加するたびに(デプロイとスケーリング)、キャッシュがウォームアップするまでレイテンシが発生します。これにより、ばらつきが生じます。ダッシュボードにウェブサーバーごとの詳細なメトリクスを表示しない限り、これを特定するのは難しいのです。

別フリートでのキャッシュ

ルックアサイド・キャッシング・フリートは、ローカル・キャッシングに比べて有意義な利点があります。たとえば、ウェブサーバーのデプロイメントによってキャッシュのヒット率が低下したり、コールドキャッシュが発生したりすることはありません。さらに、ウェブサーバーフリート全体でキャッシュを共有することで、キャッシュがサーバー全体で集合的なインテリジェンスを構築するため、キャッシュヒット率が向上します。各Lambdaは一度に1つのリクエストしか処理できないため、これはAWS Lambdaをウェブサーバーレイヤーとして使用する場合に特に有用です。別々のフリートはまた、より効率的です。各ウェブサーバーでメモリを浪費する代わりに、キャッシュ・フリートでサイズを調整し、よりよく推論することができます(関係性の分離)。

とはいえ、個別のキャッシュ・フリートにもそれなりの課題があります。キャッシュ・フリートは監視、管理、拡張する必要があります。キャッシュ・フリートに対するセキュリティ・パッチの適用や、メンテナンス・ウィンドウへの対応に責任を持たなければなりません。フリートでのスケールアップ(ノードの追加)、スケールダウン(ノードの削除)、デプロイ(ノードの交換)は、コールドキャッシュを引き起こす可能性があります。

ローカルキャッシュの利点の1つは、デプロイメントごとにリセットされることです。キャッシュ内のアイテムがウェブサーバのデプロイメント間で互換性がないことを心配する必要はありません。別個のキャッシュフリートがある場合、Web サーバーへのデプロイはより困難になります。特に、データをキャッシュする方法を変更する場合はなおさらです。今度は、毒薬になりかねない、古くなったデータのスキーマに対処しなければなりません。

個別のキャッシュ・フリートは、リクエストのクリティカル・パスにRPCコールを追加します。これは、ローカルのインメモリ・ルックアップよりもはるかに複雑です。RPC呼び出しでは、接続プール、タイムアウト、再試行などを意図的に扱うようにクライアントをチューニングしなければなりません。キャッシュ・クライアントは一般的にタイムアウトを60秒に設定しますが、これはAmazon DynamoDBのクエリが結果を得るのにかかる時間よりも有意義に長い時間です。このタイムアウトは理にかなっているかもしれませんが、いくつかの重要な意味があります。メンテナンスウィンドウでは、タイムアウトするまで接続がハングアップする可能性があります。これは、ウェブサーバのスレッドへのバックプレッシャーにつながる可能性があります。このことは、TinderがAmazon ElastiCacheを使った経験について書いているブログでよくカバーされています。

フリートとのやりとりに使用するクライアントが正しく設定され、タイムアウト、再試行、フェイルオーバー・シナリオに正しく対処できることを確認する必要があります。そして、最初にキャッシュを追加した理由である、コストを確実に管理する必要があります。しかし現在では、キャッシュ・フリートと、このフリート管理のためのキャッシュ・チームをサポートしなければなりません。

結論

キャッシュは、スケーラビリティの問題を解決するための最良のツールの一つです。ホットキーやスロットリング、あるいは基礎となるデータベースのコスト最適化にも役立ちます。このブログでは、キャッシングに飛びつく前に考慮すべき核心的な事柄と、その過程で私が学んだ教訓について概説しました。

キャッシュをインストゥルメント化することで、自分自身とキャッシュに責任を持たせることができます。TTLを明示することで、古くなったキャッシュデータをデバッグする際の頭痛の種を減らすことができます。どこに何をキャッシュするか(ローカル、ルックアサイド・キャッシュ・フリートなど)を戦略的に選択することで、キャッシュを最大限に活用することができます。これらの核となる教訓を適用すれば、キャッシュをより長く、より幸せに使うことができることになります。