アーキテクチャ●●●●○

イベントソーシング

Event Sourcing ・ いべんとそーしんぐ

現在の状態ではなく、状態を変えたイベントの列を真実として追記し、状態は再生して導く設計。

✦ 30秒で試す — 航海口座の残高は、実はどこにも保存されていない本当は3千円の出金を、3万円と誤って記録した。この方式での直し方は?予想してみる

概要

イベントソーシングは、データの「現在の状態」ではなく、状態を変化させてきた「イベントの列」を真実(source of truth)として保存する設計です。銀行口座を例にすると、ふつうは「残高 14,000円」という現在値を保存しますが、イベントソーシングでは「10,000円入金」「3,000円出金」「5,000円入金」「2,000円入金」という出来事を発生順にすべて記録し、残高はそれらを足し合わせて導く、という考え方をします。

つまり「今どうなっているか」は保存せず、「何が起きたか」だけを追記していきます。現在の状態が欲しければ、先頭からイベントを順に再生(リプレイ)して積み上げれば求まります。会計の元帳(一度書いたら消さず、修正も新しい記帳で表す)とよく似た、追記専用(append-only)の記録モデルです。監査のためのログが最初から業務データそのものに組み込まれている設計、と言い換えることもできます。

なぜ生まれたか

一般的なデータベースは、行を上書き(UPDATE)して現在の値を持ちます。この方式は「今の値」を読むには最速ですが、上書きの瞬間に過去が消えます。残高が 14,000円だとして、それがどんな入出金の積み重ねでそうなったのか、いつ・なぜその値になったのかは、履歴を別途ログに残していなければ復元できません。

そのため多くのシステムが、業務テーブルとは別に監査ログや履歴テーブルを後付けし、二重に書き込んで整合を保つ、という手間をかけてきました。それでも「なぜこの状態になったのか」を完全に追うのは難しく、金融や在庫のように過去の全経緯が問われる領域では大きな弱点でした。加えて、マイクロサービスで状態の変化を他のサービスに伝えたいとき、「変わった結果」だけでなく「何が起きたか」をメッセージキューに流したいという需要も高まりました。

イベントソーシングは、この「状態を上書きして過去を捨てる」前提をひっくり返します。Martin Fowler は2005年の記事でこのパターンに「Event Sourcing」という名前を与えて整理し、2010年前後には Greg Young が CQRS と組み合わせた形で広めました(彼が関わった専用データベース EventStore も生まれています)。起きた出来事そのものを一次データとして残せば、監査ログは仕組みとして最初から存在し、現在の状態はいつでも再計算でき、状態の変化はそのまま他のシステムへ配信できるイベントになります。

詳細

イベントを積み上げて状態を導く

イベントソーシングの中核は、「状態はイベントの畳み込み(左から順に積み上げたもの)である」という等式です。空の初期状態から始めて、記録されたイベントを1つずつ適用していくと、任意の時点の状態が再現できます。次の図は、保存されたイベント列をイベントストアから読み出し、順に再生して現在の残高を組み立てる流れです。

イベントストアアプリイベントストアアプリ0 → 10000 → 7000 → 12000 → 14000 円この口座のイベントを全部ください口座開設10000円入金3000円出金5000円入金2000円入金順に適用して残高を積み上げる

現在の残高 14,000円はどこにも保存されておらず、イベントを再生した結果として毎回導かれます。この「保存されているのはイベントだけ、状態は計算で出す」という性質が、イベントソーシングのすべての利点と難しさの源になっています。下の部品で、カーソルを動かして過去のどの時点にも戻れること、誤りを直すときに過去を消さずに済むことを確かめてください。

⚡ 体験: 残高をイベントから組み上げる
イベント列(追記専用・押すと取り消しを追記)口座開設#1+10,000#2 入金−3,000#3 出金+5,000#4 入金−30,000#5 出金+2,000#6 入金スナップショット(4件目時点 ¥12,000)¥0−¥16,000スナップショットで省略再生: スナップショット+2件
追記する
再生した位置6 / 6件導いた残高−¥16,000保存されている残高なし

いまの残高 −¥16,000 は、どこにも保存されていません。金のカーソルを左へドラッグして、イベントを巻き戻してください。

イベントストアという追記専用の器

イベントを保存する場所をイベントストアと呼びます。最大の特徴は追記専用で、一度書いたイベントは書き換えも削除もしないことです。「間違えた」ときも過去のイベントを消すのではなく、「取消」という新しいイベントを追記して表現します。部品で見たとおり、誤りのイベントは残ったまま現在の状態だけが直るので、どの時点の状態も当時のまま再現でき、「いつ誤りが入り、いつ直したか」まで記録に残ります。

追記しかしないとはいえ、同時に書き込む人がいれば競合は起きます。同じ口座に2つのコマンドが同時に届き、どちらも「残高 10,000円」を前提に出金を判断したら、合計で残高を超える出金が通ってしまいます。そこでイベントストアへの追記では、「この口座のイベントが v5 まであることを前提に v6 を書く」という期待バージョンを添え、前提が崩れていたら(誰かが先に v6 を書いていたら)追記を拒否します。後から来た側は最新のイベントを読み直してやり直します。ロックを取らずに衝突だけを検出する、楽観ロックと悲観ロックでいう楽観ロックと同じ考え方です。

