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 IDENTITY 或 UUID |
| 时间字段 | 统一用 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 到归档表 |
| 大宽表 | 适当反范式化 + 物化视图 |
扩展阅读