在上一篇文章中,我们通过 VACUUM 和 Autovacuum 治理了表的“存量垃圾”。但当一个表增长到数亿甚至数十亿行时,即使没有膨胀,全表扫描、全表索引维护、全表备份本身就会成为不可承受的成本。分区表的核心价值不是“让查询更快”,而是“让维护可以分而治之” ——查询只扫描目标分区(分区裁剪)、旧数据用 DETACH 而非 DELETE 来清理、索引和备份可以按分区独立操作。 本文从三种分区策略的选型讲起,深入拆解分区裁剪的生效条件、ATTACH 与 DETACH 的锁行为差异(这是运维中最容易踩坑的地方),以及 PostgreSQL 19 的 MERGE PARTITIONS / SPLIT PARTITION 如何让分区策略“可演进”。最后通过一个按月日志分区表的完整实战,帮你掌握分区表的建、用、维全流程。
一、三种分区策略:选型的核心是“数据怎么进来、怎么出去”
PostgreSQL 声明式分区支持三种策略:
HASH FOR VALUES WITH (MODULUS n, REMAINDER r) 无明显区间/枚举特征 均匀分散、无明显范围特征的数据
一个经验法则:RANGE 覆盖了 90% 以上的生产分区场景,尤其是按时间分区。原因在于时间分区同时满足了三个需求:分区裁剪(查询最近 7 天只扫描 7 个分区)、生命周期管理(删除 90 天前的数据只需 DROP 或 DETACH 一个分区)、以及增量维护(只对最新分区建索引和备份)。
LIST 的适用场景:当数据天然按少数枚举值分组、且各组的数据量和访问模式差异较大时。例如多租户 SaaS 按 tenant_id 分区,但租户数量不宜过多——几十个租户适合 LIST,几千个租户应该考虑 HASH 或按 tenant_id 的哈希值分区。
HASH 的适用场景:当数据没有自然的时间或枚举维度,但需要均匀分散以避免单个分区过大时。HASH 分区的分区裁剪能力弱于 RANGE 和 LIST(WHERE 条件通常只能命中一个分区,除非查询本身包含取模条件),它的主要价值是写入和存储的均匀分布。
二、分区裁剪:什么情况下“只扫一个分区”,什么情况下“全部分区都扫”
分区裁剪(Partition Pruning)是分区表性能收益的核心来源,但它有明确的生效条件。
2.1 裁剪的两个时机
计划期裁剪:优化器在生成执行计划时,根据 WHERE 子句和分区的 CHECK 约束,直接排除不满足条件的分区。这要求 WHERE 条件中的分区键是常量或计划期可确定的值。
执行期裁剪(PostgreSQL 11+) :当分区键的值在计划期未知(如预编译语句的参数、嵌套循环连接中动态产生的值)时,裁剪推迟到执行期进行。
2.2 裁剪失效的核心条件
如果查询的 WHERE 条件中不包含分区键,所有分区都会被扫描。 这是分区裁剪最根本的限制。
-- 分区键:created_at-- ✅ 裁剪生效:只扫描 2026-10 分区SELECT*FROMlogsWHEREcreated_at>='2026-10-01'ANDcreated_at<'2026-11-01';-- ❌ 裁剪失效:WHERE 中没有 created_at,全部 12 个分区都会被扫描SELECT*FROMlogsWHEREuser_id=123;另一个常见的裁剪失效场景:对分区键使用函数或表达式,例如 WHERE DATE(created_at) = ‘2026-10-01’——这与 B-tree 索引失效的原理相同,分区裁剪也无法识别“函数结果”与“分区边界”之间的关系。
验证裁剪是否生效:在 EXPLAIN 输出中观察是否出现 Append 节点,以及 Append 下挂载的子计划数量。如果只有 1 个子计划,说明裁剪成功;如果有 12 个子计划,说明全部分区都被扫描。
三、ATTACH 与 DETACH:锁行为是运维的关键
分区表的日常运维涉及“添加新分区”和“分离旧分区”。这两个操作的锁行为差异极大,选错就会在生产环境锁表。
3.1 ATTACH PARTITION:没有 CONCURRENTLY 选项
ATTACH PARTITION 总是在父表上获取 SHARE UPDATE EXCLUSIVE 锁,在新分区上获取 ACCESS EXCLUSIVE 锁。这意味着:
父表上的读写可以继续(SHARE UPDATE EXCLUSIVE 与 ACCESS SHARE 和 ROW EXCLUSIVE 兼容)。
新分区在 ATTACH 期间不可访问(ACCESS EXCLUSIVE 阻塞一切)。
在添加新分区时,这个锁行为通常可以接受——新分区本来就没有数据在读写。但如果新分区是一个已经存在数据的大表(例如将历史表挂载到分区父表),ATTACH 需要在父表上扫描以验证约束,可能持有锁的时间较长。
3.2 DETACH PARTITION:默认锁全表,CONCURRENTLY 是生产必选项
这是运维中最容易出问题的地方。
默认的 DETACH PARTITION 在父表上获取 ACCESS EXCLUSIVE 锁——阻塞父表上的一切读写。对于一个正在被业务频繁访问的分区父表,这会直接导致业务中断。
DETACH PARTITION CONCURRENTLY(PostgreSQL 14+) 在父表上只获取 SHARE UPDATE EXCLUSIVE 锁,不阻塞业务的读写。它的实现是两阶段协议:
阶段 A:在一个短暂的事务中,将目标分区标记为 “detach pending”。这个事务只持有极短的锁。
阶段 B:在后台完成实际的分离操作,不阻塞父表的写入。
DETACH CONCURRENTLY 的两个严格限制:
不能在事务块中执行(因为它需要跨多个事务阶段完成)。
当父表存在 DEFAULT 分区时禁止使用。
生产环境的铁律:在正在承载业务读写的分区父表上,始终使用 DETACH PARTITION CONCURRENTLY。默认的 DETACH 只适合开发环境或确认没有并发写入的表。
四、PostgreSQL 19:分区策略“可演进”
在 PostgreSQL 19 之前,分区策略一旦设定就很难调整——分区太大只能手动拆建迁移,分区太小太碎也只能忍受。PostgreSQL 19 引入了两个 ALTER TABLE 扩展,让分区边界可以在不转储和重载数据的情况下重新调整:
4.1 MERGE PARTITIONS:合并多个分区
-- 把 Q1 和 Q2 合并为一个上半年分区ALTERTABLEcustomer_ordersMERGEPARTITIONS(orders_2026_q1,orders_2026_q2)INTOorders_2026_h1;关键机制:合并复用现有的分区数据文件,不进行逐行拷贝。这意味着即使合并的分区很大,操作也是元数据级别的,速度极快。
4.2 SPLIT PARTITION:拆分单个分区
-- 把 Q3 分区按月度拆分为三个子分区ALTERTABLEcustomer_orders SPLITPARTITIONorders_2026_q3INTO(PARTITIONorders_2026_07FORVALUESFROM('2026-07-01')TO('2026-08-01'),PARTITIONorders_2026_08FORVALUESFROM('2026-08-01')TO('2026-09-01'),PARTITIONorders_2026_09FORVALUESFROM('2026-09-01')TO('2026-10-01'));适用场景:业务增长超预期,个别分区变得异常大;或者数据留存周期调整,小分区太多太碎。
合并与拆分的战略价值:分区策略不再是“设计时一次决定、运行时不可更改”的刚性约束。你可以从粗粒度的季度分区开始,随着数据量增长拆分为月度;也可以把历史的小时分区合并为更大的归档分区——分区边界跟着业务节奏演进。
五、分区表的索引:父表建索引的传播与 CONCURRENTLY 的限制
在分区父表上 CREATE INDEX 会自动在所有子分区上创建对应的索引。但这里有一个重要的限制:你不能直接在父表上执行 CREATE INDEX CONCURRENTLY 。
在生产环境为大分区表添加索引的标准流程是三步:
-- 第一步:在每个分区上分别 CONCURRENTLY 创建索引(不阻塞各分区读写)CREATEINDEXCONCURRENTLY idx_logs_created_2026_10ONlogs_2026_10(created_at);-- 第二步:在父表上创建“仅父表”的索引(标记为 invalid,不实际建索引)CREATEINDEXidx_logs_createdONONLY logs(created_at);-- 第三步:将每个分区的索引 attach 到父索引上ALTERINDEXidx_logs_created ATTACHPARTITIONidx_logs_created_2026_10;直接在父表上 CREATE INDEX(非 CONCURRENTLY)会对整个分区表加锁,对大表来说是不可接受的。三步法保证了每一步都不阻塞业务读写,是生产环境的标准操作。
六、实战:按月范围分区日志表
场景:一个事件日志表,每月新增约 5000 万行,需要保留 12 个月,查询以“最近 N 天”为主。
6.1 建表
-- 父表:按 created_at 范围分区CREATETABLEevent_logs(id BIGSERIAL,event_typeTEXTNOTNULL,payload JSONB,created_at TIMESTAMPTZNOTNULLDEFAULTNOW(),PRIMARYKEY(id,created_at)-- 分区键必须包含在主键中)PARTITIONBYRANGE(created_at);关键约束:分区表的主键必须包含分区键。因为分区键决定了行在哪个物理分区中,如果不包含分区键,数据库无法保证主键在整个分区表中的唯一性。
6.2 创建分区
CREATETABLEevent_logs_2026_10PARTITIONOFevent_logsFORVALUESFROM('2026-10-01')TO('2026-11-01');CREATETABLEevent_logs_2026_11PARTITIONOFevent_logsFORVALUESFROM('2026-11-01')TO('2026-12-01');6.3 验证分区裁剪
EXPLAIN(ANALYZE,BUFFERS)SELECT*FROMevent_logsWHEREcreated_at>='2026-10-15'ANDcreated_at<'2026-10-16';期望结果:Append 节点下只有 event_logs_2026_10 一个子计划。如果出现 12 个子计划,说明裁剪没有生效——检查 WHERE 是否引用了分区键、是否对分区键使用了函数。
6.4 自动化分区管理(pg_partman)
手动创建每月分区容易遗漏。pg_partman 扩展可以自动预创建未来分区并管理保留策略(自动 detach 或 drop 旧分区) :
SELECTpartman.create_parent(p_parent_table :='public.event_logs',p_control :='created_at',p_interval :='1 month',p_premake :=3-- 预创建未来 3 个分区);配合 pg_cron 定时运行 partman.run_maintenance(),可以实现零人工干预的分区生命周期管理。
6.5 旧分区的清理:DETACH CONCURRENTLY + DROP
-- 生产环境:先 CONCURRENTLY 分离,不阻塞读写ALTERTABLEevent_logs DETACHPARTITIONevent_logs_2025_10 CONCURRENTLY;-- 分离后,分区成为独立表,可以安全 DROPDROPTABLEevent_logs_2025_10;DETACH 而非 DELETE 是分区表生命周期管理的核心优势:DELETE 需要逐行标记死元组,再由 VACUUM 回收,成本随行数线性增长;DETACH 是元数据操作,成本几乎恒定,与分区大小无关。
七、小结
三种分区策略:RANGE 覆盖 90% 场景(尤其是时间分区),LIST 适合少数枚举值分组,HASH 适合均匀分散但裁剪能力弱。
分区裁剪的硬条件:WHERE 中必须包含分区键,且不能对分区键使用函数。用 EXPLAIN 的 Append 子计划数量验证裁剪是否生效。
ATTACH vs DETACH 的锁行为:ATTACH 父表 SHARE UPDATE EXCLUSIVE,生产可用;默认 DETACH 是 ACCESS EXCLUSIVE,阻塞一切读写;生产环境必须用 DETACH CONCURRENTLY(PG14+) ,但它不能在事务块中执行,且父表存在 DEFAULT 分区时禁止使用。
PostgreSQL 19 的 MERGE PARTITIONS / SPLIT PARTITION :复用现有数据文件,元数据级操作,让分区策略可演进,不再“一次设定、终身不变”。
分区表索引的三步法:分区上 CONCURRENTLY 建索引 → 父表 ONLY 建索引 → ATTACH 到父索引。不能直接在父表上 CREATE INDEX CONCURRENTLY 。
生命周期管理:用 pg_partman 自动预创建和保留管理,用 DETACH CONCURRENTLY + DROP 替代 DELETE,成本从 O(n) 降到 O(1)。