最近、イベントソーシングについて調べていて RDB でテーブルの正規化を行なっている実装例があまりないことに驚きました。そして、正規化をしない理由について、言及している情報があまりなかった。
イベントソーシングでは、イベントストアという発生したイベント情報を記録するテーブルを定義するのが主流であり、ペイロードはjson
やblob
形式で保存されます。
調査前の無知な自分は、がっつり正規化した下図のような構成を想定していました。
--- config: theme: forest --- erDiagram models { UUID model_id PK DATETIME created_at } model_events { UUID model_event_id PK UUID model_id FK INT event_no DATETIME occured_at } model_event_A_events { UUID model_event_id FK "PKも兼ねる" UUID model_invoice_id STRING hogehoge } model_event_B_events { UUID model_event_id FK "PKも兼ねる" UUID model_invoice_id STRING foofoo STRING barbar } models ||--o{ model_events : has model_events ||--o{ model_event_A_events : has model_events ||--o{ model_event_B_events : has
イベントソーシングで扱いたいモデルを表現するテーブル(models)を定義、さらに対応するイベント群の親テーブル(model_events)を定義します。
そして、イベントそれぞれに対応するテーブル(例: model_event_A_events)をイベントの数だけ定義するシンプルな構成です。この構成では、対象となるモデルの実態が存在することがmodels
を見れば分かるのと、イベントの拡張に対して、テーブルを追加するだけで対応できる長所があります。
例: 銀行口座のイベントソーシング
- accounts
- account_events
- (入金)acount_event_deposit_events
- (引出)acount_event_withdrawal_events
結論
結論としては「正規化をする強い理由がない」に至りました。
個人的には特に理由がなければ RDB を使った方が良いと思いますが、求められる要件・性能によっては NoSQL などの選択はありえます。
観点
比較対象とするイベントストア。
Building an Event Storage | CQRS で紹介されているテーブル定義です。
--- config: theme: forest --- erDiagram aggregates { UUID id PK string type int version datetime created_at datetime updated_at } events { UUID id PK UUID aggregate_id FK int version string type json payload datetime created_at } aggregates ||--o{ events : has
一度、発生したイベントは更新しない
イベントソーシングではイベントは一度、発生したら更新・削除することは推奨されません。 先のイベントを取り消したい場合、新たに取り消し用のイベントを発生させます。つまり、データの挿入はあれど、更新(編集・削除)はないため、正規化によるデータ整合性がメリットになり得ません。
aggregates
とevents
では外部キー制約を持つ点と、他のテーブルは必ずしもイベントソーシングを用いるかは分からないため、RDB を使いつつjson
やblob
を用いるという方針はしっくりきました。
読み込みが圧倒的に多い
アプリケーション特性にもよりますが、多くの場合、データは書き込み < 読み込みとなります。
イベントソーシングでは、先ほどのような正規化を行うとイベントの数だけJOIN
が必要となる対象のテーブルが増えるため、パフォーマンスが悪化していきます。マテリアライズド・ビューやスナップショットを活用する方法は考えられますが、いずれも正規化をしない設計にパフォーマンス・コストが劣ることは確定です。
-- 対象モデルのイベントを取得する SELECT models.model_id, model_events.occured_at, CASE WHEN model_event_A_events.model_event_id IS NOT NULL THEN 'A-Event' WHEN model_event_B_events.model_event_id IS NOT NULL THEN 'B-Event' ELSE 'Unknown' END AS event_type, model_event_A_events.*, model_event_B_events.* FROM models JOIN model_events ON models.id = model_events.model_id JOIN model_event_A_events ON model_events.id = model_event_A_events.model_event_id JOIN model_event_B_events ON model_events.id = model_event_B_events.model_event_id WHERE models.model_id = 'some-uuid-value' ;
正規化をするメリットがない以上、パフォーマンスを悪化させる設計を選ぶ必要はありません。
複雑さとスキーマ定義
データ復元(読み込み)と書き込み時の複雑さは、テーブルのスキーマ定義に依存します。
ペイロードをjson
で扱うと、本当にデータが登録されているのか、期待する型のデータとして復元できるのかを保証することが難しくなります。
アプリケーション側(Repository
の実装クラスなど)で、読み込み時のフィールド存在チェック・書き込み時のバリデーションなど、複雑さを吸収することになるでしょう。
RDB でスキーマ定義を丁寧に行えば、その限りではありませんが、読み込み時のパフォーマンスを考慮するとイベント単位でテーブルを分割することは避けたいです。しかし、1つのテーブルで複数イベントの構造を表現しようとすると、存在しうるフィールドを全てnullable
として定義する必要があるため、結果的にスキーマの見通しが悪くなります。
event_id | event_name | version | occured_at | aggregate_id | hoge | foo | bar | piyo |
---|---|---|---|---|---|---|---|---|
1001 | EventA | 1 | 2025-08-31 09:00:00 | 2001 | NULL | orange | NULL | NULL |
1002 | EventB | 2 | 2025-08-31 09:05:00 | 2001 | 100 | apple | NULL | xyz |
1003 | EventC | 3 | 2025-08-31 09:10:00 | 2001 | NULL | foo1 | bar1 | NULL |
多少、アプリケーション側の実装が複雑になってもjson
やblob
を使う価値はありそうです。
データ分析
json
やblob
のフィールドに対してのクエリは、標準 SQL として定義されておらず RDB によってサポートされている構文・機能が異なります。
たとえば、ユーザーがどういった操作を頻繁にしているかを分析したいという要求が考えられそうですが、正規化されていないテーブルを SQL で分析するのは大変です。
とはいえ、必ずしも SQLだけで完結する必要はない(エクスポートして前処理をする)のと、分析用のdbt
にデータを同期するといったアプローチも検討できるため、正規化をするかどうかの大きな理由にはなりません。