/note/tech

「状態」ではなく「変化(イベント)」を保存したい

要約:

■ 1. 記事の背景と概要

  • 履歴というデータを取り扱うことのために考えていること・戦い方について整理した内容である
  • 履歴のデータはとても重要になる場合があり履歴管理や監査証跡・時点復元・法令対応などで「過去の状態を正確に残したい」という要件が発生することがある
  • この記事の概要:
    • 「状態」の保存は一見シンプルだが履歴要件が入ると破綻しやすい
    • Bi-temporalは強力な設計手法だがクエリ・運用の複雑さが課題になりやすい
    • Event Sourcingは「変化」を中心に据えることで履歴が自然に残すことができる
    • ただしRead ModelやVersioningなど別の複雑さとのトレードオフがある

■ 2. 一般的なRDB設計の問題点

  • ほとんどのRDBで管理されているデータは「現在の状態」だけを保存するように設計されている
  • このテーブルで「注文をキャンセルした」という履歴を残そうとすると以下の方法が考えられる:
    • 方式A:変更前スナップショット方式
    • 方式B:ステータス更新+別途「変更履歴テーブル」にINSERT
    • 方式C:有効期間を持たせるテンポラルテーブル方式

■ 3. 方式A:変更前スナップショット方式の問題点

  • 変更前の状態だけを履歴テーブルに保存する方式である
  • 悩みポイント:
    • 「何に変わったか」が分からない:変更前の状態しか保存していないため履歴テーブルだけを見ても「何に変わったのか」が分からない
    • 時点復元が困難:valid_toがないのでどの履歴レコードがいつまで有効だったか分からない
    • 変更理由・操作内容が残らない:なぜ変更したのかどんな業務操作だったのかこれらのコンテキストが失われるため監査証跡としては不十分になる
    • 連続した変更の追跡が複雑:A→B→Cと3回変更された場合履歴から「2025-01-02時点の状態」を導くには推論する必要がある

■ 4. 方式B:ステータス更新+変更履歴テーブルの問題点

  • ステータス更新と同時に変更履歴テーブルにINSERTする方式である
  • 悩みポイント:
    • ステータス以外の属性変更が追跡できない:この方式はstatusの変更だけを追跡しておりtotal_amountが変更された場合その履歴は残らない
    • 元テーブルと履歴テーブルの整合性が保証されない:2つのテーブルを別々に更新するためアプリケーションコードで「必ず両方更新する」ルールを徹底する必要がある
    • 履歴の正しさを検証できない:元テーブルの現在のstatusと履歴テーブルの最新to_statusが一致している保証もなく履歴の連続性も保証されない
    • 「作成」と「削除」の表現が曖昧:注文が最初に作成されたときのfrom_statusや注文が削除されたときのto_statusをどうするかが曖昧になる
    • 時点復元は改善されるが完全ではない:status以外の属性の時点復元はできない

■ 5. 方式C:テンポラルテーブル方式の問題点

  • 有効期間を持たせるテンポラルテーブル方式である
  • 悩みポイント:
    • 「現在の状態」を取得するクエリが複雑になる:シンプルだったSELECTが毎回時間条件付きになる
    • 関連テーブルもテンポラルにすると「ある時点での注文とその明細」を取得するクエリがより複雑になる
    • 更新では「終了+INSERT」の2ステップが必要:必ずトランザクションで実行しないと不整合が起きてしまいアプリケーションコードも煩雑になる
    • 誤入力の訂正で監査証跡が失われる:過去の行を直接UPDATEすることになり「元々何が記録されていたか」が消えてしまう
    • Valid Timeだけでは「いつシステムがその情報を認識していたか」が分からない:これを解決するにはSystem Timeも必要になりBi-temporalへの対応が求められる
    • 「同時点で複数有効」を防ぐ制約が難しい:標準的なUNIQUE制約では表現できない

■ 6. Bi-temporalの構造と利点

  • Bi-temporalテーブルの各行は通常4つのタイムスタンプを持つ:
    • Valid From/To:現実世界でその事実が有効な期間
    • System From/To:データベースがその事実を知っていた期間
  • この構造により「現在の知識に基づくと先月の価格は10000円であるしかし先週時点の知識では先月の価格は9800円であると認識していた」という複雑な問いに答えることができる

■ 7. Bi-temporalの複雑さと問題点

  • 誤入力訂正が入ると「過去のSystem Timeを書き換える」必要がある
  • 1つの訂正で複数行の更新・追加が必要になる:「金額10000円→9800円」という1つの訂正で既存の2行を終了し新しく2行をINSERTし合計4行の操作が必要になる
  • 「何が訂正されたか」の追跡が困難:system_toで終了した行と新しくINSERTした行の関連付けは暗黙的であり訂正理由も記録されない
  • クエリもさらに複雑になる:「2025-01-03時点で業務上もシステム上も有効だった注文一覧」を取得するだけで複雑なクエリになる
  • Bi-temporalにテーブルを紐づけるとさらに複雑になる:紐づく先のテーブルもBi-temporalにしないと整合性が取れなくなる

■ 8. 状態中心の設計が難しくしている理由

  • ここまで見てきた方式やBi-temporalなどすべてに共通するのは「状態」を保存しようとしていることである
  • 状態とは「ある時点での結果」である
  • 状態を保存するということは結果だけを切り取って保存することになる
  • そのため以下の情報が失われる:
    • なぜその状態になったのか
    • どうやってその状態になったのか
    • 誰がその変化を引き起こしたのか
  • 履歴を残そうとすると失われた情報を補うために追加のカラムやテーブルが必要になり結果として設計が複雑化していく

