ロングタームイベントパターンについて考える
まえがき
この記事は愛の手書きです。
Webアプリケーション開発において、「Statusカラム」をもつテーブル設計はよくお見かけするものです。
状態を表現するのにミュータブルな設計はあるあるです。
しかし、「Statusカラム」で状態を管理すると技術的な負債になったり保守性が下がったりするようです。
そこでイミュータブルデータモデルについてまとめ、どうするべきか考えていきます。
従来の設計の問題点
ECサイトの注文を例に考えてみます。
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL, -- 'pending', 'processing', 'shipped', 'delivered'
-- statusによって使われるカラムが変わる
payment_date TIMESTAMP NULL, -- 'processing'以降で使用
payment_method VARCHAR NULL, -- 'processing'以降で使用
shipping_date TIMESTAMP NULL, -- 'shipped'以降で使用
tracking_number VARCHAR NULL, -- 'shipped'以降で使用
delivered_date TIMESTAMP NULL, -- 'delivered'で使用
delivery_signature VARCHAR NULL, -- 'delivered'で使用
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
「Statusカラム」で状態を管理するつらみ
- NULLカラムの混在
- Statusによって使用されるカラムが異なったり、NULLがデフォルトになる
- 履歴の喪失
- 状態変更の履歴が残らない😢
- 複雑なバリデーションがアプリケーションに寄る
- Status事に異なるバリデーションロジックが必要になり、それがアプリケーションに寄る。
- 保守性の低下
- 新しい状態やカラムの追加に気を使うのがつらい
- 並行処理がしづらい
- 同時更新による状態の競合がつらい
イミュータブルデータモデルの基本原理
基本思想
「データは変更せず、常に新しいレコードを追加する」
従来の設計との違い
-- ミュータブル(変更可能)な設計
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance DECIMAL NOT NULL,
updated_at TIMESTAMP
);
-- 残高変更時は既存レコードをUPDATE
UPDATE accounts SET balance = 1500, updated_at = NOW() WHERE id = 1;
-- イミュータブルな設計
CREATE TABLE account_events (
id BIGINT PRIMARY KEY,
account_id BIGINT NOT NULL,
event_type VARCHAR NOT NULL, -- 'deposit', 'withdrawal', 'transfer'
amount DECIMAL NOT NULL,
balance_after DECIMAL NOT NULL,
occurred_at TIMESTAMP NOT NULL,
transaction_id VARCHAR UNIQUE
);
-- 残高変更時は新しいイベントをINSERT
INSERT INTO account_events (account_id, event_type, amount, balance_after, occurred_at, transaction_id)
VALUES (1, 'deposit', 500, 1500, NOW(), gen_random_uuid());
イミュータブルデータモデルのメリット
-
完全な監査ログが取れる
- 変更履歴がレコードとしてすべて残る
-
並行処理がしやすい
- 競合状態が発生しづらい
ロングタームイベントパターン
基本思想
「長期間で完結するイベント(プロセス)をステータス管理ではなく、個別のイベントテーブルで表現する」
例
-- 注文作成イベント
CREATE TABLE order_placed (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL UNIQUE,
customer_id BIGINT NOT NULL,
total_amount DECIMAL NOT NULL,
placed_at TIMESTAMP NOT NULL
);
-- 支払完了イベント
CREATE TABLE order_payment_completed (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES order_placed(order_id),
payment_method VARCHAR NOT NULL,
payment_amount DECIMAL NOT NULL,
payment_date TIMESTAMP NOT NULL,
transaction_id VARCHAR UNIQUE NOT NULL
);
-- 出荷イベント
CREATE TABLE order_shipped (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
shipping_address TEXT NOT NULL,
tracking_number VARCHAR NOT NULL,
carrier VARCHAR NOT NULL,
shipped_date TIMESTAMP NOT NULL
);
-- 配達完了イベント
CREATE TABLE order_delivered (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
delivered_date TIMESTAMP NOT NULL,
recipient_signature VARCHAR,
delivery_notes TEXT
);
-- キャンセルイベント
CREATE TABLE order_cancelled (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
cancellation_reason TEXT NOT NULL,
cancelled_by BIGINT NOT NULL, -- user_id
cancelled_at TIMESTAMP NOT NULL,
refund_amount DECIMAL
);
特徴
- 状態ごとのテーブル分離
-
NULL
カラムがない - ビジネスロジックの分離
CQRS (Command Query Responsibility Segregation) パターン
蛇足ですが、こちらも触れてみます。
基本思想
「書き込み用(Command)と読み込み(Query)のモデルを完全に分離する」
例
-- Command側(書き込み専用)- イベントストア
CREATE TABLE domain_events (
id BIGINT PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
aggregate_type VARCHAR NOT NULL,
event_type VARCHAR NOT NULL,
event_data JSONB NOT NULL,
occurred_at TIMESTAMP NOT NULL,
version INT NOT NULL,
UNIQUE(aggregate_id, version)
);
-- Query側(読み込み専用)- ビューテーブル
CREATE TABLE order_summary_view (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
customer_name VARCHAR NOT NULL,
total_amount DECIMAL NOT NULL,
current_status VARCHAR NOT NULL,
payment_status VARCHAR NOT NULL,
shipping_status VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL,
last_updated TIMESTAMP NOT NULL
);
↑はイベントハンドラによるビューの更新も併せてやる必要がある
CQRSのメリット
- 読み書きが独立しているのでスケールしやすい
- 読み込み・書き込みを個別に最適化しやすい
- 複雑なクエリの最適化がしやすい
*ビューテーブルで複雑な集計やJOINを事前にできる - 責務の分離がしやすい
- ビジネスロジックと表示ロジックを分けることができる
Saga パターン
基本思想
「長期にわたる分散トランザクションを、小さなローカルトランザクションで連鎖的に管理し、失敗時は補償処理で一貫性を保つ」
分散トランザクションのつらみ
外部サービスなどを挟むとき、トランザクションの管理やロールバックが難しい。
注文システム ←→ 在庫システム ←→ 決済システム ←→ 配送システム
オーケストレーション型Saga
中央集権的にトランザクション(処理)を管理する。
-- Saga定義テーブル
CREATE TABLE saga_definitions (
saga_type VARCHAR PRIMARY KEY,
steps JSONB NOT NULL -- ステップ定義
);
-- Saga実行状況管理
CREATE TABLE saga_instances (
saga_id BIGINT PRIMARY KEY,
saga_type VARCHAR NOT NULL REFERENCES saga_definitions(saga_type),
aggregate_id BIGINT NOT NULL,
current_step_index INT NOT NULL DEFAULT 0,
status VARCHAR NOT NULL, -- 'running', 'completed', 'failed', 'compensating'
compensation_started_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP
);
-- 個別ステップの実行履歴
CREATE TABLE saga_step_executions (
id BIGINT PRIMARY KEY,
saga_id BIGINT NOT NULL REFERENCES saga_instances(saga_id),
step_index INT NOT NULL,
step_name VARCHAR NOT NULL,
execution_type VARCHAR NOT NULL, -- 'forward', 'compensation'
status VARCHAR NOT NULL, -- 'pending', 'completed', 'failed'
started_at TIMESTAMP,
completed_at TIMESTAMP,
error_message TEXT,
retry_count INT DEFAULT 0
);
処理シーケンス例
実装例 オーケストレーション型(AI)
type SagaOrchestrator struct {
db *sql.DB
}
func (s *SagaOrchestrator) ExecuteOrderSaga(ctx context.Context, orderID int64) error {
// Sagaの状態をDBで管理
tx, _ := s.db.BeginTx(ctx, nil)
defer tx.Rollback()
sagaID := s.createSaga(tx, orderID, "order_fulfillment")
// ステップ1: 在庫確保
if err := s.inventoryService.Reserve(ctx, orderID); err != nil {
s.markSagaFailed(tx, sagaID, "inventory_failed")
tx.Commit()
return err
}
s.markStepCompleted(tx, sagaID, "inventory_reserved")
// ステップ2: 決済処理
if err := s.paymentService.Process(ctx, orderID); err != nil {
// 補償処理を順次実行
s.inventoryService.Release(ctx, orderID) // 在庫戻し
s.markSagaFailed(tx, sagaID, "payment_failed")
tx.Commit()
return err
}
s.markStepCompleted(tx, sagaID, "payment_completed")
// オーケストレーターが全体の成功/失敗を制御
s.markSagaCompleted(tx, sagaID)
return tx.Commit()
}
コレオグラフィ型Saga
基本思想
各サービスが「前のサービスの成功イベント」を受け取って、自身の処理を実行し、次のサービスに「自身の成功/失敗イベント」を渡す設計思想。
ここまでくるとアプリケーション側の設計しそうな気もするが…。
処理シーケンス例
実装例 コレオグラフィ型(AI)
// 各サービスが独立したトランザクション管理
type InventoryService struct {
db *sql.DB
}
func (s *InventoryService) HandleOrderPlaced(ctx context.Context, event OrderEvent) error {
// 自身のローカルトランザクション
tx, _ := s.db.BeginTx(ctx, nil)
defer tx.Rollback()
// 在庫確保 + イベント発行を同一トランザクションで
if err := s.reserveStock(tx, event.ProductID, event.Quantity); err != nil {
// 失敗イベントを発行(Outboxパターン)
s.publishEvent(tx, "inventory_reservation_failed", event.OrderID)
return tx.Commit() // 失敗も正常にコミット
}
// 成功イベントを発行
s.publishEvent(tx, "inventory_reserved", event.OrderID)
return tx.Commit()
}
// 補償処理も独立して管理
func (s *InventoryService) HandleReleaseRequest(ctx context.Context, event OrderEvent) error {
tx, _ := s.db.BeginTx(ctx, nil)
defer tx.Rollback()
s.releaseStock(tx, event.OrderID)
s.publishEvent(tx, "inventory_released", event.OrderID)
return tx.Commit()
}
オーケストレーション型とコレオグラフィ型の違い
観点 | オーケストレーション型 | コレオグラフィ型 |
---|---|---|
制御方式 | 中央のオーケストレーターが制御 | 各サービスが自律的に判断 |
トランザクション管理 | Sagaテーブルで集中管理 | 各サービスでローカル管理 |
状態管理 |
saga_instances テーブル |
分散イベントログ |
補償処理 | 明示的な逆順実行 | イベント駆動の補償要求 |
エラーハンドリング | 集中的なエラー制御 | 各サービスが独立処理 |
実装の複雑さ | オーケストレーターに集約 | 各サービスに分散 |
デバッグ | 中央ログで追跡容易 | 分散ログで追跡困難 |
単一障害点 | オーケストレーターが該当 | なし |
スケーラビリティ | オーケストレーターがボトルネック | 各サービス独立スケール |
サービス結合度 | 高い(オーケストレーターと結合) | 低い(イベント経由のみ) |
適用場面 | 複雑なビジネスロジック | 成熟したマイクロサービス |
Saga パターンのメリット
- 分散システムでの一貫性が保てる
- マイクロサービス間での整合性が保ちやすい
- 失敗からの回復がしやすい
- 部分的な失敗からロジックで回復できる
- スケーラビリティがある
- 各ステップを独立して実行できるのでスケールしやすい
どんなときに使うんだろう🤔
変更管理ベースでトレーシングしたいときかなぁ。
導入場面(想像)
- 複雑な状態遷移を持つ
- 監査要件が厳しい(決済など)
- 長期間にわたるワークフローがある(非同期な処理が多い)
- 分散システム・マイクロサービス
いらん場面(想像)
- 小さいCRUDのみのアプリ
- 開発速度優先のプロジェクト
ただただ複雑になるだけでつらみが増すケースもあるだろうから…。
ただただ雑に思ったことを深堀りするセクション
インデックス戦略とクエリの最適化
-
時系列データになるのでパーティショニングが重要。
-
RANGE
でスキャンの範囲を削減することができる?
-
-
状態取得のクエリパフォーマンス
- 現在の状態を効率的に取得するのに困難になりそう。
- どうする?
- スナップショット戦略
- 最新イベントのみフラグを立てる
- 部分インデックス
- スナップショットテーブル更新して最新レコードへのインデックスを効かせる
- スナップショット戦略
-
複合インデックス
- カーディナリティはよさそう。
- でもイベント種別をまるっと取ってくるとなるとなると🤔どうなるんだろう。
データ爆発おじさん
イベントのたびにレコードが追加されるのでとんでもないレコード数になる。
-
アーカイブ戦略
- アクティブな期間を設けてそれ以外は、コールドストレージに移動する
- 物理バックアップもあり(PostgreSQLはポイントインタイムリカバリがしやすい)
-
スナップショット作成
- イベント数の閾値もしくは日次週次でスナップショット
- インクリメンタルスナップショットがデータ量的にはグッド。
- 排他制御が難しそう
-
古いイベントの削除ポリシー
- 論理削除、カスケード削除など適切なタイミングで適切な削除方法を取る必要がある。
整合性の保証
実際実装レベルになると頭パンクしそう。
-
楽観的ロックと悲観ロックの使い分け
- イミュータブルデータモデルでイベントソーシング的に状態を管理するなら、原則楽観的ロック。
- システム自体のスループットを上げたいので。
- 悲観的ロックは?
- 金融取引みたいな整合性マストの処理。タイムアウトとデッドロック検出はマスト。
- イミュータブルデータモデルでイベントソーシング的に状態を管理するなら、原則楽観的ロック。
-
バージョニング
- イベントスキーマの変化と後方互換性の両立がむずい。
- アグリゲートレベルのバージョニング
- 各エンティティが独立したバージョン番号を持つ。ユニーク制約をつけることで並行更新時の競合を検出。
- イベントストリームのバージョニング
- 全体的な順序保証と因果関係の追跡を行う。
- Vector Clock?
- 論理時間の管理。
- 各ノードが独立したカウンターを持ち、イベント発生時に自身のカウンターをインクリメント、他ノードとの通信時にベクター全体を更新して、因果関係を追跡する。
エラーハンドリングとリトライ
-
冪等性の担保
-
Idempotency Key
(Stripeでみたやつ)による重複検出。 - アトミックな重複検出機能がほしい。
-
Content-based Idempotency
でイベントペイロードのハッシュを用いた意味的な重複検出もあり
-
-
デッドレターキュー
- 指数バックオフによる自動リトライと回数制限がよさそう。
- この辺の階層化とか何も分からんので勉強。
-
部分的失敗の状態復旧
- 補償ベーストランザクション。
- チェックポイントとリスタートを行うケースも有る。
- PostgreSQLだと
SAVEPOINT
とかいう神機能があるらしい - 実際この辺は書いてみないとかな。
時間の取り扱いについて
-
イベント発生時刻・処理時刻の使い分け?
- 明確に分離することが整合性担保の第一歩。
- 因果順序の判定はイベント発生時刻、処理順序の管理は処理時刻を使う。
- 差し込みにも上限を設けて一定時間を超えた処理時刻は別で取り扱う必要がありそう。
-
タイムゾーンの考慮
-
UTC正規化が基本戦略。
- 時刻の曖昧性は致命的なので、統一する。
- アプリケーション側に表示ロジックやその他のロジックは任せる卍
-
UTC正規化が基本戦略。
-
遅延到着イベントをどうする?
- ヒーローとは遅れて到着するものだ🪭
- ということで基本的に遅れてイベントは到着する。
- 許容時間を設けて正常系と異常系を分ける。
- PostgreSQLならNotifyで気持ちよくなれそう
- ヒーローとは遅れて到着するものだ🪭
まとめ
マーティンファウラー教に入信しました。
思考放棄でステータスカラムでの管理はNG。
将来を見据えたデータ設計がだいじ。
特に状態管理にはロングタームイベントパターンが有効な場合がある😎
過ぎたるは及ばざるが如し。
Discussion