書きと読みを、分ける — CQRS と射影パイプラインで両立させる"速い検索"と"堅い更新"
更新は文書指向で堅く、検索は正規化データで速く。2つのモデルを射影でつなぐ設計。
PROBLEM1つのモデルで両方は最適化できない
通販基幹エンジンでは、1件の注文が「明細・配送先・決済・履歴」といった多くの要素を入れ子で抱えます。この業務レコードを更新するときは、関連する要素をまとめて一貫性を保ちたい。ところが同じデータを「顧客名で検索」「期間で集計」「一覧で並べ替え」したいとき、入れ子構造のままではフィルタも結合も重くなります。更新に最適な形と、検索・集計に最適な形は、そもそも別物なのです。
APPROACH書き込みと読み取りでモデルを分ける
CQRS(Command Query Responsibility Segregation)は、この矛盾を「モデルを2つ持つ」ことで解きます。書き込み側と読み取り側で、データの持ち方そのものを分離するのです。
- 書き込みストア(文書DB):1つの業務レコードを1ドキュメントに入れ子で保持。関連要素がまとまっているため、更新の整合を1ドキュメント単位で取りやすい。
- 読み取りストア(リレーショナルDB):検索・一覧・集計に最適化した正規化テーブル。SQLテンプレートで高速に問い合わせできる。
PROJECTIONイベントで射影して同期する
2つのモデルは、変更イベントを介してつなぎます。書き込みストアで業務レコードが更新されると、その変更がイベントとして流れ、射影(プロジェクション)処理が読み取りストアの正規化テーブルへ反映します。読み取り側はあくまで検索・集計のための「作られた形」であり、真実は常に書き込み側にあります。
CONTRAST同じ業務データ、2つの表現
同じ注文データが、書き込み側では入れ子ドキュメントとして、読み取り側では複数テーブルに分解された形として、それぞれの目的に合わせて表現されます。
TRADE-OFF速さの代償は結果整合と運用コスト
この設計の利点は明快です。更新は文書単位で堅牢に、検索・集計は正規化データで軽快に、両方を取れる。大量の注文が積み上がっても検索が重くならず、書き込み側と読み取り側を独立にスケールできます。
一方で代償もあります。射影には遅延があり、書き込み直後に読み取り側へ反映されない結果整合を受け入れる必要があります。さらに、2つのスキーマを二重管理し、射影パイプラインを運用し続けるコストもかかります。だからこそ、直後の整合が絶対に必要な画面(たとえば決済確定の確認)は、読み取り側を待たず書き込みストアから直接読む、といった使い分けが要になります。
-- 読み取りストア:正規化テーブルへのテンプレート検索
SELECT o.id, o.customer, o.ordered_at, SUM(i.amount) AS total
FROM orders o
JOIN order_items i ON i.order_id = o.id
WHERE o.customer LIKE :keyword
AND o.ordered_at BETWEEN :from AND :to
GROUP BY o.id, o.customer, o.ordered_at
ORDER BY o.ordered_at DESC
LIMIT :size OFFSET :offset;更新の堅さと検索の速さは、1つのモデルでは両立しにくい。書き込みは文書指向で整合を守り、読み取りは正規化して速く引く。両者をイベント射影でつなげば両取りできるが、結果整合と二重スキーマの運用コストが対価になる。整合が要る画面は書き込み側から直接読む使い分けを設計に織り込むこと。