■ 9. 変化を中心に考えるイベント中心の発想

  • 「状態中心」の発想を変える:
    • イベント中心の発想では「何が起きたか?」を記録する
    • 状態を知りたい場合はイベントを順番に適用して現在の状態を導出する
  • イベントは「起きた事実」そのものである
  • 状態とイベントの比較:
    • 状態は結果でありイベントは原因である
    • 状態は変わりうるがイベントは変わらない
    • 状態は「今どうなっているか」でありイベントは「何が起きたか」である
    • 状態はスナップショットでありイベントは事実の記録である

■ 10. 状態中心とイベント中心の具体的比較

  • 状態中心の場合:
    • 「各時点での状態のスナップショット」を保存する
    • どの行がどう関連しているか何が起きたのかこのデータだけでは分かりにくい
  • イベント中心の場合:
    • 「何が起きたか」をそのまま保存する
    • 何が起きたかなぜ起きたか誰が起こしたかすべて明示的に残すことができる
    • occurred_atは業務上発生した時刻でsystem_fromはシステムが記録した時刻である

■ 11. 現在有効なデータの取得方法

  • 状態中心の場合はWHERE句で時間条件を指定するクエリを実行する
  • イベント中心の場合は2つの方法がある:
    • 方法1:イベントをリプレイして導出する:イベントが少なければ十分だがイベントが膨大になるとリプレイのコストが高くなる
    • 方法2:Read Modelを使う:現在の状態を別テーブルに保持しておきイベント発生時に更新する
  • Read Modelはイベントから導出された状態のキャッシュでありいつでもイベントから再構築できるため要件に応じて自由に設計できるのが特徴である

■ 12. Event Sourcingの課題1:最新状態の取得

  • イベントをリプレイして状態を導出する方式はイベント数が少なければ問題ないが数万〜数十万件になると現実的ではなくなる
  • 解決策としてSnapshotとRead Modelを導入することが考えられる:
    • Snapshot:定期的に「ある時点での状態」を保存しておきそこからリプレイを開始する
    • Read Model:現在の状態を別テーブルに保持しイベント発生時に更新する
  • ただしSnapshotやRead Modelを導入するとシステムの複雑性は増していく

■ 13. Event Sourcingの課題2:有効期間の表現

  • Event Sourcingは「何が起きたか」を記録するが「いつからいつまで有効だったか」という期間情報は直接表現しない
  • 単純に「最新の状態」を持つRead Modelだけでは不十分で期間情報を持った別のRead Modelが必要になる
  • Bi-temporalが必要だった理由は「訂正時に過去の認識を残すため」だったがEvent Sourcingではイベントは不変で追記のみであり訂正もイベントとして記録される
  • つまりイベントストアがシステム時間の役割を担っているのでRead Modelは業務上の有効期間だけを持てば十分である

■ 14. Event Sourcingの課題3:イベントスキーマのVersioning

  • イベントは不変なので一度保存したイベントのスキーマを変更することはできない
  • しかしビジネス要件はフェーズが進むにつれて変化していく
  • 過去のイベントを読み込むとき新しいコードで正しく処理するにはv1からv3に変換するロジックであるUpcasterパターンを用意する必要がある
  • Upcasterパターンとは主にEvent Sourcingで使われる設計パターンで過去に保存された古い形式のイベントを現在の最新のイベント形式に変換するための仕組みである
  • 上記以外に「過去のイベントを物理的に新しいスキーマに書き換える」アプローチもあるがEvent Sourcingの原則を破ることになり監査証跡としての価値が下がる可能性がある

■ 15. Event Sourcingの課題4:取り消しと訂正

  • イベントは不変だが「このイベントは間違いだった」というケースは現実に発生する
  • この問題に対応するためにCompensating Event補償イベントというものを導入する必要がある
  • 「取り消し」や「訂正」も新しいイベントとして記録する
  • ただし「どこまで遡って訂正するか」「関連するイベントへの影響をどう扱うか」は慎重に設計しないと破綻してしまう

■ 16. Event Sourcingの課題5:結果整合性とその他

  • イベント発行からRead Modelの更新は同期的に行われるわけではないためタイムラグがある
  • そのためユーザーが「保存したのに反映されていない」と感じるStale Read問題が発生する
  • UI側で楽観的更新を行ったり同期的な読み取りが必要なケースではイベントストアから直接リプレイすることで対応する
  • 他にも「イベント設計」「Read Modelの設計と更新ロジック」「Snapshot戦略」「エラーハンドリング」など考えないといけないことはたくさんある

■ 17. Event Sourcingを採用すべきかの判断

  • Event Sourcingは「履歴」という要件にはとても強力だがトレードオフは必ず発生する
  • メリット:
    • 完全な履歴が自動的に残る
    • 任意の時点の状態を復元できる
    • 「何が起きたか」が明示的
    • 監査証跡として価値がある
  • デメリット:
    • 最新状態の取得に工夫が必要
    • Read Modelの設計は難しい
    • イベントスキーマのVersioningへの対応が必要
    • 結果整合性への対応が必要
  • Bi-temporalのようなテンポラルデータモデルで運用し続けるのも難しい
  • 少なくとも「履歴」という要件が厳しいときにはEvent Sourcingを1つの選択肢として検討してみると良いかもしれない
  • Event Sourcingを導入するときに一番大切なのは「やり切ると決めたら最後までやり切る」という気持ちである