Rails: キャッシュとRead Modelの違いを例で理解する(翻訳)
以下のような、そこそこ複雑なビューがあるとします。カレンダー的なテーブルがあって、横軸は貸部屋、縦軸は利用可能な期間を表しています。
もちろん、このビューでは以下のような機能が欲しくなります。
- 利用可能かどうかや場所を指定してフィルタする機能
- ソート機能
- 貸部屋リストのページネーション
- 期間のページネーション(別の期間を参照する)
サーバーがJSONを配信すると、SPAフロントエンドのようなクライアントがそのJSONを消費する形になります。
以下のようなさまざまなテーブルにあるデータをjoinする必要があります。
apartments
addresses
bookings
- 日付のシーケンス
すると、やがてこうなります。
- 当初はテーブルにクエリをかけて少しばかりjoinすれば済む
- やがてクエリを最適化するようになり、いくつかのクエリを手書きするようになる
- ビューの拡張が何年も繰り返され、データが追加される
- 顧客のデータセットが肥大化し、ページが遅いと不満を漏らすようになる
- ページを高速化するソリューションを考え始めるようになる。おそらくキャッシュ?
キャッシュ
こういうときは、JSONレスポンスをキャッシュしたくなることでしょう。
- フィルタやページやソート状態のあらゆる組み合わせについてレスポンスをキャッシュする必要がある
- キャッシュはおそらく最初のリクエストで行われるが、まだ遅いリクエストがいくつかあり、パフォーマンスを予測できない
- キャッシュのウォームアップは面倒で、しかも美しいと思えない
- そしてコンピュータサイエンスにおける2番目に困難な問題、すなわち「キャッシュの無効化」問題に遭遇する(ちなみに最も困難なのは「ネーミング」です)。
キャッシュ以外に手段はないのでしょうか?
それでは次をご覧ください。
Read Model
Read Modelによるソリューションは、キャッシュといささか異なります。
- 顧客が望むクエリに完全に最適化された新しいDBテーブルを構築する。
- テーブルは1個にしておく(ページやソート状態やフィルタのさまざまな組み合わせをキャッシュするのと逆のアプローチです)。このテーブルには、さらにページネーションやソートやフィルタを行うためのデータをすべて含めましょう(Read Modelは一般に1個のテーブルでなくともよく、SQLテーブルでなくても一向に構いません: ディスク上のファイルをメモリオブジェクトに読み込めれば何でもよいのです)。
- すべてのフィールドをクライアントから利用可能にしておく。部屋の住所を表示する必要がある場合、
addresses
テーブルをjoinするのではなく、Read Modelのテーブルに入れておけば、いつでもその行を取り出す準備が整います。DBの非正規化やデータの冗長化は容認されており、むしろ大歓迎です。 - フィルタやソートの式を複雑にする必要が生じたら、事前に計算してフィールドに入れておくことで、クエリを可能な限り手軽に実行できるようになる。
しかしRead ModelをWrite Modelで常に最新に保つにはどうすればよいのでしょうか?
Read Modelを最新に保つ方法その1: vanilla方式
vanilla(プレーン)方式なら以下のようにかなり素直に書けます。
ApplicationRecord.transaction do
booking = Booking.create!(params)
CalendarReadModel.handle_booking_created(booking)
end
読み取りに関連するメソッドを元のモデルからRead Modelに移せばよいのです。快感ですね。
今後は、Read Modelに関係するものを変更するたびにRead Modelを更新する必要があります(上の例では、予約作成意外に部屋の追加や住所・価格の変更のときにも必要です)。やることが増えると思いますか?このぐらいなら容認できるかもしれません。
しかしRead Modelを更新する方法はひとつではありません。
Read Modelを最新に保つ方法その2: イベントドリブン方式
もうひとつのアプローチは、部屋を予約するときにイベントをパブリッシュする方法です。
ApplicationRecord.transaction do
booking = Booking.create!(params)
event_store.publish(BookingCreated.new(data: { booking_id: booking.id })
end
そのイベントを、Read Modelを更新するハンドラでサブスクライブします。
event_store.subscribe(
-> event { CalendarReadModel.handle_booking_created(event) },
to: [BookingCreated]
)
こちらの方が癒着が減ります。関連するモデルすべてでこの作業を行う必要はあるので作業が減るわけではありませんが、実装は明らかにクリーンかつシンプルになり、パブリッシュするイベントを他のさまざまなことにも活用できるようになります。
ご注目いただきたいのは、このハンドラは同期的であり、vanilla方式の例と同様に同じトランザクション内で実行される点です。ハンドラを非同期にすることも可能ですが、その場合は「最終的な一貫性」を考慮する必要があります。つまり、Read Modelに表示されるものとWrite Modelで許可されるものの間に少し遅れが生じる可能性があるということです。この欠点は、多くの場合に容認できます。
お知らせ
ARKADEMY.DEVに参加してArkencyのトップクラス教育プログラムコースにアクセスしましょう!「Railsアーキテクトマスタークラス」「アンチ"IF"コース」「忙しいプログラマーのためのブログ執筆コース」「Async Remoteコース」「TDD動画クラス」「ドメイン駆動Rails動画コース」以外にもさまざまなコースが新設中です。
概要
原著者の許諾を得て翻訳・公開いたします。