CQRS/ESの『整合性どうするの?』に答えてみる
株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。C#イベントソーシング・CQRSのフレームワーク、Sekibanを社内で開発しています。
はじめに
Xでこんな疑問を見かけました。
技術的には「現在のポイントに対して+20」みたいなコマンドも存在するしその場合はコマンドにも読み取りが伴ってきて、責務的境界と技術的境界ってズレないか
書き込みDBと読み取りDBをカッチリ分けている例が多くて、そこんとこみんな整合性どうしてんのって思って今でもわからない
「読み書きのDBを分けたら、古いデータを読んでしまうのでは?」という疑問は、CQRS/ESを学び始めたときに多くの方が感じるところだと思います。
この記事では、CQRS/ES(イベントソーシング)でどう整合性を守っているのかを解説します。
Findyさんから登壇の録画が公開されています。よろしかったらFindy登録は必要ですが以下のリンクからご覧ください。
結論:整合性はコマンド側で守る
CQRS/ESにおける整合性は、コマンド側のモデルで守ります。
「読み取りDB」と呼ばれるものは、整合性の判断には使いません。
よくある誤解:「書き込みDBは書き込み専用」
CQRS/ESの解説スライドでよく見る図があります。
この図から受ける印象は:
- 書き込みDBは書き込みだけ
- 読み取りDBは読み取りだけ
- 完全に分離されている
しかし、この理解だと混乱が生じます。
実際には、コマンド側でも読み取りは行われています。
実際の動き:イベントストアは「読む」ためにも使われる
CQRS/ESでの構成を整理すると、以下のようになります。
| 用語 | 実体 | 役割 |
|---|---|---|
| コマンド側DB | イベントストア | イベントの保存 + 集約の状態構成 |
| クエリ側DB | 投影済みビュー | 検索・一覧・分析用 |
重要なのは、イベントストアは読み込みにも使われるという点です。
コマンドを実行するとき、次のような流れになります。
- 対象の集約に関するイベントをイベントストアから読み込む
- イベントをリプレイして現在の状態(ステート)を構成する
- その状態に対してビジネスルールを適用する
- 問題なければ、新しいイベントを保存する
つまり、コマンド側でも読み取りは常に行われているのです。
また、この「イベントから構成されたステート」を返すAPIエンドポイントを作ることもできます。単一の集約の詳細を表示する画面では、クエリ側DBではなく、このコマンド側のステートを直接取得することが普通に行われます。
「+20ポイント」はどう処理される?
具体例で見てみましょう。「現在のポイントに+20する」というコマンドを考えます。
// ステート:ポイントの現在状態
public record PointState(
Guid UserId,
int CurrentPoints,
int Version);
// イベント:ポイントが加算された
public record PointsAdded(
Guid UserId,
int Amount,
string Reason);
// コマンド実行の流れ
public async Task<EventOrNone> HandleAddPoints(AddPointsCommand command, ICommandContext context)
{
// 1. イベントストアから現在の状態を取得
var state = await context.GetStateAsync<PointState>(command.UserId);
// 2. ビジネスルールをチェック(例:上限チェック)
if (state.CurrentPoints + command.Amount > 100000)
throw new InvalidOperationException("ポイント上限を超えます");
// 3. イベントを返す
return EventOrNone.From(new PointsAdded(command.UserId, command.Amount, command.Reason));
}
ポイントは、読み取るのはクエリ側DBではなく、コマンド側のイベントストアから構成されたステートだということです。
このステートは常に最新です。なぜなら、イベントストアに保存されたすべてのイベントをリプレイして構成しているからです。
整合性を守る2つの仕組み
CQRS/ESでは、整合性を守るために主に2つの仕組みを使います。
1. 楽観的バージョンチェック
イベントストアには、集約ごとに「最新バージョン(イベントの最新ID)」があります。
// コマンドに「参照したバージョン」を含める
public record AddPointsCommand(
Guid UserId,
int Amount,
string Reason,
int ExpectedVersion); // この時点で見ていたバージョン
// 保存時にバージョンをチェック
// もし ExpectedVersion と実際のバージョンが違えば、
// 誰かが先に更新している → 競合エラー
これにより、古いデータを前提にした更新を防げます。
また、ポイント付与のようなケースでは、冪等性キーを使う方法もあります。
// ポイント付与に一意のIDを含める
public record PointsAdded(
Guid UserId,
int Amount,
string Reason,
Guid TransactionId); // この付与の一意ID
// 同じTransactionIdが既に存在すれば、重複として無視
2. 型による状態制約
もう一つの強力な仕組みが、型で状態を表現する方法です。
例えば、20人までしか入れない会場を考えます。
// 状態を型で表現
public interface IRoomState { }
public record AvailableRoom(
Guid RoomId,
int CurrentCount,
int MaxCapacity) : IRoomState;
public record FullRoom(
Guid RoomId,
int MaxCapacity) : IRoomState;
// イベントで型が遷移する
public static class RoomDecider
{
public static IRoomState Evolve(AvailableRoom state, PersonJoined ev)
{
var newCount = state.CurrentCount + 1;
// 20人になったら FullRoom に型が変わる
if (newCount >= state.MaxCapacity)
return new FullRoom(state.RoomId, state.MaxCapacity);
return state with { CurrentCount = newCount };
}
// FullRoom には Join を受け付けるメソッドがない
// → 型レベルで「満員の部屋には入れない」を表現
}
21人目が入ろうとしても、その時点で型は FullRoom になっています。FullRoom には参加を受け付けるメソッドがないので、そもそもその操作が表現できません。削除フラグのような実行時チェックに頼る必要がなくなります。
アクターモデル:同時実行を防ぐ
ここまでの整合性チェックを成立させるには、1つの集約に対して同時に複数のコマンドを実行しないことが重要です。
CQRS/ESシステムの多くが採用しているのがアクターモデルです。
アクターモデルの特徴:
- 集約ごとに1つの実行単位(アクター)を持つ
- コマンドはメッセージとして順番に処理される
- 1つのコマンドが完了するまで次は実行されない
これにより、先ほどの AvailableRoom → FullRoom の遷移も、同時実行による競合なく正しく行われます。
さらにアクターモデルは、分散環境でも効果を発揮します。どのサーバーにリソースがあるかを意識せず、「この集約にこのコマンドを実行して」と依頼するだけで、適切なサーバーにルーティングされます。
イベントの読み込みは重くならない?
毎回イベントを全部読んでステートを構成するとなると、パフォーマンスが気になるところです。実際には、いくつかの仕組みで対処しています。
アクターがステートをメモリに保持する
アクターモデルでは、コマンド実行後もアクターはしばらくメモリに残ります。
同じ集約に対して次のコマンドが来たとき、すでにメモリ上にステートがあれば、イベントストアへの読み込みは不要です。前回のステートに新しいイベントを適用するだけで済みます。
スナップショットによる高速化
一定期間アクセスがなければ、アクターはメモリから解放されます。
このとき、現在のステートをスナップショットとして保存しておきます。次に同じ集約が呼び出されたときは、スナップショットから復元し、その後に追加されたイベントだけをリプレイします。
100個のイベントがある集約でも、スナップショットが90個目の時点で保存されていれば、残り10個をリプレイするだけです。
このように、アクターモデルとスナップショットの組み合わせにより、パフォーマンスの問題は実用上ほとんど発生しません。
では、クエリ側DBは何のため?
ここまでの説明で、単一集約の読み取りにクエリ側DBは登場していませんでした。
クエリ側DBの役割は以下のようなケースに限定されます:
- 複数集約をまたいだ一覧表示
- 検索・並び替え
- 集計・分析
- レポーティング
これらは、多少の遅延が許容される操作です。
「一覧は古いが、詳細は正しい」現象
大規模システムでよくある現象があります。
一覧画面では古い情報なのに、詳細画面を開くと正しい
これは不具合ではありません。CQRS/ESの意図された動作です。
| 画面 | データソース | 特性 |
|---|---|---|
| 一覧画面 | クエリ側DB(投影済みビュー) | 結果整合性(多少の遅延あり) |
| 詳細画面 | コマンド側(イベントストア) | 強整合性(常に最新) |
一覧は「だいたい正しい」でよく、詳細は「必ず正しい」必要がある。この使い分けがCQRS/ESの設計意図です。
用語を整理する
「読み込みDB / 書き込みDB」という言い方が、混乱を招きやすいと感じています。
私は以下のように整理しています:
| 用語 | 役割 |
|---|---|
| コマンド側DB(イベントストア) | 単一集約の状態構成、整合性チェック、ビジネスルールの適用 |
| クエリ側DB | 複数集約のビュー、検索・一覧・分析、結果整合性を許容 |
こう考えると、CQRS/ESの構造が整理しやすくなるのではないかと思います。
まとめ
CQRS/ESの整合性について、ポイントをまとめます。
-
コマンドは必ずコマンド側モデルの状態を読む
- クエリ側DBは整合性判断には使わない
-
整合性は集約・バージョン・型で保証する
- 楽観的バージョンチェック
- 型による状態制約
-
アクターモデルで同時実行を防ぐ
- 1つの集約に対して1つのコマンドずつ処理
-
パフォーマンスはアクターとスナップショットで確保
- メモリ上のステート保持
- スナップショットからの復元
「読み書きDBの完全分離」ではなく「コマンド側とクエリ側の役割分担」として捉えると、CQRS/ESの設計が見えやすくなると思います。この記事が、同じ疑問を持つ方の参考になれば幸いです。
Discussion