多くのアプリケーションは、同じテーブルを同じモデルで読み書きします。これはデータの形がずれてくるまではうまくいきます。書き込みには 1 件の注文ごとに厳密な業務ルールが必要な一方、読み取りでは何千件もの注文にまたがる一覧、検索、合計が求められます。CQRS はこの 2 つを分け、イベントソーシングはそもそも何を保存するかを変えます。
この 2 つはよくセットで紹介されますが、別々の判断です。この記事では、それぞれの内容、組み合わせ方、両方を組み合わせたシステムの図の姿、そして多くのチームが早すぎる導入を後悔する原因になるコストを説明します。
CQRS を 1 段落で
Command Query Responsibility Segregation とは、データを変更するモデルと読み取るモデルを別々にすることです。コマンド(PlaceOrder、CancelOrder)はルールを適用する書き込み側に送られます。クエリは、それを必要とする画面に合わせた形の読み取り側に送られます。このパターンを Greg Young の考案としている Martin Fowler の CQRS の記事 には、繰り返す価値のある警告もあります。ほとんどのシステムにとって、CQRS はリスクのある複雑さを持ち込むというものです。アプリケーション全体ではなく、読み取りと書き込みが本当に食い違う部分に使いましょう。
CQRS に 2 つのデータベースは必要ありません。書き込みモデルと、同じテーブルに対する SQL ビューのセットだけでも、すでに CQRS です。別々のストアは、読み取りの負荷や形が必要とするようになってから導入します。
イベントソーシングを 1 段落で
イベントソーシングは、現在の状態ではなく変更を保存します。注文はステータス列を持つ行ではなく、OrderPlaced、PaymentAuthorized、OrderShipped というイベントのストリームです。現在の状態は、それらをリプレイして得られるものです。Fowler の イベントソーシングの記事 がこの考え方を詳しく説明しています。何も上書きされないので、完全な監査履歴が自然に手に入り、過去に対する新しいビューをいつでも作れます。
2 つの組み合わせ方
イベントソーシングを使うと、ほぼ必然的に CQRS が必要になります。イベントのリプレイは、変更前に 1 件の注文を読み込むには良い方法ですが、「100 ドルを超える未処理の注文をすべて表示」に答えるにはひどい方法です。そのため、書き込み側はイベントストリームを扱い、読み取り側はそのイベントから更新される別のリードモデルを持ちます。
流れは次のとおりです。
- コマンドがコマンド側に届きます。
- ハンドラーがストリームをリプレイして集約(注文)を読み込み、業務ルールを確認して、どの新しいイベントを出すかを決めます。
- ストリームの 期待バージョン を付けて、イベントをイベントストアに追記します。その間に別のコマンドが追記していれば追記は失敗し、コマンドは再試行されます。これは楽観的同時実行制御で、行ロックの代わりになります。
- プロジェクターがイベントストリームを購読し、リードモデルを更新します。注文一覧用のテーブル、検索インデックス、ダッシュボード用のカウンターなどです。
- クエリはリードモデルだけを読みます。
イベントストア
イベントストアに必要な機能は多くありませんが、それらは正確に備わっている必要があります。
- 追記専用のストリーム: 集約ごとに 1 つで、イベントごとにバージョン番号を持ちます。
- 条件付き追記: 期待バージョンに基づき、アトミックに行います。
- ストリームの読み取り: 先頭から、または特定のバージョンから読みます。
- サブスクリプション: すべての新しいイベントを順番に受け取り、プロジェクターが保存して再開に使える位置を持ちます。
専用のイベントストアはこれを提供しますし、ストリーム ID とバージョンに一意制約を付けたリレーショナルテーブルでも、サブスクリプション用のアウトボックスや変更フィードと組み合わせれば同じことができます。
リードモデルと結果整合性
プロジェクションはイベントが保存された後に実行されるので、リードモデルは数ミリ秒、バックログがあるときはもっと長く遅れます。それを前提に設計します。
- コマンドの後は新しいバージョンを返し、リードモデルが追いつくまでクライアントに待ってもらうか、コマンドのレスポンスから結果を表示します。
- プロジェクターごとに独自の位置を持たせ、遅い検索インデックスが注文一覧を遅らせないようにします。
- プロジェクションを冪等にします。プロジェクターは再起動してイベントを読み直します。OrderShipped を 2 回適用しても、出荷が 2 件と数えられてはいけません。
その見返りとして、リードモデルは使い捨てにできます。新しい画面に新しい形が必要なら、プロジェクターを書いて履歴をリプレイすればよいのです。プロジェクションにバグがあれば、修正してリードモデルを捨て、作り直します。
具体例:新しいレポート
ローンチから 1 か月後、経理チームが過去分も含めて、商品カテゴリー別・日別の売上を求めてきたとします。CRUD のシステムなら、マイグレーションとバックフィルのスクリプトを書くことになり、変更前の履歴はすでに失われているかもしれません。イベントソーシングなら、OrderPlaced と OrderRefunded を購読するプロジェクターを書き、イベントログの先頭からリプレイさせます。追いついた時点で、レポートはローンチ以降のすべての注文をカバーし、その後は自動で最新の状態を保ちます。
アーキテクチャ図
ダイアグラムの内容
- User
- Command side
- Event store
- Event stream
- Projectors
- Read model: order list
- Read model: search index
- Read model: daily totals
- Query side
- PlaceOrder
- append
- subscription
- GET /orders
図の読み方は次のとおりです。
- 上段、書き込みの経路: ユーザーの PlaceOrder コマンドがコマンド側に届き、コマンド側がイベントストアにイベントを追記します。サブスクリプションがイベントストリームに流し込みます。
- 右: プロジェクターがストリームを消費し、3 つのリードモデルを最新に保ちます。それぞれ、自分のクエリに合ったストレージに置かれています。
- 左下、読み取りの経路: クエリはクエリ側に届きます。クエリ側はリードモデルだけを読み、業務ルールは持ちません。
- Markdown ブロック は 1 つのストリームとそのバージョンを示していて、期待バージョンのチェックを説明しやすくしています。
コスト
- スキーマの進化に終わりはない。 イベントは永久に保存されるので、古いバージョンのイベントも読める状態を保つ必要があります。アップキャスト(古いイベントを読み込み時に現在の形に変換すること)を初日から計画しましょう。
- データの削除が難しい。 消去を求める規制は不変のログとぶつかります。個人データはイベントに入れないか、個人ごとに暗号化して鍵を削除します。
- 長いストリームは読み込みが遅くなる。 数百イベントごとに集約の状態のスナップショットを取れば解決します。
- デバッグの場所が変わる。 バグはリードモデルの誤りとして現れ、修正はリプレイになるので、そのためのツールが必要です。
- チームのスキル。 イベントによるモデリングは別の考え方です。イベントソーシングを使うコアドメインは 1 つで十分で、システムの残りは CRUD のままでかまいません。
図に描くべきこと
- コマンドとクエリで明確に分かれた 2 つの経路。
- 信頼できる唯一の情報源としてのイベントストア: リードモデルは派生物として描きます。
- 各プロジェクターとそのリードモデル: どのビューが遅れうるかを読み手がわかるようにします。
- バージョン付きのストリームの例: 議論の足がかりにします。
イベントとコンシューマーの仕組みは イベント駆動アーキテクチャの記事 で、専用のイベントストアを使わない場合にイベントを確実に発行する方法は トランザクショナルアウトボックスパターン で説明しています。ストリームそのものは Kafka のアーキテクチャ図 で扱っています。
Archboard で作る
- Google または GitHub でサインインし、図を作成します。
- C4 モデルパックから 人物 を 1 つと、コマンド側、プロジェクター、クエリ側用の コンテナ ノードを追加します。
- イベントストア用に データベース を、リードモデル用に データベース ノードを追加し、さらにデータパックからイベントストリームとカウンター用に Kafka と Redis を追加します。
- 書き込みの経路を上に、読み取りの経路を下に描き、プロジェクターが右側からリードモデルに流し込むようにします。
- イベントストリームの例を書いた基本パックの Markdown ブロックを追加します。
この例で使うダイアグラムノードは 10 個で、Free プランの範囲に収まります。
よくある質問
CQRS とイベントソーシングは一緒に使わなければなりませんか? いいえ。CQRS は普通のデータベースでも機能しますし、イベントソーシングで 1 つのリードモデルだけを更新することもできます。ただ、リプレイしたストリームに対するクエリは遅いので、何らかの形の CQRS を伴わないイベントソーシングはまれです。
CQRS を使うべきでないのはどんなときですか? 読み取りと書き込みが同じ形のデータを使うときです。ほとんどの CRUD 画面がこれに当たります。CQRS は、両者が食い違うシステムの部分に適用します。
リードモデルはどうやってイベントストアとの整合性を保ちますか? 結果整合性によってです。プロジェクターはイベントを順番に処理して位置を記録し、障害の後はそこから再開します。クライアントは短い遅延を許容する必要があります。
イベントの構造を後から変えるにはどうすればいいですか? イベントをバージョン管理し、古いバージョンは読み込み時に変換します(アップキャスト)。保存済みのイベントをその場で書き換えてはいけません。
イベントソーシングのスナップショットとは何ですか? 特定のバージョン時点での集約の状態を保存したコピーです。これがあれば、読み込みはストリーム全体ではなく、スナップショットとそれ以降のイベントを読むだけで済みます。