Skip to content

分区裁剪(Partition Pruning) ​

分区裁剪(Partition Pruning)是分区表最重要的性能机制:查询只涉及部分分区时,规划器或执行器把无关分区的子计划直接剔除,让 Append 只扫描少数几个分区,甚至只扫描一个。没有裁剪,分区表就是「一堆表的拼接」;有了裁剪,分区表才能真正做到「只碰相关数据」。

本页讲清楚裁剪的两条路径(静态裁剪与动态裁剪)、EXPLAIN 中如何识别裁剪是否生效、哪些写法会破坏裁剪,以及一套「裁剪失效排查清单」。

前置知识

阅读本节前,建议先了解:声明式分区概述、范围分区(RANGE)、创建与附加分区

基础概念 ​

裁剪解决什么问题 ​

分区表把一张大表拆成若干张物理表。查询 WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01' 时,只有 1 月的分区可能含有符合条件的数据。如果规划器能识别这一点,就可以完全不生成其他月份分区的扫描计划。

text
没有裁剪:                       有裁剪:
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,这是静态裁剪。

sql
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
text
                    QUERY PLAN
--------------------------------------------------
 Seq Scan on orders_2026_01
   Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))

注意:当裁剪后只剩 一个分区 时,Append 节点会消失,计划里直接出现该分区。这是「裁剪到单分区」的标志性表现。

动态裁剪的典型场景 ​

PREPARE 的参数值在计划期未知:

sql
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 月分区。

更复杂的情况是嵌套循环:内层扫描的分区键值来自外层当前行,直到运行到那一行才知道。

sql
-- 外层每取一行,内层才知道要用哪个分区键值
SELECT *
FROM tenants t
JOIN orders o ON o.tenant_id = t.tenant_id
WHERE o.created_at >= now() - interval '1 month';

PostgreSQL 11 起的运行时裁剪允许在执行过程中决策:

text
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:

sql
SHOW enable_partition_pruning;
text
 enable_partition_pruning
--------------------------
 on

官方说明它同时控制计划期裁剪与执行期裁剪。若被关闭(全局或会话级),所有分区都会被扫描:

sql
SET enable_partition_pruning = off;
EXPLAIN (COSTS OFF) SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
text
                 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 的排除:

sql
SHOW constraint_exclusion;
text
 constraint_exclusion
----------------------
 partition

排障提示

排查裁剪失效时,先确认这两个参数都没被改动。很多「裁剪突然不工作了」的案例,最后发现是连接池或会话里执行了 SET 把参数关掉了。

裁剪不等于「用上索引」 ​

初学者常把两件事混为一谈:裁剪决定「扫描哪些分区」(分区级过滤),索引扫描决定「在一个分区内如何访问数据」(块级过滤)。两者正交:裁剪后剩下的分区里,若条件选择性不高,仍然会做顺序扫描。

sql
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
text
                    QUERY PLAN
-------------------------------------------------
 Seq Scan on orders_2026_01
   Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))

裁剪掉了几十个分区,但剩下的那个分区还是顺序扫描——这已经是最优解的一部分:分区级过滤已经省掉了绝大部分 I/O。

裁剪对分区数量的依赖 ​

裁剪的收益随分区数量增长而增长。只有当分区足够多、单个分区足够小时,「只扫一个分区」才比「扫整张表」快得多:

表规模分区数单分区占比裁剪收益
1 亿行425%有限
1 亿行24(月)~4%显著
10 亿行60(月)~1.7%非常显著
100 万行12~8%极小(表本身很快)

这也是为什么小表分区往往得不偿失——裁剪省下的 I/O 还不如 Append 的规划开销。

EXPLAIN 中的裁剪证据 ​

识别裁剪是否生效,主要看三类信息:

现象含义
Append 下只有少数几个子节点静态或动态裁剪生效
Subplans Removed: N运行时裁剪移除了 N 个子计划
never executed运行时裁剪后某些子计划在本次执行中未被执行
Append 消失、直接出现单分区裁剪到单分区
sql
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
text
                    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 + 全部子节点,即可确认裁剪原本是生效的。

