PostgreSQL 分区表什么时候变慢:分区裁剪失效、730 个分区规划 65 ms、DEFAULT 分区挡住新分区实测
在 PostgreSQL 17.11 测试库(400 万行自造事件数据)上对比不分区、按月 24 个分区、按天 730 个分区:常量条件计划期裁剪、CURRENT_DATE / now() 执行期裁剪(Subplans Removed)、分区键上套 date_trunc / ::date / to_char 导致 24 个分区全扫、不带分区键的查询在 730 个分区上规划 61–69 ms、预编译语句 generic plan 首次规划 55.6 ms;以及主键必须包含分区键、DEFAULT 分区里的数据挡住新建分区、ATTACH 前先加 CHECK、DETACH + DROP 与 DELETE 的 WAL 和空间对比。附可复跑脚本、只读分区体检 SQL 和样例 CSV。
PostgreSQL 17.11 实测(测试库、自造数据):查询条件写成 created_at::date = '2026-09-03',按月分区的 24 个分区全扫,95–104 ms;改成 created_at >= '2026-09-03' AND created_at < '2026-09-04' 后只扫 1 个分区,0.93–0.95 ms。按天分 730 个分区后,不带分区键的 user_id = ? LIMIT 20 光规划就要 61–69 ms(不分区表 0.38–0.46 ms),393 个分区被执行到。DEFAULT 分区里有 3 行 11 月数据时,新建 11 月分区直接报错。删一个月 188215 行:DELETE 254.6 ms、WAL 10 MB、表 355 MB 不变小;DETACH CONCURRENTLY + DROP 约 14 ms、WAL 14 kB。
适用对象
- 分区键上别套函数和类型转换:date_trunc、::date、to_char 都会让裁剪失效,24 个分区全扫,比等价的半开区间写法慢约 100–300 倍。
- CURRENT_DATE、now() 这类稳定函数不能在计划期裁剪,改在执行器启动时裁剪,EXPLAIN 里显示 Subplans Removed: 21。执行不受影响,但规划时间是常量写法的 3–4 倍(2.4–3.7 ms 对 0.7–0.9 ms)。
- 分区不是越细越好:730 个按天分区,带分区键查 7 天规划 5.1–5.5 ms(按月 0.7–0.9 ms);不带分区键时规划 61–69 ms。业务里有大量不带时间条件的查询,就别按天分。
- 预编译语句在分区很多时要看 plan_cache_mode:generic plan 第一次规划 55.6 ms,之后 0.14 ms;custom plan 第一次规划 5.1 ms,第二次 0.35 ms(分区元数据已在会话缓存里)。
- DEFAULT 分区要监控:里面一旦有本该属于新分区的数据,CREATE TABLE … PARTITION OF 会报错;清理历史数据用 DETACH PARTITION CONCURRENTLY + DROP,而不是 DELETE。
9 个场景的结论一览
脚本对每条查询跑一次 EXPLAIN (ANALYZE, BUFFERS),再跑 2 次只取 Planning / Execution Time,并统计计划里各分区扫描节点“执行了”和“never executed”的个数。完整 21 行结果在样例 CSV 里。
| 场景 | 慢的情况 | 快的情况 | 结论 |
|---|---|---|---|
| P1 常量范围 7 天 | 不分区 50–108 ms | 按月 15–19 ms;按天 8.3–9.5 ms | 裁剪后只扫命中分区 |
| P2 CURRENT_DATE / now() | 规划 2.4–3.7 ms | 常量写法规划 0.7–0.9 ms | 执行期裁剪,Subplans Removed: 21 |
| P3 分区键套函数 | 24 个分区全扫 95–296 ms | 半开区间 0.93–0.95 ms | 别在分区键上套函数 |
| P4 不带分区键 | 730 个分区规划 61–69 ms | 不分区表规划 0.38–0.46 ms | 分区越多,规划越贵 |
| P5 预编译语句 | generic plan 首次规划 55.6 ms | 之后 0.14 ms | 分区多时注意首次和估算 |
| P6 主键不含分区键 | 直接报错 | 主键改为 (id, created_at) | 唯一约束必须带分区键 |
| P7 DEFAULT 分区 | 有 3 行 11 月数据,新建 11 月分区报错 | 挪走后 10.3 ms 建成 | DEFAULT 要监控 |
| P8 ATTACH | 不带 CHECK 12.0 ms(全表验证) | 先 ADD CHECK,ATTACH 0.44 ms | 大表先加约束 |
| P9 删一个月 | DELETE 254.6 ms、WAL 10 MB、空间不释放 | DETACH + DROP 约 14 ms、WAL 14 kB | 按分区清理 |
裁剪失效最常见的原因:分区键上套了函数
先看裁剪正常的情况:WHERE created_at >= '2026-09-01' AND created_at < '2026-09-08',不分区表走 Bitmap Heap Scan 读 27412 个块,50–108 ms;按月分区只剩 ev_m_202609 一个分区,15–19 ms;按天分区剩 7 个分区,8.3–9.5 ms,返回都是 42014 行。
然后我把同样的意图换了 3 种常见写法。date_trunc('month', created_at) = '2026-09-01'、created_at::date = '2026-09-03'、to_char(created_at, 'YYYY-MM') = '2026-09',计划里 24 个分区一个不少,分别 146–217 ms、95–104 ms、282–296 ms。裁剪只认“分区键 运算符 常量/参数”这种形式,表达式包住分区键以后,规划器无法据此排除分区,而分区索引建在 created_at 上,这几个表达式也用不上它。
改成半开区间 created_at >= '2026-09-03' AND created_at < '2026-09-04',只剩 1 个分区,0.93–0.95 ms,比 ::date 写法快约 100 倍。ORM 或报表工具自动生成的 SQL 最容易出这个问题,排查时直接看计划里有几个 Seq Scan / Index Scan on 分区名就行。
| WHERE 条件 | 扫描的分区 | Planning | Execution(3 次) |
|---|---|---|---|
| date_trunc('month', created_at) = '2026-09-01' | 24 个 | 2.4–3.2 ms | 217.4 / 146.5 / 183.6 ms |
| created_at::date = '2026-09-03' | 24 个 | 2.1–2.4 ms | 103.8 / 100.6 / 94.5 ms |
| to_char(created_at,'YYYY-MM') = '2026-09' | 24 个 | 2.0–2.2 ms | 282.0 / 289.4 / 295.7 ms |
| created_at >= '2026-09-03' AND created_at < '2026-09-04' | 1 个 | 0.7–0.8 ms | 0.95 / 0.93 / 0.93 ms |
P3 平均执行时间(按月分区,查 2026-09 的数据)
to_char
282–296 ms
date_trunc
146–217 ms
::date
95–104 ms
半开区间
0.93–0.95 ms
PostgreSQL 17.11,测试库、自造数据,400 万行。
CURRENT_DATE 和 now():能裁剪,但发生在执行器启动时
WHERE created_at >= CURRENT_DATE - 7 和 created_at >= now() - interval '7 days',在 ev_m 上都只执行了 3 个分区(10 月以及还没有数据的 11、12 月),计划里有一行 Subplans Removed: 21。这一行我一开始以为只有 EXPLAIN ANALYZE 才有,实际上普通 EXPLAIN 里也会显示,因为这一步裁剪发生在执行器初始化阶段,而 EXPLAIN 也会走到这一步。
代价在规划:这两种写法规划 2.4–3.7 ms,常量写法 0.7–0.9 ms。原因是规划器不能在计划期用稳定函数的值,只能先给 24 个分区都生成计划,再在启动时删掉 21 个。分区数少时影响不大;分区数上千、QPS 又高时,可以在应用里算好日期再以常量或参数传入。
730 个按天分区:不带分区键的查询,规划 61–69 ms,执行只要 7–10 ms
WHERE user_id = 4242 ORDER BY created_at DESC LIMIT 20,不带分区键。不分区表一个 (user_id, created_at) 索引搞定,规划 0.38–0.46 ms、执行 0.15–0.27 ms。按月分区规划 2.0–2.7 ms,有序 Append 从最新的分区往回找,13 个分区执行到、11 个 never executed。按天分区规划 61–69 ms、执行 7.1–10.3 ms,393 个分区被执行到、337 个 never executed。
加一个 created_at >= '2026-10-01' 的下限,按天分区只剩 92 个分区参与规划(10 月 1 日到 12 月 31 日,包括还没数据的未来分区),规划降到 10.9–12.0 ms、执行 1.0–1.1 ms。还有个细节:未来分区建得越早,这类查询要规划的空分区越多,本次 69 个空的叶子分区也在规划范围里。
作者在游戏行业做过几千亿条日志的分区设计,那个量级不分区每天都写不进去,所以要权衡的从来不是分不分,而是按什么粒度分、查询是不是都带分区键。业务里大量查询不带分区键时,按月或更粗的粒度往往更合适,本次 730 个日分区就是反例。
| 表 | Planning(3 次) | Execution(3 次) | 分区扫描节点 执行 / never executed |
|---|---|---|---|
| ev_flat 不分区 | 0.46 / 0.38 / 0.40 ms | 0.27 / 0.16 / 0.15 ms | — |
| ev_m 按月 24 个 | 2.66 / 2.41 / 2.04 ms | 0.71 / 0.37 / 0.37 ms | 13 / 11 |
| ev_d 按天 730 个 | 65.3 / 69.4 / 60.7 ms | 10.3 / 7.6 / 7.1 ms | 393 / 337 |
| ev_d + created_at >= '2026-10-01' | 10.9 / 12.0 / 11.3 ms | 1.03 / 1.14 / 1.04 ms | 92 / 0 |
P4 规划时间(3 次平均)
不分区
0.38–0.46 ms
按月 24 个
2.0–2.7 ms
按天 730 个
61–69 ms
按天 + 时间下限
10.9–12.0 ms
PostgreSQL 17.11,测试库、自造数据,400 万行。
预编译语句:generic plan 第一次规划 55.6 ms,行数估算少一半
PREPARE s(timestamp, timestamp) AS SELECT count(*) FROM ev_d WHERE created_at >= $1 AND created_at < $2,参数是 9 月 1 日到 8 日。plan_cache_mode = force_custom_plan 时每次按实际参数规划,第一次 5.1 ms,第二次 0.35 ms(分区元数据已在缓存里),执行 7.7–9.5 ms,走 Seq Scan。
force_generic_plan 时,第一次要给 730 个分区做一份不依赖参数的计划,规划 55.6 ms,执行时 Subplans Removed: 723;之后复用计划,规划 0.14 ms。另一个差别是行数:generic plan 估计 20058 行,实际 42014 行,选了 Index Only Scan;查询更复杂、有 JOIN 时,这种估算偏差可能把后面的连接方式带偏。默认 auto 会先用 5 次 custom plan 再决定,连接池里每个新连接都会重新经历一次首次规划。
两个直接报错的坑:主键不含分区键、DEFAULT 分区挡住新分区
在分区表 ev_m 上 ADD PRIMARY KEY (id),报 ERROR: unique constraint on partitioned table must include all partitioning columns,DETAIL 写明 PRIMARY KEY constraint on table "ev_m" lacks column "created_at"。分区表的唯一性只能在每个分区内保证,所以主键要改成 (id, created_at),400 万行建了 808 ms。全局唯一的 id 要靠序列或者业务侧保证。
DEFAULT 分区那个坑更隐蔽。ev_def 只建了 10 月分区和 DEFAULT,11 月分区忘了建,于是 3 行 11 月的数据进了 DEFAULT(另有 200000 行没有对应分区的老数据也在里面)。之后补建 11 月分区,报 ERROR: updated partition constraint for default partition "ev_def_default" would be violated by some row。把这 3 行挪走(DELETE 10.2 ms)后再建,10.3 ms 成功,这一步要扫描 DEFAULT 分区确认没有冲突的行,DEFAULT 越大越慢。
我的做法:提前建好未来 2–3 个周期的分区;如果保留 DEFAULT,就每天看一次里面有没有数据,有就告警。文末只读 SQL 第 3、4 段就是做这件事的。
加分区和删历史数据:ATTACH 前先加 CHECK,清理用 DETACH + DROP
把一张 181083 行的普通表 ATTACH 成分区,不带约束时要全表扫描验证分区约束,12.0 ms;先 ADD CONSTRAINT ck CHECK (created_at >= '2026-09-01' AND created_at < '2026-10-01')(12.9 ms),再 ATTACH 只要 0.44 ms。总耗时差不多,但验证扫描挪到了 ATTACH 之前、这张表还没挂上分区树的时候,ATTACH 本身只要 0.44 ms。本次表小,差别只有十几毫秒,表越大越值得这样做。如果这张表在 ATTACH 前还有读写,可以用 ADD CONSTRAINT … NOT VALID 再 VALIDATE CONSTRAINT 降低锁级别,这条路径本次没有测。
删 2025 年 1 月的 188215 行:在不分区的 ev_flat 上 DELETE 用了 254.6 ms,产生 10 MB WAL,表文件 355 MB,删完和 VACUUM(544.3 ms)之后都还是 355 MB,空间只是在表内可复用。按月分区上 DETACH PARTITION … CONCURRENTLY 2.4 ms + DROP TABLE 11.2 ms,WAL 只有 14 kB,空间立刻还给文件系统。按天分区上 DETACH(非 CONCURRENTLY)4.6 ms + DROP 2.9 ms。
| 做法 | 耗时 | WAL | 表空间 |
|---|---|---|---|
| DELETE(不分区 ev_flat) | 254.6 ms | 10 MB | 355 MB → 355 MB |
| 之后 VACUUM | 544.3 ms | — | 仍 355 MB |
| DETACH CONCURRENTLY + DROP(按月分区) | 2.4 + 11.2 ms | 14 kB | 整个分区文件删除 |
在自己的库上先跑只读 SQL:分区数、DEFAULT 分区、未来分区、主键和分区索引
实验脚本会建删库,只能在测试实例跑。到自己的库上,先跑这份只读体检 SQL(8 段,只有 SELECT,已过站内只读守卫测试):版本与 enable_partition_pruning、plan_cache_mode 等参数;每张分区表的分区方式、分区键、分区数、有没有 DEFAULT、总大小;DEFAULT 分区里的估算行数;每张 range 分区表最新 3 个分区的边界(看未来分区建好没有);叶子分区数和空分区数;分区表上的主键 / 唯一约束;分区索引是否完整(indisvalid);最大的 10 个叶子分区。
在实验库上跑的结果:ev_d 729 个分区(P9 删掉了 1 个)、69 个空分区;ev_def 的 DEFAULT 分区估算 200000 行、18 MB,会被第 3 段列出来;最新分区边界到 2026-12-31。注意 reltuples 是统计估算,刚批量写入、还没 ANALYZE 的分区可能显示 0 或 -1。
觉得有用?转给正在做这件事的同事或朋友
参考依据
以下来源用于确认市场趋势、政策背景和术语边界;具体落地方案仍以客户的数据范围、权限和交付目标为准。
常见问题
PostgreSQL 分区裁剪为什么没生效?
最常见是在分区键上套了函数或类型转换,例如 created_at::date、date_trunc、to_char。本次这 3 种写法都扫了全部 24 个分区,改成 created_at >= … AND created_at < … 的半开区间后只扫 1 个分区。
EXPLAIN 里的 Subplans Removed 是什么意思?
表示分区是在执行器启动时裁掉的,常见于 CURRENT_DATE、now() 或预编译参数。本次 CURRENT_DATE - 7 在 24 个月分区上显示 Subplans Removed: 21,执行正常,但规划时间是常量写法的 3–4 倍。
PostgreSQL 分区数多少合适?
看查询是否都带分区键。本次 730 个按天分区,带时间条件查 7 天规划 5.1–5.5 ms;不带分区键时规划 61–69 ms。大量查询不带分区键时,分区宜少不宜多。
分区表的主键为什么必须包含分区键?
PostgreSQL 只能在每个分区内部保证唯一。本次 ADD PRIMARY KEY (id) 直接报错,改成 (id, created_at) 后成功。
删历史数据用 DELETE 还是删分区?
能按分区删就按分区删。本次删一个月 188215 行,DELETE 254.6 ms、WAL 10 MB、表不变小;DETACH CONCURRENTLY + DROP 约 14 ms、WAL 14 kB,空间立即释放。
下一步
推荐动作
你提供只读分区体检 SQL 的结果和几条慢查询的 EXPLAIN (ANALYZE)(可脱敏),我们判断是裁剪没生效、分区粒度不合适还是 DEFAULT / 未来分区管理的问题,并给出能在测试环境验证的改法;生产变更由你方授权执行。