最近、イベントソーシングについて調べていて 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にデータを同期するといったアプローチも検討できるため、正規化をするかどうかの大きな理由にはなりません。
参考文献