スナップショット — 再生コストの抑え方

毎回すべてのイベントを再生していては、履歴が数万件に育つと現在の状態を求めるだけで重くなります。そこで実務では、ある時点までの積み上げ結果を「スナップショット」として定期的に保存し、以降はスナップショットとその後の差分のイベントだけを再生します。スナップショットは再計算のキャッシュにすぎず、真実はあくまでイベント列のほうです。壊れても、イベント列から作り直せます。

先頭から全部を再生する口座開設+10,000−3,000+5,000+2,000¥0¥10,000¥7,000¥12,000¥14,000左から順に1件ずつ適用(畳み込み)残高 ¥14,000保存せず毎回導くスナップショットから差分だけを再生する1〜4件目は読まないスナップショット4件目時点 ¥12,000+2,000残高 ¥14,00012,000 + 2,000スナップショットは再計算のキャッシュ。消えてもイベント列から作り直せる
状態はイベント列の畳み込み。スナップショットは途中までの結果をとっておくキャッシュで、差分だけを足せばよくなる

CQRS との組み合わせ

イベントソーシングは、書き込みモデルと読み取りモデルを分離する CQRS としばしばセットで語られます。イベント列は「何が起きたか」を厳密に記録するのには最適ですが、「現在の一覧を条件で検索する」といった読み取りには向きません。そこで、イベントが追記されるたびにそれを購読して、検索や表示に最適化した別の読み取り用データ(リードモデル、投影=プロジェクションとも呼ぶ)を組み立てておきます。書き込みはイベントストアへ、読み取りは投影から、という役割分担です。イベントから投影を作るのは非同期になりがちで、書いた直後に読むとまだ反映されていない、という結果整合性の性質が表に出ます。

落とし穴 — スキーマの進化と「消せない」ことの重さ

強力な設計ですが落とし穴もあります。まず、一度書いたイベントの形式(スキーマ)を後から変えにくいことです。過去のイベントは未来永劫再生され続けるため、フィールドの意味を変えると古いイベントの再生が壊れます。イベントにバージョンを付け、読み込むときに古い形式を新しい形式へ変換する(アップキャスト)といった「イベントの進化」への配慮が要ります。

次に、消せないことそのものが問題になる場面があります。GDPR のような個人情報保護の規則では、本人の求めに応じてデータを削除しなければなりませんが、追記専用のイベント列から個人情報だけを消すのは設計思想に反します。よく使われるのが crypto shredding(暗号シュレッディング)で、個人情報は利用者ごとの鍵で暗号化してイベントに記録しておき、削除の求めがあればその鍵だけを捨てます。イベント列は壊さずに、その人の情報だけが二度と読めなくなります。最初から個人情報をイベントの外(削除できる別のストア)に置き、イベントには参照だけを載せる設計もあります。

設計としての難度が高いぶん、履歴が本質的に重要な領域(金融、在庫、監査が厳しい業務)に絞って使うのが現実的で、あらゆる CRUD をこれで置き換えようとするのは過剰になりがちです。データベースの内部が同じ発想で動いていることも覚えておくとよいでしょう。WAL は変更の記録を先に追記し、テーブルはそこから導かれる、という意味で、データベースの中に隠れたイベントソーシングだと言えます。

試問

口座の入出金を、起きた順にイベントとして追記していく画面です。残高はどこにも保存されておらず、金のカーソルより左のイベントを順に足し合わせて導かれます(カーソルはドラッグで動かせる)。イベントを押すと、それを打ち消す「取消」イベントが末尾に追記され、下のボタンで入金・出金も追記できます。5件目の「出金 −30,000」は誤入力で、本当は「出金 −3,000」でした。

✦ 命題挑戦中

過去のイベントを1つも書き換えずに、現在の残高を本当の ¥11,000 に正し、そのうえで誤入力の直後の残高もそのまま再現して見せよ

  • 誤入力の #5(出金 −30,000)を打ち消す
  • 全部のイベントを再生した現在の残高が、本当の ¥11,000(誤入力の本当の額は 出金 −3,000)
  • カーソルを誤入力の直後に合わせ、当時の残高 −¥18,000 をそのまま再現する
⚡ 体験: 残高をイベントから組み上げる
イベント列(追記専用・押すと取り消しを追記)口座開設#1+10,000#2 入金−3,000#3 出金+5,000#4 入金−30,000#5 出金+2,000#6 入金スナップショット(4件目時点 ¥12,000)¥0−¥16,000スナップショットで省略再生: スナップショット+2件
追記する
再生した位置6 / 6件導いた残高−¥16,000保存されている残高なし

いまの残高 −¥16,000 は、どこにも保存されていません。金のカーソルを左へドラッグして、イベントを巻き戻してください。