本記事は、「PostgreSQL19でテンポラルテーブル設計の新時代へ(2)」の続きです。
同時実行問題と性能影響について改めてまとめたほか、パーティションテーブルとの組み合わせ、System-Timeテンポラルテーブルについて書きます。
同時実行問題と性能影響のおさらい
2026/8/13 に PostgreSQL 19 beta3 が出ました。テンポラルテーブル関連では、いくつかのバグ修正の他、行単位セキュリティ(RLS) との組み合わせで、RLS を設定する CREATE POLICY コマンドにテンポラルテーブル対応の構文が加わりました。
さて、beta2 でドキュメントに明記された(デフォルトの)READ COMMITTED 分離レベルでは行ロックが無いと安全ではない、ということのインパクトを改めて確認しておきます。テンポラルテーブル設計を導入したときの性能ペナルティがそれなりに大きくなりそうです。
beta3 で各パターンについて改めて性能計測してみました。前回よりもう少しだけ大きい仮想マシンを使っています。
オンライン参照性能
参照性能は更新のpgbenchを5回行った後に実施しています。性能比 100:68 といったところです。
オンライン更新性能
更新性能は、透過的テンポラルテーブル化を導入すると、性能比 100:64 くらいですが、そこに行ロックを加えると 100:32、リトライで対応すると 100:24 と、さらに性能を削られます。
グラフにはありませんが、前回記事の通り、外部キー制約付きの更新性能は、行ロックでも、リトライでも 1TPS以下に落ち込みます。さらに行ロックを加えても、そこそこの頻度でデッドロックが出るという問題があり、論外といえます。
なお、行ロックはトランザクション冒頭に次のように与えています。ビューだけで表現できているのは良いのですが、これまでは無くて良かった行ロックを足しているという意味では、透過的とは言えません。
BEGIN;
SELECT bid FROM pgbench_branches WHERE bid = :bid FOR UPDATE;
SELECT tid FROM pgbench_tellers WHERE tid = :tid FOR UPDATE;
SELECT aid FROM pgbench_accounts WHERE aid = :aid FOR UPDATE;
-- 後略
さて、性能比を見て、どうでしょうか。正直、アクセス集中の OLTPデータベースに適用するにはオーバーヘッドが大きいと言わざるをえません。
パーティションテーブルとの組み合わせ
PostgreSQL 17以降から、パーティションテーブルに対しても排他制約が利用可能になっています。PostgreSQL 18 で PRIMARY KEY や UNIQUE に WITHOUT OVERLAPS 構文が追加されましたが、これはそのままパーティションテーブルにも適用可能です。
以下は pgbench 標準シナリオを透過的テンポラルテーブル化したものについて、pgbnech_accounts をパーティションテーブル化した場合の定義です。
CREATE TABLE public.t_pgbench_accounts (
aid integer CONSTRAINT t_pgbench_accounts_aid_not_null NOT NULL,
bid integer,
abalance integer,
filler character(84),
valid_at tstzrange NOT NULL DEFAULT tstzrange(CURRENT_TIMESTAMP, NULL)
)
PARTITION BY RANGE (aid);
CREATE TABLE public.t_pgbench_accounts_1 (
aid integer CONSTRAINT t_pgbench_accounts_aid_not_null NOT NULL,
bid integer,
abalance integer,
filler character(84),
valid_at tstzrange NOT NULL DEFAULT tstzrange(CURRENT_TIMESTAMP, NULL)
);
CREATE TABLE public.t_pgbench_accounts_2 (
aid integer CONSTRAINT t_pgbench_accounts_aid_not_null NOT NULL,
bid integer,
abalance integer,
filler character(84),
valid_at tstzrange NOT NULL DEFAULT tstzrange(CURRENT_TIMESTAMP, NULL)
);
-- 中略 --
CREATE TABLE public.t_pgbench_accounts_10 (
aid integer CONSTRAINT t_pgbench_accounts_aid_not_null NOT NULL,
bid integer,
abalance integer,
filler character(84),
valid_at tstzrange NOT NULL DEFAULT tstzrange(CURRENT_TIMESTAMP, NULL)
);
ALTER TABLE ONLY public.t_pgbench_accounts ATTACH PARTITION public.t_pgbench_accounts_1 FOR VALUES FROM (MINVALUE) TO (100001);
ALTER TABLE ONLY public.t_pgbench_accounts ATTACH PARTITION public.t_pgbench_accounts_2 FOR VALUES FROM (100001) TO (200001);
-- 中略 --
ALTER TABLE ONLY public.t_pgbench_accounts ATTACH PARTITION public.t_pgbench_accounts_10 FOR VALUES FROM (900001) TO (MAXVALUE);
ALTER TABLE public.t_pgbench_accounts
ADD CONSTRAINT t_pgbench_accounts_pkey PRIMARY KEY (aid, valid_at WITHOUT OVERLAPS);
CREATE VIEW public.pgbench_accounts AS
SELECT aid, bid, abalance, filler FROM public.t_pgbench_accounts
WHERE valid_at @> CURRENT_TIMESTAMP;
CREATE RULE rule_upd_accounts AS ON UPDATE TO public.pgbench_accounts
DO INSTEAD UPDATE public.t_pgbench_accounts
FOR PORTION OF valid_at FROM CURRENT_TIMESTAMP TO NULL
SET aid = NEW.aid, bid = NEW.bid, abalance = NEW.abalance, filler = NEW.filler
WHERE aid = OLD.aid;
CREATE RULE rule_del_accounts AS ON DELETE TO public.pgbench_accounts
DO INSTEAD DELETE FROM public.t_pgbench_accounts
FOR PORTION OF valid_at FROM CURRENT_TIMESTAMP TO NULL WHERE aid = OLD.aid;
-- 以下の pgbench_accounts以外についての定義を省略 --
データ投入は親テーブルに対する COPY文で、パーティション化していない場合と同様に投入可能です。データ投入後は、pgbench の標準シナリオや、行ロックを付加したシナリオが実行可能です。
$ pgbench -n -U postgres -c 50 -T 60 db1
pgbench (19beta2)
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 10
query mode: simple
number of clients: 50
number of threads: 1
maximum number of tries: 1
duration: 60 s
number of transactions actually processed: 34704
number of failed transactions: 12 (0.035%)
latency average = 86.290 ms (including failures)
initial connection time = 191.102 ms
tps = 579.239443 (without initial connection time)
例外的な動作は?
行が属するパーティションが変わるような UPDATE を行うとどうなるでしょうか。
b1=# UPDATE pgbench_accounts SET filler = 'YYY' WHERE aid = 2;
UPDATE 1
db1=# UPDATE pgbench_accounts SET aid = 1000002 WHERE aid = 2;
UPDATE 1
db1=# SELECT * FROM t_pgbench_accounts WHERE aid = 2;
aid | bid | abalance | filler | valid_at
-----+-----+----------+----------------------------------------------------------+-------------------------------------------------------------------
2 | 1 | 0 | | ["2026-08-11 11:52:26.829785+09","2026-08-11 12:37:01.412841+09")
2 | 1 | 0 | YYY | ["2026-08-11 12:37:01.412841+09","2026-08-11 12:37:08.79775+09")
(2 rows)
db1=# SELECT * FROM t_pgbench_accounts WHERE aid = 1000002;
aid | bid | abalance | filler | valid_at
---------+-----+----------+--------------------------------------+-----------------------------------
1000002 | 1 | 0 | YYY | ["2026-08-11 12:37:08.79775+09",)
(1 row)
(★見やすさのためにfiller列を実際より短くしています)
実行すると履歴が途切れてしまいます。しかしながら、aid = 2 の行は削除されて、同時に aid = 1000002 の行が加わったとすれば、記録に矛盾はありません。これはパーティション化しない場合の主キー列の値を変えた場合と同じことです。
最新データと履歴をパーティションで分ける
最新データと更新履歴とを別パーティションにする方法もやってみます。以下のようにテーブル定義します。
CREATE TABLE tbl2 (id int, col1 text,
valid_at tstzrange DEFAULT tstzrange(CURRENT_TIMESTAMP, NULL))
PARTITION BY RANGE (upper(valid_at));
CREATE TABLE tbl2_current PARTITION OF tbl2 DEFAULT;
CREATE TABLE tbl2_2026 PARTITION OF tbl2
FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
CREATE TABLE tbl2_2025 PARTITION OF tbl2
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
CREATE TABLE tbl2_old PARTITION OF tbl2
FOR VALUES FROM (MINVALUE) TO ('2025-01-01');
範囲型は大小関係比較ができませんので、そのままではパーティションキーにできません。そこで upper(valid_at) と上限値をパーティションキーにします。各パーティションに主キー制約も作っておきます。
ALTER TABLE tbl2_current ADD CONSTRAINT tbl2_current_pkey PRIMARY KEY (id, valid_at WITHOUT OVERLAPS);
ALTER TABLE tbl2_2026 ADD CONSTRAINT tbl2_2026_pkey PRIMARY KEY (id, valid_at WITHOUT OVERLAPS);
ALTER TABLE tbl2_2025 ADD CONSTRAINT tbl2_2025_pkey PRIMARY KEY (id, valid_at WITHOUT OVERLAPS);
ALTER TABLE tbl2_old ADD CONSTRAINT tbl2_old_pkey PRIMARY KEY (id, valid_at WITHOUT OVERLAPS);
パーティション全体に対する統合された主キーインデックスは作れません。統合インデックスにはパーティションキーが含まれないといけないのですが、範囲型の valid_at をパーティションキーにはできないためです。
それではデータを投入して、更新してみます。
db1=# INSERT INTO tbl2 (id, col1) VALUES (1, 'A');
INSERT 0 1
db1=# UPDATE tbl2 FOR PORTION OF valid_at FROM CURRENT_TIMESTAMP TO NULL
SET col1 = 'BB' WHERE id = 1;
UPDATE 1
db1=# SELECT * FROM tbl2;
id | col1 | valid_at
----+------+-------------------------------------------------------------------
1 | A | ["2026-09-09 15:08:16.845465+09","2026-09-09 15:11:01.714283+09")
1 | BB | ["2026-09-09 15:11:01.714283+09",)
(2 rows)
db1=# SELECT * FROM tbl2_current;
id | col1 | valid_at
----+------+------------------------------------
1 | BB | ["2026-09-09 15:11:01.714283+09",)
(1 row)
db1=# SELECT * FROM tbl2_2026;
id | col1 | valid_at
----+------+-------------------------------------------------------------------
1 | A | ["2026-09-09 15:08:16.845465+09","2026-09-09 15:11:01.714283+09")
(1 row)
db1=# UPDATE tbl2 FOR PORTION OF valid_at FROM CURRENT_TIMESTAMP TO NULL
SET col1 = 'CCC' WHERE id = 1;
UPDATE 1
db1=# SELECT * FROM tbl2;
id | col1 | valid_at
----+------+-------------------------------------------------------------------
1 | BB | ["2026-09-09 15:08:16.845465+09","2026-09-09 15:11:01.714283+09")
1 | A | ["2026-09-09 15:11:01.714283+09","2026-09-09 15:17:09.358583+09")
1 | CCC | ["2026-09-09 15:17:09.358583+09",)
(3 rows)
db1=# SELECT * FROM tbl2_current;
id | col1 | valid_at
----+------+------------------------------------
1 | CCC | ["2026-09-09 15:17:09.358583+09",)
(1 row)
db1=# SELECT * FROM tbl2_2026;
id | col1 | valid_at
----+------+-------------------------------------------------------------------
1 | BB | ["2026-09-09 15:08:16.845465+09","2026-09-09 15:11:01.714283+09")
1 | A | ["2026-09-09 15:11:01.714283+09","2026-09-09 15:17:09.358583+09")
(2 rows)
期待通り、最新データ用のパーティションと、履歴用のパーティションに、それぞれ行が格納されました。
システムタイムは?
テンポラルテーブルには Business-Time (または Application-Time) のテンポラルテーブルと、System-Time のテンポラルテーブルがあります。
前者は、元のテーブル定義にタイムスタンプや日付の列があって、その値について範囲と値を記録に残しておくというものです。ここでのタイムスタンプはアプリケーション上の意味に基づく値です。
後者は、データベース上のデータを変更したということを記録するものです。アプリケーションの SQL がテンポラルテーブルに協調的でなくとも何であれ必ず記録することができます。基準となるタイムスタンプは更新トランザクションをコミットした時点となります。
この分類に照らせば PostgreSQL で実現しているのは Business-Time です。他の多くの DBMS製品でサポートしているのは System-Time テンポラルテーブルです(IBM DB2 は両方をサポートしています)。
System-Time テンポラルテーブルが優れている点は、コミット時点で有効範囲が記録されているため、過去時点の検索を行うときにデータの整合性が保証されることです。CURRENT_TIMESTAMP関数によるタイムスタンプはトランザクション開始時点の時刻ですので、CURRENT_TIMESTAMP を使った Business-Time テンポラルテーブルに時点を指定した問い合わせをしても、その時点において参照可能であったデータとはずれが生じます。更新頻度の低いテーブルなら良いですが、複数のテーブルにまたがって並行する多数の更新トランザクションで高頻度に更新されるテーブルであったり、長時間の更新バッチ処理で書き換えられるテーブルでは、このずれは看過できないものとなります。
トランザクション内で(未来の時点となる)コミット時刻を得ることはできませんので、Business-Time のテンポラルテーブル機能のうえで SQL を工夫しても、System-Time のテンポラルテーブルのようにはできません。System-TIme のテンポラルテーブルの実現には PostgreSQL自体の機能追加が必要となります。
PostgreSQLのテーブルの各行には xmin(=行を作った時点)、xmax(=行が無効になった時点)という 2つのトランザクションID(XID)がメタデータとして記録されています。一方で、コミット済トランザクションのXIDを与えるとコミット時刻を返す関数pg_xact_commit_timestamp(xid) が PostgreSQL 18 から用意されています。これらは今後にコミット時刻によるテンポラルテーブルを実現するための道具だてになるはずです。
おわりに
PostgreSQL 19 のテンポラルテーブル機能について見てきました。
PostgreSQL 19 までで実現されたテンポラルテーブル機能は Business-Time であって、通常のテーブル定義に融合している形態であることが特徴です。分離された過去分専用の領域に格納されるのではなく、通常のテーブルの行に格納されます。これを PostgreSQL 特有の GiST範囲インデックスで、現時点の問い合わせ、過去時点の問い合わせをどちらもこなす、という設計です。
別の DBMS で System-Time テンポラルテーブルを利用してきたものから移植してくる場合には、そのままの機能を実現するのが難しい一方、過去時点の問い合わせも頻繁に行うような使い方には適していて、新しい活用法の可能性が示されていると言えます。
追記
この (3) の記事を書き終わって数日後、残念ながら PostgreSQL のソースコードリポジトリ 19系列ブランチで、テンポラルテーブル機能が revert されてしまいました。今回の 19 リリースには含めないと判断されたということです。

