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 字面上写了什么条件。

范围分区:时间边界与分区裁剪 父表 orders(PARTITION BY RANGE (order_time)) 边界规则:左闭右开 [FROM, TO) · 主键必须包含分区键 order_time orders_2024_01 [2024-01-01, 2024-02-01) 1 月订单 orders_2024_02 [2024-02-01, 2024-03-01) 2 月订单 · 命中 orders_2024_03 [2024-03-01, 2024-04-01) 3 月订单 查询条件 WHERE order_time >= '2024-02-01' AND order_time < '2024-03-01' 条件直接作用于分区键 → 优化器可裁剪 EXPLAIN 计划(裁剪后) Append → Seq Scan on orders_2024_02 仅扫描 1 个分区 orders_2024_01 / 03 被跳过

四、常见问题与判断依据

裁剪失效最常见的原因是分区键被表达式包裹,例如 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 中只扫描命中分区;越界写入有明确报错或兜底策略。做到这三点,范围分区就从“建了表”变成了“真正可用的分区治理”。