この記事は、 ミクシィグループ Advent Calendar 2019 の20日目の記事です。

本当は全然別な話を書こうかと思っていたのですが、今朝同期が分報で↓の記事について言及していたのをみて触発され、急遽パフォーマンスに関する話をすることにしました。素敵な記事を書いてくださったリクルートの濱田さん、ありがとうございます!

Railsアプリの処理を100倍以上に高速化して得られた知見 – PSYENCE:MEDIA


この記事では、「キャッシュをしないといけない」と思った時点で考えなければならないことを雑多にまとめました。だいたい上の項目ほど一般的な話で、下に行けばいくほど特定の状況におけるtipsみたいになってるはずです。

そもそもなぜキャッシュしたいか

「負荷が高いからキャッシュをしよう」みたいな話で一口にまとめられてしまいがちですが、キャッシュはただの手段であり、それが解決しようとしている問題は様々です。 今自分が扱っているデータがどのような性質を持っていて、何が問題になっていて、それがどの程度にまで緩和されれば解決したとみなせるのかを意識することで、より効果的なキャッシュの方法を考えることができます。(もしくは、キャッシュ以外の選択肢が見つかるかもしれません。キャッシュ以外で解決できる問題ならばキャッシュを使わないに越したことは無いでしょう。)

どこにキャッシュするか

これは何を解決したいかによって大きくかわってきます。 例えば、より高いレイテンシを求めているなら、キャッシュはキャッシュされる側より物理的に近い位置にいなければ効果は薄いでしょう。しかし、これもどの程度のレイテンシが必要なのかによって解決策は異なってきます。極端に高いレイテンシが必要な場合、L1キャッシュに乗り切るかどうかが重要になってくるかもしれません。逆に今問題となっているのがマイクロサービス間のレイテンシのような場合、呼び出し側のメモリ上やディスク上に結果をキャッシュすることで十分なレイテンシを得ることができるかもしれません。また、インターネットの向こうにあるサーバーとのレイテンシが問題になっている場合は、エッジロケーション程度の距離があっても十分満足のいくレイテンシを得られるかもしれません。

なお、下の方のtipsでは、基本的にはKey-Value型のキャッシュサーバーを用いてRDBのデータをキャッシュすることを想定していることが多いです。

キャッシュを一箇所に置くか、複数箇所に置くか

負荷の集中が問題になっている場合、多くの場合は同じデータを複数の場所に置く必要が発生します。しかし、同一のデータが複数の場所に置かれれば置かれるほど扱いが大変になってくるので、直面している問題を解決できる範囲でできる限りシンプルな方法を取るのがベストだと思います。

キャッシュ上のデータとキャッシュされる側の整合性

ここでいう整合性とは、キャッシュされている側のデータが新しい値に更新された場合、それと同じタイミングでキャッシュ上の古い値は無効化されているか、新しい値に更新されていることが保証されていることを言います。

多くの場合、キャッシュの整合性を完璧に取るのは非常に難しいか、不可能でしょう。キャッシュ上のデータが完璧に整合性が保たれていると信じるのは諦めて、整合性が保たれていなくてもいいようなコードを書くほうが楽なことが多いです。 楽観的ロックやCASを利用するとこれを比較的手軽に実現できますが、予めこれらを使うことを想定してそもそものデータ構造やキャッシュの手段を考えておく必要があります。これらの機能の利用を全く想定してしなかったところに後付けで入れるのは、場合によってはかなり大変になるかもしれません。

キャッシュの原子性と独立性

キャッシュされる側とキャッシュする側の間にまたがるトランザクションを張れるばあいはそれがベストですが、普通はそんなことはできません。 これが問題になってくる場合、キャッシュされる側の操作に対しても原子性や独立性が保たれていないか、もしくはコミットされていない変更を用いてキャッシュを更新していることが多いでしょう。このような状況は避けるべきです。

キャッシュの期限をどれくらいに設定すべきか

明示的に無効化できないようなキャッシュ機構の場合は、データの更新頻度に合わせて適切なttlを設定する必要があります。キャッシュされる側の負荷の許す限りで、できる限り短く設定したほうが万が一の場合にも対処しやすいかもしれません。

明示的に無効化できるようなキャッシュ機構でも、ほとんど使われないデータがいつまでもキャッシュに残ってしまうような状況は避けるべきです。LRUのような方法で使われていないキャッシュを掃除できるようにしておくか、それが無理であるなら適当なタイミングでキャッシュが無効化されるようにしておくとよいでしょう。

キャッシュの更新による整合性の破壊

同一のキャッシュキーに対応する値を複数のプロセスが並行して行う場合、値の不整合が生じる可能性があります。 トランザクションが使えるのであれば、書き込みが必要なケースではキャッシュを見ないようにし、値の取得と更新はトランザクション内部で行えばよいでしょう。

キャッシュの値を使って値を更新するような場合は、CASや最低でも楽観的ロックあたりができないと悲惨なことになるかもしれません。

キャッシュの無効化による整合性の破壊

キャッシュの更新で整合性が保てなくなるんなら更新じゃなくて無効化だけすればいいじゃないかと思うかも知れませんが、無効化しかしない場合でも不整合は起こり得ます。

