Stream / Event Sourcing の本質 ─ append-only と時間
ここまでの DB は全て「現在の状態」を保存していた。OLTP は最新の行、Cache は最新の値、NoSQL は最新の item。だが**「過去に何が起きたか」**を時間軸で保存する DB タイプがある。Stream(Kafka 系)と Event Sourcing(EventStore 系)。
この章では Kafka を主役に、Stream の本質を見る。
“Turning the database inside out”
Martin Kleppmann の有名な講演(2015):
従来の DB は 状態(state) を中心に置き、ログ(WAL)を内部に隠していた。
これを裏返そう。ログを真ん中に置き、状態は log の派生物(projection)と捉える。
これが「database inside out」の核心。
graph TB
subgraph "従来: 状態が中心"
DB1[(State)]
DB1 -.内部に.- WAL1[WAL]
end
subgraph "Inside out: ログが中心"
L[Log<br/>append-only]
L --> P1[Projection 1<br/>OLTP DB]
L --> P2[Projection 2<br/>Search Index]
L --> P3[Projection 3<br/>OLAP DWH]
L --> P4[Projection 4<br/>Cache]
end
style L fill:#e1f5ff
Kafka は、その “中央のログ” の実装。
Jay Kreps(Kafka 共同作者・Confluent CEO)も Kleppmann の発表を称賛。Kreps 自身は LinkedIn で Kafka を作る前から「log は分散システムの中心抽象である」と書いていた(“The Log: What every software engineer should know about real-time data’s unifying abstraction”)。
Pat Helland の Outside Data として再解釈
第 3 章で導入した Pat Helland の Inside vs Outside data の枠で見ると:
Stream に流れているデータは “Outside Data” そのもの
- Immutable: 一度書いた event は変更できない
- Identifier で参照: offset / event ID で一意
- Stable: 一度受け取った event は誰が読んでも同じ
OLTP(Inside)から Outbox Pattern や CDC で event が流れ出し、Stream(Outside)が中央のログになり、そこから複数の DB(OLAP / Search / Cache)に派生状態が作られる。現代のデータパイプラインの基本形。
Kafka の構造
graph TB
subgraph "Producer 側"
P1[Producer 1]
P2[Producer 2]
end
subgraph "Kafka Cluster"
T[Topic: orders]
T --> Pa[Partition 0]
T --> Pb[Partition 1]
T --> Pc[Partition 2]
end
subgraph "Consumer 側"
C1[Consumer Group A<br/>OLTP 同期]
C2[Consumer Group B<br/>OLAP ETL]
C3[Consumer Group C<br/>Search Index]
end
P1 --> T
P2 --> T
Pa --> C1
Pa --> C2
Pa --> C3
主要要素
| 要素 | 役割 |
|---|---|
| Topic | 論理的なログの単位(“orders”, “payments” 等) |
| Partition | Topic を物理的に分割した単位。各 partition は order が保証される append-only log |
| Producer | event を書く側 |
| Consumer Group | event を読む側のグループ。各 consumer は partition を分担 |
| Offset | partition 内の event の位置 |
| Retention | event をどれだけ保持するか(時間 / size / 圧縮) |
Partition と順序保証
順序が保証されるのは partition 内のみ。orders topic を 10 partition に分けたとして、partition 横断では順序保証がない。
// 同じ user の event は同じ partition に
const partition = hash(userId) % numPartitions;
// → 同一 user の event は順序保証
// → user 横断では並列処理可能(順序保証なし)
これが Kafka の設計判断:順序保証と並列度のトレードオフを partition key で制御する。
Append-only の意味
Stream の最も重要な特性が append-only。
Topic: orders
Partition 0:
offset 0: { event: 'OrderCreated', orderId: 1, ... }
offset 1: { event: 'OrderPaid', orderId: 1, ... }
offset 2: { event: 'OrderCreated', orderId: 2, ... }
offset 3: { event: 'OrderShipped', orderId: 1, ... }
...
- 過去の event は変更不可
- 削除も基本なし(retention で古い event を消すのみ)
- 新しい event は末尾に追加
これは Pat Helland が Immutability Changes Everything で主張する世界観そのもの。immutable なデータは推論しやすい、複製しやすい、再生しやすい。
Event Sourcing ─ 状態を log から再生する
「状態は log から再生できる」を極端に推し進めたパターンが Event Sourcing。
// Order の状態を event の畳み込みで構築
function reconstructOrder(events: OrderEvent[]): Order {
return events.reduce((order, event) => {
switch (event.type) {
case 'OrderCreated': return new Order(event.id, []);
case 'ItemAdded': return order.addItem(event.item);
case 'OrderPaid': return order.markPaid();
case 'OrderShipped': return order.markShipped();
}
}, null);
}
Aggregate の現在状態は、過去の event の畳み込み(fold)として表現される。「現在」は派生物であって、event log がソース・オブ・トゥルース。
利点:
- 完全な監査ログ(なぜ今この状態か、全 event で説明できる)
- 時間旅行(任意時点の状態を再構築可能)
- 新しい派生 view を後から追加できる(log を再 replay して別 schema に)
代償:
- Read が複雑(畳み込みが必要 → スナップショットや Projection で緩和)
- 設計が難しい(event の粒度、idempotency)
- schema 進化が深刻(5 年前の event を今のコードで読めるか)
設計の核心:Log Compaction と Retention
Kafka には Log Compaction という機能がある:
通常の log:
offset 0: { key: 'user:123', value: 'Alice' }
offset 1: { key: 'user:123', value: 'Alice2' }
offset 2: { key: 'user:456', value: 'Bob' }
offset 3: { key: 'user:123', value: 'Alice3' }
Compaction 後(最新の値だけ残す):
offset 1: ─ 削除(より新しい同 key あり)
offset 0: ─ 削除(同上)
offset 2: { key: 'user:456', value: 'Bob' }
offset 3: { key: 'user:123', value: 'Alice3' }
key 単位の “最新状態” を保持し続ける topic。これは「現在状態を log で表現する」用途に最適。
例:
- 設定の配信(key = 設定 ID、value = 最新設定)
- DB の Change Data Capture(key = primary key、value = 最新の row)
- ユーザープロファイル(key = user ID、value = 最新プロファイル)
Retention の選択
| 戦略 | 用途 |
|---|---|
| 時間 retention(7 日等) | event 監査、リプレイ可能性 |
| Size retention(100GB 等) | リソース上限あり |
| Compaction(log compaction) | “最新状態” を log で表現 |
| Compaction + Time | 古いものは消すが key の最新は保持 |
| Forever(infinite retention) | event sourcing、完全な歴史 |
Stream と DDD
Domain Event の概念が DDD にもある(Vernon の章)。Aggregate の状態変化を event として発火し、他の Bounded Context に通知する。
class Order {
pay(): void {
this.status = 'paid';
this.events.push(new OrderPaidEvent(this.id, /* ... */));
}
}
// Repository で save 時に event を発火
async save(order: Order): Promise<void> {
await tx.update(...); // OLTP に保存
for (const event of order.events) {
await eventBus.publish(event); // Kafka に流す
}
}
ここで Outbox Pattern が必要になる。OLTP の更新と Kafka への publish を atomic にしないと、片方が失敗すると整合性が崩れる。これは設計編で扱う。
この章の要点
- Stream は「ログを中央に置く」アーキテクチャの実装。Kafka がその代表
- “Turning the database inside out”(Kleppmann):状態は log の派生物
- Append-only で immutable。Pat Helland の Outside data そのもの
- Topic / Partition / Consumer Group / Offset が基本構造
- 順序保証は partition 内のみ。partition key で並列度を制御
- Event Sourcing は「状態 = event の畳み込み」を極端に推し進めたパターン
- Log Compaction で “最新状態を log で表現” できる
- Domain Event は OLTP → Stream の橋渡し
次章への問いかけ
Stream の世界では、データは時間を超えて流れていく。append-only と immutability の世界に踏み入れたら、Pat Helland の世界観に近づく。
次章で Stream の設計編。イベント粒度・Partition Key 設計・Schema 進化・Retention の選択。Outbox Pattern もここで扱う。