范围分区(RANGE)
范围分区(Range Partitioning)是声明式分区里最常用、收益也最直接的一种:它把分区键的取值切成若干段互不重叠的左闭右开区间,每一段对应一个分区。因为绝大多数业务查询都带时间范围条件,范围分区几乎总能配合分区裁剪(Partition Pruning)把扫描范围压到一个或少数几个分区上。
本节讲清 FROM (...) TO (...) 的边界语义、MINVALUE / MAXVALUE 的正确用法、date_trunc 对齐、多列范围键与 DEFAULT 分区,最后给出「插入报错」这类问题的完整排查路径。
前置知识
阅读本节前,建议先了解:声明式分区概述
基础概念
左闭右开:范围分区唯一需要牢记的语义
官方定义很明确:每个范围的下界是包含的(inclusive),上界是排他的(exclusive)。即:
partition_bound: [ lower , upper )所以 [1, 10) 与 [10, 20) 是相邻且不重叠的,值 10 属于第二个分区。
[1,10) [10,20) [20,30)
-----+-------------+-------------+----->
1 10 20 30
└── 属于分区1 ─┘└── 属于分区2 ─┘
▲
10 落在这里这条规则带来两个非常重要的好处:
| 好处 | 说明 |
|---|---|
| 相邻边界可以「共用同一个值」 | 不需要写 TO ('2026-02-01') 和 FROM ('2026-02-02') 这种别扭写法 |
| 无缝衔接,无空洞 | 月度分区只需 [01-01, 02-01)、[02-01, 03-01) 依次排开 |
分区边界的四种形式
partition_bound_spec is:
IN ( partition_bound_expr [, ...] ) |
FROM ( { partition_bound_expr | MINVALUE | MAXVALUE } [, ...] )
TO ( { partition_bound_expr | MINVALUE | MAXVALUE } [, ...] ) |
WITH ( MODULUS numeric_literal, REMAINDER numeric_literal )其中 FROM ... TO ... 用于 RANGE,IN (...) 用于 LIST,WITH (...) 用于 HASH。DEFAULT 则是一个独立的关键字形式(用于给父表补一个「兜底」分区)。
MINVALUE 与 MAXVALUE
MINVALUE / MAXVALUE 表示「该类型的最小值 / 最大值」,用于表达「无下界」和「无上界」:
| 写法 | 含义 |
|---|---|
FROM (MINVALUE) TO ('2026-01-01') | 所有小于 2026-01-01 的值(历史归档区) |
FROM ('2026-01-01') TO (MAXVALUE) | 所有大于等于 2026-01-01 的值(无上界) |
FROM (MINVALUE) TO (MAXVALUE) | 全量,一般不这么写(等于没分区) |
-- 历史归档分区 + 无上界的「当前区」
CREATE TABLE logs_old PARTITION OF logs FOR VALUES FROM (MINVALUE) TO ('2026-01-01');
CREATE TABLE logs_recent PARTITION OF logs FOR VALUES FROM ('2026-01-01') TO (MAXVALUE);MINVALUE / MAXVALUE 不是语法糖,是真实边界
MAXVALUE 代表的是「比该类型任何合法值都大」的哨兵值,不是 NULL,也不是无穷。使用它之后,后续就无法再插入一个比它更大的分区——所以不建议用 TO (MAXVALUE) 长期承载新数据,而应预先按月建分区。
单列范围键 vs 多列范围键
| 类型 | 写法 | 语义 |
|---|---|---|
| 单列 | PARTITION BY RANGE (created_at) | 按一列切段 |
| 多列 | PARTITION BY RANGE (tenant_id, created_at) | 字典序比较,先比第一列再比第二列 |
多列范围分区的边界也是元组比较:
PARTITION BY RANGE (tenant_id, created_at)
-- 分区边界是元组
FOR VALUES FROM (1, '2026-01-01') TO (1, '2026-02-01')常见用途一览
| 场景 | 分区粒度 | 说明 |
|---|---|---|
| 订单 / 交易表 | 月 | 最经典,配合轮转删除 |
| 日志 / 事件表 | 天或小时 | 量极大,按天配合保留策略 |
| 时序指标表 | 天 | 常与 DEFAULT 或 MAXVALUE 搭配 |
| 按 ID 段归档 | 固定区间 | 如 [0, 10000000)、[10000000, 20000000) |
| 多租户 + 时间 | 租户 × 月 | 两级或复合键 |
语法与用法
-- 创建按范围分区的父表
CREATE TABLE table_name ( ... )
PARTITION BY RANGE ( column_name [, ... ] )
-- 创建分区(左闭右开)
CREATE TABLE partition_name PARTITION OF parent
FOR VALUES FROM ( lower_bound ) TO ( upper_bound )
-- 边界项可以是常量,也可以是 MINVALUE / MAXVALUE
CREATE TABLE partition_name PARTITION OF parent
FOR VALUES FROM ( MINVALUE ) TO ( '2026-01-01' )
-- 兜底分区
CREATE TABLE partition_name PARTITION OF parent DEFAULT
-- 把已有表挂进来(带边界校验)
ALTER TABLE parent ATTACH PARTITION partition_name
FOR VALUES FROM ( lower_bound ) TO ( upper_bound )最简可运行示例:
CREATE TABLE orders (
id bigint NOT NULL,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
created_at date NOT NULL
) PARTITION BY RANGE (created_at);CREATE TABLECREATE TABLE orders_2026_01 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');CREATE TABLE插入后按边界自动路由:
INSERT INTO orders VALUES (1, 1001, 99.00, '2026-01-15');INSERT 0 1SELECT tableoid::regclass AS partition FROM orders WHERE id = 1; partition
----------------
orders_2026_01
(1 row)用 psql 确认边界
\d+ orders Partitioned table "public.orders"
Column | Type | Collation | Nullable | Default
------------+---------------+-----------+----------+---------
id | bigint | | not null |
user_id | bigint | | not null |
amount | numeric(12,2) | | not null |
created_at | date | | not null |
Partition key: RANGE (created_at)
Partitions: orders_2026_01 FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')也可以用目录函数拿到边界表达式:
SELECT
child.relname AS partition,
pg_get_expr(child.relpartbound, child.oid) AS bound
FROM pg_inherits i
JOIN pg_class parent ON i.inhparent = parent.oid
JOIN pg_class child ON i.inhrelid = child.oid
WHERE parent.relname = 'orders'
ORDER BY child.relname; partition | bound
----------------+-------------------------------------------------
orders_2026_01 | FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')
(1 row)详细说明
date_trunc 对齐分区边界
手工写 '2026-01-01' 容易在跨月、跨年时算错。用 date_trunc 生成边界更可靠——但要注意:它生成的是时间戳,作为边界常量需要能隐式转换为分区键类型。
-- 用 date_trunc 对齐到月初,生成稳定的边界常量
SELECT date_trunc('month', DATE '2026-01-15')::date AS month_start,
(date_trunc('month', DATE '2026-01-15') + interval '1 month')::date AS month_end; month_start | month_end
-------------+------------
2026-01-01 | 2026-02-01
(1 row)批量预建分区时,用它生成 DDL 比手写更安全:
-- 生成未来 12 个月的建分区语句
SELECT format(
'CREATE TABLE orders_%s PARTITION OF orders FOR VALUES FROM (%L) TO (%L);',
to_char(m, 'YYYY_MM'), m::date, (m + interval '1 month')::date
) AS ddl
FROM generate_series(
date_trunc('month', DATE '2026-01-01'),
date_trunc('month', DATE '2026-12-01'),
interval '1 month') AS m; ddl
------------------------------------------------------------------------------------
CREATE TABLE orders_2026_01 PARTITION OF orders FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE orders_2026_02 PARTITION OF orders FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
...
(12 rows)为什么用 format 而不是字符串拼接
%L 会正确加引号与转义,%s 用于标识符片段。手工 || 拼接字符串在遇到特殊字符或时区时会出错。
边界必须是该类型的常量
分区边界表达式在建表时就要能求值为常量,不能依赖运行期数据:
-- 错误!partition bound is not a constant
CREATE TABLE bad_part PARTITION OF orders
FOR VALUES FROM (CURRENT_DATE) TO (CURRENT_DATE + 1);ERROR: cannot use column reference in partition bound expression正确做法是在应用层算好日期再拼 DDL,或者用 DO 块动态执行:
DO $$
DECLARE
m date := date_trunc('month', CURRENT_DATE)::date;
BEGIN
EXECUTE format(
'CREATE TABLE IF NOT EXISTS orders_%s PARTITION OF orders FOR VALUES FROM (%L) TO (%L)',
to_char(m, 'YYYY_MM'), m, (m + interval '1 month')::date
);
END $$;时间戳与日期类型的边界陷阱
如果分区键是 timestamptz,边界常量会按会话时区解释,跨时区部署时可能错位:
-- 分区键为 timestamptz
CREATE TABLE events (id bigint, ts timestamptz) PARTITION BY RANGE (ts);
-- 边界 '2026-01-01' 会被当作本地时区的 2026-01-01 00:00
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');建议显式带时区偏移,避免服务器时区变化导致边界漂移:
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01 00:00:00+08') TO ('2026-02-01 00:00:00+08');如果业务只关心「哪一天」,直接用 date 类型分区键更省心:
created_at date NOT NULL -- 推荐:边界语义无歧义多列范围键与元组比较
多列范围键按字典序比较,很适合「多租户 + 时间」的组合:
CREATE TABLE metrics (
tenant_id int NOT NULL,
logdate date NOT NULL,
value numeric
) PARTITION BY RANGE (tenant_id, logdate);CREATE TABLECREATE TABLE metrics_t1_2026_01 PARTITION OF metrics
FOR VALUES FROM (1, '2026-01-01') TO (1, '2026-02-01');
CREATE TABLE metrics_t2_2026_01 PARTITION OF metrics
FOR VALUES FROM (2, '2026-01-01') TO (2, '2026-02-01');CREATE TABLE
CREATE TABLE路由判断是元组比较:(tenant_id, logdate) >= (1, '2026-01-01') AND < (1, '2026-02-01')。
INSERT INTO metrics VALUES (1, '2026-01-20', 42);
SELECT tableoid::regclass AS partition FROM metrics; partition
----------------------
metrics_t1_2026_01
(1 row)多列范围键的裁剪条件更严格
只有查询条件同时约束了前缀列,裁剪才有效。WHERE logdate = '2026-01-20' 单独出现时无法裁剪(因为第一列 tenant_id 未受限)——查询里必须带 tenant_id。
插入路由失败:no partition of relation ... found for row
这是范围分区最常见的运行期错误。它只在插入/更新时出现,含义是「分区键的值不落在任何分区的区间内」。
INSERT INTO orders VALUES (99, 1, 1.00, '2027-06-01');ERROR: no partition of relation "orders" found for row
DETAIL: Partition key of the failing row contains (created_at) = (2027-06-01).排查顺序:
| 步骤 | 命令 | 目的 |
|---|---|---|
| 1 | SELECT pg_get_expr(relpartbound, oid) FROM pg_class WHERE relname='orders' | 确认已有边界 |
| 2 | 检查报错 DETAIL 里的键值 | 定位越界值 |
| 3 | 确认是否漏建某个月的分区 | 找区间空洞 |
| 4 | 若有 DEFAULT 分区,检查其内容 | 兜底分区可能已积压数据 |
| 5 | 补建缺失分区或修正数据 | 修复 |
-- 一次列出所有分区的边界,检查有无空洞
SELECT
child.relname AS partition,
pg_get_expr(child.relpartbound, child.oid) AS bound
FROM pg_inherits i
JOIN pg_class child ON i.inhrelid = child.oid
JOIN pg_class parent ON i.inhparent = parent.oid
WHERE parent.relname = 'orders'
ORDER BY bound;ORDER BY bound 会把边界按表达式文本排序(对本例的 ISO 日期串恰好等价于时间序),便于肉眼扫出空缺月份。
DEFAULT 分区:兜底与它的代价
DEFAULT 分区接收所有「不匹配任何其他分区」的行:
CREATE TABLE orders_default PARTITION OF orders DEFAULT;CREATE TABLE-- 越界的 2027 年数据不再报错,而是落进 default
INSERT INTO orders VALUES (99, 1, 1.00, '2027-06-01');
SELECT tableoid::regclass AS partition FROM orders WHERE id = 99; partition
----------------
orders_default
(1 row)它的价值与风险都很明确:
| 价值 | 风险 |
|---|---|
| 越界数据不丢,系统不中断 | 数据「悄悄」进入不该去的地方 |
| 给预建分区留出容错窗口 | 一旦 DEFAULT 里存在属于新分区的行,就无法创建该新分区 |
-- 错误!updated partition constraint for default partition would be violated
CREATE TABLE orders_2027_06 PARTITION OF orders
FOR VALUES FROM ('2027-06-01') TO ('2027-07-01');ERROR: updated partition constraint for default partition "orders_default" would be violated by some row正确做法:先把越界行移出去,再加分区。
-- 建临时表承接,再迁回
CREATE TABLE orders_tmp (LIKE orders INCLUDING ALL);
INSERT INTO orders_tmp SELECT * FROM orders_default WHERE created_at >= '2027-06-01';
DELETE FROM orders_default WHERE created_at >= '2027-06-01';
ALTER TABLE orders DETACH PARTITION orders_default;
CREATE TABLE orders_2027_06 PARTITION OF orders
FOR VALUES FROM ('2027-06-01') TO ('2027-07-01');
ALTER TABLE orders ATTACH PARTITION orders_default DEFAULT;
INSERT INTO orders SELECT * FROM orders_tmp;生成列与触发器辅助路由
分区键必须是表中真实存在的列(或它的表达式)。如果原始数据只有 created_at 时间戳、却想按「业务日」分区,可以用生成列(Generated Column):
CREATE TABLE events (
id bigint NOT NULL,
created_at timestamptz NOT NULL,
biz_date date NOT NULL GENERATED ALWAYS AS ((created_at AT TIME ZONE 'Asia/Shanghai')::date) STORED
) PARTITION BY RANGE (biz_date);CREATE TABLE存储生成列可以参与分区键。注意两个约束:
| 约束 | 说明 |
|---|---|
表达式必须 IMMUTABLE | AT TIME ZONE 'Asia/Shanghai' 是 IMMUTABLE(字面量时区),可用 |
必须是 STORED(默认) | 虚拟生成列不能作为分区键 |
如果分区键需要依赖行内其他数据(如查表决定),生成列传不进去(不能引用其他表),此时只能在应用层写入正确的分区键列。
分区裁剪与范围分区的天然契合
范围分区之所以最常用,是因为它天然支持 >=、<、BETWEEN 这类范围裁剪:
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; Append
Subplans Removed: 2
-> Seq Scan on orders_2026_01
Filter: ((created_at >= '2026-01-01'::date) AND (created_at < '2026-02-01'::date))
(3 rows)而下面这种写法会破坏裁剪,因为函数包住了分区键:
-- 不推荐:date_trunc 包住分区键,无法在规划期裁剪
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE date_trunc('month', created_at) = '2026-02-01'; Append
-> Seq Scan on orders_2026_01
-> Seq Scan on orders_2026_02
-> Seq Scan on orders_2026_03
(4 rows)改成对分区键直接施加范围条件即可恢复裁剪:
SELECT * FROM orders
WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01';轮转分区的标准流程
范围分区最典型的运维模式是「每月新增一个、删除一个」:
-- 1) 预建下个月的分区
CREATE TABLE orders_2026_04 PARTITION OF orders
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
-- 2) 归档并删除最老的分区
ALTER TABLE orders DETACH PARTITION orders_2026_01;
-- (此时 orders_2026_01 已是普通表,可 COPY 备份或直接 DROP)
DROP TABLE orders_2026_01;| 阶段 | 锁级别 | 备注 |
|---|---|---|
CREATE TABLE ... PARTITION OF | 父表 ACCESS EXCLUSIVE | 建议空闲窗口执行 |
ATTACH PARTITION | 父表 SHARE UPDATE EXCLUSIVE(PostgreSQL 12+) | 对并发更友好 |
DETACH PARTITION | 父表 ACCESS EXCLUSIVE | 快速 |
DETACH PARTITION CONCURRENTLY | 父表 SHARE UPDATE EXCLUSIVE(PostgreSQL 14+) | 不能在事务块内执行 |
实战示例
场景:为 IoT 设备的读数表建立按天范围分区,处理越界数据,并验证裁剪。
第 1 步:建父表
DROP TABLE IF EXISTS readings CASCADE;
CREATE TABLE readings (
device_id bigint NOT NULL,
ts timestamptz NOT NULL,
value numeric(10,3) NOT NULL
) PARTITION BY RANGE (ts);第 2 步:预建三天的分区
CREATE TABLE readings_2026_01_01 PARTITION OF readings
FOR VALUES FROM ('2026-01-01 00:00:00+08') TO ('2026-01-02 00:00:00+08');
CREATE TABLE readings_2026_01_02 PARTITION OF readings
FOR VALUES FROM ('2026-01-02 00:00:00+08') TO ('2026-01-03 00:00:00+08');
CREATE TABLE readings_2026_01_03 PARTITION OF readings
FOR VALUES FROM ('2026-01-03 00:00:00+08') TO ('2026-01-04 00:00:00+08');第 3 步:灌数
INSERT INTO readings (device_id, ts, value)
SELECT
(g % 50) + 1,
TIMESTAMPTZ '2026-01-01 00:00:00+08' + (g || ' minutes')::interval,
(random() * 100)::numeric(10,3)
FROM generate_series(1, 4320) AS g; -- 3 天 × 1440 分钟INSERT 0 4320第 4 步:确认分布
SELECT tableoid::regclass AS partition, count(*)
FROM readings GROUP BY 1 ORDER BY 1; partition | count
----------------------+-------
readings_2026_01_01 | 1440
readings_2026_01_02 | 1440
readings_2026_01_03 | 1440
(3 rows)第 5 步:验证裁剪
EXPLAIN (COSTS OFF)
SELECT count(*) FROM readings
WHERE ts >= '2026-01-02 00:00:00+08' AND ts < '2026-01-03 00:00:00+08'; Aggregate
-> Append
Subplans Removed: 2
-> Seq Scan on readings_2026_01_02
Filter: ((ts >= '2026-01-02 00:00:00+08'::timestamp with time zone) AND (ts < '2026-01-03 00:00:00+08'::timestamp with time zone))
(4 rows)第 6 步:制造一次越界,观察报错
INSERT INTO readings VALUES (7, '2026-02-10 10:00:00+08', 1.5);ERROR: no partition of relation "readings" found for row
DETAIL: Partition key of the failing row contains (ts) = (2026-02-10 10:00:00+08).第 7 步:加 DEFAULT 兜底后再插
CREATE TABLE readings_default PARTITION OF readings DEFAULT;
INSERT INTO readings VALUES (7, '2026-02-10 10:00:00+08', 1.5);
SELECT tableoid::regclass AS partition FROM readings WHERE device_id = 7;CREATE TABLE
INSERT 0 1
partition
-------------------
readings_default
(1 row)第 8 步:清理
DROP TABLE readings CASCADE;注意事项
- 范围边界是左闭右开。 把上界写成「月底」而不是「下月初」会造成数据落不进任何分区。
-- 错误!1 月 31 日的行无处可去
CREATE TABLE orders_2026_01 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2026-01-31');MINVALUE/MAXVALUE是类型极值,不是-inf记号。 用了TO (MAXVALUE)之后就不能再建更大的分区。
-- 不推荐:一旦这样写,未来只能靠 DEFAULT 或重建来承接新数据
CREATE TABLE logs_all PARTITION OF logs FOR VALUES FROM ('2026-01-01') TO (MAXVALUE);- 分区边界必须是常量。 不允许引用列、参数或易变函数。
-- 错误!cannot use column reference in partition bound expression
CREATE TABLE p PARTITION OF orders FOR VALUES FROM (created_at) TO (created_at + 1);timestamptz分区键的边界受时区影响。 跨时区或多环境部署时务必显式写偏移量。
-- 推荐:显式时区,避免依赖服务器 TimeZone 设置
FOR VALUES FROM ('2026-01-01 00:00:00+08') TO ('2026-02-01 00:00:00+08')UPDATE改分区键可能报「no partition ... found for row」。 这是搬行失败,不是数据问题本身。
-- 目标月份没建分区时会失败
UPDATE orders SET created_at = '2027-06-01' WHERE id = 1;DEFAULT分区会阻塞新分区的创建。 务必定期清空它,或仅在短期使用。
-- 错误!updated partition constraint for default partition would be violated
CREATE TABLE orders_2027_06 PARTITION OF orders
FOR VALUES FROM ('2027-06-01') TO ('2027-07-01');- 函数包住分区键会破坏裁剪。
WHERE date_trunc('month', created_at) = ?这类写法要改写成范围条件。
-- 不推荐
WHERE date_trunc('day', created_at) = '2026-01-02'
-- 推荐
WHERE created_at >= '2026-01-02' AND created_at < '2026-01-03'多列范围键的裁剪要求前缀列被约束。 只约束第二列的查询无法裁剪。
分区数不是越多越好。 按小时分区一年就是 8760 个分区,规划开销会明显上升。
ATTACH PARTITION会扫描待挂表以校验边界约束。 若想跳过扫描,先在待挂表上建一个等价的CHECK约束(挂完后可删)。
-- 预加 CHECK 可跳过 ATTACH 时的全表扫描
ALTER TABLE orders_staging ADD CONSTRAINT ck_bound
CHECK (created_at >= DATE '2026-05-01' AND created_at < DATE '2026-06-01');
ALTER TABLE orders ATTACH PARTITION orders_staging
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
ALTER TABLE orders_staging DROP CONSTRAINT ck_bound;最佳实践
- 时间表一律
RANGE分区,粒度为月(高频表用天)。 粒度的选择标准是「单分区大小适中且轮转周期符合保留策略」。
) PARTITION BY RANGE (created_at);- 用
format+generate_series批量生成建分区 DDL,避免手工写边界出错。
SELECT format('CREATE TABLE orders_%s PARTITION OF orders FOR VALUES FROM (%L) TO (%L);',
to_char(m, 'YYYY_MM'), m::date, (m + interval '1 month')::date)
FROM generate_series(date_trunc('month', CURRENT_DATE),
date_trunc('month', CURRENT_DATE) + interval '11 months',
interval '1 month') AS m;提前预建未来 3~12 个月的分区,并设置监控告警。 「已使用到倒数第二个分区」就应告警。
DEFAULT分区只作为短期兜底,并每日检查它是否为空。
SELECT count(*) FROM orders_default;分区键类型优先选
date而非timestamptz,除非业务真的需要时区精度——date的边界语义没有歧义。ATTACH大表前预加CHECK约束以跳过扫描,并在完成后删除该冗余约束。
ALTER TABLE staging ADD CONSTRAINT ck_range CHECK (created_at >= DATE '2026-06-01' AND created_at < DATE '2026-07-01');
ALTER TABLE orders ATTACH PARTITION staging FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');- 把轮转脚本纳入定时任务,并在切换时校验新分区已成功创建。
SELECT count(*) > 0 AS ok FROM pg_class c
JOIN pg_inherits i ON i.inhrelid = c.oid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = 'orders' AND c.relname = 'orders_' || to_char(CURRENT_DATE, 'YYYY_MM');- 查询里始终用「分区键的范围条件」表达时间过滤,让裁剪生效。
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'- 删除历史数据用
DETACH+DROP,而不是DELETE。
ALTER TABLE orders DETACH PARTITION orders_2026_01;
DROP TABLE orders_2026_01;下一节
继续学习:列表分区(LIST)