キャッシュされる側のデータが更新される直前にキャッシュが切れると、キャッシュ上には古いデータが残ってしまう場合があります。

例えばキャッシュキーkに対応する値を2つのプロセスP, Qが取得・更新しようとしている場合、以下のような問題が発生します:

  1. Pがkに対応する値を取得する(このときの値をXとする)
  2. kのキャッシュが(ttlを過ぎるか、この2つ以外の他のプロセス等によって)無効化される
  3. Qがkに対応する値Xを取得する(キャッシュミス)
  4. Pがkに対応する値をX’に更新する(このときkに対応するキャッシュがあればexpireする)
  5. QはキャッシュミスしたのでXをキャッシュに積んでおく

この手の問題はそれ自体を解決するよりも、そもそも古いデータがキャッシュに載っていることを考慮してCASなりするほうが楽なことが多いです。

書き込みに対して読み込みの頻度が極端に高いデータ

楽観的ロックやCASのような仕組みでは、基本的に書き込み時にしかキャッシュが古くなっていることを検知できません。ほとんど読み取りにしか使われないようなデータの場合は、適切にTTLを設定するか、書き込みのタイミングで確実にキャッシュが無効化できるようにしておくとよいでしょう。

書き込みの頻度が十分に低いのであれば、書き込み用のモードの場合だけキャッシュを経由せずにデータを取りに行くようにすることによって、CAS等を使わずに書き込み時の不整合を防ぐことができるかもしれません。

キャッシュのN+1問題

キャッシュのレイテンシが相対的に高い場合、これが問題になってくることがあります。

この問題は、キャッシュの構造を工夫することによって解決できるかもしれません。

複数のキーに対応する値を一括で取ってくることができるようなキャッシュ機構であれば、工夫すれば定数回のキャッシュとのやりとりだけで済ませることができます。

例えば特定条件で検索した結果の一覧をキャッシュしたい場合、「検索条件→検索結果のidのリスト」と「id→レコードの値」の二種類のキャッシュを持っておけば、高い空間効率でキャッシュを行うことができます。

キャッシュの保存先の変更

キャッシュサーバーを変える、キャッシュサーバーの台数を増減させる、キャッシュキーを変える、もしくはそもそもキャッシュ機構そのものを入れ替えるといった場合、気をつけないと不整合が発生してしまいます。

キーkに対応するキャッシュの保存先がAからA’に変わる場合、変更の途中で以下のようなことが発生する可能性があります:

  1. プロセスPに変更が反映される
  2. Pはkの値を書き換え、同時にA’にあるkの値を無効化する
  3. まだ変更が反映されていないプロセスQが、kに対応する値を取得しようとする
  4. このとき、QはAを見に行くため、無効化されていない古い値が帰ってくる

あらゆるプロセスに対して変更をを完全に同時に反映させるのは基本的に無理なので、ほとんどの場合はこの問題に対して何かしらの対処をする必要があります。 これは例えば、次のような方法で解決することができます

  1. 先にA’はキャッシュの書き込み・無効化だけ行えるようにしておく
  2. キャッシュにアクセスする全プロセスがA, A’の存在を認知するようになる
  3. A’からキャッシュを読み取り、Aは書き込みと無効化だけするようにする
  4. 全プロセスがAからの読み取りをやめたら、Aへは参照しないようにする

ネガティブキャッシュ

ネガティブキャッシュとは、「無い」ことをキャッシュすることです。

あるキャッシュキーに対するキャッシュが存在せず、キャッシュされる側にも対応する値がなかった場合、「そのような値は存在しない」という事実をキャッシュしておくのは、キャッシュされる側の負荷が問題になっている場合は有益であることが多いです。

キャッシュサーバーの障害

キャッシュサーバーに対して障害が発生したときに、キャッシュされている側への負荷の影響を最小限にする必要があります。複数台のキャッシュサーバーに対して分散してキャッシュを保存している場合、分散のアルゴリズムが悪ければ一台のサーバーの障害だけですべてのキャッシュが無効になってしまう可能性もあります。

また、キャッシュサーバーの台数を十分多くしておくことで、一台の故障に対する影響を最小限に留めることができるかもしれません。

極端な時間的局所性があるデータ

サービスによっては、ある時刻までは全く使われないが、ある時刻になった瞬間からかなり頻繁に参照されるようになるデータがあるかもしれません。このようはデータはキャッシュの観点から見ると厄介です。ある時刻になるまでは全く使われないためキャッシュにも乗らず、それが必要な時刻になった瞬間に一気にキャッシュされる側への負荷が跳ね上がってしまう可能性ががあります。

どのような値がいつから使われるのか予め分かっているのであれば、前もってキャッシュにデータを積んでおくことで負荷を軽減できるかもしれません。

おわりに

全くまとまりの無い記事でしたが、より高いパフォーマンスが求められるようなサービスを作る際の参考になれば幸いです。

明日は弊サービスのスーパー新卒エンジニア、ずーみんの担当です。何を書くつもりなのかは全く知りませんが、たぶん面白いことを書いてくれると思うので乞うご期待!!

関連記事はありません。