强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

PostgreSQL 完全指南 / 09 - 表设计

第 09 章 · 表设计

良好的表设计是数据库性能和可维护性的基石。本章讲解范式理论、继承、分区表和表空间。


9.1 范式化设计

三大范式

范式要求示例
1NF每列原子值,不可再分地址拆分为省/市/区
2NF满足 1NF + 非主属性完全依赖主键订单详情表拆分
3NF满足 2NF + 非主属性不传递依赖部门信息独立成表

反范式化

-- 范式化:需要 JOIN
SELECT e.name, d.budget FROM employees e JOIN departments d ON e.department = d.name;

-- 反范式化:冗余字段,减少 JOIN
CREATE TABLE employees_denorm (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name TEXT,
    department TEXT,
    dept_budget NUMERIC(15,2)  -- 冗余字段
);
策略优点缺点
范式化数据一致、更新简单查询需要 JOIN
反范式化查询快、减少 JOIN数据冗余、更新复杂

💡 技巧:OLTP 系统建议至少满足 3NF;报表/分析系统可以适当反范式化。


9.2 表继承(Inheritance)

-- 父表
CREATE TABLE vehicles (
    id SERIAL PRIMARY KEY,
    make TEXT NOT NULL,
    model TEXT NOT NULL,
    year INT NOT NULL
);

-- 子表继承父表
CREATE TABLE cars () INHERITS (vehicles);
CREATE TABLE trucks () INHERITS (vehicles);

ALTER TABLE cars ADD COLUMN num_doors INT DEFAULT 4;
ALTER TABLE trucks ADD COLUMN payload_kg NUMERIC(10,2);

-- 查询父表会包含子表数据
SELECT * FROM vehicles;         -- 所有车辆(包括 cars 和 trucks)
SELECT ONLY * FROM vehicles;    -- 仅父表数据

-- 约束继承
CREATE TABLE cars (
    num_doors INT CHECK (num_doors BETWEEN 2 AND 5)
) INHERITS (vehicles);

⚠️ 注意事项:表继承是 PostgreSQL 的传统特性,新项目建议使用声明式分区表(见下节)。继承的外键约束、唯一约束不会自动传播到子表。


9.3 声明式分区表(Declarative Partitioning)

PG 10+ 引入声明式分区,是大数据量表的核心优化手段。

分区类型

类型说明适用场景
RANGE按范围分区时间、ID 区间
LIST按列表分区地区、状态
HASH按哈希分区均匀分布

RANGE 分区(最常用)

-- 创建分区主表
CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY,
    customer_id INT NOT NULL,
    order_date DATE NOT NULL,
    total NUMERIC(12,2) NOT NULL,
    status SMALLINT DEFAULT 0
) PARTITION BY RANGE (order_date);

-- 创建分区
CREATE TABLE orders_2023 PARTITION OF orders
    FOR VALUES FROM ('2023-01-01') TO ('2024-01-01');

CREATE TABLE orders_2024 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');

CREATE TABLE orders_2025 PARTITION OF orders
    FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

-- 默认分区(处理不在任何范围的数据)
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

-- 在分区上创建索引(自动在所有分区创建)
CREATE INDEX idx_orders_date ON orders (order_date);
CREATE INDEX idx_orders_customer ON orders (customer_id);

-- 插入数据会自动路由到对应分区
INSERT INTO orders (customer_id, order_date, total) VALUES
    (1, '2024-06-15', 100.00),
    (2, '2023-12-25', 250.00);

-- 查询特定分区
SELECT * FROM orders_2024;
-- 查询整个表(自动扫描所有分区)
SELECT * FROM orders WHERE order_date >= '2024-01-01';

LIST 分区

CREATE TABLE sales (
    id BIGSERIAL,
    region TEXT NOT NULL,
    amount NUMERIC(12,2) NOT NULL,
    sale_date DATE NOT NULL
) PARTITION BY LIST (region);

CREATE TABLE sales_north PARTITION OF sales FOR VALUES IN ('North', 'Northeast');
CREATE TABLE sales_south PARTITION OF sales FOR VALUES IN ('South', 'Southeast');
CREATE TABLE sales_east PARTITION OF sales FOR VALUES IN ('East');
CREATE TABLE sales_west PARTITION OF sales FOR VALUES IN ('West');
CREATE TABLE sales_default PARTITION OF sales DEFAULT;

HASH 分区

CREATE TABLE users (
    id BIGINT GENERATED ALWAYS AS IDENTITY,
    name TEXT NOT NULL,
    email TEXT NOT NULL
) PARTITION BY HASH (id);

CREATE TABLE users_p0 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE users_p1 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE users_p2 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE users_p3 PARTITION OF users FOR VALUES WITH (MODULUS 4, REMAINDER 3);

多级分区(分区的分区)

