目次を表示する

DB 設計の軸 2026 ─ ドメイン駆動と特性駆動の二つの流派を行き来する 19 章

Stream / Event Sourcing の本質 ─ append-only と時間

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 PatternCDC で 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” 等)
PartitionTopic を物理的に分割した単位。各 partition は order が保証される append-only log
Producerevent を書く側
Consumer Groupevent を読む側のグループ。各 consumer は partition を分担
Offsetpartition 内の event の位置
Retentionevent をどれだけ保持するか(時間 / 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 もここで扱う。