3. 范围分区实战
范围分区是 PostgreSQL 声明式分区中最常用的一类,尤其适合按时间字段切分的大表,例如订单、日志、账单、事件流水。它的核心思想是:每一行数据根据分区键的取值,被路由到唯一一个覆盖该取值区间的分区中。时间字段天然有序、单调增长,因此范围分区既能保证数据写入集中在最新分区,又能让历史数据按区间独立归档、清理或迁移。
本节以一张订单表为例,完整走一遍“设计分区边界 → 创建分区表 → 创建分区 → 验证分区裁剪”的过程。示例中的表名、字段和分区边界均为教学假设,实际项目应按业务数据量与保留周期调整。
一、按时间字段设计分区边界
设计范围分区,第一步不是写 DDL,而是确定三件事:分区键、分区粒度、边界规则。分区键选择时间字段时,通常用业务发生时间(如 order_time),而不是入库时间,因为查询条件大多围绕业务时间。分区粒度取决于单分区数据量与保留策略:按月分区适合每月百万到千万级数据,按日分区适合每日数据量极大或需要快速删除最近数据的场景。
PostgreSQL 的范围分区边界是“左闭右开”区间:[下限, 上限)。也就是说,FOR VALUES FROM ('2024-01-01') TO ('2024-02-01') 覆盖 1 月整月,1 月 1 日 00:00:00 属于该分区,2 月 1 日 00:00:00 不属于。这个约定必须在设计、建表和验证中保持一致,否则会出现数据落错分区或插入失败。
边界设计还要考虑“默认分区”。如果插入的时间落在所有已定义分区之外,PostgreSQL 会直接报错 no partition of relation ... found for row。为避免写入中断,可以创建一个默认分区兜底,但默认分区会削弱分区裁剪效果,因此更推荐用定时任务提前创建未来分区。
二、创建分区表与分区
下面创建一张按 order_time 月度分区的订单表。父表只定义结构和分区策略,不存数据;每个分区是独立物理表,但通过父表统一访问。
CREATE TABLE orders (
order_id bigint NOT NULL,
customer_id bigint NOT NULL,
order_time timestamptz NOT NULL,
amount numeric(12,2) NOT NULL,
status text NOT NULL,
PRIMARY KEY (order_id, order_time)
) PARTITION BY RANGE (order_time);
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');
注意主键必须包含分区键 order_time,因为唯一约束只能在单个分区内保证,跨分区唯一性无法由数据库自动维护。如果业务要求 order_id 全局唯一,需要额外用唯一索引或应用层保证。
插入数据时,PostgreSQL 会自动路由:
INSERT INTO orders VALUES
(1001, 501, '2024-01-15 10:20:00+08', 199.00, 'paid'),
(1002, 502, '2024-02-03 09:05:00+08', 88.50, 'paid'),
(1003, 503, '2024-03-21 18:40:00+08', 320.00, 'pending');
可以用 SELECT tableoid::regclass, * FROM orders; 观察每行实际落在哪个分区。若插入 '2024-04-01' 的数据,会因为没有对应分区而报错,这正是需要提前建分区的直接原因。
三、验证查询计划中的分区裁剪
分区裁剪(partition pruning)是范围分区最重要的收益:当查询条件能确定分区键范围时,优化器只扫描命中的分区,跳过其余分区。验证方法是 EXPLAIN,重点看 Append 节点下出现了哪些子表。
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM orders
WHERE order_time >= '2024-02-01'
AND order_time < '2024-03-01';
预期计划中只出现 orders_2024_02,说明裁剪生效。如果写成 WHERE date_trunc('month', order_time) = '2024-02-01',由于分区键被函数包裹,优化器通常无法在计划阶段裁剪,只能扫描所有分区后再过滤,性能差距会随分区数量放大。
裁剪分为计划期裁剪和运行期裁剪。常量条件在生成计划时即可裁剪;参数化查询或 PREPARE 语句可能在执行阶段裁剪。判断依据始终是 EXPLAIN 输出中实际参与扫描的分区数量,而不是 SQL 字面上写了什么条件。
四、常见问题与判断依据
裁剪失效最常见的原因是分区键被表达式包裹,例如 to_char(order_time,'YYYY-MM')='2024-02'、order_time::date = '2024-02-01'。这类写法会让优化器无法把条件映射到分区边界。正确做法是把条件改写成对分区键的裸区间比较,或使用 order_time >= ... AND order_time < ...。
另一个问题是分区数量过多。每个分区在计划期都要参与裁剪判断,分区数达到数千时,规划时间会明显上升。经验上,单表分区数控制在几百以内较稳妥,必要时改用按周或按月分区,而不是按日无限细分。
| 观察点 | 正常表现 | 异常表现与处理 |
|---|---|---|
| 插入越界时间 | 落入对应分区 | 报 no partition found,需补建分区或加默认分区 |
| EXPLAIN 输出 | Append 下只列命中分区 | 列出全部分区,检查条件是否包裹分区键 |
| 分区数量 | 与保留周期匹配 | 过多导致规划变慢,调整粒度或归档旧分区 |
| 主键约束 | 包含分区键 | 缺少分区键会建表失败 |
最后给出一个可直接套用的月度分区创建模板,把月份替换为变量即可批量生成:
CREATE TABLE orders_2024_04
PARTITION OF orders
FOR VALUES FROM ('2024-04-01') TO ('2024-05-01');
完成以上步骤后,判断本节目标是否达成的标准很明确:数据能按时间正确落入对应分区;对分区键的区间查询在 EXPLAIN 中只扫描命中分区;越界写入有明确报错或兜底策略。做到这三点,范围分区就从“建了表”变成了“真正可用的分区治理”。