DDL 與 Schema 管理:PostgreSQL 資料結構定義與分區策略 | PostgreSQL

2026/06/19
DDL 與 Schema 管理:PostgreSQL 資料結構定義與分區策略 | PostgreSQL

DDL(Data Definition Language) 負責定義、修改和刪除資料庫物件。在 PostgreSQL 中,DDL 不僅影響邏輯結構,更直接操作系統目錄(System Catalog),並可能持有排他鎖(Exclusive Lock) 影響並行工作負載。掌握 DDL 的鎖定行為與宣告式分區(Declarative Partitioning),是生產環境 Schema 管理的核心能力。

DDL 與系統目錄的關係

執行 CREATE TABLE 時,PostgreSQL 在系統目錄中寫入多筆紀錄:

目錄表 用途
pg_class 表格、視圖、索引、序列等關係物件
pg_attribute 每個欄位的名稱、型別、位置、是否可 NULL
pg_constraint PK、FK、UNIQUE、CHECK 等約束
pg_index 索引的詳細資訊
pg_namespace Schema(命名空間)資訊
-- 建立表格後,查看系統目錄紀錄
CREATE TABLE orders (
    order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id bigint NOT NULL,
    total_amount numeric(12, 2)
);

-- 查看 pg_class 中的紀錄
SELECT relname, relkind, relnatts, relhaspkey
FROM pg_class
WHERE relname = 'orders';
-- relkind: 'r'=普通表格, 'i'=索引, 'v'=視圖, 'p'=分區父表

-- 查看欄位資訊
SELECT attname, atttypid::regtype, attnum, attnotnull
FROM pg_attribute
WHERE attrelid = 'orders'::regclass
  AND attnum > 0
ORDER BY attnum;

-- 查看 Constraint 資訊
SELECT conname, contype, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'orders'::regclass;

CREATE / ALTER / DROP TABLE

CREATE TABLE

-- 基本建表
CREATE TABLE IF NOT EXISTS products (
    product_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    sku        varchar(50)    NOT NULL UNIQUE,
    name       text           NOT NULL,
    price      numeric(10, 2) NOT NULL CHECK (price >= 0),
    category_id int           REFERENCES categories(category_id) ON DELETE SET NULL,
    is_active  boolean        NOT NULL DEFAULT true,
    metadata   jsonb,
    created_at timestamptz    NOT NULL DEFAULT now(),
    updated_at timestamptz    NOT NULL DEFAULT now()
);

-- 從查詢結果建立表格(含資料)
CREATE TABLE orders_archive AS
SELECT * FROM orders WHERE created_at < '2024-01-01';

-- 指定儲存參數
CREATE TABLE high_write_table (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    data text
) WITH (fillfactor = 70);  -- 留 30% 空間給 UPDATE(HOT 優化)

ALTER TABLE

-- 新增欄位(PG11+ 有預設值幾乎即時完成,無需重寫表格)
ALTER TABLE products ADD COLUMN in_stock boolean NOT NULL DEFAULT true;

-- 刪除欄位
ALTER TABLE products DROP COLUMN weight_kg;

-- 修改欄位型別(需確認相容性,可能重寫表格)
ALTER TABLE products ALTER COLUMN name TYPE varchar(200);

-- 重命名欄位
ALTER TABLE products RENAME COLUMN sku TO product_code;

-- 設定/移除 NOT NULL
ALTER TABLE products ALTER COLUMN metadata SET NOT NULL;
ALTER TABLE products ALTER COLUMN metadata DROP NOT NULL;

DROP / TRUNCATE

-- 安全刪除
DROP TABLE IF EXISTS temp_import;

-- 級聯刪除(同時刪除依賴物件)
DROP TABLE IF EXISTS categories CASCADE;

-- 清空表格(比 DELETE 快,重置序列)
TRUNCATE TABLE orders_archive RESTART IDENTITY;
TRUNCATE TABLE orders, order_items CASCADE;  -- 多表同時清空

Constraints 詳細說明

PRIMARY KEY

-- 單欄主鍵
CREATE TABLE users (
    user_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email text NOT NULL
);