CREATE TABLE events (
    id BIGSERIAL,
    event_date DATE NOT NULL,
    region TEXT NOT NULL,
    data JSONB
) PARTITION BY RANGE (event_date);

CREATE TABLE events_2024 PARTITION OF events
    FOR VALUES FROM ('2024-01-01') TO ('2025-01-01')
    PARTITION BY LIST (region);

CREATE TABLE events_2024_north PARTITION OF events_2024 FOR VALUES IN ('North');
CREATE TABLE events_2024_south PARTITION OF events_2024 FOR VALUES IN ('South');
CREATE TABLE events_2024_default PARTITION OF events_2024 DEFAULT;

分区管理

-- 分离分区(不删除数据)
ALTER TABLE orders DETACH PARTITION orders_2023;

-- 分离分区并发(PG 14+,不阻塞查询)
ALTER TABLE orders DETACH PARTITION orders_2023 CONCURRENTLY;

-- 附加分区
ALTER TABLE orders ATTACH PARTITION orders_2023
    FOR VALUES FROM ('2023-01-01') TO ('2024-01-01');

-- 自动创建分区(使用 pg_partman 扩展)
-- CREATE EXTENSION pg_partman;
-- SELECT partman.create_parent('public.orders', 'order_date', 'native', 'monthly');

-- 查看分区信息
SELECT
    parent.relname AS parent,
    child.relname AS partition,
    pg_get_expr(child.relpartbound, child.oid) AS bounds
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'orders'
ORDER BY child.relname;

⚠️ 注意事项

  • 分区键必须是主键/唯一约束的一部分
  • 分区数量不宜过多(建议 < 1000),否则规划时间会增加
  • 跨分区查询仍会扫描多个分区,需要合适的索引

9.4 表空间(Tablespace)

-- 创建表空间(需要 OS 目录权限)
CREATE TABLESPACE fast_ssd LOCATION '/mnt/ssd/pgdata';

-- 在指定表空间创建表
CREATE TABLE hot_data (
    id SERIAL PRIMARY KEY,
    data TEXT
) TABLESPACE fast_ssd;

-- 在指定表空间创建索引
CREATE INDEX idx_hot_data ON hot_data (id) TABLESPACE fast_ssd;

-- 修改默认表空间
SET default_tablespace = fast_ssd;

-- 移动表到新表空间
ALTER TABLE hot_data SET TABLESPACE pg_default;

-- 查看表空间
SELECT spcname, pg_size_pretty(pg_tablespace_size(spcname))
FROM pg_tablespace;

9.5 临时表

-- 会话级临时表(会话结束自动删除)
CREATE TEMP TABLE tmp_results AS
SELECT department, AVG(salary) AS avg_sal
FROM employees GROUP BY department;

-- 事务级临时表
CREATE TEMP TABLE tmp_txn_data (
    id INT, value TEXT
) ON COMMIT DROP;

-- 临时表不影响其他会话
-- 临时表不会被 pg_dump 导出

9.6 生成列(Generated Columns,PG 12+)

CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    price NUMERIC(10,2) NOT NULL,
    tax_rate NUMERIC(4,2) NOT NULL DEFAULT 0.13,
    price_with_tax NUMERIC(10,2) GENERATED ALWAYS AS (price * (1 + tax_rate)) STORED
);

INSERT INTO products (price, tax_rate) VALUES (100.00, 0.13);
SELECT * FROM products;
-- id | price  | tax_rate | price_with_tax
-- 1  | 100.00 | 0.13     | 113.00
属性STORED注意
存储物理存储占用磁盘空间
更新基列变化时自动更新不可手动 INSERT/UPDATE
索引✅ 可以建索引

9.7 表设计最佳实践

原则建议
主键类型BIGINT GENERATED ALWAYS AS IDENTITYUUID
时间字段统一用 TIMESTAMPTZ
金额字段NUMERIC(p,s)
字符串优先 TEXT,需要约束时用 VARCHAR(n)
布尔字段BOOLEAN DEFAULT FALSE
外键必须创建索引
软删除deleted_at TIMESTAMPTZ 而非 is_deleted BOOLEAN
大字段者 TOAST 自动压缩;超大内容用 Large Object 或外部存储
表注释使用 COMMENT ON TABLE/COLUMN
COMMENT ON TABLE orders IS '订单主表';
COMMENT ON COLUMN orders.status IS '订单状态:0=待支付 1=已支付 2=已发货 3=已完成 9=已取消';

业务场景

场景设计方案
时序数据RANGE 分区(按月/天)
多租户LIST 分区(按租户)或 Schema 隔离
热/冷数据分离热数据 SSD 表空间 + 冷数据 HDD 表空间
数据归档分离旧分区 → ATTACH 到归档表
大宽表适当反范式化 + 物化视图

扩展阅读