语法与用法 ​

查看分区键与分区边界 ​

裁剪的正确性完全依赖分区边界。先确认边界是什么:

sql
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;
text
     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

静态裁剪:常量条件 ​

sql
-- 只命中 1 月
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
text
                 QUERY PLAN
---------------------------------------------
 Seq Scan on orders_2026_01
   Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))
sql
-- 跨 1、2 月
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE created_at >= '2026-01-15' AND created_at < '2026-02-15';
text
                 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 ​

sql
EXPLAIN (COSTS OFF) SELECT * FROM orders WHERE created_at = '2026-02-14 10:00:00+08';
text
                 QUERY PLAN
---------------------------------------------
 Seq Scan on orders_2026_02
   Filter: (created_at = '2026-02-14 10:00:00+08'::timestamp with time zone)
sql
-- IN 列表也能裁剪出多个分区
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE created_at IN ('2026-01-10', '2026-03-20', '2026-05-05');
text
                 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 参数 ​

sql
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');
text
                 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

动态裁剪:运行时(子查询) ​

sql
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:

text
                          QUERY PLAN
--------------------------------------------------------------
 Seq Scan on orders_2026_01 (actual time=... rows=...)
   Filter: ...
   InitPlan 1
     ->  ...
 Planning Time: 1.8 ms
 Execution Time: 52 ms

详细说明 ​

破坏裁剪的写法一:函数包裹分区键 ​

对分区键施加函数,规划器无法把条件映射到分区边界:

sql
-- 裁剪失效:date_trunc 包裹了分区键
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE date_trunc('month', created_at) = '2026-01-01';
text
                 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) = ...)
   ...(全部分区)

正确写法是把条件还原成对裸列的区间比较:

sql
-- 裁剪生效:裸列 + 区间
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
text
                 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:

sql
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at < '2026-02-01'::text;
text
                 QUERY PLAN
---------------------------------------------
 Append
   ->  Seq Scan on orders_2026_01   Filter: (created_at < ('2026-02-01'::cstring)::timestamp ...)
   ...(全部分区)

正确做法是让常量类型与列类型一致:

sql
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at < timestamptz '2026-02-01';
text
                 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 条件只要有一个分支不局限在少数分区,整个查询就退化为全扫:

sql
-- 一个分支能裁剪、另一个分支不能 → 整体全扫
EXPLAIN (COSTS OFF)
SELECT * FROM orders
WHERE (created_at >= '2026-01-01' AND created_at < '2026-02-01') OR region = 'east';
text
                 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,让可裁剪的分支单独裁剪:

sql
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 起,分区裁剪可以在嵌套循环连接中动态进行:内层的分区键值来自外层当前行,执行器每一轮都可能重新裁剪。

sql
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';
text
                          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」的报表查询意义重大。

多级分区的逐级裁剪 ​

多级分区里裁剪是逐级的:一级裁完,在每个保留的一级分区内部再对二级裁剪。

sql
EXPLAIN (COSTS OFF)
SELECT * FROM events
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'
  AND tenant_id = 42;
text
                 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),也可能是连接顺序导致的内层未触发。

text
                          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'::textts < timestamptz '2026-02-01'
区间表达代替函数to_char(ts,'YYYY-MM') = ?ts >= ? AND ts < ?
常量在计划期可求值变量/函数返回值字面量或 IMMUTABLE 表达式
OR 不跨维度ts < x OR region = y拆分为 UNION ALL
sql
-- 反例
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,计划期不知参数值),执行期仍能裁剪。

sql
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 往往更划算(裁剪收益大于规划开销);反之分区少、参数选择性低时,通用计划更省。

sql
SET plan_cache_mode = force_custom_plan;    -- 每次按参数值重新规划,裁剪最充分
SET plan_cache_mode = force_generic_plan;   -- 规划一次反复使用,避免重复规划开销

裁剪失效排查清单 ​