-- 複合主鍵
CREATE TABLE order_items (
    order_id   bigint,
    product_id bigint,
    quantity   int NOT NULL,
    PRIMARY KEY (order_id, product_id)
);

實作機制:PK = 唯一索引 + NOT NULL。建立 PK 時自動建立 B-tree 索引。

FOREIGN KEY

CREATE TABLE orders (
    order_id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id bigint NOT NULL,
    CONSTRAINT fk_orders_customer
        FOREIGN KEY (customer_id)
        REFERENCES customers(customer_id)
        ON DELETE RESTRICT     -- 不允許刪除被引用的客戶
        ON UPDATE CASCADE      -- 客戶 ID 變更時自動更新
);

-- 延遲 FK 檢查(適合批量資料載入)
CREATE TABLE order_items (
    item_id  bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_id bigint,
    CONSTRAINT fk_item_order
        FOREIGN KEY (order_id) REFERENCES orders(order_id)
        DEFERRABLE INITIALLY DEFERRED  -- Transaction 結束時才檢查
);

實作機制:FK 透過隱藏的觸發器(Trigger) 實作——AFTER INSERT/UPDATE 驗證值存在,AFTER DELETE/UPDATE(在參照表上)驗證不違反引用完整性。

UNIQUE

-- 單欄唯一
ALTER TABLE users ADD CONSTRAINT uq_email UNIQUE (email);

-- 部分唯一索引(只對符合條件的資料強制唯一)
CREATE UNIQUE INDEX uq_active_sku
ON products (sku)
WHERE is_active = true;
-- 非活躍產品可有重複 SKU,活躍產品不行

CHECK

CREATE TABLE products (
    product_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    price      numeric(10, 2) NOT NULL,
    sale_price numeric(10, 2),
    status     text NOT NULL,
    CONSTRAINT chk_price CHECK (price > 0),
    CONSTRAINT chk_sale CHECK (sale_price IS NULL OR sale_price < price),
    CONSTRAINT chk_status CHECK (status IN ('draft', 'active', 'discontinued'))
);

-- 新增 CHECK 但跳過既有資料驗證(先上線,再排程驗證)
ALTER TABLE products
ADD CONSTRAINT chk_new CHECK (metadata IS NOT NULL) NOT VALID;

-- 後續驗證(只需 ShareUpdateExclusiveLock,不阻塞讀寫)
ALTER TABLE products VALIDATE CONSTRAINT chk_new;

EXCLUDE

-- 需要先安裝 btree_gist 擴展
CREATE EXTENSION IF NOT EXISTS btree_gist;

-- 同一房間同一時段不可重複預約
CREATE TABLE room_bookings (
    booking_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    room_id    int NOT NULL,
    during     tstzrange NOT NULL,
    CONSTRAINT no_overlap EXCLUDE USING gist (
        room_id WITH =,    -- 同一房間
        during WITH &&     -- 時間範圍重疊
    )
);

Declarative Partitioning

PostgreSQL 10 引入宣告式分區,以父表/子表架構實作。父表本身不儲存資料,只作為邏輯入口;每個子表是獨立表格,有自己的索引和統計資訊。

Range Partitioning(範圍分區)

最常見,適合時間序列資料

-- 建立分區父表
CREATE TABLE events (
    event_id   bigint NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    event_type text NOT NULL,
    payload    jsonb,
    PRIMARY KEY (event_id, created_at)  -- 主鍵必須包含分區鍵
) PARTITION BY RANGE (created_at);

-- 建立子分區(每月一個)
CREATE TABLE events_2026_01 PARTITION OF events
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_2026_02 PARTITION OF events
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE events_2026_03 PARTITION OF events
    FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

-- 預設分區(捕獲未命中的資料)
CREATE TABLE events_default PARTITION OF events DEFAULT;

-- 查詢時規劃器自動做 Partition Pruning
SELECT * FROM events
WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01';
-- 只掃描 events_2026_02
-- 動態建立下個月分區(適合放在定時任務中)
DO $$
DECLARE
    next_month date := date_trunc('month', now() + interval '1 month');
    partition_name text;
