纲要
本文围绕 PostgreSQL 分区表的日常维护展开,涵盖以下核心技术点:
-
默认分区 (
DEFAULT PARTITION)- 定义与作用:作为"垃圾桶"捕获不匹配任何现有分区规则的行
- 风险与代价:新增分区时的冲突检测与全表扫描开销
- 维护策略:定期巡检、数据迁移与
DETACH操作 - 替代方案:
UDF触发器路由与pg_pathman插件
-
分区的新增与卸载 (
ATTACH PARTITION/DETACH PARTITION)CREATE TABLE ... PARTITION OF语法与八级锁 (ACCESS EXCLUSIVE) 问题- 最佳实践:
CREATE TABLE+ATTACH PARTITION方式与锁级别优化 - PostgreSQL 12 改进:
ATTACH PARTITION降级为SHARE UPDATE EXCLUSIVE - PostgreSQL 14 改进:
DETACH PARTITION CONCURRENTLY支持在线卸载
-
分区智能连接与聚合 (
enable_partitionwise_join/enable_partitionwise_aggregate)- 功能原理:分区间的两两 JOIN 与聚合
- 适用条件:相同的分区策略、分区键作为连接条件的一部分
- 代价与收益:CPU/内存消耗增加 vs 数据扫描量降低
- 默认状态:
off,按需开启
-
分区表使用时机与原则
- 官方建议:表大小超过物理内存时考虑分区
- 量化参考:记录数 > 20 亿或表空间 > 200 GB
- 反模式:小表分区与过度分区带来的性能损失
-
分区插件生态
pg_pathman:PG 12 以前的主流方案,纯 SQL 脚本,运维友好pg_partman:分区管理自动化工具- 原生分区的演进:PG 10 引入 → PG 11 增强 → PG 12 性能大幅提升
默认分区的设计与风险
PostgreSQL 的分区表采用声明式分区语法,每个分区通过 FOR VALUES 子句定义明确的边界。当插入的行不满足任何现有分区的约束时,PostgreSQL 默认会报错而非自动创建新分区。
默认分区(DEFAULT PARTITION)的引入正是为了解决这一问题。它的约束本质上是所有其他分区约束的"取反"集合------只要行不落入任何已定义分区,就会落入默认分区。这使得默认分区成为一个"来者不拒"的垃圾桶。
然而,默认分区的便利性背后隐藏着严重的维护成本:
新增分区时的冲突检测 :当需要为新的数据范围(如十二月)创建分区时,如果默认分区中已存在属于该范围的数据,ATTACH PARTITION 操作将失败并报错 would be violated by some row。此时必须先将默认分区中的冲突数据迁移或删除,而这一过程需要对默认分区进行全表扫描以定位冲突行。若默认分区达到上百 GB,扫描开销极为可观。
挂载默认分区时的约束验证:将一张已有数据的表挂载为默认分区时,PostgreSQL 需要验证该表中的数据不与任何现有分区的约束重叠。这一验证同样需要全表扫描,在大数据量场景下效率低下。
分区裁剪失效:在 Greenplum 等基于 PostgreSQL 的分布式数据库中,默认分区的存在可能导致分区裁剪(Partition Pruning)失效,即使查询条件明确指向特定分区,优化器仍可能扫描默认分区。
默认分区的约束验证
通过查询系统表 pg_constraint,可以观察默认分区的约束定义。以下示例展示了一个按月范围分区的表及其默认分区的约束关系:
sql
-- 创建分区表
CREATE TABLE sales (
id SERIAL,
sale_date DATE NOT NULL,
amount NUMERIC(10,2)
) PARTITION BY RANGE (sale_date);
-- 创建一月分区
CREATE TABLE sales_2026_01 PARTITION OF sales
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- 创建二月分区
CREATE TABLE sales_2026_02 PARTITION OF sales
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- 创建默认分区
CREATE TABLE sales_default PARTITION OF sales DEFAULT;
-- 查看默认分区的约束(本质上是前两个分区约束的取反)
SELECT conname, contype, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'sales_default'::regclass;
默认分区的约束类似于 NOT (sale_date >= '2026-01-01' AND sale_date < '2026-02-01') AND NOT (sale_date >= '2026-02-01' AND sale_date < '2026-03-01'),即不满足任何已定义分区范围的行。
默认分区维护策略
针对默认分区的维护,建议采取以下策略:
- 定期巡检:通过监控默认分区的数据量,设置告警阈值(如超过 10 GB 即触发告警)。
- 数据迁移 :在新增分区前,先将默认分区中属于新分区范围的数据导出备份,清理后再执行
ATTACH。 - 避免在生产环境长期保留大容量默认分区:默认分区应视为临时缓冲,而非长期存储区域。
触发器路由方案
一种替代默认分区的方案是使用触发器(UDF)实现动态路由:
sql
CREATE OR REPLACE FUNCTION sales_insert_router()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.sale_date >= '2026-01-01' AND NEW.sale_date < '2026-02-01' THEN
INSERT INTO sales_2026_01 VALUES (NEW.*);
ELSIF NEW.sale_date >= '2026-02-01' AND NEW.sale_date < '2026-03-01' THEN
INSERT INTO sales_2026_02 VALUES (NEW.*);
ELSE
RAISE EXCEPTION 'No partition for sale_date %', NEW.sale_date;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER sales_insert_trigger
BEFORE INSERT ON sales
FOR EACH ROW EXECUTE FUNCTION sales_insert_router();
局限性:触发器方案在高并发写入场景下性能较差,因为每一行插入都需要额外的函数调用和条件判断,无法与 Oracle 的 Interval Partition 或原生分区路由的性能相媲美。
分区的新增与卸载:锁策略详解
新增分区的两种方式
PostgreSQL 提供了两种新增分区的语法,二者在锁级别上存在显著差异。
方式一:CREATE TABLE ... PARTITION OF
sql
CREATE TABLE sales_2026_03 PARTITION OF sales
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
该语法会在父表上获取 ACCESS EXCLUSIVE 锁(八级锁),这是 PostgreSQL 中最重的锁级别,会阻塞父表上的所有读写操作。在生产环境中,这一操作可能导致数分钟乃至数小时的服务中断。
方式二:CREATE TABLE + ATTACH PARTITION
sql
-- 先创建与父表结构一致的表
CREATE TABLE sales_2026_03 (LIKE sales INCLUDING ALL);
-- 再将其挂载为分区
ALTER TABLE sales ATTACH PARTITION sales_2026_03
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
在 PostgreSQL 12 及以上版本中,ATTACH PARTITION 仅在父表上获取 SHARE UPDATE EXCLUSIVE 锁。该锁级别与 VACUUM 和 ANALYZE 相同,允许并发的读写操作,仅阻塞 DDL 变更。
锁级别对比
| 操作方式 | PostgreSQL 版本 | 父表锁级别 | 是否阻塞读写 |
|---|---|---|---|
CREATE TABLE ... PARTITION OF |
所有版本 | ACCESS EXCLUSIVE |
是 |
CREATE TABLE + ATTACH PARTITION |
< 12 | ACCESS EXCLUSIVE |
是 |
CREATE TABLE + ATTACH PARTITION |
≥ 12 | SHARE UPDATE EXCLUSIVE |
否 |
锁级别验证
以下示例演示如何观察 ATTACH PARTITION 操作中的锁级别:
sql
-- 窗口1:开启事务并执行 ATTACH
BEGIN;
ALTER TABLE sales ATTACH PARTITION sales_2026_03
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
-- 窗口2:查询锁信息
SELECT pid, locktype, mode, granted, relation::regclass
FROM pg_locks
WHERE relation = 'sales'::regclass;
预期输出中 mode 列显示为 ShareUpdateExclusiveLock,而非 AccessExclusiveLock。
卸载分区的两种方式
方式一:DETACH PARTITION(不带 CONCURRENTLY)
sql
ALTER TABLE sales DETACH PARTITION sales_2026_01;
该操作在父表上获取 ACCESS EXCLUSIVE 锁,阻塞所有并发访问。
方式二:DETACH PARTITION CONCURRENTLY(PG 14+)
sql
ALTER TABLE sales DETACH PARTITION sales_2026_01 CONCURRENTLY;
PostgreSQL 14 引入了 CONCURRENTLY 选项,将父表上的锁级别降为 SHARE UPDATE EXCLUSIVE。该操作内部使用两个事务完成,第一个事务进行准备,第二个事务完成卸载,整个过程不阻塞父表的读写操作。
注意 :DETACH PARTITION CONCURRENTLY 的执行时间会比普通 DETACH 稍长,因其需要等待所有并发事务完成。但这一代价在需要保持服务连续性的场景中是值得的。
卸载后数据保留 :DETACH 仅解除父子关系,子表及其数据仍然保留。若需彻底删除,可随后执行 DROP TABLE。
sql
-- 解绑分区(保留数据)
ALTER TABLE sales DETACH PARTITION sales_2026_01;
-- 确认数据仍存在
SELECT COUNT(*) FROM sales_2026_01;
-- 彻底删除
DROP TABLE sales_2026_01;
分区智能连接与聚合
PostgreSQL 从版本 11 开始引入了分区智能连接(Partition-wise Join)和分区智能聚合(Partition-wise Aggregate)功能,通过两个配置参数控制:
enable_partitionwise_join:默认offenable_partitionwise_aggregate:默认off
工作原理
未启用时:优化器将两个分区表的所有子表数据全部扫描后,在全局范围内执行 JOIN 或 GROUP BY。
启用后 :优化器将 JOIN 或 GROUP BY 下推到各个分区对之间执行------分区1 JOIN 分区1、分区2 JOIN 分区2......最后将各分区的结果汇总。
#mermaid-svg-4rbdGXRLKRUgXEmC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4rbdGXRLKRUgXEmC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4rbdGXRLKRUgXEmC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4rbdGXRLKRUgXEmC .error-icon{fill:#552222;}#mermaid-svg-4rbdGXRLKRUgXEmC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4rbdGXRLKRUgXEmC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4rbdGXRLKRUgXEmC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4rbdGXRLKRUgXEmC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4rbdGXRLKRUgXEmC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4rbdGXRLKRUgXEmC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4rbdGXRLKRUgXEmC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4rbdGXRLKRUgXEmC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4rbdGXRLKRUgXEmC .marker.cross{stroke:#333333;}#mermaid-svg-4rbdGXRLKRUgXEmC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4rbdGXRLKRUgXEmC p{margin:0;}#mermaid-svg-4rbdGXRLKRUgXEmC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4rbdGXRLKRUgXEmC .cluster-label text{fill:#333;}#mermaid-svg-4rbdGXRLKRUgXEmC .cluster-label span{color:#333;}#mermaid-svg-4rbdGXRLKRUgXEmC .cluster-label span p{background-color:transparent;}#mermaid-svg-4rbdGXRLKRUgXEmC .label text,#mermaid-svg-4rbdGXRLKRUgXEmC span{fill:#333;color:#333;}#mermaid-svg-4rbdGXRLKRUgXEmC .node rect,#mermaid-svg-4rbdGXRLKRUgXEmC .node circle,#mermaid-svg-4rbdGXRLKRUgXEmC .node ellipse,#mermaid-svg-4rbdGXRLKRUgXEmC .node polygon,#mermaid-svg-4rbdGXRLKRUgXEmC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4rbdGXRLKRUgXEmC .rough-node .label text,#mermaid-svg-4rbdGXRLKRUgXEmC .node .label text,#mermaid-svg-4rbdGXRLKRUgXEmC .image-shape .label,#mermaid-svg-4rbdGXRLKRUgXEmC .icon-shape .label{text-anchor:middle;}#mermaid-svg-4rbdGXRLKRUgXEmC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4rbdGXRLKRUgXEmC .rough-node .label,#mermaid-svg-4rbdGXRLKRUgXEmC .node .label,#mermaid-svg-4rbdGXRLKRUgXEmC .image-shape .label,#mermaid-svg-4rbdGXRLKRUgXEmC .icon-shape .label{text-align:center;}#mermaid-svg-4rbdGXRLKRUgXEmC .node.clickable{cursor:pointer;}#mermaid-svg-4rbdGXRLKRUgXEmC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4rbdGXRLKRUgXEmC .arrowheadPath{fill:#333333;}#mermaid-svg-4rbdGXRLKRUgXEmC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4rbdGXRLKRUgXEmC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4rbdGXRLKRUgXEmC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4rbdGXRLKRUgXEmC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4rbdGXRLKRUgXEmC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4rbdGXRLKRUgXEmC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4rbdGXRLKRUgXEmC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4rbdGXRLKRUgXEmC .cluster text{fill:#333;}#mermaid-svg-4rbdGXRLKRUgXEmC .cluster span{color:#333;}#mermaid-svg-4rbdGXRLKRUgXEmC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4rbdGXRLKRUgXEmC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4rbdGXRLKRUgXEmC rect.text{fill:none;stroke-width:0;}#mermaid-svg-4rbdGXRLKRUgXEmC .icon-shape,#mermaid-svg-4rbdGXRLKRUgXEmC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4rbdGXRLKRUgXEmC .icon-shape p,#mermaid-svg-4rbdGXRLKRUgXEmC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4rbdGXRLKRUgXEmC .icon-shape .label rect,#mermaid-svg-4rbdGXRLKRUgXEmC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4rbdGXRLKRUgXEmC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4rbdGXRLKRUgXEmC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4rbdGXRLKRUgXEmC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 启用分区智能连接
orders_1
JOIN
order_items_1
orders_2
JOIN
order_items_2
UNION ALL
结果
未启用分区智能连接
orders 分区表
全局 JOIN
order_items 分区表
结果
启用条件
分区智能连接并非在所有场景下都能生效,需满足以下条件:
- 两个分区表的分区策略必须完全一致(同为 Range 分区、List 分区或 Hash 分区)
- 分区键的数据类型必须一一对应
- 分区键必须作为 JOIN 条件的一部分
- 分区的边界必须能够一一匹配
代价与收益
| 维度 | 未启用 | 启用后 |
|---|---|---|
| 数据扫描量 | 全量数据 | 仅匹配分区对的数据 |
| CPU 消耗 | 较低 | 较高(分区对间的 JOIN 调度开销) |
| 内存消耗 | 较低 | 较高(多个并行 JOIN 工作区) |
| 适用场景 | 小数据集或分区数较少 | 大数据集且分区数较多 |
在分区数较多(如数百个)且数据量巨大的场景下,启用分区智能连接可以显著降低 JOIN 的数据扫描量,但会带来额外的 CPU 和内存开销。建议在测试环境中通过 EXPLAIN ANALYZE 对比开启前后的执行计划与性能数据,再决定是否在生产环境中启用。
启用示例
sql
-- 会话级别启用
SET enable_partitionwise_join = on;
SET enable_partitionwise_aggregate = on;
-- 执行 JOIN 查询
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.order_id, o.order_date, SUM(i.quantity * i.unit_price)
FROM orders o
JOIN order_items i ON o.order_id = i.order_id AND o.order_date = i.order_date
GROUP BY o.order_id, o.order_date;
分区表的使用时机
官方建议
PostgreSQL 官方文档建议:当表的大小超过物理内存大小时,即可考虑使用分区表。这是因为当表无法全部缓存在内存中时,分区可以将数据按维度(如时间)分割,使查询仅扫描相关分区,从而减少 I/O 开销。
量化参考
以下为经验参考值,可根据实际硬件配置调整:
| 指标 | 参考阈值 | 说明 |
|---|---|---|
| 记录数 | > 20 亿 | 单表记录数过多时,索引维护和 VACUUM 成本剧增 |
| 表空间 | > 200 GB | 超过物理内存大小,全表扫描代价高 |
| 更新频率 | 频繁更新大表 | 分区可减少 VACUUM 的扫描范围 |
| 数据保留策略 | 需定期清理历史数据 | 分区可通过 DROP 实现快速数据淘汰 |
反模式:何时不应使用分区
分区表示非银弹,以下场景应避免使用:
- 小表分区:仅一百条记录却划分十个分区,每个分区仅十条数据。优化器需要额外处理分区路由逻辑,反而增加查询规划时间,得不偿失。
- 分区策略不当:如按哈希分区但查询条件从不包含分区键,导致每次查询都扫描全部分区。
- 过度分区:分区数量过多(如数千个)时,查询规划时间可能显著增加。PostgreSQL 12 虽对此有大幅优化,但仍需谨慎评估。
分区插件生态
在 PostgreSQL 原生分区功能成熟之前,社区曾涌现多款分区管理插件:
| 插件 | 适用版本 | 特点 |
|---|---|---|
pg_pathman |
PG 9.5 - PG 12 | 纯 SQL 脚本,运维友好,性能优异 |
pg_partman |
PG 9.4+ | 功能丰富,支持自动创建和清理分区 |
pg_pitr |
--- | 在线转换普通表为分区表 |
原生分区的演进:
- PG 10:正式引入声明式分区(Declarative Partitioning)
- PG 11 :支持 Hash 分区、
UPDATE分区键自动跨分区移动 - PG 12 :分区性能大幅提升,与
pg_pathman性能持平,支持外键引用分区表 - PG 14 :引入
DETACH PARTITION CONCURRENTLY,支持在线卸载
在 PG 12 之前,建议优先考虑 pg_pathman;PG 12 及以后版本,原生分区已足够成熟,可直接使用。
API 速览
CREATE TABLE ... PARTITION OF
所属:PostgreSQL DDL
语法:
sql
CREATE TABLE partition_name PARTITION OF parent_table
{ FOR VALUES partition_bound_spec | DEFAULT }
[ PARTITION BY { RANGE | LIST | HASH } ( column_name [, ...] ) ]
[ WITH ( storage_parameter = value [, ... ] ) ]
[ TABLESPACE tablespace_name ];
参数说明:
FOR VALUES:定义分区的值范围(Range/List)或模数余数(Hash)DEFAULT:创建默认分区PARTITION BY:允许分区表本身作为父表进行二级分区
锁级别 :父表上获取 ACCESS EXCLUSIVE 锁
示例:
sql
CREATE TABLE sales_2026_q1 PARTITION OF sales
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
ALTER TABLE ... ATTACH PARTITION
所属:PostgreSQL DDL
语法:
sql
ALTER TABLE parent_table ATTACH PARTITION partition_name
{ FOR VALUES partition_bound_spec | DEFAULT };
锁级别:
- PG 11 及以下:
ACCESS EXCLUSIVE - PG 12 及以上:
SHARE UPDATE EXCLUSIVE
示例:
sql
ALTER TABLE sales ATTACH PARTITION sales_2026_q1
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
ALTER TABLE ... DETACH PARTITION
所属:PostgreSQL DDL
语法:
sql
ALTER TABLE parent_table DETACH PARTITION partition_name
[ CONCURRENTLY | FINALIZE ];
锁级别:
- 不带
CONCURRENTLY:ACCESS EXCLUSIVE - 带
CONCURRENTLY(PG 14+):SHARE UPDATE EXCLUSIVE
示例:
sql
-- 普通卸载(八级锁)
ALTER TABLE sales DETACH PARTITION sales_2026_q1;
-- 并发卸载(PG 14+,共享更新排他锁)
ALTER TABLE sales DETACH PARTITION sales_2026_q1 CONCURRENTLY;
SET enable_partitionwise_join
所属:PostgreSQL 运行时配置
类型 :boolean
默认值 :off
作用域 :session / global
描述:启用或禁用查询规划器使用分区智能连接
示例:
sql
SET enable_partitionwise_join = on;
SET enable_partitionwise_aggregate
所属:PostgreSQL 运行时配置
类型 :boolean
默认值 :off
作用域 :session / global
描述:启用或禁用查询规划器使用分区智能聚合
示例:
sql
SET enable_partitionwise_aggregate = on;
Demo 简单示例
以下使用 Node.js 和 pg 驱动演示分区表的创建、新增分区(含锁级别对比)与并发卸载。
运行说明
环境要求:
- Node.js 16+
- PostgreSQL 14+(建议 14 以体验
CONCURRENTLY特性) pgnpm 包
安装依赖:
bash
npm init -y
npm install pg
启动:
bash
node partition_demo.js
代码说明
js
const { Client } = require('pg');
const client = new Client({
host: 'localhost',
port: 5432,
database: 'testdb',
user: 'postgres',
password: 'postgres'
});
async function runDemo() {
await client.connect();
try {
// 1. 创建分区表
await client.query(`
DROP TABLE IF EXISTS sales CASCADE;
CREATE TABLE sales (
id SERIAL,
sale_date DATE NOT NULL,
amount NUMERIC(10,2)
) PARTITION BY RANGE (sale_date);
`);
console.log('✅ 分区表 sales 创建成功');
// 2. 创建具体分区(一月、二月)
await client.query(`
CREATE TABLE sales_2026_01 PARTITION OF sales
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE sales_2026_02 PARTITION OF sales
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
`);
console.log('✅ 一月、二月分区创建成功');
// 3. 创建默认分区
await client.query(`
CREATE TABLE sales_default PARTITION OF sales DEFAULT;
`);
console.log('✅ 默认分区创建成功');
// 4. 插入测试数据
await client.query(`
INSERT INTO sales (sale_date, amount) VALUES
('2026-01-15', 100.50),
('2026-02-15', 200.75),
('2026-12-15', 999.99); -- 落入默认分区
`);
console.log('✅ 测试数据插入成功');
// 5. 查询各分区数据分布
const distResult = await client.query(`
SELECT 'sales_2026_01' as partition_name, COUNT(*) as row_count FROM sales_2026_01
UNION ALL
SELECT 'sales_2026_02', COUNT(*) FROM sales_2026_02
UNION ALL
SELECT 'sales_default', COUNT(*) FROM sales_default
`);
console.log('📊 数据分布:');
distResult.rows.forEach(row => {
console.log(` ${row.partition_name}: ${row.row_count} 行`);
});
// 6. 新增三月分区 ------ 使用 CREATE TABLE + ATTACH(PG 12+ 仅 ShareUpdateExclusiveLock)
await client.query(`
CREATE TABLE sales_2026_03 (LIKE sales INCLUDING ALL);
`);
await client.query(`
ALTER TABLE sales ATTACH PARTITION sales_2026_03
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
`);
console.log('✅ 三月分区通过 CREATE + ATTACH 方式创建成功(ShareUpdateExclusiveLock)');
// 7. 演示 DETACH CONCURRENTLY(PG 14+)
// 先插入一条三月数据
await client.query(`
INSERT INTO sales (sale_date, amount) VALUES ('2026-03-15', 300.25);
`);
// 并发卸载(PG 14+)
await client.query(`
ALTER TABLE sales DETACH PARTITION sales_2026_03 CONCURRENTLY;
`);
console.log('✅ 三月分区通过 DETACH CONCURRENTLY 卸载成功(ShareUpdateExclusiveLock)');
// 8. 验证卸载后数据仍保留在子表中
const checkResult = await client.query(`
SELECT COUNT(*) as cnt FROM sales_2026_03;
`);
console.log(`📊 卸载后 sales_2026_03 表中仍有 ${checkResult.rows[0].cnt} 行数据`);
// 9. 清理
await client.query(`DROP TABLE sales_2026_03;`);
console.log('✅ 清理完成');
} catch (err) {
console.error('❌ 错误:', err.message);
} finally {
await client.end();
}
}
runDemo();
PostgreSQL 原生指令注释:
sql
-- 等价的 PostgreSQL 原生操作
-- 创建分区表
CREATE TABLE sales (
id SERIAL,
sale_date DATE NOT NULL,
amount NUMERIC(10,2)
) PARTITION BY RANGE (sale_date);
-- 创建分区
CREATE TABLE sales_2026_01 PARTITION OF sales
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE sales_2026_02 PARTITION OF sales
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- 创建默认分区
CREATE TABLE sales_default PARTITION OF sales DEFAULT;
-- 插入数据
INSERT INTO sales (sale_date, amount) VALUES
('2026-01-15', 100.50),
('2026-02-15', 200.75),
('2026-12-15', 999.99);
-- 新增分区(推荐方式)
CREATE TABLE sales_2026_03 (LIKE sales INCLUDING ALL);
ALTER TABLE sales ATTACH PARTITION sales_2026_03
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
-- 并发卸载(PG 14+)
ALTER TABLE sales DETACH PARTITION sales_2026_03 CONCURRENTLY;
-- 删除子表
DROP TABLE sales_2026_03;
技术点总结
该 Demo 演示了以下核心技术:
- 声明式分区表的创建 :
PARTITION BY RANGE语法 - 默认分区的创建与行为:不匹配任何分区规则的行落入默认分区
- 分区新增的最佳实践 :
CREATE TABLE ... LIKE+ATTACH PARTITION方式,避免八级锁 - 分区卸载的并发方式 :PG 14 的
DETACH PARTITION CONCURRENTLY DETACH与DROP的区别 :DETACH保留数据,DROP彻底删除
多语言示例
以下分别使用 Go、Python 和 Java 实现与 Node.js 示例相同的分区表维护场景,涵盖默认分区、ATTACH PARTITION 与 DETACH PARTITION CONCURRENTLY 的核心操作。每种语言均提供完整的可运行代码及关键说明。
Go
运行说明
-
环境要求:Go 1.21+,PostgreSQL 14+
-
依赖驱动:
github.com/lib/pq(纯 Go PostgreSQL 驱动) -
安装依赖:
bashgo mod init partition_demo go get github.com/lib/pq -
启动:将数据库连接信息填入
main.go后执行go run main.go
代码说明
使用 database/sql 标准库配合 lib/pq 驱动,通过 Exec 和 Query 执行原生 SQL。事务控制采用 Begin / Commit 确保原子性。代码结构与 Node.js 版本一致,依次执行创建分区表、创建子分区、创建默认分区、插入测试数据、新增分区(CREATE TABLE + ATTACH)和并发卸载(DETACH CONCURRENTLY)。
代码
go
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/lib/pq"
)
func main() {
// 连接字符串:host=localhost port=5432 user=postgres password=postgres dbname=testdb sslmode=disable
connStr := "host=localhost port=5432 user=postgres password=postgres dbname=testdb sslmode=disable"
db, err := sql.Open("postgres", connStr)
if err != nil {
log.Fatal("连接失败:", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatal("Ping 失败:", err)
}
// 1. 创建分区表
_, err = db.Exec(`
DROP TABLE IF EXISTS sales CASCADE;
CREATE TABLE sales (
id SERIAL,
sale_date DATE NOT NULL,
amount NUMERIC(10,2)
) PARTITION BY RANGE (sale_date);
`)
if err != nil {
log.Fatal("创建分区表失败:", err)
}
fmt.Println("✅ 分区表 sales 创建成功")
// 2. 创建一月、二月分区
_, err = db.Exec(`
CREATE TABLE sales_2026_01 PARTITION OF sales
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE sales_2026_02 PARTITION OF sales
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
`)
if err != nil {
log.Fatal("创建具体分区失败:", err)
}
fmt.Println("✅ 一月、二月分区创建成功")
// 3. 创建默认分区
_, err = db.Exec(`
CREATE TABLE sales_default PARTITION OF sales DEFAULT;
`)
if err != nil {
log.Fatal("创建默认分区失败:", err)
}
fmt.Println("✅ 默认分区创建成功")
// 4. 插入测试数据
_, err = db.Exec(`
INSERT INTO sales (sale_date, amount) VALUES
('2026-01-15', 100.50),
('2026-02-15', 200.75),
('2026-12-15', 999.99);
`)
if err != nil {
log.Fatal("插入数据失败:", err)
}
fmt.Println("✅ 测试数据插入成功")
// 5. 查询各分区数据分布
rows, err := db.Query(`
SELECT 'sales_2026_01' as partition_name, COUNT(*) as row_count FROM sales_2026_01
UNION ALL
SELECT 'sales_2026_02', COUNT(*) FROM sales_2026_02
UNION ALL
SELECT 'sales_default', COUNT(*) FROM sales_default
`)
if err != nil {
log.Fatal("查询分布失败:", err)
}
defer rows.Close()
fmt.Println("📊 数据分布:")
for rows.Next() {
var name string
var count int
rows.Scan(&name, &count)
fmt.Printf(" %s: %d 行\n", name, count)
}
// 6. 新增三月分区(CREATE TABLE + ATTACH)
_, err = db.Exec(`
CREATE TABLE sales_2026_03 (LIKE sales INCLUDING ALL);
ALTER TABLE sales ATTACH PARTITION sales_2026_03
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
`)
if err != nil {
log.Fatal("新增三月分区失败:", err)
}
fmt.Println("✅ 三月分区通过 CREATE + ATTACH 创建成功 (ShareUpdateExclusiveLock)")
// 7. 插入一条三月数据并演示 DETACH CONCURRENTLY (PG14+)
_, err = db.Exec(`INSERT INTO sales (sale_date, amount) VALUES ('2026-03-15', 300.25);`)
if err != nil {
log.Fatal("插入三月数据失败:", err)
}
_, err = db.Exec(`ALTER TABLE sales DETACH PARTITION sales_2026_03 CONCURRENTLY;`)
if err != nil {
log.Fatal("并发卸载失败:", err)
}
fmt.Println("✅ 三月分区通过 DETACH CONCURRENTLY 卸载成功 (ShareUpdateExclusiveLock)")
// 8. 验证卸载后子表数据仍保留
var cnt int
err = db.QueryRow(`SELECT COUNT(*) FROM sales_2026_03;`).Scan(&cnt)
if err != nil {
log.Fatal("查询子表失败:", err)
}
fmt.Printf("📊 卸载后 sales_2026_03 表中仍有 %d 行数据\n", cnt)
// 9. 清理
_, err = db.Exec(`DROP TABLE sales_2026_03;`)
if err != nil {
log.Fatal("清理失败:", err)
}
fmt.Println("✅ 清理完成")
}
技术点总结
- 使用
database/sql标准接口,无需 ORM,SQL 直观可控 - 事务由
Exec自动提交,若需事务可显式使用Begin/Commit - 通过
QueryRow获取单行结果,Query遍历多行 - 锁级别验证(如需要)可通过查询
pg_locks实现,与 Node.js 一致
Python
运行说明
-
环境要求:Python 3.9+,PostgreSQL 14+
-
依赖驱动:
psycopg2-binary(或psycopg2) -
安装依赖:
bashpip install psycopg2-binary -
启动:修改连接参数后执行
python partition_demo.py
代码说明
使用 psycopg2 连接 PostgreSQL,通过 cursor.execute() 执行 SQL。使用 with 上下文管理自动提交或回滚。代码顺序与 Node.js 相同,所有 SQL 语句均以字符串形式传递,参数化查询用于插入数据(安全防注入)。fetchall() 获取结果集。
代码
python
import psycopg2
import psycopg2.extras
def main():
conn = psycopg2.connect(
host="localhost",
port=5432,
database="testdb",
user="postgres",
password="postgres"
)
conn.autocommit = True # 简化示例,每条SQL自动提交
cur = conn.cursor()
try:
# 1. 创建分区表
cur.execute("""
DROP TABLE IF EXISTS sales CASCADE;
CREATE TABLE sales (
id SERIAL,
sale_date DATE NOT NULL,
amount NUMERIC(10,2)
) PARTITION BY RANGE (sale_date);
""")
print("✅ 分区表 sales 创建成功")
# 2. 创建一月、二月分区
cur.execute("""
CREATE TABLE sales_2026_01 PARTITION OF sales
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE sales_2026_02 PARTITION OF sales
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
""")
print("✅ 一月、二月分区创建成功")
# 3. 默认分区
cur.execute("""
CREATE TABLE sales_default PARTITION OF sales DEFAULT;
""")
print("✅ 默认分区创建成功")
# 4. 插入测试数据(使用参数化查询)
data = [('2026-01-15', 100.50), ('2026-02-15', 200.75), ('2026-12-15', 999.99)]
for dt, amt in data:
cur.execute("INSERT INTO sales (sale_date, amount) VALUES (%s, %s);", (dt, amt))
print("✅ 测试数据插入成功")
# 5. 查询分布
cur.execute("""
SELECT 'sales_2026_01' as partition_name, COUNT(*) as row_count FROM sales_2026_01
UNION ALL
SELECT 'sales_2026_02', COUNT(*) FROM sales_2026_02
UNION ALL
SELECT 'sales_default', COUNT(*) FROM sales_default
""")
rows = cur.fetchall()
print("📊 数据分布:")
for name, count in rows:
print(f" {name}: {count} 行")
# 6. 新增三月分区(CREATE + ATTACH)
cur.execute("""
CREATE TABLE sales_2026_03 (LIKE sales INCLUDING ALL);
ALTER TABLE sales ATTACH PARTITION sales_2026_03
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
""")
print("✅ 三月分区通过 CREATE + ATTACH 创建成功 (ShareUpdateExclusiveLock)")
# 7. 插入三月数据并并发卸载
cur.execute("INSERT INTO sales (sale_date, amount) VALUES ('2026-03-15', 300.25);")
cur.execute("ALTER TABLE sales DETACH PARTITION sales_2026_03 CONCURRENTLY;")
print("✅ 三月分区通过 DETACH CONCURRENTLY 卸载成功 (ShareUpdateExclusiveLock)")
# 8. 验证保留数据
cur.execute("SELECT COUNT(*) FROM sales_2026_03;")
cnt = cur.fetchone()[0]
print(f"📊 卸载后 sales_2026_03 表中仍有 {cnt} 行数据")
# 9. 清理
cur.execute("DROP TABLE sales_2026_03;")
print("✅ 清理完成")
except Exception as e:
print("❌ 错误:", e)
finally:
cur.close()
conn.close()
if __name__ == "__main__":
main()
技术点总结
- 使用
psycopg2的autocommit=True简化事务控制,实际生产可按需使用conn.commit() - 参数化查询(
%s占位符)防止 SQL 注入 cursor.fetchall()和fetchone()方便获取结果- 支持
CONCURRENTLY语法,需确保 PostgreSQL 版本 ≥ 14
Java
运行说明
- 环境要求:JDK 17+,PostgreSQL 14+
- 依赖驱动:
org.postgresql:postgresql:42.7.3(Maven/Gradle) - 构建工具:Maven 或 Gradle,以下示例使用 Maven 项目结构
- 启动:编译后执行
main方法
代码说明
使用 JDBC 原生 API,加载 org.postgresql.Driver。通过 DriverManager.getConnection 获取连接,使用 Statement 和 PreparedStatement 执行 SQL。为简洁采用 try-with-resources 自动释放资源。逻辑顺序与其他语言一致,注意 Java 中 executeUpdate 用于 DDL/DML,executeQuery 用于查询。
代码
java
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class PartitionDemo {
public static void main(String[] args) {
String url = "jdbc:postgresql://localhost:5432/testdb";
String user = "postgres";
String password = "postgres";
try (Connection conn = DriverManager.getConnection(url, user, password)) {
// 设置自动提交,简化示例
conn.setAutoCommit(true);
// 1. 创建分区表
try (Statement stmt = conn.createStatement()) {
stmt.executeUpdate("""
DROP TABLE IF EXISTS sales CASCADE;
CREATE TABLE sales (
id SERIAL,
sale_date DATE NOT NULL,
amount NUMERIC(10,2)
) PARTITION BY RANGE (sale_date);
""");
System.out.println("✅ 分区表 sales 创建成功");
}
// 2. 创建一月、二月分区
try (Statement stmt = conn.createStatement()) {
stmt.executeUpdate("""
CREATE TABLE sales_2026_01 PARTITION OF sales
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE sales_2026_02 PARTITION OF sales
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
""");
System.out.println("✅ 一月、二月分区创建成功");
}
// 3. 默认分区
try (Statement stmt = conn.createStatement()) {
stmt.executeUpdate("CREATE TABLE sales_default PARTITION OF sales DEFAULT;");
System.out.println("✅ 默认分区创建成功");
}
// 4. 插入测试数据(PreparedStatement)
String insertSql = "INSERT INTO sales (sale_date, amount) VALUES (?, ?)";
Object[][] data = {{"2026-01-15", 100.50}, {"2026-02-15", 200.75}, {"2026-12-15", 999.99}};
try (PreparedStatement pstmt = conn.prepareStatement(insertSql)) {
for (Object[] row : data) {
pstmt.setObject(1, row[0]);
pstmt.setObject(2, row[1]);
pstmt.addBatch();
}
pstmt.executeBatch();
System.out.println("✅ 测试数据插入成功");
}
// 5. 查询分布
String query = """
SELECT 'sales_2026_01' as partition_name, COUNT(*) as row_count FROM sales_2026_01
UNION ALL
SELECT 'sales_2026_02', COUNT(*) FROM sales_2026_02
UNION ALL
SELECT 'sales_default', COUNT(*) FROM sales_default
""";
try (Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(query)) {
System.out.println("📊 数据分布:");
while (rs.next()) {
String name = rs.getString("partition_name");
int count = rs.getInt("row_count");
System.out.printf(" %s: %d 行%n", name, count);
}
}
// 6. 新增三月分区(CREATE + ATTACH)
try (Statement stmt = conn.createStatement()) {
stmt.executeUpdate("""
CREATE TABLE sales_2026_03 (LIKE sales INCLUDING ALL);
ALTER TABLE sales ATTACH PARTITION sales_2026_03
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
""");
System.out.println("✅ 三月分区通过 CREATE + ATTACH 创建成功 (ShareUpdateExclusiveLock)");
}
// 7. 插入三月数据并并发卸载
try (PreparedStatement pstmt = conn.prepareStatement("INSERT INTO sales (sale_date, amount) VALUES (?, ?)")) {
pstmt.setObject(1, "2026-03-15");
pstmt.setObject(2, 300.25);
pstmt.executeUpdate();
}
try (Statement stmt = conn.createStatement()) {
stmt.executeUpdate("ALTER TABLE sales DETACH PARTITION sales_2026_03 CONCURRENTLY;");
System.out.println("✅ 三月分区通过 DETACH CONCURRENTLY 卸载成功 (ShareUpdateExclusiveLock)");
}
// 8. 验证保留数据
try (Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM sales_2026_03;")) {
if (rs.next()) {
int cnt = rs.getInt(1);
System.out.printf("📊 卸载后 sales_2026_03 表中仍有 %d 行数据%n", cnt);
}
}
// 9. 清理
try (Statement stmt = conn.createStatement()) {
stmt.executeUpdate("DROP TABLE sales_2026_03;");
System.out.println("✅ 清理完成");
}
} catch (SQLException e) {
System.err.println("❌ 错误: " + e.getMessage());
e.printStackTrace();
}
}
}
技术点总结
- 使用 JDBC 标准接口,驱动为
postgresql-42.x.x.jar - 通过
Statement执行 DDL,通过PreparedStatement执行参数化 DML 和批处理 try-with-resources自动关闭资源,防止连接泄漏- 原生支持
CONCURRENTLY语法,需 PG 14+ - 可通过
conn.setAutoCommit(true)简化,生产环境建议显式事务控制
多语言对比
| 维度 | Node.js (pg) | Go (database/sql + lib/pq) | Python (psycopg2) | Java (JDBC) |
|---|---|---|---|---|
| 驱动/ORM | pg 驱动 |
lib/pq |
psycopg2-binary |
postgresql-42.x.x |
| 连接方式 | new Client() |
sql.Open() |
psycopg2.connect() |
DriverManager.getConnection() |
| 执行 DDL | client.query() |
db.Exec() |
cur.execute() |
stmt.executeUpdate() |
| 参数化查询 | 模板字符串($1) |
? 占位符(lib/pq 使用 $1) |
%s 占位符 |
? 占位符(JDBC) |
| 批处理 | 数组 + Promise.all |
不支持原生批处理(可循环) | executemany() |
addBatch() + executeBatch() |
| 事务控制 | BEGIN / COMMIT 或 client.query('BEGIN') |
db.Begin() |
conn.autocommit=False + conn.commit() |
conn.setAutoCommit(false) + conn.commit() |
| 锁级别验证 | 查询 pg_locks |
同 SQL | 同 SQL | 同 SQL |
ATTACH 锁降级 (PG12+) |
支持 | 支持 | 支持 | 支持 |
DETACH CONCURRENTLY (PG14+) |
支持 | 支持 | 支持 | 支持 |
| 错误处理 | try/catch | error 返回值 | try/except | try/catch |
| 资源管理 | 手动 client.end() |
defer db.Close() |
with 或 finally |
try-with-resources |
所有语言的实现均围绕相同的 SQL 操作展开,核心差异仅在于各自生态的 API 风格和连接管理方式。在生产环境中,可根据团队技术栈选择最适合的语言,分区表维护的 SQL 逻辑在各语言间完全通用。
官方文档
- PostgreSQL CREATE TABLE --- 分区表语法
- PostgreSQL ALTER TABLE --- ATTACH PARTITION / DETACH PARTITION
- PostgreSQL 表分区 --- 声明式分区
- PostgreSQL 运行时配置 --- enable_partitionwise_join
- PostgreSQL 运行时配置 --- enable_partitionwise_aggregate
参考链接
- PostgreSQL 分区表官方 Wiki
- pg_partman 官方文档
- pg_pathman 官方文档
- PostgreSQL 14 DETACH PARTITION CONCURRENTLY 新特性讨论
总结
本文系统梳理了 PostgreSQL 分区表维护的三大核心议题:默认分区的设计与风险 ------虽能捕获不匹配任何规则的行,但新增分区时的冲突检测需全表扫描,建议定期巡检并控制其大小;分区新增与卸载的锁策略 ------CREATE TABLE ... PARTITION OF 持有八级锁,生产环境应改用 CREATE TABLE + ATTACH PARTITION(PG 12+ 降级为 SHARE UPDATE EXCLUSIVE),PG 14 进一步支持 DETACH PARTITION CONCURRENTLY 实现在线卸载;分区智能连接与聚合 ------通过 enable_partitionwise_join 和 enable_partitionwise_aggregate 参数启用,可减少 JOIN 数据扫描量,但需满足分区策略一致、分区键作为连接条件等前提,默认 off 按需开启。
此外,本文还提供了分区表使用时机的量化参考(记录数 > 20 亿或表空间 > 200 GB)以及 pg_pathman、pg_partman 等插件生态的演进脉络。PG 12 之后原生分区性能已大幅提升,建议优先使用原生方案。