PostgreSQL笔记62: 分区表维护最佳实践——默认分区、锁策略与性能调优

纲要

本文围绕 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'),即不满足任何已定义分区范围的行。

默认分区维护策略

针对默认分区的维护,建议采取以下策略:

  1. 定期巡检:通过监控默认分区的数据量,设置告警阈值(如超过 10 GB 即触发告警)。
  2. 数据迁移 :在新增分区前,先将默认分区中属于新分区范围的数据导出备份,清理后再执行 ATTACH
  3. 避免在生产环境长期保留大容量默认分区:默认分区应视为临时缓冲,而非长期存储区域。

触发器路由方案

一种替代默认分区的方案是使用触发器(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 锁。该锁级别与 VACUUMANALYZE 相同,允许并发的读写操作,仅阻塞 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:默认 off
  • enable_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 分区表
结果

启用条件

分区智能连接并非在所有场景下都能生效,需满足以下条件:

  1. 两个分区表的分区策略必须完全一致(同为 Range 分区、List 分区或 Hash 分区)
  2. 分区键的数据类型必须一一对应
  3. 分区键必须作为 JOIN 条件的一部分
  4. 分区的边界必须能够一一匹配

代价与收益

维度 未启用 启用后
数据扫描量 全量数据 仅匹配分区对的数据
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 实现快速数据淘汰

反模式:何时不应使用分区

分区表示非银弹,以下场景应避免使用:

  1. 小表分区:仅一百条记录却划分十个分区,每个分区仅十条数据。优化器需要额外处理分区路由逻辑,反而增加查询规划时间,得不偿失。
  2. 分区策略不当:如按哈希分区但查询条件从不包含分区键,导致每次查询都扫描全部分区。
  3. 过度分区:分区数量过多(如数千个)时,查询规划时间可能显著增加。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 ];

锁级别

  • 不带 CONCURRENTLYACCESS 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 特性)
  • pg npm 包

安装依赖

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 演示了以下核心技术:

  1. 声明式分区表的创建PARTITION BY RANGE 语法
  2. 默认分区的创建与行为:不匹配任何分区规则的行落入默认分区
  3. 分区新增的最佳实践CREATE TABLE ... LIKE + ATTACH PARTITION 方式,避免八级锁
  4. 分区卸载的并发方式 :PG 14 的 DETACH PARTITION CONCURRENTLY
  5. DETACHDROP 的区别DETACH 保留数据,DROP 彻底删除

多语言示例

以下分别使用 Go、Python 和 Java 实现与 Node.js 示例相同的分区表维护场景,涵盖默认分区、ATTACH PARTITIONDETACH PARTITION CONCURRENTLY 的核心操作。每种语言均提供完整的可运行代码及关键说明。

Go

运行说明

  • 环境要求:Go 1.21+,PostgreSQL 14+

  • 依赖驱动:github.com/lib/pq(纯 Go PostgreSQL 驱动)

  • 安装依赖:

    bash 复制代码
    go mod init partition_demo
    go get github.com/lib/pq
  • 启动:将数据库连接信息填入 main.go 后执行 go run main.go

代码说明

使用 database/sql 标准库配合 lib/pq 驱动,通过 ExecQuery 执行原生 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

  • 安装依赖:

    bash 复制代码
    pip 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()

技术点总结

  • 使用 psycopg2autocommit=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 获取连接,使用 StatementPreparedStatement 执行 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 / COMMITclient.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() withfinally try-with-resources

所有语言的实现均围绕相同的 SQL 操作展开,核心差异仅在于各自生态的 API 风格和连接管理方式。在生产环境中,可根据团队技术栈选择最适合的语言,分区表维护的 SQL 逻辑在各语言间完全通用。

官方文档

参考链接

总结

本文系统梳理了 PostgreSQL 分区表维护的三大核心议题:默认分区的设计与风险 ------虽能捕获不匹配任何规则的行,但新增分区时的冲突检测需全表扫描,建议定期巡检并控制其大小;分区新增与卸载的锁策略 ------CREATE TABLE ... PARTITION OF 持有八级锁,生产环境应改用 CREATE TABLE + ATTACH PARTITION(PG 12+ 降级为 SHARE UPDATE EXCLUSIVE),PG 14 进一步支持 DETACH PARTITION CONCURRENTLY 实现在线卸载;分区智能连接与聚合 ------通过 enable_partitionwise_joinenable_partitionwise_aggregate 参数启用,可减少 JOIN 数据扫描量,但需满足分区策略一致、分区键作为连接条件等前提,默认 off 按需开启。

此外,本文还提供了分区表使用时机的量化参考(记录数 > 20 亿或表空间 > 200 GB)以及 pg_pathmanpg_partman 等插件生态的演进脉络。PG 12 之后原生分区性能已大幅提升,建议优先使用原生方案。

相关推荐
熊出没1 小时前
解密数仓中的ODS、DWD、DWS、ADS
数据库·数据仓库
梦想不只是梦与想1 小时前
MySQL 不同操作系统的安装方式
数据库·mysql·mysql安装
熊文豪1 小时前
向量数据库单独建一套,这笔账划不划算
数据库
一只旭宝1 小时前
预约系统版本2(基于第一版改良)
服务器·数据库·c++
卓怡学长1 小时前
w192基于springboot教务管理系统
java·数据库·spring boot·spring·maven·intellij-idea
码农颜1 小时前
6.4.1 监听连接
java·数据库·mysql
桐桐桐1 小时前
Python 实战:批量生成带来源参数的 WhatsApp 短链 + 二维码
服务器·数据库·python·前端框架·ip·跨境电商·独立站
stereohomology2 小时前
AI解读:某IM软件本地数据库密钥提取与解密
数据库·攻防·加密解密·数字平权
zzzll11112 小时前
大模型技术原理与应用实践
java·数据库·人工智能