BEGIN
    partition_name := 'events_' || to_char(next_month, 'YYYY_MM');
    EXECUTE format(
        'CREATE TABLE IF NOT EXISTS %I PARTITION OF events
         FOR VALUES FROM (%L) TO (%L)',
        partition_name, next_month, next_month + interval '1 month'
    );
END $$;

List Partitioning(列表分區)

適合依離散值分區,如地區、狀態:

CREATE TABLE orders (
    order_id bigint NOT NULL,
    region   text NOT NULL,
    amount   numeric(12, 2),
    PRIMARY KEY (order_id, region)
) PARTITION BY LIST (region);

CREATE TABLE orders_apac PARTITION OF orders
    FOR VALUES IN ('TW', 'JP', 'KR', 'SG');
CREATE TABLE orders_emea PARTITION OF orders
    FOR VALUES IN ('DE', 'FR', 'GB', 'NL');
CREATE TABLE orders_amer PARTITION OF orders
    FOR VALUES IN ('US', 'CA', 'MX', 'BR');
CREATE TABLE orders_other PARTITION OF orders DEFAULT;

Hash Partitioning(雜湊分區)

適合均勻分散負載

CREATE TABLE user_activities (
    activity_id bigint NOT NULL,
    user_id     bigint NOT NULL,
    action      text,
    PRIMARY KEY (activity_id, user_id)
) PARTITION BY HASH (user_id);

CREATE TABLE user_activities_0 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE user_activities_1 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE user_activities_2 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE user_activities_3 PARTITION OF user_activities
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

Schema 管理

Schema(命名空間) 用於邏輯隔離資料庫物件,避免命名衝突:

-- 建立 Schema
CREATE SCHEMA IF NOT EXISTS reporting;
CREATE SCHEMA IF NOT EXISTS staging;

-- 在特定 Schema 中建立物件
CREATE TABLE reporting.monthly_summary (
    summary_id serial PRIMARY KEY,
    report_month date NOT NULL,
    total_sales numeric(15, 2)
);

-- 設定搜索路徑
SET search_path TO myapp, public;

-- 資料庫級設定(永久)
ALTER DATABASE mydb SET search_path TO myapp, public;

-- 授權
GRANT USAGE ON SCHEMA reporting TO readonly_role;
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO readonly_role;

-- 設定未來建立的表格也自動授權
ALTER DEFAULT PRIVILEGES IN SCHEMA reporting
    GRANT SELECT ON TABLES TO readonly_role;

Online DDL 鎖影響

大多數 ALTER TABLE 操作需要 AccessExclusiveLock(表格級排他鎖),會阻塞所有讀寫操作

鎖類型 影響 典型操作
ShareUpdateExclusiveLock 不阻塞讀寫 CREATE INDEX CONCURRENTLYVALIDATE CONSTRAINT
ShareLock 阻塞寫入 傳統 CREATE INDEX
AccessExclusiveLock 阻塞所有 大多數 ALTER TABLE
等待鎖的連鎖阻塞:

Session A: 持有 ShareLock(長查詢)
Session B: 等待 AccessExclusiveLock(ALTER TABLE)← 被 A 阻塞
Session C: 等待 ShareLock(普通 SELECT)← 被 B 阻塞!
Session D: 等待 ShareLock(普通 SELECT)← 也被 B 阻塞!

→ 即使很短的 DDL 也可能造成連鎖阻塞

安全 DDL 策略

-- 策略一:lock_timeout 避免長時間等待
BEGIN;
SET lock_timeout = '3s';
SET statement_timeout = '60s';
ALTER TABLE orders ADD COLUMN payment_method text;
COMMIT;
-- 超時則捕獲錯誤並稍後重試

-- 策略二:CREATE INDEX CONCURRENTLY(不阻塞讀寫)
CREATE INDEX CONCURRENTLY idx_orders_customer ON orders (customer_id);
-- 注意:不能在 Transaction 中執行
-- 如果中途失敗,會留下 INVALID 索引,需手動 DROP 再重試

-- 策略三:NOT VALID + VALIDATE 兩步走
ALTER TABLE orders
ADD CONSTRAINT chk_amount CHECK (amount > 0) NOT VALID;
-- 第二步:驗證既有資料(不阻塞讀寫)
ALTER TABLE orders VALIDATE CONSTRAINT chk_amount;