按顺序逐项检查:

步骤检查内容命令
1enable_partition_pruning 是否为 onSHOW enable_partition_pruning;
2constraint_exclusion 是否被关掉SHOW constraint_exclusion;
3分区边界是否与查询区间真的相交查 pg_class.relpartbound
4分区键是否被函数/表达式包裹看 EXPLAIN 的 Filter
5常量类型是否与列类型一致看计划中的强制转换
6是否有 OR 跨维度拆分查询或重写
7是否参数化查询走了通用计划plan_cache_mode / EXPLAIN EXECUTE
8是否存在 DEFAULT 分区兜底而被纳入DEFAULT 总会出现在 Append 里
sql
-- 快速自检
SHOW enable_partition_pruning;
SHOW constraint_exclusion;
SELECT pg_get_partkeydef('orders'::regclass);   -- 分区键定义

DEFAULT 分区对裁剪的影响 ​

DEFAULT 分区无法被裁剪掉——规划器无法排除「某些行的分区键值不匹配任何显式分区,落入 DEFAULT」的可能:

sql
-- orders 有 DEFAULT 分区时的计划
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
text
                 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 与边界函数辅助排查 ​

裁剪问题的根源常在边界定义。用系统函数把边界与分区键打印出来:

sql
SELECT relid::regclass AS partition,
       pg_get_expr(relpartbound, relid) AS bound
FROM pg_partition_tree('orders')
WHERE relkind = 'r'
ORDER BY relid::regclass;
text
    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
sql
-- 分区键列与策略
SELECT pg_get_partkeydef('orders'::regclass) AS partkeydef;
text
   partkeydef
------------------
 RANGE (created_at)

若边界用的是 timestamptz 而查询常量是本地时区字面量,务必核对时区转换后的实际边界值——这会导致「看起来该命中却没命中」的裁剪问题。

实战示例 ​

场景:一张按月分区的 orders 表,业务反馈「按月份查询很慢」。逐步定位是否裁剪失效。

第 1 步:确认表结构确实是分区表 ​

sql
SELECT pg_get_partkeydef('orders'::regclass) AS partkeydef;
SELECT relkind FROM pg_class WHERE relname = 'orders';
text
   partkeydef
------------------
 RANGE (created_at)

 relkind
---------
 p

relkind = 'p' 确认是分区表,分区键是 created_at。

第 2 步:查看原查询计划 ​

sql
EXPLAIN (ANALYZE, COSTS OFF, BUFFERS)
SELECT count(*) FROM orders WHERE date_trunc('month', created_at) = '2026-01-01';
text
                          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 ms

Append 展开了几乎所有分区,shared read 达到百万级——典型的裁剪失效。

第 3 步:定位破坏裁剪的写法 ​

问题在 date_trunc('month', created_at):函数包裹了分区键。

sql
-- 验证:换成裸列区间条件后对比
EXPLAIN (ANALYZE, COSTS OFF, BUFFERS)
SELECT count(*) FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
text
                          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 ​

把「按月的函数条件」改造为「区间条件」。通用的月初/月末计算:

sql
-- 给定任意月份字符串,换算成 [月初, 下月初) 区间
SELECT to_date('2026-01', 'YYYY-MM')                          AS month_start,
       to_date('2026-01', 'YYYY-MM') + interval '1 month'     AS month_end;
text
     month_start     |      month_end
---------------------+---------------------
 2026-01-01 00:00:00 | 2026-02-01 00:00:00

改写后的查询:

sql
SELECT count(*) FROM orders
WHERE created_at >= to_date($1, 'YYYY-MM')
  AND created_at <  to_date($1, 'YYYY-MM') + interval '1 month';

第 5 步:确认参数化查询仍能裁剪 ​

sql
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');
text
                          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 分区是否拖累 ​

sql
SELECT pg_size_pretty(pg_total_relation_size('orders_default')) AS default_size;
text
 default_size
--------------
 1                                               -- 空

