Sansan Builders Box

Sansanのものづくりを支える技術やデザイン、プロダクトマネジメントの情報を発信

Webアプリケーションにおける正しいキャッシュ戦略

こんにちは。プロダクト開発部のサーバサイドエンジニアの荒川です。普段はSansanのスマホアプリのAPIの開発をしています。

今回扱うテーマは皆さん大好きキャッシュ(Cache) です。

Webアプリケーションを開発するエンジニアである以上、キャッシュの存在からは逃れられないでしょう。 例えばパフォーマンスを向上させる手段として、キャッシュを仕込むことは往々にしてあるかと思います。

キャッシュを使えばパフォーマンスが向上しそう、というイメージも強いため安易に選択する戦略になりがちですが、正しく扱うことは本質的に難しいです。 しかしキャッシュを上手に使えば、ユーザ体験を圧倒的に向上させることができます。

そんな諸刃の剣キャッシュ💰について考慮するべきこと、その戦略を改めてまとめてみました。

今回の対象

今回の対象は、アプリケーションレベルでのキャッシュ戦略を取り扱います。 いわゆるキャッシュメモリ、ブラウザのキャッシュ、HTTPにおけるキャッシュ、CDNにおけるキャッシュなどとは文脈上異なるので注意してください。

さて整理すべきは、クライアント(Client)、サーバ(Server)、キャッシュ(Cache)、データベース(Database)の4つです。 本エントリでは以下のアーキテクチャをベースとしていきます。

f:id:ad-sho-loko:20190322023513p:plain
アーキテクチャ図

システム構成はシンプルなクライアントサーバのWebシステムです。 説明の都合上、各種サーバの分散化や冗長化などは割愛しています。

その他の用語を軽く説明します。

クライアント(Client)は、Webアプリケーションにおいてはブラウザに該当します。

サーバ(Server)は、アプリケーションサーバです。キャッシュサーバやデータベースと通信をするためにアプリケーションコードが動いています。

キャッシュ(Cache)は、キャッシュサーバです。当たり前ですが、I/Oについてはデータベースよりも高速でなければいけません。

データベース(Database)は、基本的にRDBMSと理解してもらって問題ありません。またS3などのストレージを追加で置いてもらってもOKです。

キャッシュに適するデータの性質

まずキャッシュとして扱うデータに求められる性質をいくつか紹介します。

消去が許容されること

大前提としてキャッシュとして扱うデータは消失することが許容されている方が望ましいです。 キャッシュの性質上、以下のような事象や操作によって保存されているデータが消えます。

  • キャッシュの有効期限(TTL)が切れる
  • キャッシュサーバを再起動する
  • 無効化(invalidate)された

などが考えられます。

このようにキャッシュの消去が発生するため、データベースから値を再取得したり、キャッシュを再生成したりする必要があります。 つまり、データのライフタイムの管理をする必要があります。

読み込み頻度が高いこと

当然ですが頻繁に読み込まれるデータをキャッシュにする方が有利です。 逆に読み込み回数が少ないデータの場合は、キャッシュの恩恵は受けられないと言えるでしょう。

言い方を変えると、データはユニークではない方が良いです。 何度も呼び出される冗長なデータをキャッシュ化することが高い効果を発揮します。

書き込み頻度が低いこと

データの更新が多ければ、その分キャッシュの整合性を保つためのコストがかかります。 そのため更新の少ない静的なデータがキャッシュするデータとして有利です。

例えば、画像ファイルなどストレージに保存されているデータも含むでしょう。

キャッシュの危険性

キャッシュとして好ましい性質を見てきましたが、この性質を満たすデータの設計を正しく行うことでレスポンスタイムを大きく減らすことができます。

しかしトランザクションにおいて確保している一貫性を損なう可能性が大いにあります。 この点はテスト時に考慮漏れになりがちだったり、そもそもテストが難しいなどもあり、結果的に重大なインシデントに発展する可能性も高いので、まずはチーム内で十分に検討するようにしましょう。

検討した結果、そもそもキャッシュを利用しないという戦略も十分ありえます。早すぎる最適化をしないようにしましょう。

以上を踏まえて2つのアーキテクチャパターンを紹介します。

  1. Cache-Aside Pattern
  2. Broker Pattern

Cache-Aside Pattern

最もシンプルな方式で頻繁に用いられるパターンです。

f:id:ad-sho-loko:20190321210258p:plain

  1. クライアントからリクエストする
  2. 該当するデータが存在するかをキャッシュに問い合わせる
  3. ヒットすれば、キャッシュからデータを取得する
  4. ヒットしなければ、データベースにアクセスして取得する
  5. 取得したデータをキャッシュに保存する

この方式を採用するための具体的なミドルウェアとしては、RedisやMemcacheなどが利用されることが多いです。 法人向けアプリケーションSansanでもセッション管理にRedisを利用しています。*1

memcachedのホームページでは、以下のサンプルコードが示されています。 このコードはまさにCache-Asideパターンを利用したものだと言えるでしょう。

function get_foo(foo_id)
    foo = memcached_get("foo:" . foo_id)
    return foo if defined foo

    foo = fetch_foo_from_database(foo_id)
    memcached_set("foo:" . foo_id, foo)
    return foo
end

メリット🙆

シンプルで理解が容易

キャッシュ戦略のなかでも最もシンプルで扱いやすいです。そのおかげで参考ドキュメントなども豊富です。 アーキテクチャに一度でも複雑性を持ち寄ると管理コストが爆発的に大きくなるので、それを最小限に抑えてくれます。

キャッシュとデータベースでデータ構造を変えられる