IF NOT EXISTS 冪等 DDL

IF NOT EXISTS 是自動化腳本和遷移工具的核心:

CREATE SCHEMA IF NOT EXISTS myapp;
CREATE TABLE IF NOT EXISTS users (
    user_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email text NOT NULL UNIQUE
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);

-- Enum 沒有 IF NOT EXISTS,需要用 DO 區塊
DO $$ BEGIN
    CREATE TYPE order_status AS ENUM ('pending', 'confirmed', 'shipped');
EXCEPTION
    WHEN duplicate_object THEN NULL;
END $$;

-- 新增欄位(PG 9.6+)
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login timestamptz;

Partition Pruning 驗證

-- 確認規劃器是否正確修剪分區
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM events
WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01';
-- 期望只掃描 events_2026_02

-- 查看分區列表
SELECT parent.relname AS parent,
       child.relname AS partition,
       pg_get_expr(child.relpartbound, child.oid) AS bound
FROM pg_class parent
JOIN pg_inherits ON inhparent = parent.oid
JOIN pg_class child ON inhrelid = child.oid
WHERE parent.relname = 'events'
ORDER BY child.relname;

常見 Pruning 失效原因

-- 失效:對分區鍵做函數運算
SELECT * FROM events WHERE date_trunc('month', created_at) = '2026-02-01';

-- 正確:使用範圍條件
SELECT * FROM events
WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01';

常見陷阱

分區表的主鍵必須包含分區鍵

-- 錯誤
CREATE TABLE events (
    event_id bigint PRIMARY KEY,  -- 不含分區鍵
    created_at timestamptz NOT NULL
) PARTITION BY RANGE (created_at);
-- ERROR: insufficient columns in PRIMARY KEY

-- 正確
CREATE TABLE events (
    event_id bigint NOT NULL,
    created_at timestamptz NOT NULL,
    PRIMARY KEY (event_id, created_at)
) PARTITION BY RANGE (created_at);

TRUNCATE 與外鍵

TRUNCATE TABLE customers;
-- ERROR: cannot truncate a table referenced in a FK constraint

TRUNCATE TABLE customers CASCADE;
-- 會同時清空 orders 等所有引用表——謹慎使用!

PG11 前後的 ADD COLUMN 行為差異

-- PG 11+:有預設值的 NOT NULL 欄位不需重寫表格(即時完成)
ALTER TABLE big_table ADD COLUMN processed boolean NOT NULL DEFAULT false;

-- PG 10 以前:需要重寫整張表,長時間持有排他鎖
-- 安全做法:先加可 NULL 欄位 → 批量填充 → 再設定 NOT NULL

版本演進

版本 重要 DDL / 分區功能
PG10 Declarative Partitioning(Range、List)
PG11 Hash 分區、DEFAULT 分區、ADD COLUMN DEFAULT 不再重寫表格
PG12 Partition Pruning 大幅改善(含 JOIN 動態修剪)
PG13 分區表支援 BEFORE 觸發器
PG14 VACUUM/ANALYZE 改善分區表效率
PG15 MERGE 語句正式引入

總結

DDL 與 Schema 管理 是 PostgreSQL 資料結構的基石:

  • CREATE TABLE 搭配 Constraints(PK、FK、UNIQUE、CHECK、EXCLUDE)確保資料完整性
  • ALTER TABLE 在生產環境要注意鎖影響,善用 lock_timeoutNOT VALID + VALIDATE 策略
  • Declarative Partitioning 提供 Range、List、Hash 三種策略,主鍵必須包含分區鍵
  • Schema 實現命名空間隔離,搭配 search_pathDEFAULT PRIVILEGES 管理權限
  • CREATE INDEX CONCURRENTLY 是生產環境建立索引的首選方式
  • IF NOT EXISTS 讓 DDL 腳本具備冪等性,適合自動化部署

下一篇,我們將深入探討 DML 進階操作——INSERT、UPDATE、DELETE、UPSERT 與 RETURNING 的實戰技巧。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。