diff --git "a/source/_posts/2025/20251006a_PostgreSQL\351\200\243\350\274\211\345\247\213\343\201\276\343\202\212\343\201\276\343\201\231_&_v18\343\201\247\345\257\276\345\277\234\343\201\227\343\201\237UUIDv7\343\201\250v4\343\201\256\346\257\224\350\274\203.md" "b/source/_posts/2025/20251006a_PostgreSQL\351\200\243\350\274\211\345\247\213\343\201\276\343\202\212\343\201\276\343\201\231_&_v18\343\201\247\345\257\276\345\277\234\343\201\227\343\201\237UUIDv7\343\201\250v4\343\201\256\346\257\224\350\274\203.md" index af3be5e3cf1..b2b880c6925 100644 --- "a/source/_posts/2025/20251006a_PostgreSQL\351\200\243\350\274\211\345\247\213\343\201\276\343\202\212\343\201\276\343\201\231_&_v18\343\201\247\345\257\276\345\277\234\343\201\227\343\201\237UUIDv7\343\201\250v4\343\201\256\346\257\224\350\274\203.md" +++ "b/source/_posts/2025/20251006a_PostgreSQL\351\200\243\350\274\211\345\247\213\343\201\276\343\202\212\343\201\276\343\201\231_&_v18\343\201\247\345\257\276\345\277\234\343\201\227\343\201\237UUIDv7\343\201\250v4\343\201\256\346\257\224\350\274\203.md" @@ -17,7 +17,7 @@ lede: "PostgreSQL 18ではUUIDv7生成に対応しました。今までのUUID v | :--- | :--- | :--- | | 10/6(月) | 澁川喜規 | v18で対応したUUIDv7とv4の比較(この記事です) | | 10/7(火) | 山本竜玄 | [explainをマスターするぜ](/articles/20251008a/) | -| 10/8(水) | 真野隼記 | 仮想生成列 | +| 10/8(水) | 真野隼記 | [仮想生成列](/articles/20251030a/) | | 10/9(木) | 岩堀敦 | [pg_dump](/articles/20251009a/) | | 10/10(金) | 市川裕也 | [現場で行った性能チューニング](/articles/20251010a/) | | 10/14(火) | 村田 靖拓 | [B-treeインデックスのスキップスキャン](/articles/20251014a/) | diff --git "a/source/_posts/2025/20251030a_PostgreSQL_18\343\201\256\346\226\260\346\251\237\350\203\275\343\200\201\344\273\256\346\203\263\347\224\237\346\210\220\345\210\227\343\201\256\344\275\277\343\201\204\346\226\271\343\202\204\345\210\266\347\264\204\343\200\201\346\240\274\347\264\215\347\224\237\346\210\220\345\210\227\343\201\250\343\201\256\344\275\277\343\201\204\345\210\206\343\201\221\343\201\253\343\201\244\343\201\204\343\201\246.md" "b/source/_posts/2025/20251030a_PostgreSQL_18\343\201\256\346\226\260\346\251\237\350\203\275\343\200\201\344\273\256\346\203\263\347\224\237\346\210\220\345\210\227\343\201\256\344\275\277\343\201\204\346\226\271\343\202\204\345\210\266\347\264\204\343\200\201\346\240\274\347\264\215\347\224\237\346\210\220\345\210\227\343\201\250\343\201\256\344\275\277\343\201\204\345\210\206\343\201\221\343\201\253\343\201\244\343\201\204\343\201\246.md" new file mode 100644 index 00000000000..b59a03f21ba --- /dev/null +++ "b/source/_posts/2025/20251030a_PostgreSQL_18\343\201\256\346\226\260\346\251\237\350\203\275\343\200\201\344\273\256\346\203\263\347\224\237\346\210\220\345\210\227\343\201\256\344\275\277\343\201\204\346\226\271\343\202\204\345\210\266\347\264\204\343\200\201\346\240\274\347\264\215\347\224\237\346\210\220\345\210\227\343\201\250\343\201\256\344\275\277\343\201\204\345\210\206\343\201\221\343\201\253\343\201\244\343\201\204\343\201\246.md" @@ -0,0 +1,640 @@ +--- +title: "PostgreSQL 18の新機能、仮想生成列の使い方や制約、格納生成列との使い分けについて" +date: 2025/10/30 00:00:00 +postid: a +tag: + - PostgreSQL +category: + - DB +thumbnail: /images/2025/20251030a/thumbnail.jpg +author: 真野隼気 +lede: "仮想生成列についてまとめます。PostgreSQLで従来から利用できた格納生成列や、生成列自体の説明から合わせて紹介します。" +--- +top.jpg + +[PostgreSQL 18連載](/articles/20251006a/)の3本目です。 + +[PostgreSQL 18がリリース](https://www.postgresql.org/about/news/postgresql-18-released-3142/)され、仮想生成列についてまとめます。PostgreSQLで従来から利用できた格納生成列や、生成列自体と合わせて紹介します。 + +# 生成列 + +生成列は他の列から計算される列のことで、テーブルに対するビューをつくるように、ある列に対してビューのような列を作ることができます。ビューにも、MViewと通常のViewがあるように、生成列も「格納生成列」と「仮想生成列」の2種類があります。格納生成列は、登録/更新時に計算されて物理的にストレージが割り当てられます(MVIEWに似ています)。仮想列は列が読み取られる時に動的に計算されます(Viewに似ています)。 + +PostgreSQL 12で「格納」生成列が利用可能となり、今回18から「仮想」生成列が利用可能となりました。ここまで説明した内容をざっと、表でまとめました。 + +| 特徴 | 格納生成列 | 仮想生成列 | +|-----------|-------------------------|---------------------------| +| サポート | PostgreSQL 12以降 | PostgreSQL 18以降 | +| 説明 | 書き込み時に計算し、ストレージに保存される生成列 | 読み取り時に計算され、ストレージに保存されない生成列 | +| ストレージ容量 | ⚠️消費する | ✅️消費なし | +| 書き込み性能 | ⚠️やや遅くなる | ✅️影響なし | +| 読み取り性能 | ✅️計算済みのため | ⚠️都度計算するため | + +生成列の全般に共通する使い方としては、「導出列」があります。導出列とは、他のカラムから算出できる列です。まさに生成列の用途にドンピシャ被りですね。一般論としては、導出列をもたせることは冗長性であるため、SSoT(信頼できる唯一の情報源)原則を守るため、作成しない方針を取るチームが多いでしょう。ただ、導出列にインデックスを貼りたいといった性能要件や、その他、設計の見える化や運用要件などで作成することがあります。 + +例をいくつか上げます。(凡例: 元の列 -> 導出列) + +- 例1: 単価×数量x税係数x割引係数 -> 請求金額 +- 例2: 姓 + 名 -> フルネーム +- 例3: 日付カラム -> 曜日 +- 例4: 生年月日 -> 年齢 +- 例5: メールアドレス -> 検索用メールアドレス(全て小文字にするなど加工し、検索用に正規化する) + +どれもアプリケーション側で計算して明示的にインサートしても良いものですが、生成列を使用することでその列が読み取り専用であることを開発者に伝えることができ、また整合性を伴わない更新事故を無くすことができます。また、テーブル定義上で宣言的に意図を伝えられる点で、きっと生成AIとも親和性が良いと思います(これは未検証、想像で書いています)。 + +ただ、[PostgreSQL設計ガイドライン](https://future-architect.github.io/arch-guidelines/documents/forDB/postgresql_guidelines.html#%E7%94%9F%E6%88%90%E5%88%97) では、ライフサイクルがアプリケーションに近く、格納生成列の定義変更はAccessExclusiveLock(SELECTもブロックされるロック)を取って全行更新の処理が必要となるため、この用途での格納生成列の使用は非推奨としていました。同様の部分の懸念はそれなりの規模感のシステムでは共通的であるため、あまり利用頻度は高くないと思います(※2025年10月29日時点では、仮想生成列についての記述はありません)。ただし、仮想生成列であれば、おそらく定義変更してもAccessExclusiveLockを取らないと思うので、障壁は下がるかもしれません。このあたりを検証していきます。 + +# 使用方法の基礎 + +検証環境情報やセットアップは村田さんの[B-treeインデックスのスキップスキャン](/articles/20251014a/#PostgreSQL-18-%E3%81%AE%E5%A0%B4%E5%90%88)記事の手順に従います。Dockerで `postgres:18` のイメージを利用します。 + +生成列の使い方ですが、[ドキュメント](https://www.postgresql.org/docs/18/sql-createtable.html#SQL-CREATETABLE-PARMS-GENERATED-STORED) にあるように、 `GENERATED ALWAYS AS (ロジック) STORED` で格納生成列、`VIRTUAL` を付けると仮想生成列になります。デフォルトは `VIRTUAL` です。身長[cm]をインチ版と尺版を生成列で作ってみます。 + +```sql +CREATE TABLE people ( + person_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + height_cm numeric, + height_in numeric GENERATED ALWAYS AS (height_cm / 2.54) STORED, -- 格納生成列 + height_syaku numeric GENERATED ALWAYS AS (height_cm * 0.033) VIRTUAL -- 仮想生成列 +); +``` + +実際にデータを登録 & 検索します。 + +```sql +postgres=# INSERT INTO people (height_cm) VALUES (170.5), (158.2), (181.0); +INSERT 0 3 +postgres=# SELECT * FROM people; + person_id | height_cm | height_in | height_syaku +-----------+-----------+---------------------+-------------- + 1 | 170.5 | 67.1259842519685039 | 5.6265 + 2 | 158.2 | 62.2834645669291339 | 5.2206 + 3 | 181.0 | 71.2598425196850394 | 5.9730 +(3 rows) +``` + +読み取りすると、自動変換された値が取得できました。インチ・尺のどちらも実生活で意識して使ったことが無いでので合っているかよく分からないですが、自動計算されるのは便利です。また、INSERT文がシンプルになるという心理的な嬉しさを思ったより感じました。 + +# 検証サマリ + +格納生成列、仮想生成列それぞれで以下の観点を比較します。 + +| 検証項目 | 結果 | +| :--- | :--- | +| 1. 生成列で自分自身の列を参照できるか | 不可(生成列は生成列を参照できないた) | +| 2. 他のテーブルの列を参照できるか | 不可(サブクエリの利用は不可) | +| 3. ネストした生成列定義は可能か | 不可(生成列は生成列を参照できないため) | +| 4. 計算途中でnull値が混入したらどうなるか | 普通のクエリと同じように `null` になる | +| 5. NOT NULL制約を付けることができるか | 可能。仮想生成列も登録時チェックになる | +| 6. 生成列は一意制約を付けることができるか | 格納生成列は可能。仮想生成列は不可。式インデックスで代用 | +| 7. 生成列はインデックスに使えるか | 格納生成列は可能。仮想生成列は式インデックスで代用 | +| 8. 生成列はPKにできるか | 格納生成列は可能。仮想生成列はインデックスを持てないので不可 | +| 9. 生成列はパーティションキーに使えるか | 不可(`STORED` / `VIRTUAL` 共に不可) | +| 10. テーブル作成後に後から生成列を追加できるか | 可能。AccessExclusiveLock を取る | +| 11. NOT NULL制約を付けた生成列を後から追加すると処理時間はどうなるか | `NOT NULL`検証のためのテーブルフルスキャンが発生し、変更時間が長くなる | +| 12. 生成列の定義変更はできるか | 不可(`DROP COLUMN` & `ADD COLUMN` で対応する必要がある) | +| 13. 利用しているカラムをRENAMEしたら? | PostgreSQLが自動で定義式を追随・更新してくれる | +| 14. 利用しているカラムをDROPしたら? | エラーになる。 `CASCADE` を付けると依存した列ごと削除可能) | + +## 1. 生成列で自分自身の列を参照できるか + +試しに、生成列で自分自身(`self_value`)を参照した定義を実行してみます。内容に意味は無いですが、 `self_value = self_value + 1` となるような計算式を設定しています。 + +```sql +CREATE TABLE self_reference ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + + -- 自分自身(value)を参照して値を生成しようとする仮想列 + "self_value" int GENERATED ALWAYS AS (self_value + 1) VIRTUAL +); +ERROR: cannot use generated column "self_value" in column generation expression +LINE 5: "self_value" int GENERATED ALWAYS AS (self_value + 1) VI... + ^ +DETAIL: A generated column cannot reference another generated column. +``` + +結果はNGです。「生成列は他の生成列を参照できない」とありますね。実現したいことも意味不明なので、失敗して当然なので想定通りかなと。この結果は、`VIRTUAL` を `STORED` に変えても同じです。 + +## 2. 他のテーブルの列を参照できるか + +まず、税率を保持するテーブルを作成します。 + +```sql +CREATE TABLE m_tax ( + region_code CHAR(2) PRIMARY KEY, + tax_rate NUMERIC(4, 2) +); +``` + +続いて、さきほど作った税率テーブルを参照する、生成列を作ってみます。 + +```sql +CREATE TABLE t_order ( + order_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + base_price NUMERIC, + region CHAR(2), + + -- taxテーブルのtax_rate列を参照する計算式 + total_price NUMERIC GENERATED ALWAYS AS ( + base_price * (1.0 + (SELECT tax_rate FROM m_tax WHERE region_code = region)) + ) VIRTUAL +); +ERROR: cannot use subquery in column generation expression +LINE 8: base_price * (1.0 + (SELECT tax_rate FROM m_tax WHER... +``` + +これはエラーになりました。サブクエリはNG(つまり、別テーブルの参照は不可)のようです。 + +[ドキュメント](https://www.postgresql.org/docs/18/sql-createtable.html#SQL-CREATETABLE-PARMS-GENERATED-STORED)にも `References to other tables are not allowed.` (他のテーブルは参照できない)と書いていますので、その通りの結果です。格納生成列、仮想生成列ともに結果は変わりません。 + +## 3. ネストした生成列定義は可能か + +「割引額」という生成列を参照する、「料金」という生成列の定義を試みます。 + +```sql +CREATE TABLE m_price ( + item_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + base_price NUMERIC, + discount_rate NUMERIC(3, 2) DEFAULT 0.0, + + -- 割引額という生成列を定義 + discount_amount NUMERIC GENERATED ALWAYS AS ( base_price * discount_rate ) VIRTUAL, + + -- 他の生成列「割引率」を「料金」という生成列を定義 + price NUMERIC GENERATED ALWAYS AS ( base_price - discount_amount ) VIRTUAL +); +ERROR: cannot use generated column "discount_amount" in column generation expression +LINE 10: ... price NUMERIC GENERATED ALWAYS AS ( base_price - discount_a... + ^ +DETAIL: A generated column cannot reference another generated column. +``` + +こちらもエラーになります。「他の生成列を参照することはできません」という内容です。デジャブ感があるのは、「生成列で自分自身の列を参照できるか」節でもこのメッセージを見たためです。格納生成列、仮想生成列のどちらも同じ結果になります。 + +ドキュメントにも、 `The generation expression can refer to other columns in the table, but not other generated columns. `(生成式はテーブル内の他の列を参照できますが、他の生成列を参照することはできません。)とあるので、記載通りの挙動です。 + +## 4. 計算途中でnull値が混入したらどうなるか + +例えば、総額=単価x数量 という生成列を定義します。この時、単価がNULLの場合にはどのように挙動するか確かめます。 + +```sql +CREATE TABLE t_order ( + item_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + unit_price NUMERIC, + quantity INT, + + -- 生成列(単価x数量)で構成 + total_price NUMERIC GENERATED ALWAYS AS (unit_price * quantity) VIRTUAL +); + +-- データ登録(unit_priceにNULLを入れる) +INSERT INTO t_order (unit_price, quantity) VALUES (NULL, 5); + +-- 検索 +SELECT * FROM t_order; + item_id | unit_price | quantity | total_price +---------+------------+----------+------------- + 1 | | 5 | +(1 row) +``` + +unit_priceがNULLの場合は、total_priceもNULLという結果です。SQL的に自然な挙動ですね。回避するには、unit_priceやquantityにNOT NULL制約を付けたり、COALESCEでNULLを実値に置き換える必要があります。 + +## 5. NOT NULL制約を付けることができるか + +ちょっとテクニカルなテーブル定義に書き換えます。生成列のtotal_priceのみNOT NULL制約を付けて、ソースのunit_price, quantity はNULL許容にします。 + +```sql +postgres=# CREATE TABLE t_order ( + item_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + unit_price NUMERIC, + quantity INT, + + -- 生成列(単価x数量)で構成。★さらに、NOT NULL制約を追加 + total_price NUMERIC GENERATED ALWAYS AS (unit_price * quantity) VIRTUAL NOT NULL +); +CREATE TABLE +``` + +これは成功します。生成列でもNOT NULL制約を付与は可能です。 + +続いて、先ほどと同様 `unit_price` に NULL 値を含んだINSERT文を実行します。格納生成列の場合はインサート時に計算しますが、仮想生成列の場合は読み取り時に計算されるため、検知できない気がしますが...? + +```sql +postgres=# INSERT INTO t_order (unit_price, quantity) VALUES (NULL, 5); +ERROR: null value in column "total_price" of relation "t_order" violates not-null constraint +DETAIL: Failing row contains (1, null, 5, virtual). +``` + +なんと、仮想生成列でも、インサート時に登録が失敗しました。仮想生成列も登録時に計算しているようです。仮想生成列は格納生成列に比べて、登録時に計算しないから高速というのは、必ずしも成立する話ではないように感じます。 + +ちなみに、格納生成列でもこの挙動は変わりません(こちらは直感的な動作かなと思います)。 + +この検証は「11」節ではさらに詳しく調べています。 + +## 6. 生成列は一意制約を付けることができるか + +格納生成列の場合は成功します。 + +```sql +postgres=# CREATE TABLE t_order_detail ( + order_id BIGINT NOT NULL, + item_id BIGINT NOT NULL, + quantity INT, + order_item_key TEXT GENERATED ALWAYS AS (order_id::TEXT || '-' || item_id::TEXT) STORED UNIQUE +); +CREATE TABLE +``` + +仮想生成の場合は、失敗します。 + +```sql +postgres=# CREATE TABLE t_order_detail ( + order_id BIGINT NOT NULL, + item_id BIGINT NOT NULL, + quantity INT, + order_item_key TEXT GENERATED ALWAYS AS (order_id::TEXT || '-' || item_id::TEXT) VIRTUAL UNIQUE +); +ERROR: unique constraints on virtual generated columns are not supported +``` + +これは後述するインデックスのサポート有無の挙動の差でしょう。なお、これまた後述する式インデックスに一意制約をつけることで、実質的に、仮想生成列に一意制約をつけることはできます。 + + +## 7. 生成列はインデックスに使えるか + +格納生成列、仮想生成列それぞれにインデックスを追加してみます。 + +```sql +CREATE TABLE m_user ( + user_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + first_name TEXT, + last_name TEXT, + email TEXT, + -- (1) 格納生成列: 大文字小文字を区別せずにメールアドレスを検索するため + email_lower TEXT GENERATED ALWAYS AS (LOWER(email)) STORED, + -- (2) 仮想生成列: 用途: 姓と名を連結して表示・検索するため + full_name TEXT GENERATED ALWAYS AS (last_name || ' ' || first_name) VIRTUAL +); + +-- 格納生成列へのインデックス追加は成功 +postgres=# CREATE INDEX idx_01_m_user ON m_user (email_lower); +CREATE INDEX + +-- 仮想生成列へのインデックス追加は失敗 +postgres=# CREATE INDEX idx_02_m_user ON m_user (full_name); +ERROR: indexes on virtual generated columns are not supported +``` + +仮想生成列へのインデックス追加はサポートされていないようです(まぁ実体がないのでそれはそう)。直接的な回避方法としては、式インデックスを使うことになるでしょう。つまり、仮想生成列の式定義と同じ式を、式インデックスに指定します。 + +例えば、以下のように `idx_02_m_user` の定義を変更します。テーブル作成と式が重複するのですが仕方なしです。 + +```sql +postgres=# CREATE INDEX idx_02_m_user ON m_user ((last_name || ' ' || first_name)); +CREATE INDEX + +-- ダミーデータ登録 +postgres=# INSERT INTO m_user (first_name, last_name, email) +VALUES ('Taro', 'Yamada', 'Taro.Yamada@example.com'), + ('Hanako', 'Suzuki', 'hanako.suzuki@example.jp'); +INSERT 0 2 + +-- 件数が少ないとbitmap scanが選択されがちなのでOFF +postgres=# SET enable_bitmapscan = OFF; +SET + +-- インデックスが使われていることを確認(実質、式インデックスの確認) +postgres=# EXPLAIN SELECT * FROM m_user +WHERE full_name = 'Yamada Taro'; + QUERY PLAN +-------------------------------------------------------------------------------- + Index Scan using idx_02_m_user on m_user (cost=0.15..12.19 rows=2 width=168) + Index Cond: (((last_name || ' '::text) || first_name) = 'Yamada Taro'::text) +(2 rows) +``` + +実行計画レベルで、式インデックスが使われていることを確認できました。多少の回避方法が必要ですが、仮想列も事実上、インデックスを貼れると思ってよいでしょう。 + + +## 8. 生成列はPKにできるか + +例えば、受注明細トランで、受注番号+商品IDを組み合わせてPKにするケースを考えます(普通は、サロゲートにして欲しい案件ですが、あくまで動作確認上の"例"です)。 + +まず格納生成列で試します。 + +```sql +CREATE TABLE t_order_detail ( + order_id BIGINT NOT NULL, + item_id BIGINT NOT NULL, + quantity INT, + + -- 受注番号+商品IDの組み合わせでPKを作成してみる + order_item_key TEXT GENERATED ALWAYS AS (order_id::TEXT || '-' || item_id::TEXT) STORED, + + PRIMARY KEY (order_item_key) +); + +-- データ登録 +INSERT INTO t_order_detail (order_id, item_id, quantity) VALUES +(1001, 201, 2), (1001, 205, 1), (1002, 201, 5); + +-- 検索 +SELECT * FROM t_order_detail; + order_id | item_id | quantity | order_item_key +----------+---------+----------+---------------- + 1001 | 201 | 2 | 1001-201 + 1001 | 205 | 1 | 1001-205 + 1002 | 201 | 5 | 1002-201 +(3 rows) +``` + +普通にPKとして扱えました。 + +続いて、仮想生成列で試します。 + +```sql +CREATE TABLE t_order_detail ( + order_id BIGINT NOT NULL, + item_id BIGINT NOT NULL, + quantity INT, + + -- 受注番号+商品IDの組み合わせでPKを作成してみる + order_item_key TEXT GENERATED ALWAYS AS (order_id::TEXT || '-' || item_id::TEXT) VIRTUAL, + + PRIMARY KEY (order_item_key) +); + +ERROR: primary keys on virtual generated columns are not supported +``` + +これはエラーになりました。仮想生成列のPKはサポートされていないようです。仮想生成列はインデックスを使えないため、PKにできないのでしょう。これについては先程の式インデックスで代替することはできません。ただし、式インデックスは、一意制約とNOT NULL制約を付与できるので、類似の機能を持たせることはできます。 + +注意として式インデックスでは、 `:::txt` による型変換が使えず `CAST()` で変換するなど微妙にクセがあることです。 + +```sql +postgres=# CREATE UNIQUE INDEX idx_01_t_order_detail +ON t_order_detail ( (CAST(order_id AS TEXT) || '-' || CAST(item_id AS TEXT)) ); +CREATE INDEX +``` + +一意性のチェックです。 + +```sql +postgres=# INSERT INTO t_order_detail (order_id, item_id, quantity) VALUES (1003, 202, 3); +INSERT 0 1 +postgres=# INSERT INTO t_order_detail (order_id, item_id, quantity) VALUES (1003, 202, 3); +ERROR: duplicate key value violates unique constraint "idx_01_t_order_detail" +DETAIL: Key (((order_id::text || '-'::text) || item_id::text))=(1003-202) already exists. +``` + +無事動作しました。ただし、あくまで仮想列自体に一意制約+NOT NULL制約をつけたわけではなく、仮想列と同等の定義を持った式インデックスに、一意制約+NOT NULL制約をつけたことになります。そのため、外部キー制約の参照の対象にはできないでしょう。 + +## 9. 生成列はパーティションキーに使えるか + +受注日時から受注日付(yyyy-MM-dd)を生成列で作成し、それをパーティションキーとするようなケースで試します。 + +```sql +postgres=# CREATE TABLE t_order ( + order_id BIGINT GENERATED BY DEFAULT AS IDENTITY, + item_name TEXT, + order_at TIMESTAMPTZ NOT NULL, + order_date DATE GENERATED ALWAYS AS ((timezone('JST', order_at)::date)) STORED, + PRIMARY KEY (order_id, order_date) +) PARTITION BY RANGE (order_date); +ERROR: cannot use generated column in partition key +LINE 7: ) PARTITION BY RANGE (order_date); + ^ +DETAIL: Column "order_date" is a generated column. +``` + +生成列はパーティションキーに使えないようです。この用途ですと、生成列を実カラムに戻し、アプリケーション側で明示的に設定する方が良いように思えます(アプリケーションの代わりにトリガーを使用しても良いですが、さすがにテクニカル過ぎるでしょう)。仮想生成列でも同じ結果になります。 + +もちろん[ドキュメント](https://www.postgresql.org/docs/18/ddl-generated-columns.html#:~:text=A%20generated%20column%20cannot%20be%20part%20of%20a%20partition%20key.)にも、`A generated column cannot be part of a partition key.`(生成列はパーティションキーには利用できません。)と書かれています。 + +## 10. テーブル作成後に後から生成列を追加できるか + +`m_user` に格納生成列、仮想生成列の順番で足してみます。 + +```sql +postgres=# CREATE TABLE m_user ( + user_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + first_name TEXT, + last_name TEXT, + email TEXT +); +CREATE TABLE + +-- 格納生成列の追加 +postgres=# ALTER TABLE m_user +ADD COLUMN email_lower TEXT GENERATED ALWAYS AS (LOWER(email)) STORED; +ALTER TABLE + +-- 仮想生成列の追加 +postgres=# ALTER TABLE m_user +ADD COLUMN full_name TEXT GENERATED ALWAYS AS (last_name || ' ' || first_name) VIRTUAL; +ALTER TABLE +``` + +結果は成功でした。ちなみに、ALTER文実行前にはBEGINEを実行し、別プロセスで`pg_locks` を確認したところ、どちらも `AccessExclusiveLock` を取っていました。格納生成列は既存行が多ければ長時間、参照もできないので注意が必要です。仮想生成列はメタデータの書き換えのみで済むため、`AccessExclusiveLock` を取りますが一瞬で終わります。 + +## 11. NOT NULL制約を付けた生成列を後から追加すると処理時間はどうなるか + +以下の `t_order` に1万件のダミーデータを登録し、[格納|生成] x[NOT NULL有無]の4パターンで処理時間を計測しました。 + +```sql +-- 初期テーブル +CREATE TABLE t_order ( + item_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + unit_price NUMERIC, + quantity INT +); + +-- ダミーデータ登録 +INSERT INTO t_order (unit_price, quantity) +SELECT + (random() * 9999 + 1)::numeric(10, 2), -- 1.00 ~ 10000.99 のランダムな単価 + (random() * 99 + 1)::int -- 1 ~ 100 のランダムな数量 +FROM + generate_series(1, 10000000); + +-- 統計情報の最新化 +ANALYZE t_order; + +-- 確認 +SELECT count(*) FROM t_order; + count +---------- + 10000000 +(1 row) +``` + +検証は以下のフローです。 + +```sql +SET autovacuum = OFF; +\timing on + +-- 格納生成列(NULL許容) +ALTER TABLE t_order ADD COLUMN total_price NUMERIC GENERATED ALWAYS AS (unit_price * quantity) STORED; +ALTER TABLE t_order DROP COLUMN total_price; + +-- 格納生成列(NOT NULL) +ALTER TABLE t_order ADD COLUMN total_price NUMERIC GENERATED ALWAYS AS (unit_price * quantity) STORED NOT NULL; +ALTER TABLE t_order DROP COLUMN total_price; + +-- 仮想生成列(NULL許容) +ALTER TABLE t_order ADD COLUMN total_price NUMERIC GENERATED ALWAYS AS (unit_price * quantity) VIRTUAL; +ALTER TABLE t_order DROP COLUMN total_price; + +-- 仮想生成列(NOT NULL) +ALTER TABLE t_order ADD COLUMN total_price NUMERIC GENERATED ALWAYS AS (unit_price * quantity) VIRTUAL NOT NULL; +ALTER TABLE t_order DROP COLUMN total_price; +``` + +| 検証パターン | 処理結果 | +| :--- | :--- | +| 格納生成列(NULL許容) | 14.4秒 | +| 格納生成列(NOT NULL) | 30.3秒 | +| 仮想生成列 (NULL許容) | 0.006秒 | +| 仮想生成列(NOT NULL) | 10.2秒 | + +格納生成列もNOT NULL化すると少し処理時間が増します。理由を深く調査はしていませんが、NOT NULL計算分が上乗せになるからでしょう。そして、仮想生成列ですが、NOT NULL制約を追加すると大幅に時間がかかります。これはおそらくテーブルフルスキャンでNOT NULLにならないかチェックするからでしょう。 + +## 12. 生成列の定義変更はできるか + +[ドキュメント](https://www.postgresql.org/docs/18/sql-altertable.html)を読む限り、生成列の定義を直接変更することはできないように思えます(文法の読み取りが間違っていたらご指摘ください)。 + +そのため、一度そのカラムを削除してから作り直すことになると思われます。例えば、先程の `email_lower` をいう検索専用の生成列を、さらに前後の空白をトリムする処理を追加します。 + + +```sql +-- (1) 既存の格納列を削除 +ALTER TABLE m_user DROP COLUMN email_lower; + +-- (2) 新しい定義で格納列を再度追加 +ALTER TABLE m_user +ADD COLUMN email_lower TEXT GENERATED ALWAYS AS (LOWER(TRIM(email))) STORED; +``` + +流れ自体は仮想生成列でも同様です。格納生成列の場合は、(2)の処理でテーブルサイズによってはかなり時間がかかると思うので、注意が必要そうです(格納生成列のまま、瞬時に切り替える手順は今のところ、テーブル単位で新旧Verを作ってリネームする方法しか思いつきませんでした。また、格納生成列をDROP & ADDするということは、統計情報も消えるということなので、インデックス項目の場合はANALYZEもしたほうが良いでしょう)。 + +## 13. 利用しているカラムをRENAME COLUMNしたらどうなるか + +生成列で利用しているカラムをリネームはできるのでしょうか?試してみます。 + +```sql +CREATE TABLE m_user ( + user_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + first_name TEXT, + last_name TEXT, + email TEXT, + email_lower TEXT GENERATED ALWAYS AS (LOWER(email)) STORED, + full_name TEXT GENERATED ALWAYS AS (last_name || ' ' || first_name) VIRTUAL +); + +-- 生成列で利用されているカラムをリネームする +postgres=# ALTER TABLE m_user RENAME COLUMN first_name TO given_name; +ALTER TABLE +postgres=# ALTER TABLE m_user RENAME COLUMN email TO email_address; +ALTER TABLE + +-- 検索 +postgres=# SELECT * FROM m_user; + user_id | given_name | last_name | email_address | email_lower | full_name +---------+------------+-----------+----------------------+----------------------+--------------- + 1 | Hanako | Suzuki | h.suzuki@example.com | h.suzuki@example.com | Suzuki Hanako +(1 row) +``` + +格納・仮想のどちらの生成列で利用しているカラム名を変更が成功し、挙動も問題なかったです。 + +`\d m_user` でテーブル定義を確認すると、生成列定義の列名も書き換わっていました。 + +```sql +postgres=# \d m_user + Table "public.m_user" + Column | Type | Collation | Nullable | Default +---------------+--------+-----------+----------+-------------------------------------------------------------- + user_id | bigint | | not null | generated by default as identity + given_name | text | | | + last_name | text | | | + email_address | text | | | + email_lower | text | | | generated always as (lower(email_address)) stored + full_name | text | | | generated always as ((last_name || ' '::text) || given_name) +Indexes: + "m_user_pkey" PRIMARY KEY, btree (user_id) +``` + +リネームにも追随してくれるの、気が効いていますね。賢い。 + +## 14. 利用しているカラムをDROP COLUMNしたときどうなるか + +格納生成列、仮想生成列それぞれで利用しているカラムを、DROPできるか試しました。 + +```sql +CREATE TABLE m_user ( + user_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + first_name TEXT, + last_name TEXT, + email TEXT, + email_lower TEXT GENERATED ALWAYS AS (LOWER(email)) STORED, + full_name TEXT GENERATED ALWAYS AS (last_name || ' ' || first_name) VIRTUAL +); + +-- 格納生成列で利用されているカラムを削除 +postgres=# ALTER TABLE m_user DROP COLUMN email; +ERROR: cannot drop column email of table m_user because other objects depend on it +DETAIL: column email_lower of table m_user depends on column email of table m_user +HINT: Use DROP ... CASCADE to drop the dependent objects too. + +-- 仮想生成列で利用されているカラムを削除 +postgres=# ALTER TABLE m_user DROP COLUMN first_name; +ERROR: cannot drop column first_name of table m_user because other objects depend on it +DETAIL: column full_name of table m_user depends on column first_name of table m_user +HINT: Use DROP ... CASCADE to drop the dependent objects too. +``` + +どちらもエラーかつ、ヒントで依存しているオブジェクト(列)もCASCADEオプションで消せるとありますね。CASCADEつけて実行してみます。 + +```sql +-- 格納生成列で利用されているカラムを削除(CASCADE追加) +postgres=# ALTER TABLE m_user DROP COLUMN email CASCADE; +NOTICE: drop cascades to column email_lower of table m_user +ALTER TABLE + +-- 仮想生成列で利用されているカラムを削除(CASCADE追加) +postgres=# ALTER TABLE m_user DROP COLUMN first_name CASCADE; +NOTICE: drop cascades to column full_name of table m_user +ALTER TABLE + +postgres=# \d m_user; + Table "public.m_user" + Column | Type | Collation | Nullable | Default +-----------+--------+-----------+----------+---------------------------------- + user_id | bigint | | not null | generated by default as identity + last_name | text | | | +Indexes: + "m_user_pkey" PRIMARY KEY, btree (user_id) +``` + +`CASCADE` を利用したら、利用していた元のカラムも同時に削除されました。強力ですね...。事故不可避なので存在自体を忘れたほうが良いでしょう。 + + +# 格納生成列と仮想生成列の使い分け + +格納生成列ですが、最初に紹介したPostgreSQL設計ガイドラインにある通り、業務要件の変更でロジックを変更したい場合の、テーブルマイグレーション(デプロイ作業)が大変過ぎるため避けるべきは変わりませんでした。 + +仮想生成列は、その手の苦労は今回動かした範囲内ではあまり感じませんでした(実データの変更は伴わず、メタデータの変更のみだからです)。一方で、NOT NULL制約を付けたときの挙動には注意で、定義変更時は既存の全行をフルスキャンするような動きになっていると思われます。システムメンテナンスタイムを確保できるシステムであっても、それなりのデータ量になりえる場合は、選択しにくいでしょう(パーティションテーブルごとに定義変更できるなどの手順が確立できればまだ考えようがありますが..)。 + +総合すると、NOT NULLを絶対に付けないかつ、アプリケーション側でロジックが散らばるのであれば、いっそDB定義側で仮想生成列を用いて、統制を図るのも一手ではないかと感じました。一方で、将来的にNOT NULL制約をつける変更もありえなくない場合は、防御的な考えから異現時点では採用すべきでない気がします。式インデックスなどハマりどころもあるので、大規模だと利用は現時点では禁止にしたい。 + +みなさんの意見もいただけると嬉しいです。 + +# まとめ + +格納生成列、仮想生成列の両方について触ってみました。私の見解としては以下の意見です。 + +- 格納生成列は使わない +- 仮想生成列は、NOT NULL制約を付けたときの挙動は気になるけど、NOT NULL制約を絶対に付けないのであれば害は少ないので、統制が取れるなら利用してもよいのでは。大規模ではハマりどころも多いのでテックリード的な視点では、現時点では禁止にしておきたい +- どちらも、パーティションキーに使えないなど制約があるので、[ドキュメント](https://www.postgresql.org/docs/18/ddl-generated-columns.html)を一読することを推奨します diff --git a/source/images/2025/20251030a/thumbnail.jpg b/source/images/2025/20251030a/thumbnail.jpg new file mode 100644 index 00000000000..9a1b91acee9 Binary files /dev/null and b/source/images/2025/20251030a/thumbnail.jpg differ diff --git a/source/images/2025/20251030a/top.jpg b/source/images/2025/20251030a/top.jpg new file mode 100644 index 00000000000..816147d1667 Binary files /dev/null and b/source/images/2025/20251030a/top.jpg differ