こんにちは!
プロダクトマネージャーをしている奥原 (@okutaku0507) です。前までサーバーサイドのリードエンジニアをしていました。
delyの開発ブログが長らく更新されておらず、不甲斐ないです。これからは活発にdelyが取り入れている最新技術や実際にあった事例、取り入れているアーキテクチャなどを中心に発信していきたいと思っています。
久しぶりの今回は、delyが運営/開発しているレシピ動画サービスであるkurashiruの涙あり、笑いありのキャッシュ戦略について歴史と実際の事例を元に書いていきたいと考えています。最後には、僕が作成したクラシルに用いられているキャッシュ戦略をgemにしたライブラリを紹介いたします。
はじめに
サーバーサイドチームはクラシルの開発から1年半程度まで、主に僕一人しかいませんでした。TVCMによる急激なユーザー数増加や新機能開発、社員100人を支える管理サイト開発などのサーバーサイド面を一人で担当せざるを得ませんでした。言い訳になってしまうかもしれないですが、最小工数で最大限いい感じにする必要があり、キャッシュ周りに十分な時間が割けず、他のエンジニアの方々から見れば継ぎ接ぎで不恰好な印象を受けるかも知れません。リソースが恐ろしく枯渇しているスタートアップの一エンジニアの苦悩と格闘の歴史だと思っていただければと思います。もしよければ、建設的な意見をいただくかあるいは仲間に加わっていただき、一緒に改善していければ幸いです。
そして、この記事はこんな方におすすめです。もちろん、キャッシュ戦略自体はどの言語で記述されていても役立つと思います。
- ユーザー側でDBへのリクエストがほぼリード
- 同じ画面のリクエストが多く、キャッシュ効率が良い
- サーバーサイドリソースが恒常的に枯渇状態
1. 全リクエストがDBにアクセス時代
ELBなどは省き、必要最低限の構成を描くとこんな感じでした。
Railsアプリケーションが一つのデータベースを参照するという、とてもシンプルな構成です。この構成で問題なのが、全てのリクエストでデータベースへ問い合わせを行なっている点です。たとえ同じクエリが走って同じ結果が得られたとしても、全て問い合わせが行われているため、とても効率が悪いです。
そして、サービスがグロースしていく中で一つの問題が起きはじめました。
プッシュ打つとサーバー落ちる不可避問題。
全端末をターゲットにプッシュを打つとサーバーが落ちるという問題に行き着きました。その原因として、様々な原因が考えられられ、メモリやCPUなど様々なメトリクスを調べましたが、わからず仕舞いでした。
そんなこんなあり、夜も眠られない日々が続きました...
※ 「ゆうすこ」は弊社代表です
そして、DBがボトルネックになって結果的にレイテンシが上がり、LBのヘルスチェックが落ちているのではないかという考えに至り、キャッシュを導入することになりました。
何はともあれ、CTOが徹夜でコードを書くという涙ぐましい努力により、僕らはいっときの平和を手にしました。
後にSREがジョインし、調べるとリソースが効率的に使われてなかったことも原因だとわかりました。
2. オーソドックスなキャッシュ時代
AWSのマネージドサービスであるElastiCacheを導入しました。
クライアントに返すAPIレスポンス(json)を、パラメータを含めたエンドポイント毎にElastiCacheにキャッシュするようにしました。そうすることで、DBへ過度に不要な問い合わせが発生しなくなりました。簡単ですが、構成は以下のようになります。
これによりしばらくは枕を高くして寝ることができました。
しかし、現実はそんなに甘くはありません。
弊社ではインシデント級のアラートはイワさんが教えてくれます。これはレイテンシが1秒を超えてるよという恐怖の通知です。この通知がプッシュの時に頻繁にくるようになりました。
このインシデントについて調査していくと、ElastiCacheのGetHitsというメトリクスが引っかかりました。
当時はElastiCacheを3台立てていたのですが、この図からわかるように、明らかに1つのサーバーにリクエストが偏っています。また、この時間のRailsのログを調査すると以下のようなエラーが出ていることがわかりました。
W, [2016-08-13T12:13:26.663212 #6100] WARN -- : localhost:11211 failed (count: 0) Timeout::Error: IO timeout
D, [2016-08-13T12:13:27.053149 #6100] DEBUG -- : #<Dalli::NetworkError: Socket operation failed, retrying...>
D, [2016-08-13T12:13:27.553223 #6100] DEBUG -- : Dalli::Server#connect localhost:11211
D, [2016-08-13T12:13:28.311837 #6111] DEBUG -- : #<Dalli::NetworkError: Socket operation failed, retrying...>
当初、一つのリクエストに対して一意にキャッシュを取得するためのキーが決まるようになっていました。プッシュでホームを開かせるように、特定のリクエストにアクセスが集中する場合、TCPポートの上限に達してポートが足りなくなってしまいます。そうすると、Rails側でキャッシュが上手く取得できず、Railsがタイムアウトするまで待ってしまうため、結果的にレイテンシが上がっていたということになります。
この解決策は単純で、集中してしまうなら分散させればいいと当時は考えました。
cache_key = “test-key”
cacher = Cacher::ObjectCacher.new(key: cache_key)
cacher.write(“test-str”)
# Cache write: test-key-1
# Cache write: test-key-2
# Cache write: test-key-3
上記のようにキャッシュキーの後ろに数字をつけるようにすることで、同じ内容を分散させることができます。厳密に言えば、クラシルで使っているdalliというgemのロジックによるため、均等に分散させることはできませんが、目的は果たすことができました。
こうして、再び弊社に平和が訪れました。
※ 現在のメトリクスをスクショしたため上と異なります
3. キャッシュ分散時代
キャッシュキーの後ろに数字をつけることで、いい感じにキャッシュが分散され、イワさんから怒られることは劇的に減りました。
しかし、まだまだ現実は甘くありませんでした。
たまーにですが、プッシュ時にイワさんから怒られることがありました。
詳しく調査をすると、どうやらイワさんから怒られる時は、ElastiCacheのキャッシュミスが急激に増加していることがわかりました。つまり、キャッシュ切れです。分散させたのはいいですが、全て同じ有効期限を設定していたが故に、切れる時は分散された全てのキャッシュが切れていたということです。
ならば、有効期限を長くすればいいという話になりますが、
- タイアップ広告が指定された時間に公開されること
- キャッシュを長くしてデータの不整合がおきること
などの理由によりそれは難しい状況でした。
また、設計はシンプルに保ちたかったので、ユーザーから来るリクエストのエコシステムの中でいい感じにやってくれることを目指した方が賢明です。
そこで、ヒラメキました!
先ほどの分散されたキャッシュキーに対して、有効期限のグラデーションをつければいいのではないか。つまり、図で書くとこのような感じです。
この図において、key-1~3は同じ内容をキャッシュしており、キャッシュの有効期限だけが異なっています。この場合、key-1の有効期限が最も短いため、最初にキャッシュ切れを生じますが、有効期限がそれよりも長いkey-2~3は生き残っているので、このキーを参照しているリクエストはDBへの問い合わせを行いません。
また、矢印の色の違いはキャッシュ内容の世代を表しています。例えば、key-1がキャッシュ切れになり、新たにキャッシュの内容を更新しようとした際に、他のkey-2~3も同時に上書きを行います。こうすることで、あと少しでキャッシュ切れになろうとしていたkey-2~3が新たに有効期限が設定されます。この仕組みを繰り返すことで、実質的にキャッシュミスが生じるのは有効期限が最も短いkey-1になり、キャッシュミスになる確率がこのケースでは1/3になります。
cache = ActiveSupport::Cache::DalliStore.new
cache.write('millas', 'cake', expires_in: 1.minutes)
# Cache write: millas-1 ({:expires_in=>60 seconds})
# Cache write: millas-2 ({:expires_in=>120 seconds})
# Cache write: millas-3 ({:expires_in=>180 seconds})
これで、弊社に恒久の平和が訪れました。
エンジニアはプッシュの度に怯えた日々を過ごすことなく、自分がリソースを割くべきことに集中することができるようになりました。
ここまでのストーリーをQiitaにも書きましたので、リンクを貼っておきます。また、この仕組みをRailsに簡単に導入することができるgemも作ったので、Railsエンジニアのリソースが枯渇していて、クラシルのようにリードが激しいメディアの方、参考にしてもらえれば幸いです。
追記
このキャッシュ切れの問題をThundering Herd問題と言うようです。
Kazuho@Cybozu Labs: キャッシュシステムの Thundering Herd 問題
4. キャッシュ分散グラデーション時代
「ついに平和が訪れた」と誰しもが確信していました。
しかし、平和というものはそんなに永くは続きませんでした。
上記の対応をした後でも、ごくごく稀にプッシュ時にイワさんから怒られることがありました。まだ敵いたの!?という感じの少年ジャンプみたいな世界観。
※ ちなみに、イワさんはルフィの心強い味方です
原因を精査すると、理由は同様でキャッシュ切れのようです。よくよく考えると、有効期限をグラデーションしたとしても、一つのキャッシュに割り当てられるリクエストは1/nになります。つまり、図で書くとこういうことです。この図で注目して欲しいのは、1つのキャッシュキーに割り当てられるリクエストの割合が、3割強ということです。
例えば、プッシュ時に10,000のリクエストが来た際にキャッシュ切れが生じると、約3,000リクエストがElastiCacheをすり抜けてDBへ問い合わせることになります。これはまずいぞ、ということになります。
ここでもヒラメキました!
このリクエストを各キャッシュキーに割り当てる配分にもグラデーションをかければいいのではないかと考えました。キャッシュの有効期限が短いキャッシュキーほど少ないリクエストを受け取るよう、いい感じのロジックを作ってリクエスト数にグラデーションをかけることにしました。図にすることこのような感じになります。
この図が意味するところは、キャッシュキーの後ろにつける数字 (magic number) が小さいほど、頻度 (リクエストが割り当て割れる配分) が低くなるということです。上の図と照らし合わせると、有効期限が最も短いキャッシュキーに割り当てられるリクエストの割合が一番小さいということを意味します。つまり、キャッシュ切れが生じるリクエストの割合が数%になることで、ほとんどのリクエストがDBに問い合わせにいくことがなくなりました。
これを導入してから、キャッシュ切れが生じることは全く無くなりました。
本当に本当の平和が弊社を包み込みました。
終わりに
delyでは、技術を使って世の中に幸せを届けたいと一緒に戦ってくれるエンジニアを強く募集しています。どのポジションも貴重なリソースが限られているベンチャーでは、やりたいことが山のようにある中で何に注力するか、そしてそれを如何にして最速でユーザーに届けられるかが重要になります。その中でも自分の強みを活かして頭がちぎれるまで考えて実装することが僕らエンジニアの見せ所だと思っています。
一方で、新しいソリューションも積極的に取り入れ、本番環境に適応しています。その取り組みの一つが評価され、AWS Summit Tokyo 2018で弊社CTOが基調講演させていただいた動画を貼っておきます。
Day3 基調講演(日本語同時通訳) | AWS Summit Tokyo 2018
僕らはサービスを伸ばしつつ、エンジニア個人が技術的に挑戦したいことに、どんどんチャレンジして、サービスとともに成長していきたいと強く思っています。エンジニアに限らず、そのようなマインドをお持ちの方と一緒に仕事ができる日を心待ちにしております。是非一度お茶でもさせてください。
デザインブログも始めました。
delyのデザインブログで新しい記事書きました!📝
— 大竹 雅登 / dely (@EntreGulss) July 19, 2018
Appleの「Essential Design Principles」の事例をまとめました。https://t.co/tsFsp8obWl