PostgreSQL: PostgreSQL分区表与表继承
最后更新:2026-08-26
1. 你将学到
- 理解声明式分区的原理与适用场景
- 掌握 RANGE / LIST / HASH / 多级分区的创建方法
- 学会分区裁剪(Partition Pruning)加速查询
- 实践分区维护操作:DETACH / ATTACH / DROP
- 对比表继承(INHERITS)与声明式分区的差异
- 为分区表设计索引策略
2. 故事
Alice 的电商平台 orders 表每天新增 100K 行。三年后单表超过 1 亿行,即使有索引,按月查询一次也要扫描整张表。DBA 建议按月做 RANGE 分区——查询最近 30 天数据只需扫描 1 个分区,而非 3 年的全表。分区上线后,月度报表查询从 12 秒降到 0.8 秒,磁盘 I/O 降低 95%。
3. Concept:声明式分区概述
(1) 为什么需要分区
当单表行数超过千万级,索引的 B-Tree 深度增加、VACUUM 耗时暴涨、查询规划器可能选择次优计划。分区将逻辑大表拆为多个物理小表,每个小表独立索引与维护。
| 指标 | 未分区(1 亿行) | 按月分区(36 个分区) |
|---|---|---|
| 单分区行数 | 100M | ~2.8M |
| 索引深度 | 5-6 层 | 3-4 层 |
| VACUUM 时间 | 30+ min | < 2 min/分区 |
| 按月查询 I/O | 全表扫描 | 仅扫 1 个分区 |
(2) PostgreSQL 分区演进
| 版本 | 特性 |
|---|---|
| PG 9.x | 表继承 + 触发器手动分区 |
| PG 10 | 声明式 RANGE / LIST 分区 |
| PG 11 | HASH 分区、分区裁剪增强、UPDATE 跨分区迁移 |
| PG 12 | ATTACH/DETACH 分区 |
| PG 13 | 多级分区裁剪优化 |
| PG 14+ | 分区裁剪性能进一步提升 |
(3) 四种分区策略
| 策略 | 适用场景 | 分区键要求 |
|---|---|---|
| RANGE | 时间序列、数值区间 | 可排序类型 |
| LIST | 枚举值分类(地区、状态) | 离散值 |
| HASH | 均匀分布、无明确范围 | 任意可哈希类型 |
| MULTI-LEVEL | RANGE + LIST/HASH 组合 | 多列组合 |
4. 操作:RANGE 分区
▶ 示例:创建按月 RANGE 分区的订单表
SQL
-- Parent table: partition key only
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY,
user_id BIGINT NOT NULL,
order_date DATE NOT NULL,
total_amount NUMERIC(12,2),
status TEXT DEFAULT 'pending'
) PARTITION BY RANGE (order_date);
-- Monthly partitions for 2024
CREATE TABLE orders_2024_01 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE orders_2024_02 PARTITION OF orders
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
CREATE TABLE orders_2024_03 PARTITION OF orders
FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
-- Default partition catches out-of-range rows
CREATE TABLE orders_default PARTITION OF orders DEFAULT;
输出:
TEXT
📖 仅展示
CREATE TABLE
▶ 示例:自动生成分区的函数
SQL
-- Generate monthly partitions for a given year
CREATE OR REPLACE FUNCTION create_monthly_partitions(
p_parent REGCLASS,
p_year INT
) RETURNS VOID AS $$
DECLARE
m INT;
p_name TEXT;
s_date TEXT;
e_date TEXT;
BEGIN
FOR m IN 1..12 LOOP
p_name := format('%s_%s_%02s', p_parent::text, p_year, m);
s_date := format('%s-%02s-01', p_year, m);
e_date := format('%s-%02s-01', p_year, m + 1);
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF %s
FOR VALUES FROM (%L) TO (%L)',
p_name, p_parent::text, s_date, e_date
);
END LOOP;
END;
$$ LANGUAGE plpgsql;
SELECT create_monthly_partitions('orders', 2025);
输出:
TEXT
📖 仅展示
CREATE TABLE
▶ 示例:RANGE 分区插入与查询
SQL
INSERT INTO orders (user_id, order_date, total_amount, status)
VALUES
(1001, '2024-01-15', 299.99, 'completed'),
(1002, '2024-02-20', 159.50, 'shipped'),
(1003, '2024-03-10', 89.00, 'pending');
-- Query with partition pruning
SELECT * FROM orders
WHERE order_date BETWEEN '2024-02-01' AND '2024-02-29';
TEXT
📖 仅展示
id | user_id | order_date | total_amount | status
------+---------+------------+--------------+--------
1002 | 1002 | 2024-02-20 | 159.50 | shipped
(1 row)
5. 操作:LIST 与 HASH 分区
▶ 示例:LIST 分区按地区拆分用户表
SQL
CREATE TABLE users_by_region (
id BIGINT GENERATED ALWAYS AS IDENTITY,
name TEXT NOT NULL,
region TEXT NOT NULL,
email TEXT
) PARTITION BY LIST (region);
CREATE TABLE users_north_america PARTITION OF users_by_region
FOR VALUES IN ('US', 'CA', 'MX');
CREATE TABLE users_europe PARTITION OF users_by_region
FOR VALUES IN ('UK', 'DE', 'FR', 'ES');
CREATE TABLE users_asia PARTITION OF users_by_region
FOR VALUES IN ('CN', 'JP', 'KR', 'SG');
CREATE TABLE users_other PARTITION OF users_by_region DEFAULT;
输出:
TEXT
📖 仅展示
CREATE TABLE
▶ 示例:HASH 分区均匀分布日志表
SQL
CREATE TABLE access_logs (
id BIGINT GENERATED ALWAYS AS IDENTITY,
user_id BIGINT,
action TEXT,
log_time TIMESTAMPTZ DEFAULT now()
) PARTITION BY HASH (user_id);
-- Create 8 hash partitions
CREATE TABLE access_logs_p0 PARTITION OF access_logs
FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE access_logs_p1 PARTITION OF access_logs
FOR VALUES WITH (MODULUS 8, REMAINDER 1);
CREATE TABLE access_logs_p2 PARTITION OF access_logs
FOR VALUES WITH (MODULUS 8, REMAINDER 2);
CREATE TABLE access_logs_p3 PARTITION OF access_logs
FOR VALUES WITH (MODULUS 8, REMAINDER 3);
CREATE TABLE access_logs_p4 PARTITION OF access_logs
FOR VALUES WITH (MODULUS 8, REMAINDER 4);
CREATE TABLE access_logs_p5 PARTITION OF access_logs
FOR VALUES WITH (MODULUS 8, REMAINDER 5);
CREATE TABLE access_logs_p6 PARTITION OF access_logs
FOR VALUES WITH (MODULUS 8, REMAINDER 6);
CREATE TABLE access_logs_p7 PARTITION OF access_logs
FOR VALUES WITH (MODULUS 8, REMAINDER 7);
输出:
TEXT
📖 仅展示
CREATE TABLE
▶ 示例:多级分区(RANGE + LIST)
SQL
CREATE TABLE order_details (
id BIGINT,
order_id BIGINT,
order_date DATE NOT NULL,
region TEXT NOT NULL,
product_id INT,
quantity INT
) PARTITION BY RANGE (order_date);
-- First level: by month
CREATE TABLE order_details_2024q1 PARTITION OF order_details
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01')
PARTITION BY LIST (region);
-- Second level: by region within each month range
CREATE TABLE order_details_2024q1_na PARTITION OF order_details_2024q1
FOR VALUES IN ('US', 'CA', 'MX');
CREATE TABLE order_details_2024q1_eu PARTITION OF order_details_2024q1
FOR VALUES IN ('UK', 'DE', 'FR');
输出:
TEXT
📖 仅展示
CREATE TABLE
6. Concept:分区裁剪
(1) 裁剪原理
分区裁剪(Partition Pruning)让查询规划器在规划阶段排除不相关分区,避免运行时扫描。
flowchart TD
A["SELECT * FROM orders<br/>WHERE order_date = '2024-02-15'"] --> B["Query Planner"]
B --> C{"Partition Pruning"}
C -->|"order_date in [2024-02-01, 2024-03-01)"| D["orders_2024_02 ✓"]
C -->|"out of range"| E["orders_2024_01 ✗"]
C -->|"out of range"| F["orders_2024_03 ✗"]
C -->|"out of range"| G["orders_default ✗"]
D --> H["Scan 1 partition only"]
(2) 验证裁剪效果
SQL
-- Enable partition pruning (default ON)
SET enable_partition_pruning = on;
-- Check which partitions are scanned
EXPLAIN (COSTS OFF) SELECT * FROM orders
WHERE order_date = '2024-02-15';
TEXT
📖 仅展示
Append
-> Seq Scan on orders_2024_02
Filter: (order_date = '2024-02-15'::date)
(3) 裁剪失效的常见原因
| 场景 | 是否裁剪 | 原因 |
|---|---|---|
WHERE order_date = '2024-02-15' |
✅ | 常量可裁剪 |
WHERE order_date = $1 (prepared stmt) |
✅ PG 11+ | 通用参数裁剪 |
WHERE order_date = now() |
✅ | 稳定函数可裁剪 |
WHERE order_date = random_func() |
❌ | volatile 函数不可裁剪 |
WHERE to_char(order_date, 'YYYY-MM') = '2024-02' |
❌ | 函数包裹分区键 |
7. 操作:分区维护
▶ 示例:DETACH 旧分区归档
SQL
-- Detach January 2023 partition (no data loss)
ALTER TABLE orders DETACH PARTITION orders_2023_01;
-- Now it's a standalone table, can be moved to cheaper storage
ALTER TABLE orders_2023_01 SET TABLESPACE archive_tbs;
-- Or export and drop
COPY orders_2023_01 TO '/archive/orders_2023_01.csv';
DROP TABLE orders_2023_01;
输出:
TEXT
📖 仅展示
-- SQL 语句执行成功
▶ 示例:ATTACH 新分区
SQL
-- Create new table first
CREATE TABLE orders_2025_01 (LIKE orders INCLUDING DEFAULTS);
-- Add check constraint for validation (speeds up ATTACH)
ALTER TABLE orders_2025_01
ADD CONSTRAINT orders_2025_01_check
CHECK (order_date >= '2025-01-01' AND order_date < '2025-02-01');
-- Attach to parent (takes exclusive lock briefly)
ALTER TABLE orders ATTACH PARTITION orders_2025_01
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
输出:
TEXT
📖 仅展示
CREATE TABLE
▶ 示例:并发 DETACH(PG 14+)
SQL
-- DETACH without blocking concurrent reads
ALTER TABLE orders DETACH PARTITION orders_2023_01 CONCURRENTLY;
输出:
TEXT
📖 仅展示
-- SQL 语句执行成功
▶ 示例:分区索引策略
SQL
-- Index on parent propagates to all partitions
CREATE INDEX idx_orders_user_id ON orders (user_id);
-- Each partition gets its own index
\d orders_2024_01
TEXT
📖 仅展示
Indexes:
"orders_2024_01_user_id_idx" btree (user_id)
| 索引策略 | 说明 |
|---|---|
| 父表创建索引 | 自动传播到所有现有及未来分区 |
| 分区单独创建索引 | 仅影响该分区,ATTACH 时需手动同步 |
| 唯一索引 | 必须包含分区键(跨分区唯一性由分区键保证) |
▶ 示例:唯一索引必须包含分区键
SQL
-- This FAILS: unique without partition key
CREATE UNIQUE INDEX idx_orders_id ON orders (id);
-- ERROR: unique constraint must contain partition key
-- This WORKS: unique with partition key
CREATE UNIQUE INDEX idx_orders_id_date ON orders (id, order_date);
输出:
TEXT
📖 仅展示
CREATE TABLE
8. Concept:表继承(INHERITS)
(1) 继承语法与特点
表继承是 PostgreSQL 的独有特性,比声明式分区更灵活——子表可以有额外列,不需要覆盖父表所有值域。
SQL
-- Parent table
CREATE TABLE people (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT
);
-- Child table inherits all columns + adds extras
CREATE TABLE employees (
salary NUMERIC(10,2),
dept TEXT,
hire_date DATE
) INHERITS (people);
-- Add primary key to child
ALTER TABLE employees ADD PRIMARY KEY (id);
(2) 继承 vs 声明式分区对比
| 特性 | 表继承(INHERITS) | 声明式分区 |
|---|---|---|
| 子表额外列 | ✅ 允许 | ❌ 必须同结构 |
| 父表存数据 | ✅ 可以 | ❌ 父表为空壳 |
| 自动路由 INSERT | ❌ 需触发器 | ✅ 自动 |
| 分区裁剪 | ❌ 需 CHECK 约束 | ✅ 自动 |
| 唯一约束跨表 | ❌ 仅单表 | ✅ 含分区键可跨表 |
| 外键引用父表 | ❌ 不支持 | ✅ 支持 |
| 灵活性 | 高 | 中 |
| 推荐场景 | 异构子类型 | 大表拆分优化 |
(3) 继承查询:ONLY 关键字
SQL
-- Query parent + all children
SELECT * FROM people;
-- Query parent ONLY (no children)
SELECT * FROM ONLY people;
-- Check which table each row comes from
SELECT tableoid::regclass, * FROM people;
9. 操作:跨分区查询优化
▶ 示例:分区统计信息收集
SQL
-- Analyze specific partition
ANALYZE orders_2024_02;
-- Analyze all partitions via parent
ANALYZE orders;
-- Check partition stats
SELECT relname, n_live_tup, last_analyze
FROM pg_stat_user_tables
WHERE relname LIKE 'orders_%'
ORDER BY relname;
输出:
TEXT
📖 仅展示
id | name | value
----+----------+-------
1 | example | 42
(1 row)
▶ 示例:跨分区 JOIN 优化
SQL
-- Partition-wise join (PG 12+)
SET enable_partitionwise_join = on;
SELECT o.id, o.total_amount, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.order_date BETWEEN '2024-01-01' AND '2024-03-31';
输出:
TEXT
📖 仅展示
id | name | value
----+----------+-------
1 | example | 42
(1 row)
| 参数 | 默认值 | 说明 |
|---|---|---|
enable_partition_pruning |
on | 分区裁剪 |
enable_partitionwise_join |
off | 分区级 JOIN |
enable_partitionwise_aggregate |
off | 分区级聚合 |
constraint_exclusion |
partition | 约束排除(继承用) |
▶ 示例:分区与继承混合场景
SQL
-- Base audit table
CREATE TABLE audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY,
table_name TEXT NOT NULL,
action TEXT NOT NULL,
changed_at TIMESTAMPTZ DEFAULT now()
) PARTITION BY RANGE (changed_at);
-- Monthly partitions, each inherited by type-specific tables
CREATE TABLE audit_log_2024_01 PARTITION OF audit_log
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
-- Additional columns for specific audit type via inheritance
CREATE TABLE audit_log_user_changes (
old_email TEXT,
new_email TEXT
) INHERITS (audit_log_2024_01);
输出:
TEXT
📖 仅展示
CREATE TABLE
10. 综合示例
SQL
-- Complete monthly partitioning setup for e-commerce orders
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY,
user_id BIGINT NOT NULL,
order_date DATE NOT NULL,
total_amount NUMERIC(12,2) DEFAULT 0,
status TEXT DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT now()
) PARTITION BY RANGE (order_date);
-- Create partitions for 2024 Q1
CREATE TABLE orders_2024_01 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE orders_2024_02 PARTITION OF orders
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
CREATE TABLE orders_2024_03 PARTITION OF orders
FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
CREATE TABLE orders_default PARTITION OF orders DEFAULT;
-- Unique index must include partition key
CREATE UNIQUE INDEX idx_orders_id_date ON orders (id, order_date);
CREATE INDEX idx_orders_user_date ON orders (user_id, order_date);
-- Insert test data
INSERT INTO orders (user_id, order_date, total_amount, status) VALUES
(1001, '2024-01-05', 299.99, 'completed'),
(1001, '2024-02-14', 159.50, 'shipped'),
(1002, '2024-01-20', 450.00, 'completed'),
(1002, '2024-03-01', 89.00, 'pending'),
(1003, '2024-02-28', 1200.00,'completed');
-- Verify partition pruning
EXPLAIN (COSTS OFF) SELECT * FROM orders
WHERE order_date BETWEEN '2024-02-01' AND '2024-02-28';
-- Detach old partition for archiving
ALTER TABLE orders DETACH PARTITION orders_2024_01;
COPY orders_2024_01 TO '/archive/orders_2024_01.csv';
-- Attach new partition for next quarter
CREATE TABLE orders_2024_04 (LIKE orders INCLUDING DEFAULTS);
ALTER TABLE orders_2024_04
ADD CONSTRAINT chk_2024_04
CHECK (order_date >= '2024-04-01' AND order_date < '2024-05-01');
ALTER TABLE orders ATTACH PARTITION orders_2024_04
FOR VALUES FROM ('2024-04-01') TO ('2024-05-01');
-- Monitor partition sizes
SELECT relname,
pg_size_pretty(pg_total_relation_size(oid)) AS size,
reltuples::bigint AS row_estimate
FROM pg_class
WHERE relname LIKE 'orders_2024%' ORDER BY relname;
❓ 常见问题
Q 分区表的主键必须包含分区键吗?
A 是的。PostgreSQL 要求分区表唯一约束(包括主键)必须包含分区键列,否则无法保证跨分区唯一性。
Q 已经存在的普通表可以转为分区表吗?
A 不能直接转换。需要创建新的分区父表,然后用 INSERT INTO...SELECT 或 ATTACH 方式迁移数据。
Q DEFAULT 分区有什么风险?
A DEFAULT 分区会接收所有不匹配的数据,如果新分区范围与 DEFAULT 已有数据重叠,ATTACH 新分区会失败。建议定期审查 DEFAULT 分区内容。
Q 分区数量有没有上限?
A PostgreSQL 无硬性上限,但每个分区在规划器中都有开销。建议单表分区数控制在数百以内,超过 1000 个分区时规划时间可能明显增加。
Q 表继承能替代声明式分区吗?
A 一般不推荐。继承缺少自动路由、裁剪和跨表约束,维护成本高。仅在需要异构子表(额外列)时使用继承。
Q HASH 分区能做分区裁剪吗?
A 可以。只要 WHERE 条件包含分区键的等值比较,PG 能计算出数据在哪个 hash bucket,从而裁剪其他分区。
Q UPDATE 导致行跨分区移动会锁表吗?
A PG 11+ 支持 UPDATE 跨分区移动,它是 DELETE + INSERT 内部操作,不会锁整表但会在两个分区上分别加行锁。
📖 小节
- 声明式分区(RANGE/LIST/HASH)是 PG 10+ 管理大表的标准方式
- RANGE 分区适合时间序列,LIST 适合枚举分类,HASH 适合均匀分布
- 分区裁剪让查询只扫描相关分区,条件必须直接基于分区键
- DETACH/ATTACH 实现分区在线维护,CONCURRENTLY 减少锁阻塞
- 分区表唯一索引必须包含分区键
- 表继承更灵活但不支持自动路由和裁剪,适合异构子类型场景
enable_partitionwise_join/aggregate可进一步提升跨分区查询性能
📝 作业
-
⭐ 创建一个按季度 RANGE 分区的
payments表,包含 2024 年四个季度分区,并验证WHERE payment_date BETWEEN '2024-Q2'的裁剪效果。 -
⭐⭐ 为一个 SaaS 多租户系统设计 LIST 分区方案:按
tenant_id哈希到 4 个分区,编写创建语句,并测试不同 tenant 的数据隔离效果。 -
⭐⭐⭐ 编写存储过程:输入年份,自动为
orders表创建 12 个月分区、添加 CHECK 约束、创建索引,并 DETACH 上一年 1 月的分区到归档表空间。测试 INSERT、跨分区查询、裁剪验证全流程。