Postgres 19に期待: ついに『時間』を扱うとき
(pgedge.com)- データが**特定の日付(先週の火曜日)時点でどのような状態だったか、という問いに対応するため、Postgres 19がネイティブな時点履歴テーブル(temporal table)**のサポートを導入。別個の監査トリガーシステムなしで変更前後のデータ追跡が可能に
- SQL:2011標準が10年以上前に定義した Temporal テーブルを、Postgres がようやくコアに反映。他のDBエンジンより遅れての参加
- 従来の
valid_from/valid_to2カラムとbtree_gist拡張ベースの排他制約(exclusion constraint)の代わりに、単一の範囲型(range type)カラムとWITHOUT OVERLAPS制約で、より直感的な表現を提供 FOR PORTION OF構文により、UPDATE・DELETE時の行の自動分割を実現。時間軸の空白や重複の防止をエンジンが処理- 今回の導入はバイテンポラル(bi-temporal)システムのうち**有効時間(application time)**に相当する半分であり、システム時間(system time)は未対応だが、今後のリリースに向けた基盤を整備
従来の方法 - The Old-Fashioned Way
- 製品価格を時系列で追跡しようとする最初の試みは、
valid_from、valid_toの2つの日付カラムとvalid_from < valid_toの CHECK 制約で構成される- しかし同一製品について日付範囲が重なる2行の挿入は防げない(例: 製品42番が同じ火曜日に $9.99 でもあり $14.99 でもある)
- 伝統的な解決策は
btree_gist拡張と排他制約(exclusion constraint)の使用EXCLUDE USING gist (product_id WITH =, daterange(valid_from, valid_to) WITH &&)の形で、重複する行の挿入時にエラーを発生させる
- この方法の問題点
- GiST は Postgres 固有のインデックスで経験が必要であり、選択的拡張である点も参入障壁になる
- 排他制約の構文が直感的ではなく、標準的なアプローチとして思いつきにくい
- テーブル自体に時点認識が組み込まれていないため、時間範囲の変更時には行を手動で分割・結合する必要があり、時点整合性の負担がアプリケーション側に押し付けられる
時間の短い歴史 - A Brief History of Time
- SQL:2011標準で、有効時間期間(
APPLICATION TIME)、WITHOUT OVERLAPS制約、時点データ操作用のFOR PORTION OF構文が導入された - Henrietta Dombrovskaya(Hetti)が Chad Slaughter とともにpg_bitemporal拡張を開発。PL/pgSQL により Postgres 内でバイテンポラルテーブルを管理するフレームワーク
- 2015年から複数のカンファレンスで概念を発表し、有効時間(valid time)(現実世界で事実が真である時点)と取引時間(transaction time)(DBがその事実を記録した時点)を同時に追跡する方法を実演
- 2つの時間次元の区別
- 有効時間は「この価格は1月から6月まで有効」という意味
- 取引時間は「この行は3月12日15時47分に挿入され、4月3日9時1分に置き換えられた」というDB視点
- これらを組み合わせると、「当時把握していた情報に基づいて、先週の火曜日の価格を何だと考えていたか」に答えるバイテンポラルテーブルを構成できる
- pg_bitemporal は
EXCLUDE USING gistを二重に使用し、effective範囲(有効時間)とasserted範囲(取引時間)にそれぞれ適用する- バイテンポラルな挿入・更新・訂正・無効化・削除のための関数と、時点推論のための**Allen の区間関係(Allen's interval relationships)**の実装を提供
- 拡張の限界
- クエリプランナが時点条件を認識するよう変更したり、エンジンレベルの制約システムと統合したり、ネイティブな操作構文を提供したりはできない → コアへの取り込みが必要
- Postgres 19 はバイテンポラルシステムの有効時間に相当する半分を受け入れた。完全ではないが大きな前進
範囲が救う - Ranges to the Rescue
- Postgres 19 の方式では、別個の
valid_from/valid_toの代わりに単一の範囲型カラムvalid_at DATERANGEを使うPRIMARY KEY (product_id, valid_at WITHOUT OVERLAPS)により、btree_gist拡張や排他制約が不要になるWITHOUT OVERLAPSは、product_idが任意の時点で一意であることを保証しつつ、範囲が重ならなければ同一製品の複数行を許可する
- 内部的には依然として GiST インデックスを使い、キーの非時点カラムには
btree_gistが必要だが、制約初期化時の依存関係は Postgres が自動処理する - 範囲表記法では、
[は包含、)は除外を意味する →[2025-01-01, 2025-07-01)は1月1日を含み、7月1日は含まない- 最後の Gadget 行
[2026-01-01,)は終端が開いた(open-ended)範囲であり、現在価格に定義済みの終了日はない
- 最後の Gadget 行
- 重複防止の動作
- 不正な範囲
[2025-03-01, 2025-01-01)を挿入すると、「範囲の下限は上限以下でなければならない」というエラー - 重複する範囲
[2025-03-01, 2025-09-01)を挿入すると、products_pkey排他制約違反エラー - 範囲を使うだけで2種類の検証を同時に得られる
- 不正な範囲
切って分ける - Slicing and Dicing
- 商品価格を 2025年3月〜9月の間だけ $10.99 に変更する場合、従来の方式では行を手動で分割・挿入する必要があり、ミスすると空白や重複が発生していた
- 時点履歴テーブルでは、その意図を直接表現できる
UPDATE products FOR PORTION OF valid_at FROM '2025-03-01' TO '2025-09-01' SET price = 10.99 WHERE product_id = 1
- 結果として Widget の行は 3行から5行に増える
- 既存の $9.99 の行は
[2025-01-01, 2025-03-01)に縮小 - 新しい $10.99 の行が残りの範囲に追加される
- 既存の $12.99 の行は
[2025-09-01, 2026-01-01)に縮小 - 新しい $10.99 の行が残る
[2025-07-01, 2025-09-01)の範囲に追加される
- 既存の $9.99 の行は
- $10.99 が2行に分かれる理由は、
FOR PORTION OFが一致した各行を独立して処理し、隣接範囲を事後的にマージ(coalesce)しないため- 最終結果には空白も重複もなく、純粋な排他ロジック(exclusion logic)にはなかった利点となる
- 例外的なケース(edge case)
FOR PORTION OFの範囲が単一の既存行の内部に完全に収まる場合、最大2つの残余行(前後)が生成される- 既存境界と正確に一致する場合、残余行は不要
- 新たに生成される時点の残余行には
INSERT権限は不要だが、既存のINSERTトリガーは発火する → 監査ログやSECURITY DEFINERトリガー関数では注意が必要
履歴を消す - Erasing History
FOR PORTION OFはDELETEでも動作し、特定の商品を 2025年6月〜10月の間だけカタログから一時的に外す例ではDELETE FROM products FOR PORTION OF valid_at FROM '2025-06-01' TO '2025-10-01' WHERE product_id = 2
- 結果
- 6月〜10月の区間が切り取られ、
[2025-04-01, 2026-01-01)を覆っていた $22.99 の行は、6月で終わる行と10月に始まる行という2つの残余行に分割される - 空白の前後にある価格データは元の値のまま保持され、DELETE によって行数が増える結果になる
- 6月〜10月の区間が切り取られ、
- 時点履歴テーブルの管理メカニズムがすべて自動で処理するため、アプリケーションレベルでの過剰削除や孤立断片(orphaned fragment)のリスクがなくなる
広告の真実 - Truth in Advertising
- 時点履歴テーブルは**時点外部キー(temporal foreign key)**なしでは完成せず、Postgres 19 は
PERIODキーワードでこれをサポートするFOREIGN KEY (product_id, PERIOD valid_at) REFERENCES products (product_id, PERIOD valid_at)の形
PERIODキーワードは、外部キー自体が時点ベースであることを示す- 参照先の product は variant の
valid_at範囲の全期間にわたって存在していなければならない - 参照テーブル内のすべての一致行の組み合わせが、参照される行の期間を完全に覆っていなければならない
- 参照先の product は variant の
- product の時間範囲を超える variant の作成(
[2025-01-01, 2027-01-01))は拒否される- Widget の価格定義が2026年半ばまでしかないのに、2027年まで有効だと主張する variant は外部キー制約違反で拒否される
- 1つ重要な制限がある
- 時点外部キーは参照動作(referential action)として
NO ACTIONのみをサポートし、CASCADE・SET NULL・SET DEFAULTは対象外 - variant が依存する product 行を削除すると常にエラーになり、連鎖的な時点操作の複雑さゆえに、アプリケーションが明示的に処理する必要がある
- 時点外部キーは参照動作(referential action)として
小さな一歩 - Baby Steps
- 現在利用できる機能: 重複防止を備えた有効時間ベースの時点履歴テーブル、時点データ操作、時点外部キー
- 最大の欠落は、取引時間とも呼ばれるシステム時間(system time)
- 有効時間は事実が現実世界で真である時点を追跡し、システム時間はDBがその事実を認識した時点を追跡する。両方を活用するシステムは多い
- この領域は 2015年以降、pg_bitemporal 拡張が担ってきた部分
- トリガーによるシステム時間のエミュレーションは可能だが、他の新しい時点機能のようにエンジンが透過的に管理するものとは異なる
- 時点履歴テーブルのドキュメントでも、システム時間はネイティブ未対応であり、エミュレーション可能だと明記されている。Postgres 20 以降で導入されるかは未定だが、基盤はすでに整っている
結び - Final Thoughts
EXCLUDE USING gist方式は動作するが、比較的ぎこちない回避策であり、pg_bitemporal のような拡張が概念実証と議論の継続を支えてきた- GiST 排他制約よりはるかに直感的なアプローチ
- 主キーの
WITHOUT OVERLAPSは平易な英語のように読め、FOR PORTION OFはその動作をそのまま表している - 時点更新・削除時の自動行分割により、ある種の潜在的バグが取り除かれる
- 主キーの
- SQL:2011 から Postgres 19 までの道のりは長く、Hetti とコミュニティが長年にわたり、このパターンの必要性と実現可能性を証明してきた。そして今、それがコアに取り込まれた
- 今後のリリースでのシステム時間サポートに注目する価値がある。Postgres がバイテンポラルの両半分を揃えれば、可能性は大きく広がる
まだコメントはありません。