分区裁剪(Partition Pruning)
分区裁剪(Partition Pruning)是分区表最重要的性能机制:查询只涉及部分分区时,规划器或执行器把无关分区的子计划直接剔除,让 Append 只扫描少数几个分区,甚至只扫描一个。没有裁剪,分区表就是「一堆表的拼接」;有了裁剪,分区表才能真正做到「只碰相关数据」。
本页讲清楚裁剪的两条路径(静态裁剪与动态裁剪)、EXPLAIN 中如何识别裁剪是否生效、哪些写法会破坏裁剪,以及一套「裁剪失效排查清单」。
前置知识
阅读本节前,建议先了解:声明式分区概述、范围分区(RANGE)、创建与附加分区
基础概念
裁剪解决什么问题
分区表把一张大表拆成若干张物理表。查询 WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01' 时,只有 1 月的分区可能含有符合条件的数据。如果规划器能识别这一点,就可以完全不生成其他月份分区的扫描计划。
没有裁剪: 有裁剪:
Append Append
-> Seq Scan on orders_01 -> Seq Scan on orders_01
-> Seq Scan on orders_02 Filter: ...
-> Seq Scan on orders_03
-> Seq Scan on orders_04
... (几十个分区)收益是双重的:扫描量下降(不扫无关分区,I/O 与 CPU 直接减少)与规划开销下降(子计划数量减少,规划与执行启动更快)。
裁剪的两个时机:静态与动态
| 类型 | 时机 | 触发条件 | 相关机制 |
|---|---|---|---|
| 静态裁剪 | 计划期(planning) | 分区键的约束值在计划期已知(常量、可求值表达式) | 规划器推导分区边界与谓词的关系 |
| 动态裁剪(执行器初始化) | 执行器启动时 | 参数值在计划期未知,但执行开始前已知(PREPARE 参数) | initplan / 参数化 |
| 动态裁剪(运行时) | 执行过程中 | 值在运行时才出现(嵌套循环当前行、子查询结果) | PostgreSQL 11+ 运行时裁剪 |
规划器遇到 WHERE created_at = '2026-01-15' 这类常量条件,会在计划期就确定只可能是 orders_2026_01,这是静态裁剪。
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; QUERY PLAN
--------------------------------------------------
Seq Scan on orders_2026_01
Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))注意:当裁剪后只剩 一个分区 时,Append 节点会消失,计划里直接出现该分区。这是「裁剪到单分区」的标志性表现。
动态裁剪的典型场景
PREPARE 的参数值在计划期未知:
PREPARE q(timestamptz) AS
SELECT * FROM orders WHERE created_at >= $1 AND created_at < $1 + interval '1 month';规划 q 时不知道 $1 是什么,无法静态确定分区。但执行 EXECUTE q('2026-03-01') 时值已确定,PostgreSQL 可以在执行器初始化阶段完成裁剪,只扫描 3 月分区。
更复杂的情况是嵌套循环:内层扫描的分区键值来自外层当前行,直到运行到那一行才知道。
-- 外层每取一行,内层才知道要用哪个分区键值
SELECT *
FROM tenants t
JOIN orders o ON o.tenant_id = t.tenant_id
WHERE o.created_at >= now() - interval '1 month';PostgreSQL 11 起的运行时裁剪允许在执行过程中决策:
Nested Loop
-> Seq Scan on tenants t
-> Append
Subplans Removed: 3 ← 每次外层换行都可能重新裁剪
-> Index Scan on orders_2026_03_p0 ...enable_partition_pruning
裁剪由参数 enable_partition_pruning 控制,默认 on:
SHOW enable_partition_pruning; enable_partition_pruning
--------------------------
on官方说明它同时控制计划期裁剪与执行期裁剪。若被关闭(全局或会话级),所有分区都会被扫描:
SET enable_partition_pruning = off;
EXPLAIN (COSTS OFF) SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; QUERY PLAN
---------------------------------------------
Append
-> Seq Scan on orders_2026_01
Filter: ...
-> Seq Scan on orders_2026_02
Filter: ...
-> Seq Scan on orders_2026_03
Filter: ...
...(所有分区)constraint_exclusion 与裁剪的关系
constraint_exclusion 是更早期的参数,控制的是表继承/CHECK 约束级别的排除。它与分区裁剪的关系:
| 参数 | 作用范围 | 默认值 |
|---|---|---|
enable_partition_pruning | 声明式分区表的裁剪(计划期 + 执行期) | on |
constraint_exclusion | 基于 CHECK 约束排除表(含继承、分区) | partition |
constraint_exclusion 默认值 partition 表示「只在处理分区表时启用约束排除」,设为 off 会同时影响基于 CHECK 的排除:
SHOW constraint_exclusion; constraint_exclusion
----------------------
partition排障提示
排查裁剪失效时,先确认这两个参数都没被改动。很多「裁剪突然不工作了」的案例,最后发现是连接池或会话里执行了 SET 把参数关掉了。
裁剪不等于「用上索引」
初学者常把两件事混为一谈:裁剪决定「扫描哪些分区」(分区级过滤),索引扫描决定「在一个分区内如何访问数据」(块级过滤)。两者正交:裁剪后剩下的分区里,若条件选择性不高,仍然会做顺序扫描。
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; QUERY PLAN
-------------------------------------------------
Seq Scan on orders_2026_01
Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))裁剪掉了几十个分区,但剩下的那个分区还是顺序扫描——这已经是最优解的一部分:分区级过滤已经省掉了绝大部分 I/O。
裁剪对分区数量的依赖
裁剪的收益随分区数量增长而增长。只有当分区足够多、单个分区足够小时,「只扫一个分区」才比「扫整张表」快得多:
| 表规模 | 分区数 | 单分区占比 | 裁剪收益 |
|---|---|---|---|
| 1 亿行 | 4 | 25% | 有限 |
| 1 亿行 | 24(月) | ~4% | 显著 |
| 10 亿行 | 60(月) | ~1.7% | 非常显著 |
| 100 万行 | 12 | ~8% | 极小(表本身很快) |
这也是为什么小表分区往往得不偿失——裁剪省下的 I/O 还不如 Append 的规划开销。
EXPLAIN 中的裁剪证据
识别裁剪是否生效,主要看三类信息:
| 现象 | 含义 |
|---|---|
Append 下只有少数几个子节点 | 静态或动态裁剪生效 |
Subplans Removed: N | 运行时裁剪移除了 N 个子计划 |
never executed | 运行时裁剪后某些子计划在本次执行中未被执行 |
Append 消失、直接出现单分区 | 裁剪到单分区 |
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; QUERY PLAN
----------------------------------------------------
Seq Scan on orders_2026_01 (actual time=0.015..12.3 rows=250000 loops=1)
Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))
Planning Time: 0.312 ms
Execution Time: 45.2 ms对照 enable_partition_pruning = off 时会看到 Append + 全部子节点,即可确认裁剪原本是生效的。
语法与用法
查看分区键与分区边界
裁剪的正确性完全依赖分区边界。先确认边界是什么:
SELECT c.relname, pg_get_expr(c.relpartbound, c.oid) AS bound
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'
ORDER BY c.relname; relname | bound
------------------+------------------------------------------------------
orders_2026_01 | FOR VALUES FROM ('2026-01-01 00:00:00+08') TO ('2026-02-01 00:00:00+08')
orders_2026_02 | FOR VALUES FROM ('2026-02-01 00:00:00+08') TO ('2026-03-01 00:00:00+08')
orders_2026_03 | FOR VALUES FROM ('2026-03-01 00:00:00+08') TO ('2026-04-01 00:00:00+08')
orders_default | DEFAULT静态裁剪:常量条件
-- 只命中 1 月
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; QUERY PLAN
---------------------------------------------
Seq Scan on orders_2026_01
Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))-- 跨 1、2 月
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE created_at >= '2026-01-15' AND created_at < '2026-02-15'; QUERY PLAN
---------------------------------------------
Append
-> Seq Scan on orders_2026_01
Filter: ((created_at >= '2026-01-15') AND (created_at < '2026-02-15'))
-> Seq Scan on orders_2026_02
Filter: ((created_at >= '2026-01-15') AND (created_at < '2026-02-15'))静态裁剪:等值与 IN
EXPLAIN (COSTS OFF) SELECT * FROM orders WHERE created_at = '2026-02-14 10:00:00+08'; QUERY PLAN
---------------------------------------------
Seq Scan on orders_2026_02
Filter: (created_at = '2026-02-14 10:00:00+08'::timestamp with time zone)-- IN 列表也能裁剪出多个分区
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE created_at IN ('2026-01-10', '2026-03-20', '2026-05-05'); QUERY PLAN
---------------------------------------------
Append
-> Seq Scan on orders_2026_01
Filter: (created_at = ANY ('{...}'::timestamp with time zone[]))
-> Seq Scan on orders_2026_03
Filter: (created_at = ANY ('{...}'::timestamp with time zone[]))
-> Seq Scan on orders_2026_05
Filter: (created_at = ANY ('{...}'::timestamp with time zone[]))动态裁剪:PREPARE 参数
PREPARE q(timestamptz, timestamptz) AS
SELECT * FROM orders WHERE created_at >= $1 AND created_at < $2;
EXPLAIN (ANALYZE, COSTS OFF) EXECUTE q('2026-04-01', '2026-05-01'); QUERY PLAN
---------------------------------------------
Seq Scan on orders_2026_04 (actual time=... rows=...)
Filter: ((created_at >= '2026-04-01') AND (created_at < '2026-05-01'))
Planning Time: 1.4 ms
Execution Time: 38 ms动态裁剪:运行时(子查询)
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM orders o
WHERE o.created_at >= (SELECT min(m) FROM months WHERE name = 'q1_start')
AND o.created_at < (SELECT max(m) FROM months WHERE name = 'q1_end');规划期不知道子查询结果,执行期才有值,此时会看到 Subplans Removed:
QUERY PLAN
--------------------------------------------------------------
Seq Scan on orders_2026_01 (actual time=... rows=...)
Filter: ...
InitPlan 1
-> ...
Planning Time: 1.8 ms
Execution Time: 52 ms详细说明
破坏裁剪的写法一:函数包裹分区键
对分区键施加函数,规划器无法把条件映射到分区边界:
-- 裁剪失效:date_trunc 包裹了分区键
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE date_trunc('month', created_at) = '2026-01-01'; QUERY PLAN
---------------------------------------------
Append
-> Seq Scan on orders_2026_01 Filter: (date_trunc('month', created_at) = ...)
-> Seq Scan on orders_2026_02 Filter: (date_trunc('month', created_at) = ...)
-> Seq Scan on orders_2026_03 Filter: (date_trunc('month', created_at) = ...)
...(全部分区)正确写法是把条件还原成对裸列的区间比较:
-- 裁剪生效:裸列 + 区间
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; QUERY PLAN
---------------------------------------------
Seq Scan on orders_2026_01
Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))同类问题还包括 created_at::date = '2026-01-15'、extract(year from created_at) = 2026、to_char(created_at,'YYYY-MM') = '2026-01'、created_at + interval '1 day' > ...。规则:分区键列必须裸露出现在比较的一侧。
破坏裁剪的写法二:隐式类型转换
条件值的类型与分区键列类型不一致时,可能引入到列上的隐式转换,从而破坏裁剪。反例是用 text 比较 timestamptz:
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at < '2026-02-01'::text; QUERY PLAN
---------------------------------------------
Append
-> Seq Scan on orders_2026_01 Filter: (created_at < ('2026-02-01'::cstring)::timestamp ...)
...(全部分区)正确做法是让常量类型与列类型一致:
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at < timestamptz '2026-02-01'; QUERY PLAN
---------------------------------------------
Seq Scan on orders_2026_01
Filter: (created_at < '2026-02-01 00:00:00+08'::timestamp with time zone)隐式转换破坏裁剪的表现很隐蔽
计划里条件仍然长得像「列 < 值」,但 Append 却展开了所有分区。判断方法是看分区键列是否是条件左侧的裸列,以及右值类型是否与列类型完全匹配。参数化查询里尤其要注意 JDBC 等驱动是否把 timestamptz 传成了 varchar。
破坏裁剪的写法三:OR 跨越不同维度
OR 条件只要有一个分支不局限在少数分区,整个查询就退化为全扫:
-- 一个分支能裁剪、另一个分支不能 → 整体全扫
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE (created_at >= '2026-01-01' AND created_at < '2026-02-01') OR region = 'east'; QUERY PLAN
---------------------------------------------
Append
-> Seq Scan on orders_2026_01 Filter: (... OR (region = 'east'))
-> Seq Scan on orders_2026_02 Filter: (... OR (region = 'east'))
...(全部分区)如果业务允许,应把这类查询拆成两个查询 UNION ALL,让可裁剪的分支单独裁剪:
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'
UNION ALL
SELECT * FROM orders WHERE region = 'east';JOIN 裁剪(PostgreSQL 11+)
PostgreSQL 11 起,分区裁剪可以在嵌套循环连接中动态进行:内层的分区键值来自外层当前行,执行器每一轮都可能重新裁剪。
EXPLAIN (ANALYZE, COSTS OFF)
SELECT c.customer_id, o.order_id
FROM customers c
JOIN orders o
ON o.customer_id = c.customer_id
AND o.created_at >= c.first_order_month
AND o.created_at < c.first_order_month + interval '1 month'; QUERY PLAN
---------------------------------------------------------------
Nested Loop
-> Seq Scan on customers c
-> Append
Subplans Removed: 5
-> Index Scan using orders_2026_01_pkey on orders_2026_01
Index Cond: ((created_at >= c.first_order_month) AND ...)
-> Index Scan using orders_2026_03_pkey on orders_2026_03
...Subplans Removed: 5 说明每一轮循环里,规划器都依据当前外层的 first_order_month 值移除了 5 个无关分区。这个能力对「按时间范围 join」的报表查询意义重大。
多级分区的逐级裁剪
多级分区里裁剪是逐级的:一级裁完,在每个保留的一级分区内部再对二级裁剪。
EXPLAIN (COSTS OFF)
SELECT * FROM events
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'
AND tenant_id = 42; QUERY PLAN
---------------------------------------------
Seq Scan on events_2026_01_p2
Filter: ((created_at >= ...) AND (created_at < ...) AND (tenant_id = 42))一级命中 1 个月,二级命中 1 个余数,最终只剩一个叶子分区。若缺少一级条件,则每个保留的一级分区下所有二级都会被扫描(见多级分区(子分区))。
Subplans Removed 与 never executed
EXPLAIN (ANALYZE) 里两个常见但容易误读的输出:Subplans Removed: N 表示运行时裁剪移除了 N 个子计划(说明动态裁剪生效,是好消息);never executed 表示某子计划本次执行没有跑(loops = 0),也可能是连接顺序导致的内层未触发。
QUERY PLAN
---------------------------------------------------------------
Nested Loop
-> Seq Scan on orders_2026_01 o
-> Index Scan using customers_pkey on customers c
Index Cond: (id = o.customer_id)
Planning Time: ...
Execution Time: ...若计划里出现 never executed,说明该分支一次都没跑;出现在分区场景时,多半是运行时裁剪把子计划停掉了。
可裁剪查询的写法清单
要让查询尽可能被裁剪,遵守以下几条:
| 规则 | 反例 | 正例 |
|---|---|---|
| 分区键裸露 | date_trunc('month', ts) = ? | ts >= ? AND ts < ? |
| 类型精确匹配 | ts < '2026-02-01'::text | ts < timestamptz '2026-02-01' |
| 区间表达代替函数 | to_char(ts,'YYYY-MM') = ? | ts >= ? AND ts < ? |
| 常量在计划期可求值 | 变量/函数返回值 | 字面量或 IMMUTABLE 表达式 |
OR 不跨维度 | ts < x OR region = y | 拆分为 UNION ALL |
-- 反例
SELECT * FROM orders WHERE to_char(created_at, 'YYYYMM') = '202601';
-- 正例
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';prepared statement 与裁剪(PostgreSQL 12+)
PostgreSQL 12 起,分区表的参数化查询有了专门的裁剪支持:即使是通用计划(generic plan,计划期不知参数值),执行期仍能裁剪。
PREPARE q(timestamptz) AS SELECT * FROM orders WHERE created_at = $1 AND order_id = $2;
EXECUTE q('2026-01-15', 1001); -- 自定义计划,静态裁剪
EXECUTE q('2026-02-15', 1002);
EXECUTE q('2026-03-15', 1003); -- 可能切换为通用计划,依赖运行时裁剪可以用 plan_cache_mode 控制这一行为。多级分区、分区数量多、参数选择性高的场景,force_custom_plan 往往更划算(裁剪收益大于规划开销);反之分区少、参数选择性低时,通用计划更省。
SET plan_cache_mode = force_custom_plan; -- 每次按参数值重新规划,裁剪最充分
SET plan_cache_mode = force_generic_plan; -- 规划一次反复使用,避免重复规划开销裁剪失效排查清单
按顺序逐项检查:
| 步骤 | 检查内容 | 命令 |
|---|---|---|
| 1 | enable_partition_pruning 是否为 on | SHOW enable_partition_pruning; |
| 2 | constraint_exclusion 是否被关掉 | SHOW constraint_exclusion; |
| 3 | 分区边界是否与查询区间真的相交 | 查 pg_class.relpartbound |
| 4 | 分区键是否被函数/表达式包裹 | 看 EXPLAIN 的 Filter |
| 5 | 常量类型是否与列类型一致 | 看计划中的强制转换 |
| 6 | 是否有 OR 跨维度 | 拆分查询或重写 |
| 7 | 是否参数化查询走了通用计划 | plan_cache_mode / EXPLAIN EXECUTE |
| 8 | 是否存在 DEFAULT 分区兜底而被纳入 | DEFAULT 总会出现在 Append 里 |
-- 快速自检
SHOW enable_partition_pruning;
SHOW constraint_exclusion;
SELECT pg_get_partkeydef('orders'::regclass); -- 分区键定义DEFAULT 分区对裁剪的影响
DEFAULT 分区无法被裁剪掉——规划器无法排除「某些行的分区键值不匹配任何显式分区,落入 DEFAULT」的可能:
-- orders 有 DEFAULT 分区时的计划
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; QUERY PLAN
---------------------------------------------
Append
-> Seq Scan on orders_2026_01
Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))
-> Seq Scan on orders_default
Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))orders_default 永远在计划里,哪怕它几乎是空的。这也是「有了完备的显式分区后应删除 DEFAULT」的一个理由。
用 pg_partition_tree 与边界函数辅助排查
裁剪问题的根源常在边界定义。用系统函数把边界与分区键打印出来:
SELECT relid::regclass AS partition,
pg_get_expr(relpartbound, relid) AS bound
FROM pg_partition_tree('orders')
WHERE relkind = 'r'
ORDER BY relid::regclass; partition | bound
------------------+----------------------------------------------------------
orders_2026_01 | FOR VALUES FROM ('2026-01-01 00:00:00+08') TO ('2026-02-01 00:00:00+08')
orders_2026_02 | FOR VALUES FROM ('2026-02-01 00:00:00+08') TO ('2026-03-01 00:00:00+08')
orders_default | DEFAULT-- 分区键列与策略
SELECT pg_get_partkeydef('orders'::regclass) AS partkeydef; partkeydef
------------------
RANGE (created_at)若边界用的是 timestamptz 而查询常量是本地时区字面量,务必核对时区转换后的实际边界值——这会导致「看起来该命中却没命中」的裁剪问题。
实战示例
场景:一张按月分区的 orders 表,业务反馈「按月份查询很慢」。逐步定位是否裁剪失效。
第 1 步:确认表结构确实是分区表
SELECT pg_get_partkeydef('orders'::regclass) AS partkeydef;
SELECT relkind FROM pg_class WHERE relname = 'orders'; partkeydef
------------------
RANGE (created_at)
relkind
---------
prelkind = 'p' 确认是分区表,分区键是 created_at。
第 2 步:查看原查询计划
EXPLAIN (ANALYZE, COSTS OFF, BUFFERS)
SELECT count(*) FROM orders WHERE date_trunc('month', created_at) = '2026-01-01'; QUERY PLAN
---------------------------------------------------------------
Aggregate
-> Append
-> Seq Scan on orders_2026_01 Filter: (date_trunc('month', created_at) = ...)
-> Seq Scan on orders_2026_02 Filter: (date_trunc('month', created_at) = ...)
-> Seq Scan on orders_2026_03 Filter: (date_trunc('month', created_at) = ...)
...
Buffers: shared read=1823456
Execution Time: 9840 msAppend 展开了几乎所有分区,shared read 达到百万级——典型的裁剪失效。
第 3 步:定位破坏裁剪的写法
问题在 date_trunc('month', created_at):函数包裹了分区键。
-- 验证:换成裸列区间条件后对比
EXPLAIN (ANALYZE, COSTS OFF, BUFFERS)
SELECT count(*) FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; QUERY PLAN
---------------------------------------------------------------
Aggregate
-> Seq Scan on orders_2026_01
Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))
Buffers: shared read=24118
Execution Time: 132 ms分区只剩一个,shared read 从 180 万降到 2.4 万,执行时间从 9.8 秒降到 132 毫秒。
第 4 步:改写业务 SQL
把「按月的函数条件」改造为「区间条件」。通用的月初/月末计算:
-- 给定任意月份字符串,换算成 [月初, 下月初) 区间
SELECT to_date('2026-01', 'YYYY-MM') AS month_start,
to_date('2026-01', 'YYYY-MM') + interval '1 month' AS month_end; month_start | month_end
---------------------+---------------------
2026-01-01 00:00:00 | 2026-02-01 00:00:00改写后的查询:
SELECT count(*) FROM orders
WHERE created_at >= to_date($1, 'YYYY-MM')
AND created_at < to_date($1, 'YYYY-MM') + interval '1 month';第 5 步:确认参数化查询仍能裁剪
PREPARE monthly(text) AS
SELECT count(*) FROM orders
WHERE created_at >= to_date($1, 'YYYY-MM')
AND created_at < to_date($1, 'YYYY-MM') + interval '1 month';
EXPLAIN (ANALYZE, COSTS OFF) EXECUTE monthly('2026-03'); QUERY PLAN
---------------------------------------------------------------
Aggregate (actual time=... rows=1 loops=1)
-> Seq Scan on orders_2026_03 (actual time=... rows=... loops=1)
Filter: ((created_at >= to_date('2026-03'::text,...)) AND ...)
Planning Time: 2.1 ms
Execution Time: 128 ms参数化后依然是单分区扫描,说明运行时裁剪生效。
第 6 步:检查 DEFAULT 分区是否拖累
SELECT pg_size_pretty(pg_total_relation_size('orders_default')) AS default_size; default_size
--------------
1 -- 空即使 DEFAULT 为空,它仍会出现在计划里。若确认显式分区已完备:
-- 移除 DEFAULT 分区(确认其中无数据)
SELECT count(*) FROM orders_default;
-- 若为 0
ALTER TABLE orders DETACH PARTITION orders_default;
DROP TABLE orders_default;第 7 步:固化到应用层
把「裁剪是否生效」做成可回归的检查,并把区间条件写进数据访问层,禁止业务代码对分区键使用函数:
-- 上线前回归:确认 Append 只展开预期分区
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; QUERY PLAN
---------------------------------------------
Seq Scan on orders_2026_01
Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))-- 应用层封装的查询模板:输入月份字符串 yyyy-mm
-- 输出始终使用 created_at >= ? AND created_at < ? 的形式
SELECT * FROM orders
WHERE created_at >= to_timestamp($1, 'YYYY-MM')
AND created_at < to_timestamp($1, 'YYYY-MM') + interval '1 month';注意事项
不要对分区键使用函数。 这是裁剪失效的头号原因,也是最常见的一类。
sql-- 错误!函数包裹分区键 SELECT * FROM orders WHERE date_trunc('month', created_at) = '2026-01-01'; -- 正确 SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';常量类型必须与分区键列类型一致。 类型不匹配会引入到列上的隐式转换,破坏裁剪。
sql-- 不推荐 ... WHERE created_at < '2026-02-01'::text -- 推荐 ... WHERE created_at < timestamptz '2026-02-01'OR跨维度会让整体退化为全扫。sql-- 不推荐:一个分支不可裁剪,整体全扫 SELECT * FROM orders WHERE (created_at < '2026-02-01') OR region = 'east';DEFAULT分区永远无法被裁掉。 只要它存在,所有裁剪后的Append里都会有它(哪怕它是空的)。别忘了检查
enable_partition_pruning与constraint_exclusion的会话值。 连接池、ORM、迁移工具都可能执行过SET。sqlSHOW enable_partition_pruning; -- 期望 on SHOW constraint_exclusion; -- 期望 partition裁剪到单分区时
Append会消失,不要误判为「没有分区」。 看计划是否有该分区名即可。textSeq Scan on orders_2026_01 ← 这就是裁剪到单分区,不是没分区多级分区必须让每一级条件都出现,才能拿到完整裁剪。
sql-- 只有二级条件:每个一级分区下所有二级叶子都要扫 SELECT * FROM events WHERE tenant_id = 42;参数化查询的通用计划可能放弃裁剪,需要用
plan_cache_mode调整。sqlSET plan_cache_mode = force_custom_plan; -- 让裁剪更充分时区会影响
timestamptz分区边界的实际值。 查询常量与分区边界若时区解释不同,会出现「该命中却没命中」。sqlSHOW TimeZone; SELECT '2026-02-01'::timestamptz; -- 检查实际转换结果
最佳实践
把分区键的比较条件写成裸列的区间形式。 这是最核心的一条规则,应在团队内形成编码规范。
sqlWHERE created_at >= :start AND created_at < :endtimestamptz分区表统一用[下界, 上界)区间参数查询。 避免date_trunc、to_char、extract等函数污染分区键。把「月初/月末」的换算放在参数构造阶段,而不是 SQL 里。
sql-- 应用层算出 start/end,SQL 只做区间比较 WHERE created_at >= $1 AND created_at < $2每个分区表都保留一条「裁剪自检」SQL,纳入上线前回归。 用
EXPLAIN确认Append只展开预期分区。sqlEXPLAIN (COSTS OFF) SELECT * FROM orders WHERE created_at >= date_trunc('month', now());显式分区完备时删除
DEFAULT分区。 它会让所有查询多扫一个分区,遮蔽裁剪效果。sqlALTER TABLE orders DETACH PARTITION orders_default; DROP TABLE orders_default;多级分区表的主力查询必须同时带上每一级的分区键条件。
sqlWHERE created_at >= $1 AND created_at < $2 AND tenant_id = $3为高频且选择性高的查询在分区内建索引。 裁剪解决「扫哪些分区」,索引解决「分区内怎么扫」,二者配合最优。
sqlCREATE INDEX orders_region_created_idx ON orders (region, created_at);参数化查询根据场景设置
plan_cache_mode。 分区多、参数选择性高时用force_custom_plan。sqlSET plan_cache_mode = force_custom_plan;给分区键的常用「月份边界」建立标准化函数(
STABLE/IMMUTABLE)并只用于参数构造。 不要把这类函数用在WHERE的分区键一侧。sql-- 用于生成参数,而非包裹列 SELECT to_date($1, 'YYYY-MM') AS month_start;
下一节
下一节:分区维护(DETACH / DROP)——学习分区轮转、归档下线、按分区清理与分区治理的运维方法。
参考链接
- PostgreSQL 18 官方文档:Partition Pruning(裁剪原理与示例)
- PostgreSQL 18 官方文档:Table Partitioning(分区总览)
- PostgreSQL 18 官方文档:Planner Method Configuration(
enable_partition_pruning) - PostgreSQL 18 官方文档:Using EXPLAIN(阅读计划)
- PostgreSQL 18 官方文档:Partition Information Functions(
pg_partition_tree等) - PostgreSQL 18 官方文档:PREPARE(参数化查询与计划缓存)