1. 分区表概述
1.1 什么是分区表?
分区表是将逻辑上的大表 拆分为物理上的小表 的技术。想象你有一个放了10年日记的书架:如果不分类,找某一天的日记要翻遍整个书架;但如果按"年份→月份"分层,找2023年10月的日记只需翻对应层------这就是分区的核心逻辑:把数据按规则"归类",让查询/操作只触达必要的数据。
1.2 分区的核心优势
PostgreSQL官网明确提到,分区的价值主要体现在4点:
- 查询加速:如果查询只涉及少数分区(比如"最近1个月的订单"),数据库会自动跳过其他分区(称为"分区剪枝"),避免全表扫描。
- 批量操作高效 :删除旧数据只需
DROP TABLE 旧分区
(比DELETE
快10倍以上),添加新数据只需CREATE TABLE 新分区
。 - 冷数据存储优化:将不常用的历史数据(比如3年前的日志)迁移到廉价存储(如S3),降低成本。
- 索引性能提升:每个分区的索引更小,更容易被缓存到内存,减少磁盘IO。
2. 三种常见的分区类型
PostgreSQL支持范围分区、列表分区、哈希分区三种内置方式,覆盖90%以上的业务场景。下面用"电商订单表"为例,逐一解释:
2.1 范围分区(RANGE Partitioning)
定义 :按连续区间 拆分数据,比如时间、数值范围。
适用场景 :数据有自然的"顺序性",且查询常按区间过滤(如"最近7天的订单""金额100-500元的订单")。
例子 :订单表按create_time
(创建时间)分月存储:
sql
-- 1. 创建分区表(指定分区方式为RANGE,分区键为create_time)
CREATE TABLE orders (
order_id bigserial PRIMARY KEY,
create_time timestamp NOT NULL,
user_id int NOT NULL,
amount decimal(10,2) NOT NULL
) PARTITION BY RANGE (create_time);
-- 2. 创建2023年10月的分区(区间:2023-10-01 ≤ create_time < 2023-11-01)
CREATE TABLE orders_202310 PARTITION OF orders
FOR VALUES FROM ('2023-10-01') TO ('2023-11-01');
-- 3. 创建2023年11月的分区(依此类推)
CREATE TABLE orders_202311 PARTITION OF orders
FOR VALUES FROM ('2023-11-01') TO ('2023-12-01');
关键细节 :范围分区的区间是左闭右开 (FROM
包含,TO
不包含),避免数据重叠(比如2023-11-01
会落到orders_202311
分区)。
2.2 列表分区(LIST Partitioning)
定义 :按枚举值 拆分数据,比如地区、状态。
适用场景 :数据有明确的"分类标签",且查询常按标签过滤(如"华东地区的订单""已完成的订单")。
例子 :订单表按region
(地区)拆分:
sql
-- 1. 创建分区表(分区方式为LIST,分区键为region)
CREATE TABLE orders (
order_id bigserial PRIMARY KEY,
region text NOT NULL,
amount decimal(10,2) NOT NULL
) PARTITION BY LIST (region);
-- 2. 创建"华东"分区(包含值:'华东')
CREATE TABLE orders_east PARTITION OF orders
FOR VALUES IN ('华东');
-- 3. 创建"华南"分区(包含值:'华南')
CREATE TABLE orders_south PARTITION OF orders
FOR VALUES IN ('华南');
关键细节 :列表分区的IN
子句必须覆盖所有可能的取值(否则插入未定义的值会报错),或添加默认分区(但默认分区会影响剪枝性能,谨慎使用)。
2.3 哈希分区(HASH Partitioning)
定义 :按哈希算法 拆分数据,将分区键的哈希值对modulus
取余,分配到不同分区。
适用场景 :数据无明显顺序/分类,但需要均匀分布 (比如用户数据、设备日志),避免单分区过大。
例子 :用户数据表按user_id
拆分到8个分区:
sql
-- 1. 创建分区表(分区方式为HASH,分区键为user_id)
CREATE TABLE user_data (
user_id int NOT NULL,
data text NOT NULL
) PARTITION BY HASH (user_id);
-- 2. 创建8个分区(modulus=8,余数0-7)
CREATE TABLE user_data_0 PARTITION OF user_data
FOR VALUES WITH (modulus 8, remainder 0);
CREATE TABLE user_data_1 PARTITION OF user_data
FOR VALUES WITH (modulus 8, remainder 1);
...
CREATE TABLE user_data_7 PARTITION OF user_data
FOR VALUES WITH (modulus 8, remainder 7);
关键细节 :modulus
(模数)决定分区数量,建议选2的幂(如8、16、32),保证数据分布更均匀。
3. 声明式分区的实现步骤
PostgreSQL的声明式分区(Declarative Partitioning)是推荐的方式(比传统继承分区更高效),核心步骤如下(以"measurement"表为例,官网经典案例):
3.1 步骤1:创建分区表
首先定义父表(虚拟表,无实际数据),指定分区方式和分区键:
sql
CREATE TABLE measurement (
city_id int NOT NULL,
logdate date NOT NULL,
peaktemp int,
unitsales int
) PARTITION BY RANGE (logdate); -- 按logdate(日期)范围分区
3.2 步骤2:创建分区
为每个区间创建子表(实际存储数据的物理表),指定区间边界:
sql
-- 2006年2月的分区:logdate ∈ [2006-02-01, 2006-03-01)
CREATE TABLE measurement_y2006m02 PARTITION OF measurement
FOR VALUES FROM ('2006-02-01') TO ('2006-03-01');
-- 2006年3月的分区:logdate ∈ [2006-03-01, 2006-04-01)
CREATE TABLE measurement_y2006m03 PARTITION OF measurement
FOR VALUES FROM ('2006-03-01') TO ('2006-04-01');
3.3 步骤3:创建索引
在父表上创建索引,PostgreSQL会自动同步到所有分区(包括未来新增的分区):
sql
-- 在logdate列创建索引(加速按日期的查询)
CREATE INDEX ON measurement (logdate);
这等价于在每个分区上创建logdate
索引,无需手动操作。
3.4 步骤4:验证分区剪枝
分区剪枝(Partition Pruning)是分区表的"灵魂"------让查询只扫描必要的分区。验证方法:用EXPLAIN
查看执行计划。
例子:查询2008年1月的记录:
sql
-- 开启分区剪枝(默认开启)
SET enable_partition_pruning = on;
-- 查看执行计划
EXPLAIN SELECT count(*) FROM measurement WHERE logdate >= '2008-01-01';
预期输出(只扫描2008年1月的分区):
arduino
Aggregate (cost=37.75..37.76 rows=1 width=8)
-> Seq Scan on measurement_y2008m01 (cost=0.00..33.12 rows=617 width=0)
Filter: (logdate >= '2008-01-01'::date)
如果关闭分区剪枝(SET enable_partition_pruning = off
),执行计划会显示扫描所有分区,性能差异巨大。
4. 分区维护:添加、删除与修改
分区表的价值,很大程度体现在快速维护上。下面介绍常见操作:
4.1 添加新分区
当有新数据写入时(比如下个月的订单),需要提前创建分区,避免插入失败。有两种方式:
方式1:直接创建分区(简单)
sql
-- 创建2024年1月的订单分区
CREATE TABLE orders_202401 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
方式2:先创建表再关联(灵活)
如果需要先导入数据再关联到父表(比如批量导入历史数据),可以用ATTACH PARTITION
:
sql
-- 1. 创建独立表(复制父表结构)
CREATE TABLE orders_202401 (LIKE orders INCLUDING DEFAULTS INCLUDING CONSTRAINTS);
-- 2. 添加CHECK约束(确保数据符合分区边界)
ALTER TABLE orders_202401 ADD CONSTRAINT orders_202401_check
CHECK (create_time >= '2024-01-01' AND create_time < '2024-02-01');
-- 3. 导入数据(比如从CSV文件)
\copy orders_202401 FROM 'orders_202401.csv' CSV HEADER;
-- 4. 关联到父表(成为分区)
ALTER TABLE orders ATTACH PARTITION orders_202401
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
关键 :步骤2的CHECK
约束会让PostgreSQL信任数据符合边界,避免扫描整个表验证(大幅提升效率)。
4.2 删除旧分区
删除历史数据只需DROP分区表 ,比DELETE
快几个数量级(无需VACUUM清理):
sql
-- 删除2006年2月的measurement分区(直接物理删除)
DROP TABLE measurement_y2006m02;
如果需要保留数据但从父表分离,可以用DETACH PARTITION
:
sql
-- 从父表分离分区(仍可单独查询)
ALTER TABLE measurement DETACH PARTITION measurement_y2006m02;
4.3 子分区的使用
如果单个分区过大(比如"2023年的订单"分区有1000万行),可以子分区 (比如按user_id
哈希再拆分成8个分区):
sql
-- 1. 创建父分区(2023年订单)
CREATE TABLE orders_2023 PARTITION OF orders
FOR VALUES FROM ('2023-01-01') TO ('2024-01-01')
PARTITION BY HASH (user_id); -- 子分区方式:按user_id哈希
-- 2. 创建子分区(8个)
CREATE TABLE orders_2023_0 PARTITION OF orders_2023
FOR VALUES WITH (modulus 8, remainder 0);
CREATE TABLE orders_2023_1 PARTITION OF orders_2023
FOR VALUES WITH (modulus 8, remainder 1);
...
注意:子分区会增加规划时间,建议最多嵌套2层(比如"年→月→用户哈希"就过了)。
5. 分区剪枝:让查询飞起来
5.1 什么是分区剪枝?
分区剪枝(Partition Pruning)是PostgreSQL的自动优化 :当查询包含分区键的过滤条件时,数据库会跳过不需要的分区,只扫描必要的分区。
比如查询"2023年10月的订单",数据库会自动跳过orders_202309
(9月)、orders_202311
(11月)等分区,只扫描orders_202310
。
5.2 如何验证分区剪枝?
用EXPLAIN
命令查看执行计划中的"Append"节点:
- 开启剪枝:
Append
节点只包含需要的分区(比如orders_202310
)。 - 关闭剪枝:
Append
节点包含所有分区(比如orders_202309
、orders_202310
、orders_202311
)。
5.3 执行时的分区剪枝
PostgreSQL不仅在规划阶段 剪枝,还会在执行阶段 剪枝------比如处理PREPARE
语句(参数化查询)或嵌套循环 join 时,能根据 runtime 参数动态跳过分区。
比如:
sql
-- 预处理查询(logdate为参数)
PREPARE get_measurement(date) AS
SELECT * FROM measurement WHERE logdate >= $1;
-- 执行时传入参数(2008-01-01),会剪枝到2008年1月的分区
EXECUTE get_measurement('2008-01-01');
6. 声明式分区的最佳实践
总结了5条关键建议,避免踩坑:
6.1 选择合适的分区键
核心原则 :分区键必须是查询中最常出现的过滤条件 (比如订单表的create_time
、用户表的user_id
)。
反例 :如果查询很少按city_id
过滤,却用city_id
做分区键,会导致分区剪枝失效,查询变慢。
6.2 控制分区数量
- 太少:比如1个分区(等于没分区),索引过大,缓存命中率低。
- 太多 :比如1000个分区,查询规划时间变长,内存消耗增加(每个分区的元数据需要加载到内存)。
建议 :分区数量控制在几十到几百(比如按月份分区,3年就是36个分区)。
6.3 考虑未来的扩展性
比如按"客户ID"列表分区,但如果未来客户数量从100增长到10000,分区数量会爆炸------此时哈希分区更合适(比如选32个分区,不管客户数量多少,数据都均匀分布)。
6.4 避免跨分区的操作
比如UPDATE measurement SET logdate = '2008-02-01' WHERE logdate = '2008-01-01'
------会将数据从measurement_y2008m01
迁移到measurement_y2008m02
,性能很差。建议:尽量避免修改分区键的值。
6.5 不要滥用默认分区
默认分区(FOR VALUES DEFAULT
)会接收所有未匹配的行,但会导致分区剪枝失效 (因为数据库无法确定默认分区是否包含需要的数据)。仅在必要时使用(比如临时接收未知数据)。
7. 课后Quiz:巩固你的知识
7.1 问题1
假设你有一个"电商订单表",需要按"订单状态"(status
,值为'pending'
(待支付)、'paid'
(已支付)、'cancelled'
(已取消))存储,且经常查询"已支付的订单"。应该选择哪种分区类型?请写出创建分区表的核心SQL。
答案 :选择列表分区 (LIST),因为"订单状态"是枚举值,符合列表分区的场景。
核心SQL:
sql
CREATE TABLE orders (
order_id bigserial PRIMARY KEY,
status text NOT NULL,
amount decimal(10,2) NOT NULL
) PARTITION BY LIST (status);
CREATE TABLE orders_pending PARTITION OF orders FOR VALUES IN ('pending');
CREATE TABLE orders_paid PARTITION OF orders FOR VALUES IN ('paid');
CREATE TABLE orders_cancelled PARTITION OF orders FOR VALUES IN ('cancelled');
7.2 问题2
当插入数据到分区表时遇到ERROR: no partition of relation "orders" found for row
,可能的原因是什么?如何解决?
答案:
- 原因 :插入的行的分区键值 不在任何已有的分区范围内(比如插入
status = 'refunded'
(已退款),但未创建对应分区)。 - 解决 :
- 检查插入的分区键值是否正确(比如是否拼写错误);
- 添加对应的分区(比如
CREATE TABLE orders_refunded PARTITION OF orders FOR VALUES IN ('refunded')
); - (可选)添加默认分区(`CREATE TABLE orders_default PARTITION OF orders FOR
往期文章归档
-PostgreSQL 查询慢?是不是忘了优化 GROUP BY、ORDER BY 和窗口函数? - cmdragon's Blog
-
PostgreSQL选Join策略有啥小九九?Nested Loop/Merge/Hash谁是它的菜? - cmdragon's Blog
-
PostgreSQL索引选B-Tree还是GiST?"瑞士军刀"和"多面手"的差别你居然还不知道? - cmdragon's Blog
-
PostgreSQL处理SQL居然像做蛋糕?解析到执行的4步里藏着多少查询优化的小心机? - cmdragon's Blog
-
PostgreSQL备份不是复制文件?物理vs逻辑咋选?误删还能精准恢复到1分钟前? - cmdragon's Blog
-
PostgreSQL里的PL/pgSQL到底是啥?能让SQL从"说目标"变"讲步骤"? - cmdragon's Blog
-
PostgreSQL UPDATE语句怎么玩?从改邮箱到批量更新的避坑技巧你都会吗? - cmdragon's Blog
-
PostgreSQL 17安装总翻车?Windows/macOS/Linux避坑指南帮你搞定? - cmdragon's Blog
-
能当关系型数据库还能玩对象特性,能拆复杂查询还能自动管库存,PostgreSQL凭什么这么香? - cmdragon's Blog
-
如何在API高并发中玩转资源隔离与限流策略? - cmdragon's Blog
免费好用的热门在线工具