イベントソーシングとCQRSの原理
監査ログと履歴を後付けで失わないために。状態ではなく「起きた事実」の列を真実の源にし、再生で状態を再構築するイベントソーシングと、読み書きを分けるCQRSの設計判断がわかります。
- 1.イベントソーシングは現在状態ではなく不変なイベント列を唯一の真実とし、状態はイベントを左畳み込み(fold)で再生して復元する。過去のあらゆる時点の状態と完全な監査証跡が原理的に得られる。
- 2.CQRSは書き込みモデル(コマンド→イベント)と読み取りモデル(投影で作る非正規化ビュー)を分離する。両者は非同期に同期されるため結合結果整合となり、読み取りに古さ(ラグ)が混じる。
- 3.スナップショットは再生コストを定数化する最適化、バージョニングはイベント定義の不変性とスキーマ進化を両立させる設計判断で、どちらも運用の生死を分ける。
状態ではなく「起きた事実」を保存する
通常のCRUDでは、テーブルの行が現在状態を保持し、更新は前の値を上書きします。balance = 1000 を balance = 700 に書き換えた瞬間、「いくら引かれたのか」「いつ誰が引いたのか」は失われます。イベントソーシングはこの上書きをやめ、状態を生んだ事実(イベント)の列そのものを永続化します。
口座の状態を、結果ではなく原因の列として持つ
Opened(initial=0)
Deposited(amount=1000)
Withdrawn(amount=300)
→ 現在の balance はどこにも保存されていない。イベントを順に適用して導出する
イベントは過去に確定した事実なので不変(immutable)であり、追記専用(append-only)のログとして積まれます。重要なのは、ここでイベントが**唯一の真実の源(source of truth)**になることです。状態テーブルがあったとしても、それは派生物であって権威ではありません。
コマンド(Command)とイベント(Event)は別物です。コマンドは「300引け(WithdrawMoney)」という要求で、拒否されうる未確定の意図。イベントは「300引かれた(MoneyWithdrawn)」という、すでに起きてしまった事実で、拒否も取り消しもできません。命名は必ず過去形にします。検証(残高不足ではないか)はコマンドを受けてイベントを生む瞬間に行い、いったんイベントになったら検証は通過済みである、という不変条件を守ります。
再生:左畳み込みで状態を再構築する
現在状態は、初期状態にイベントを順に適用した**畳み込み(fold)**の結果です。状態遷移関数を apply(state, event) とすると、現在状態は次式で定義されます。
state = fold(apply, initial_state, [e1, e2, ..., en])
= apply(apply(... apply(initial, e1) ..., e(n-1)), en)
この性質から、イベントソーシングの強みが演繹的に出てきます。第一に、任意の過去時点の状態を、その時刻までのイベントだけ畳み込めば復元できる(タイムトラベル)。第二に、apply を新しく書けば過去のイベント列から新しいビューを後から作れる(後付けの分析・新機能)。第三に、イベント列がそのまま完全な監査証跡になる。CRUD では設計時に監査ログを仕込み忘れたら過去は取り戻せませんが、イベントソーシングでは履歴が構造的に保証されます。
apply には満たすべき制約があります。純粋関数であり、決定的でなければならないこと。同じイベント列を畳み込めば、いつ何度実行しても同じ状態になる必要があります。ここで apply の中で現在時刻を読む、乱数を引く、外部APIを叩く、といった非決定的な操作をすると、再生のたびに結果がぶれて真実が揺らぎます。非決定な値はイベント生成時に確定させてイベントへ焼き込み、apply はイベントが運んできた値を反映するだけにします。
CQRS:読み書きモデルを分離する
イベント列は「事実の追記」には最適ですが、「現在の全口座を残高順に並べる」のような問い合わせには絶望的に向きません。毎回すべてのイベントを畳み込む必要があるからです。ここでCQRS(Command Query Responsibility Segregation)が要請されます。コマンド側(書き込み)とクエリ側(読み取り)で別々のモデルを持つ設計です。
コマンド側: コマンド受理 → 不変条件を検証 → イベントを発行 → イベントストアへ追記
クエリ側 : イベントを購読 → 投影を更新 → 用途別の読み取りビューを提供
書き込み側はイベントストアという正規化された真実だけを扱い、読み取り側は用途ごとに非正規化したビューを複数持てます。一覧用、検索用、集計用を別々のストア(RDB、検索エンジン、KVS)に最適化して同居させられる——これがCQRSとイベントソーシングが相性良く語られる理由です。なお両者は独立した概念で、CQRS はイベントソーシングなしでも、その逆でも成立します。
コマンド側のイベント追記と、クエリ側の投影更新は非同期です。イベントを書いた直後にクエリ側を読むと、まだ投影が追いついておらず古い値が返る(read-your-writes が壊れる)。CQRS を入れた瞬間、読み取りは結合的な強整合をあきらめ、**結果整合(eventual consistency)**になります。書いた本人が即座に結果を見たいUIでは、コマンドの戻り値で楽観的に画面を更新する、投影ラグを監視して許容範囲に収める、といった対処が要ります。整合性モデルの選択そのものについては /devops/consistency-models/ を参照してください。
投影(projection):イベントからビューを作り続ける
投影は、イベントストリームを消費して読み取りビューへ反映する処理です。各投影は「どこまで読んだか」を示す**チェックポイント(読み取り位置)**を持ち、新着イベントを順に apply 相当の更新でビューへ畳み込みます。
投影が満たすべき最重要の性質はべき等性です。投影プロセスはクラッシュや再起動で、同じイベントを二度処理しうるからです(/devops/idempotency-exactly-once/ で扱う at-least-once の世界)。「カウンタに+1する」ような非べき等な更新を素朴に書くと、再処理で数が狂います。対策は二つです。
- イベントに単調増加の通し番号(グローバル順序またはストリーム内シーケンス)を振り、投影は処理済み番号以下を捨てる。重複を番号で弾く。
- ビュー更新自体をべき等にする(絶対値で上書き、
UPSERT、集合への挿入など)。
再生可能性も投影の利点です。投影のバグを直したら、チェックポイントをゼロに戻して全イベントを流し直せばビューを作り直せます。新しい読み取りモデルが必要になったら、新しい投影を空から再生して構築する。イベントが真実である限り、ビューはいつでも捨てて再生成できる使い捨ての派生物になります。
スナップショット:再生コストを定数化する
集約(aggregate、ひとつの不変条件の境界=口座など)のイベントが何万件にもなると、コマンドのたびに全イベントを畳み込むのは非現実的です。スナップショットは、ある時点までの畳み込み結果を保存しておく最適化です。
復元 = 直近スナップショット state@v
+ それ以降のイベント [e(v+1), ..., en] を畳み込む
これで再生コストは「スナップショット以降の差分」だけになり、おおむね定数時間に収まります。設計上の鉄則は、スナップショットはあくまでキャッシュであり、真実ではないことです。スナップショットを消してもイベント列から完全に再構築できなければなりません。だからスナップショットにはそれが対応するイベントのバージョン番号を必ず持たせ、apply のロジックを変えたら古いスナップショットは破棄して作り直せるようにします。スナップショットを権威にすると、上書き型CRUDの問題がそのまま戻ってきます。
| 論点 | スナップショットなし | スナップショットあり |
|---|---|---|
| 復元コスト | イベント数に比例(O(n)) | 差分のみ・ほぼ定数 |
| 真実の源 | イベント列のみで単純 | イベント列(スナップショットは派生キャッシュ) |
| apply変更時 | 再生すれば自動で反映 | 古いスナップショットの破棄・再生成が必要 |
| 向く集約 | イベントが少ない短命な集約 | 長寿命で履歴が膨らむ集約 |
イベントの版管理:不変な事実を進化させる
最大の運用上の難所がバージョニング(スキーマ進化)です。イベントは不変なので、過去に書かれたイベントの形を後から変えることはできません。しかしビジネスは変わり、イベントに項目を足したい・名前を変えたい・分割したいが必ず起きます。古い形式のイベントは永久にストアに残り続け、apply も投影も全バージョンを解釈できなければならない——これが上書き型DBのマイグレーションと根本的に違う点です。
実務で使われる手法を整理します。
- 後方互換な追加(弱いスキーマ):項目の追加はデフォルト値で吸収する。最も安全で第一選択。
- アップキャスティング(upcasting):古いイベントを読み出す瞬間に、新しい形へ変換する関数を通す。ストア上の生イベントは不変のまま、メモリ上で最新版に揃える。
- 新イベント型の併存:
OrderPlacedV2を新設し、applyは V1 と V2 の両方を処理する。意味が変わる変更で使う。 - イベントの書き換え(コピー&変換):全イベントを新ストリームへ変換コピーする最終手段。監査証跡の連続性が切れるため慎重に。
イベントソーシングのレビューで突かれる点は明快です。(1) イベントは過去形・不変・追記専用か(更新や削除をしていないか)。(2) apply は決定的な純粋関数か(中で時刻・乱数・外部I/Oを使っていないか=再生で結果がぶれないか)。(3) 投影はべき等か(再処理で壊れないか、チェックポイントで重複を弾いているか)。(4) スナップショットを真実にしていないか(イベントだけから再構築可能か)。(5) 古いイベントを全バージョン解釈できるか(アップキャスティング戦略があるか)。さらに、集約の境界=不変条件を守るトランザクション境界として正しく切れているかも問われます。境界をまたぐ強整合は持てず、結果整合に落ちます。
イベントの順序保証も論点です。多くの実装は集約(ストリーム)単位では厳密順序を保証しますが、集約をまたいだグローバル順序は保証しないか、保証すると書き込みのスケールが制約されます。集約間の因果を扱うときは、論理時刻(/devops/logical-clocks/)で順序を補い、複数集約にまたがる更新はサーガ(補償トランザクション)で結果整合に組むのが定石です。マイクロサービス間でイベントを連携する設計判断は /devops/microservices/ とも地続きです。
まとめ
- イベントソーシングは不変なイベント列を唯一の真実とし、状態は
fold(apply, initial, events)の再生で導出する。過去の任意時点の状態と完全な監査証跡が原理的に得られる。 - 再生が成り立つ条件は apply が決定的な純粋関数であること。非決定な値はイベント生成時に焼き込み、再生でぶれないようにする。
- CQRS は書き込み(コマンド→イベント)と読み取り(投影ビュー)を分離する。両者は非同期同期のため結果整合になり、読み取りラグを前提に設計する。
- 投影はべき等かつ再生可能で、ビューは使い捨ての派生物。スナップショットは再生コストを定数化するキャッシュであり、真実にしてはならない。
- バージョニングは不変イベントとスキーマ進化を両立させる難所で、後方互換な追加・アップキャスティング・新イベント型併存を使い分ける。古いイベントは全バージョン解釈し続ける必要がある。
DevOps/インフラ Article
イベントソーシングとCQRSの原理を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
イベントソーシング
比較で見る軸
難易度: advanced / カテゴリ: DevOps/インフラ / タグ数: 5
導入後に効く点
CQRSは書き込みモデル(コマンド→イベント)と読み取りモデル(投影で作る非正規化ビュー)を分離する。両者は非同期に同期されるため結合結果整合となり、読み取りに古さ(ラグ)が混じる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- DevOps/インフラ
- タグ数
- 5
判断チェックリスト
- 自社の用途が「イベントソーシング / CQRS」に近いか確認する。
- 強みである「イベントソーシングは現在状態ではなく不変なイベント列を唯一の真実とし、状態はイベントを左畳み込み(fold)で再生して復元する。過去のあらゆる時点の状態と完全な監査証跡が原理的に得られる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。