即使 DEFAULT 为空,它仍会出现在计划里。若确认显式分区已完备:

sql
-- 移除 DEFAULT 分区(确认其中无数据)
SELECT count(*) FROM orders_default;
-- 若为 0
ALTER TABLE orders DETACH PARTITION orders_default;
DROP TABLE orders_default;

第 7 步:固化到应用层 ​

把「裁剪是否生效」做成可回归的检查,并把区间条件写进数据访问层,禁止业务代码对分区键使用函数:

sql
-- 上线前回归:确认 Append 只展开预期分区
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
text
                 QUERY PLAN
---------------------------------------------
 Seq Scan on orders_2026_01
   Filter: ((created_at >= '2026-01-01') AND (created_at < '2026-02-01'))
sql
-- 应用层封装的查询模板:输入月份字符串 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。

    sql
    SHOW enable_partition_pruning;   -- 期望 on
    SHOW constraint_exclusion;       -- 期望 partition
  • 裁剪到单分区时 Append 会消失,不要误判为「没有分区」。 看计划是否有该分区名即可。

    text
    Seq Scan on orders_2026_01    ← 这就是裁剪到单分区,不是没分区
  • 多级分区必须让每一级条件都出现,才能拿到完整裁剪。

    sql
    -- 只有二级条件:每个一级分区下所有二级叶子都要扫
    SELECT * FROM events WHERE tenant_id = 42;
  • 参数化查询的通用计划可能放弃裁剪,需要用 plan_cache_mode 调整。

    sql
    SET plan_cache_mode = force_custom_plan;   -- 让裁剪更充分
  • 时区会影响 timestamptz 分区边界的实际值。 查询常量与分区边界若时区解释不同,会出现「该命中却没命中」。

    sql
    SHOW TimeZone;
    SELECT '2026-02-01'::timestamptz;   -- 检查实际转换结果

最佳实践 ​

  • 把分区键的比较条件写成裸列的区间形式。 这是最核心的一条规则,应在团队内形成编码规范。

    sql
    WHERE created_at >= :start AND created_at < :end
  • timestamptz 分区表统一用 [下界, 上界) 区间参数查询。 避免 date_trunc、to_char、extract 等函数污染分区键。

  • 把「月初/月末」的换算放在参数构造阶段,而不是 SQL 里。

    sql
    -- 应用层算出 start/end,SQL 只做区间比较
    WHERE created_at >= $1 AND created_at < $2
  • 每个分区表都保留一条「裁剪自检」SQL,纳入上线前回归。 用 EXPLAIN 确认 Append 只展开预期分区。

    sql
    EXPLAIN (COSTS OFF) SELECT * FROM orders WHERE created_at >= date_trunc('month', now());
  • 显式分区完备时删除 DEFAULT 分区。 它会让所有查询多扫一个分区,遮蔽裁剪效果。

    sql
    ALTER TABLE orders DETACH PARTITION orders_default;
    DROP TABLE orders_default;
  • 多级分区表的主力查询必须同时带上每一级的分区键条件。

    sql
    WHERE created_at >= $1 AND created_at < $2 AND tenant_id = $3
  • 为高频且选择性高的查询在分区内建索引。 裁剪解决「扫哪些分区」,索引解决「分区内怎么扫」,二者配合最优。

    sql
    CREATE INDEX orders_region_created_idx ON orders (region, created_at);
  • 参数化查询根据场景设置 plan_cache_mode。 分区多、参数选择性高时用 force_custom_plan。

    sql
    SET plan_cache_mode = force_custom_plan;
  • 给分区键的常用「月份边界」建立标准化函数(STABLE/IMMUTABLE)并只用于参数构造。 不要把这类函数用在 WHERE 的分区键一侧。

    sql
    -- 用于生成参数,而非包裹列
    SELECT to_date($1, 'YYYY-MM') AS month_start;

下一节

下一节:分区维护(DETACH / DROP)——学习分区轮转、归档下线、按分区清理与分区治理的运维方法。

参考链接 ​