サンプルコードであったようにアプリケーション側でキャッシュとデータベースを区別しなければなりません。 これを上手く利用すれば、例えばデータベースに保存している全情報のうちIDだけをキャッシュサーバに保存する、などのデータ構造の応用を効かせることができます。

データをWriteする場合にデータベースに書き込むだけで良い

つまりキャッシュに対するWriteを意識しなくてよいということです。

キャッシュにWriteされるのはキャッシュミスした直後です。 キャッシュへとWriteしたタイミングで、データを無効化(invalidate)しなければ、取得できるデータの不整合が発生するので注意が必要です。

デメリット🙅

初回のデータ取得が遅い

キャッシュとデータベース両方にアクセスをする必要があるので、初回データの読み込み時は遅くなることが想定されます。 さらにデータの追加・更新でデータベースにWriteした後に、初めてデータを取得しようとすると必ずキャッシュミスが発生します。

アプリケーション側でキャッシュとデータベースを区別する必要がある

先述の通り、キャッシュとデータベースのうち、どちらから値を取得するかを制御する必要があります。 アプリケーションコード内のあらゆる箇所でこの制御を入れようとすれば、あっという間にカオスになります。 そのため、データソース層で抽象化するなどして、設計ルールを敷くことを強くお勧めします。

Broker Pattern

次に紹介するパターンは、アプリ側がデータストアを意識する必要がないパターンです。このパターンを使うためのサービスとしては、Amazon DynamoDB Accelerator (DAX) が一例です。*2

f:id:ad-sho-loko:20190321212505p:plain

  1. クライアントからリクエストする
  2. 該当するデータが存在するかをキャッシュに問い合わせる
  3. ヒットすれば、そのまま返却する
  4. ヒットしなければ、キャッシュがDBから値を取得し、キャッシュに保存する
  5. キャッシュからクライアントに返却する

メリット🙆

アプリケーションコードでキャッシュとデータベースを区別する必要がない

両者の区別を行う中間処理をミドルウェアに任せることができます。 アプリケーションコード内に分岐処理を含める必要がなくなるためアーキテクチャの透過性向上に繋がります。

Write直後にキャッシュミスが発生しない

データをWriteする際に常にキャッシュに書き込む操作が走るため、初回Write直後のデータ取得時にキャッシュミスが発生しません。 しかしこの点に関しては一長一短で、正しく設計を行わないと単にキャッシュが汚れていくスピードが高まる可能性も秘めています。

デメリット🙅

Writeの際に必ずキャッシュとデータベースに保存される

データを新しく書き込む場合と更新する場合のどちらでも、キャッシュとデータベースを両方書き込む必要があります。 したがって、書き込んだデータがその後一度も読み込まれないとしてもキャッシュに乗ります。

そのため高頻度でデータのWriteがかかるアプリケーションには向いていないでしょう。

データ構造を一致させる必要がある

メリットで見たように両者の区別をミドルウェアに任せているために、保存するデータのモデルを統一しなければなりません。 設計によっては不必要なデータをキャッシュさせねばならず、キャッシュの肥大化につながります。

初回読み込みが遅い

Cache-Aside Patternとほぼ同様で、デプロイや再起動の直後における最初の読み込みは遅いです。 対策としてはデプロイ直後にデータを読み込んでおくwarmingなどをするとよいでしょう。

キャッシュサーバの運用面について

これまで2つのパターンを紹介しましたが、次は視点を変えて運用面からキャッシュサーバの取扱いの考察をしてみましょう。

まず管理すべきサーバ(インスタンス)が単純に一つ増加しますね。その管理コストとキャッシュを導入するメリットを勘案しましょう。

管理コストとして最も大きいものは、キャッシュサーバに障害が発生することを想定する必要が出てくることです。 下手すれば単一障害点になりかねません。また想定を超えた通信量によってキャッシュ自体がボトルネックになってしまうケースもあります。 これらに対処するためには、他のサーバと同様に冗長化・分散化も考慮する必要が発生します。

ちなみに運用面に関しては、Amazon ElastiCache*3などの完全フルマネージド型を採用してコストを落とす戦略も選択肢に入れるべきでしょう*4

まとめ

アプリケーションにおけるキャッシュについてまとめてみましたがいかがだったでしょうか?*5

キャッシュはとても奥が深いテクニックです。今回紹介したアプリケーションのアーキテクチャレベルでのキャッシュだけでなく、HTTPでのキャッシュ、DBでのクエリキャッシュのみならず、動的計画法(メモ化)などもキャッシュの一種と言えます。これらを正しく理解して適材適所でアプリケーションに活かしていきましょう。

最後に、キャッシュは銀の弾丸ではありません。開発時のここぞという箇所で使っていきたいですね。

参考

Webアプリケーションのキャッシュ戦略とそのパターン / Pattern and Strategy of Web Application Caching - Speaker Deck

Webアプリケーションのキャッシュ戦略に関してまとめられており、本当に素晴らしい資料です。 本エントリ作成時にも大変参考にさせていただきました。

Caching Strategies and How to Choose the Right One · CodeAhoy

英語ですが、キャッシュのパターンが幾つか紹介されています。

Caching in Web Applications | Codementor

Webアプリケーションにおけるキャッシュ戦略について記載があります。こちらも英語です。

*1:Amazon ElastiCacheにて管理されています

*2:詳しい説明は DAX と DynamoDB の整合性モデル - Amazon DynamoDB よりどうぞ

*3:今までElasticCacheだと思っていた。c要らないのね..

*4:ただし容赦なくメンテナンス等で止まりますが

*5:全体的にAWSの知見しかなくて申し訳ない

© Sansan, Inc.