0Pricing
SQL Academy · レッスン

イベントから状態を再構築する

イベントを現在の状態に畳み込みます

「イベントから状態を再構築する」はCoddyKit上の無料SQL Academyレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはSQL Academy学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 SQL Academyコースには全4レッスンが含まれています。

状態の再構築とは

イベントソーシングでは、データを変更可能な行としてではなく、不変のイベントログとして保存します。何らかの現在の状態を知るには、それらのイベントを再生し、1つの結果に集約する必要があります。

これをイベントからの状態の再構築と呼びます。銀行口座を例に考えてみましょう。残高を保存するのではなく、すべての入金と出金を保存します。残高は、それらすべてのイベントの合計として常に求められます。

シンプルなイベントテーブル

まず、銀行口座システム用の最小限のイベントログを作成してみましょう。各行は、入金または出金という発生した事象を、金額とタイムスタンプとともに表します。

このテーブルが更新されたり削除されたりすることはありません。新しい事実は常に新しい行として追記されます。

CREATE TABLE account_events (
  event_id   SERIAL PRIMARY KEY,
  account_id INT NOT NULL,
  event_type VARCHAR(20) NOT NULL,  -- 'deposit' or 'withdrawal'
  amount     NUMERIC(12, 2) NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

INSERT INTO account_events (account_id, event_type, amount, created_at) VALUES
  (1, 'deposit',    1000.00, '2024-01-01 09:00:00+00'),
  (1, 'deposit',     500.00, '2024-01-03 14:00:00+00'),
  (1, 'withdrawal',  200.00, '2024-01-05 10:00:00+00'),
  (1, 'deposit',     300.00, '2024-01-07 11:00:00+00'),
  (1, 'withdrawal',  150.00, '2024-01-09 16:00:00+00');

イベントを残高に集約する

現在の残高を再構築するには、すべてのイベントを集約します。入金は残高に加算し、出金は残高から減算します。CASE式を使うと、合計する前にイベントの種類ごとに正しい符号を付けられます。

この1つのクエリだけで、過去のイベントログから現在の状態を完全に導き出せます。

SELECT
  account_id,
  SUM(
    CASE event_type
      WHEN 'deposit'    THEN  amount
      WHEN 'withdrawal' THEN -amount
      ELSE 0
    END
  ) AS current_balance
FROM account_events
WHERE account_id = 1
GROUP BY account_id;

特定時点の状態

イベントソーシングの特に強力な点の1つは、任意の時点の状態を再構築できることです。集約する前に、単純にWHERE created_at <= :target_timeフィルターを追加します。

これだけで、スキーマを追加で変更することなく、タイムトラベルクエリを実行できます。履歴はすでにイベントログに記録されているためです。

-- What was the balance at the end of January 5th?
SELECT
  account_id,
  SUM(
    CASE event_type
      WHEN 'deposit'    THEN  amount
      WHEN 'withdrawal' THEN -amount
      ELSE 0
    END
  ) AS balance_at_snapshot
FROM account_events
WHERE account_id = 1
  AND created_at <= '2024-01-05 23:59:59+00'
GROUP BY account_id;

ウィンドウ関数による累計残高

1つの合計値だけでなく、累計残高、つまり各イベント後の残高も計算できます。SUM(...) OVER (ORDER BY ...)ウィンドウ関数は、イベントが時系列順に蓄積されるのに合わせて累積合計を計算します。

これは監査証跡や状態遷移のデバッグに非常に役立ちます。

SELECT
  event_id,
  created_at,
  event_type,
  amount,
  SUM(
    CASE event_type
      WHEN 'deposit'    THEN  amount
      WHEN 'withdrawal' THEN -amount
      ELSE 0
    END
  ) OVER (PARTITION BY account_id ORDER BY created_at, event_id)
    AS running_balance
FROM account_events
WHERE account_id = 1
ORDER BY created_at, event_id;

状態をスナップショットテーブルに実体化する

ログが大きくなると、クエリのたびにすべてのイベントを再生する処理は高コストになることがあります。一般的な最適化方法は、現在の状態をスナップショットテーブルに実体化し、定期的または必要に応じて再構築することです。

スナップショットには集約結果が保存されるため、クエリは毎回ログ全体を再生する代わりに、スナップショットからデータを読み取ります。

CREATE TABLE account_snapshots (
  account_id      INT PRIMARY KEY,
  current_balance NUMERIC(12, 2) NOT NULL,
  as_of_event_id  INT NOT NULL,
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Populate / refresh the snapshot from the event log
INSERT INTO account_snapshots (account_id, current_balance, as_of_event_id, updated_at)
SELECT
  account_id,
  SUM(CASE event_type WHEN 'deposit' THEN amount WHEN 'withdrawal' THEN -amount ELSE 0 END),
  MAX(event_id),
  NOW()
FROM account_events
GROUP BY account_id
ON CONFLICT (account_id) DO UPDATE
  SET current_balance = EXCLUDED.current_balance,
      as_of_event_id  = EXCLUDED.as_of_event_id,
      updated_at      = EXCLUDED.updated_at;

スナップショットの増分更新

新しいイベントが到着しても、履歴全体を再生する必要はありません。スナップショットに最後に処理したevent_idを記録しておけば、スナップショット作成後に到着したイベントだけを差分として適用できます。

この増分更新パターンにより、大規模なログでもスナップショットをすばやく更新できます。

-- Apply only new events since the last snapshot
UPDATE account_snapshots AS snap
SET
  current_balance = snap.current_balance + delta.net,
  as_of_event_id  = delta.max_event_id,
  updated_at      = NOW()
FROM (
  SELECT
    ae.account_id,
    SUM(CASE ae.event_type WHEN 'deposit' THEN ae.amount WHEN 'withdrawal' THEN -ae.amount ELSE 0 END) AS net,
    MAX(ae.event_id) AS max_event_id
  FROM account_events ae
  JOIN account_snapshots s ON s.account_id = ae.account_id
  WHERE ae.event_id > s.as_of_event_id
  GROUP BY ae.account_id
) AS delta
WHERE snap.account_id = delta.account_id;

テンポラルテーブルとシステムバージョン管理

SQL:2011では、データベース自体が管理するシステムバージョン管理テンポラルテーブルが導入されました。すべての行にvalid_fromとvalid_toの列が自動的に追加され、データベースエンジンによって管理されます。

PostgreSQLはこの機能を標準ではサポートしていませんが、エミュレートできます。MariaDBやSQL Serverなど、WITH SYSTEM VERSIONINGを直接サポートしているデータベースもあります。

-- Emulating a temporal table in PostgreSQL
CREATE TABLE account_state_history (
  account_id      INT NOT NULL,
  current_balance NUMERIC(12, 2) NOT NULL,
  valid_from      TIMESTAMPTZ NOT NULL,
  valid_to        TIMESTAMPTZ NOT NULL DEFAULT 'infinity'
);

-- Insert initial state
INSERT INTO account_state_history (account_id, current_balance, valid_from)
VALUES (1, 1000.00, '2024-01-01 09:00:00+00');

-- On update: close old row, insert new row
UPDATE account_state_history
  SET valid_to = '2024-01-03 14:00:00+00'
WHERE account_id = 1 AND valid_to = 'infinity';

INSERT INTO account_state_history (account_id, current_balance, valid_from)
VALUES (1, 1500.00, '2024-01-03 14:00:00+00');

テンポラル履歴をクエリする

エミュレートしたテンポラルテーブルを用意すると、有効期間の範囲でフィルタリングして、過去の任意の時点で残高がいくらだったかを確認できます。対象のタイムスタンプを含む範囲の行が、その時点の状態を表します。

このパターンでは、クエリのロジックとイベントの再生を切り離せます。状態履歴テーブルはあらかじめ集約されているためです。

-- What was the account balance on January 4th?
SELECT
  account_id,
  current_balance,
  valid_from,
  valid_to
FROM account_state_history
WHERE account_id = 1
  AND valid_from <= '2024-01-04 00:00:00+00'
  AND valid_to   >  '2024-01-04 00:00:00+00';

複数エンティティでのイベントソーシング

実際のシステムでは、一度に多数のエンティティのイベントを追跡します。entity_id列とentity_type列を持つ共有イベントログを使えば、1つのテーブルから任意のオブジェクトの状態を再構築できます。

ここでは、複数の商品にまたがる在庫の移動を追跡します。各商品の現在の在庫を再構築する処理も、グループ化した集約だけで実現できます。

CREATE TABLE inventory_events (
  event_id    SERIAL PRIMARY KEY,
  product_id  INT NOT NULL,
  event_type  VARCHAR(20) NOT NULL,  -- 'received', 'shipped', 'adjusted'
  quantity    INT NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

INSERT INTO inventory_events (product_id, event_type, quantity, created_at) VALUES
  (101, 'received',  200, '2024-03-01 08:00:00+00'),
  (101, 'shipped',    50, '2024-03-02 12:00:00+00'),
  (101, 'shipped',    30, '2024-03-04 15:00:00+00'),
  (102, 'received',  150, '2024-03-01 08:00:00+00'),
  (102, 'adjusted',  -10, '2024-03-03 09:00:00+00');

-- Rebuild current stock for all products
SELECT
  product_id,
  SUM(CASE event_type WHEN 'received' THEN quantity WHEN 'shipped' THEN -quantity ELSE quantity END) AS stock_on_hand
FROM inventory_events
GROUP BY product_id
ORDER BY product_id;

CTEでクエリを明確にする

状態を再構築するクエリは複雑になることがあります。集約処理をCTEで包むと、読みやすくなり、再構築した状態を他のテーブルと簡潔に結合できます。

ここでは、アカウント残高を再構築してからアカウントの参照テーブルと結合し、所有者の名前を出力に含めます。

CREATE TABLE accounts (
  account_id INT PRIMARY KEY,
  owner_name VARCHAR(100) NOT NULL
);

INSERT INTO accounts (account_id, owner_name) VALUES
  (1, 'Alice'),
  (2, 'Bob');

INSERT INTO account_events (account_id, event_type, amount, created_at) VALUES
  (2, 'deposit',   2000.00, '2024-01-02 10:00:00+00'),
  (2, 'withdrawal', 400.00, '2024-01-06 11:00:00+00');

WITH rebuilt_balances AS (
  SELECT
    account_id,
    SUM(CASE event_type WHEN 'deposit' THEN amount WHEN 'withdrawal' THEN -amount ELSE 0 END) AS balance
  FROM account_events
  GROUP BY account_id
)
SELECT
  a.account_id,
  a.owner_name,
  rb.balance
FROM accounts a
JOIN rebuilt_balances rb USING (account_id)
ORDER BY a.account_id;

理解度チェック

SQLでイベントから状態を再構築する方法を理解できているか確認しましょう。

レッスンのまとめ

このレッスンでは、不変のイベントログからSQLを使って現在の状態と過去の状態を再構築する方法を学びました。

重要なポイント:

  • イベントを集約することで状態を導き出します。SUMの中で、符号付きのCASE式を使います。
  • タイムスタンプのフィルターを追加するだけで、追加コストなしに特定時点のクエリを実行できます。
  • ウィンドウ関数を使うと、イベントごとの累積状態を生成できます。
  • スナップショットテーブルは集約結果を実体化してパフォーマンスを向上させます。増分更新では新しいイベントだけを適用します。
  • エミュレートしたテンポラルテーブルには、有効期間とともにあらかじめ集約された状態の行が保存されるため、過去の状態をすばやく検索できます。
  • CTEを使うと、導き出した状態を他のテーブルと結合する必要がある場合でも、再構築クエリを読みやすく保てます。

これらのパターンは、イベントソーシングや監査に適したデータベース設計の基盤となります。

よくある質問

「イベントから状態を再構築する」レッスンは無料ですか?

はい。「イベントから状態を再構築する」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、SQL Academyコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 SQL Academyコースには全4レッスンが含まれています。

「イベントから状態を再構築する」で何を学びますか?

イベントを現在の状態に畳み込みます ブラウザで直接実行するハンズオンコードでSQL Academyを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

SQL Academyを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのSQL Academyは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。

「イベントから状態を再構築する」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このSQL Academyレッスンでコードを書いて実行できますか?

はい。すべてのSQL Academyレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. 履歴を保持する理由
  2. 追記専用イベントテーブル
  3. 時間的行とバージョン管理された行
  4. イベントから状態を再構築する
← SQL Academyに戻る