PostgreSQL 表分区策略与维护

PostgreSQL 声明式分区(Declarative Partitioning)全解析:RANGE / LIST / HASH 三大策略选型、分区裁剪(Partition Pruning)原理与 EXPLAIN 验证、DETACH/ATTACH 在线维护、自动分区与 pg_partman、索引与唯一约束的分区限制、分区数量对计划时间的影响,含生产迁移路径与常见坑。

当一张表的数据量突破数千万行,删除历史数据、维护索引、执行 VACUUM 的成本都会随之线性上升。DELETE FROM logs WHERE created_at < now() - interval '90 days' 会留下大量死元组(dead tuple),把清理压力转嫁给 autovacuum;一次全表 REINDEX 可能锁住整张表数分钟。分区(Partitioning) 的核心价值就在于把「一张巨型表」拆成「一组可独立管理的小表」,让删除历史变成 DROP TABLE,让维护只作用于热分区。

PostgreSQL 从 10 开始提供声明式分区(Declarative Partitioning),把分区能力内建到 SQL 语法里,取代了早期基于继承(Inheritance)+ 触发器 + CHECK 约束的手工方案。手工方案需要为父表写 BEFORE INSERT 触发器把行重定向到子表,查询父表又要靠 constraint_exclusion 做裁剪,既慢又易错。声明式分区把这些逻辑下沉到优化器与执行器,裁剪更快、路由更可靠。

本文覆盖三种分区策略的选型依据、分区裁剪的执行计划验证、在线维护手法,以及生产环境中最容易踩的约束与索引限制。

一、分区解决什么问题

分区不是「性能银弹」,它只解决特定形态的问题。先判断你的场景是否属于以下三类:

  1. 生命周期管理:日志、订单、事件流这类按时间滚动的数据,需要定期清理过期数据。分区后 DROP TABLE logs_2026_01 是 O(1) 元数据操作,而 DELETE 是 O(n) 且产生大量 WAL 与死元组。
  2. 热点数据局部化:90% 的查询只命中最近 7 天,把热分区放进更快的存储或让它保持更小的体积,可以显著降低索引高度与缓冲区压力。
  3. 批量加载与维护窗口:新数据可以并行写入独立分区,索引可以逐分区重建,避免单表锁。

反过来,以下场景不该分区:

  • 表只有几百万行,索引已经能覆盖查询。
  • 查询没有稳定的分区键谓词(无法裁剪,反而增加计划开销)。
  • 分区键选择不当,导致数据严重倾斜(如按 tenant_id 的 HASH 分区但某租户占 80% 数据)。

分区数量是关键成本。PostgreSQL 在规划阶段需要为每个分区做一次裁剪判断,分区数超过数百后,计划时间(planning time)会明显上升,因为 pg_class、pg_inherits 的元数据扫描与锁获取都会放大。经验值:单表分区数控制在 100~500 以内,超过就应考虑二级分区或改用其他方案。

可以用下面的查询观察一张分区表的实际规模与元数据开销:

SELECT
    parent.relname  AS parent_table,
    count(*)        AS partition_count,
    pg_size_pretty(sum(pg_total_relation_size(child.oid))) AS total_size
FROM pg_inherits i
JOIN pg_class parent ON parent.oid = i.inhparent
JOIN pg_class child  ON child.oid  = i.inhrelid
GROUP BY parent.relname
ORDER BY partition_count DESC;

二、声明式分区的三种策略

创建分区表的骨架是 PARTITION BY,它决定行如何路由到子分区。分区键一旦确定,后期变更成本极高,因此必须在设计阶段就想清楚查询形态。

2.1 RANGE 分区

按范围切分,最典型的是时间。适用于日志、时序、流水类数据。

CREATE TABLE events (
    id          bigint GENERATED ALWAYS AS IDENTITY,
    tenant_id   int         NOT NULL,
    created_at  timestamptz NOT NULL,
    payload     jsonb,
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_09 PARTITION OF events
    FOR VALUES FROM ('2026-09-01') TO ('2026-10-01');

CREATE TABLE events_2026_10 PARTITION OF events
    FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');

注意主键必须包含分区键 created_at,因为 PostgreSQL 要求唯一约束必须覆盖所有分区键列——否则无法保证跨分区的唯一性。这是分区表最常见的建表报错来源。

RANGE 分区的边界是左闭右开 [FROM, TO),相邻分区的边界必须严格衔接、不能重叠也不能留空洞,否则插入会报 no partition of relation found for row。

时间分区的粒度需要权衡:按月分区,一张 3 年表约 36 个分区,管理方便;按天分区,3 年就是 1095 个分区,计划开销明显。日志类高频写入建议「按月 + 保留 3 个月热数据」,而不是无脑按天。

2.2 LIST 分区

按枚举值切分,适合按地区、状态、租户组这类离散维度。

CREATE TABLE orders (
    id       bigint GENERATED ALWAYS AS IDENTITY,
    region   text NOT NULL,
    amount   numeric(12,2),
    PRIMARY KEY (id, region)
) PARTITION BY LIST (region);

CREATE TABLE orders_cn PARTITION OF orders FOR VALUES IN ('cn', 'hk', 'tw');
CREATE TABLE orders_us PARTITION OF orders FOR VALUES IN ('us', 'ca');
CREATE TABLE orders_default PARTITION OF orders DEFAULT;

DEFAULT 分区是兜底:任何不匹配已有分区值的行都会落到这里。生产上建议保留一个 DEFAULT 分区,避免新枚举值插入时直接报错中断业务。但要注意,DEFAULT 分区会削弱分区裁剪——规划器无法排除它,因为任何值理论上都可能落在里面。若数据分布稳定,可以定期把 DEFAULT 里的行迁移到正式分区再重建它:

BEGIN;
CREATE TABLE orders_jp PARTITION OF orders FOR VALUES IN ('jp');
INSERT INTO orders_jp SELECT * FROM orders_default WHERE region = 'jp';
DELETE FROM orders_default WHERE region = 'jp';
COMMIT;

LIST 分区的一个隐性风险是「枚举漂移」:业务新增一个国家代码,如果没有提前建分区又没有 DEFAULT,写入立即失败。因此 LIST 分区的自动化建分区需求往往比 RANGE 更迫切。

2.3 HASH 分区

按哈希取模均匀打散,适合没有天然范围维度、只想把写压力与维护成本分摊的场景。

CREATE TABLE sessions (
    id      uuid NOT NULL,
    user_id bigint NOT NULL,
    data    jsonb,
    PRIMARY KEY (id, user_id)
) PARTITION BY HASH (user_id);

CREATE TABLE sessions_p0 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE sessions_p1 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE sessions_p2 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE sessions_p3 PARTITION OF sessions
    FOR VALUES WITH (MODULUS 4, REMAINDER 3);

HASH 分区的裁剪只支持等值谓词(user_id = 42),范围查询会扫描所有分区。它的主要收益是降低单分区体积与并行维护,而不是加速范围查询。

三种策略的对照:

维度RANGELISTHASH
典型键时间、ID 区间地区、状态高基数列
裁剪支持等值 + 范围仅等值仅等值
数据均衡依赖分布依赖基数天然均匀
滚动删除优秀(DROP)一般一般
适用场景时序/日志多租户/多区域写打散

2.4 多级分区

PostgreSQL 支持对分区再分区,形成两级结构,用来同时满足「按时间滚动」与「按维度打散」两种需求:

CREATE TABLE metrics (
    device_id  int         NOT NULL,
    created_at timestamptz NOT NULL,
    value      double precision
) PARTITION BY RANGE (created_at);

CREATE TABLE metrics_2026_10 PARTITION OF metrics
    FOR VALUES FROM ('2026-10-01') TO ('2026-11-01')
    PARTITION BY HASH (device_id);

CREATE TABLE metrics_2026_10_h0 PARTITION OF metrics_2026_10
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);

二级分区会让分区总数迅速膨胀(外层 36 × 内层 4 = 144),务必用第一节的查询定期盘点总数。实践中,多级分区只在「单级分区数量已经触顶、又必须引入第二个维度」时才使用。

三、分区裁剪与执行计划验证

分区裁剪(Partition Pruning) 是分区性能的关键:规划器根据 WHERE 中的分区键谓词,只保留可能命中的分区。裁剪发生在计划期(plan-time) 与执行期(run-time) 两个阶段。计划期裁剪要求谓词是常量;当谓词来自参数(如 PREPARE 的占位符)时,裁剪推迟到执行期,称为 run-time pruning。

验证裁剪是否生效,看 EXPLAIN 输出:

EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM events
WHERE created_at >= '2026-10-01' AND created_at < '2026-10-08';
Aggregate (actual time=12.4..12.4 rows=1 loops=1)
  ->  Append (actual time=0.3..10.1 rows=8412 loops=1)
        Subplans Removed: 11
        ->  Seq Scan on events_2026_10 (actual time=0.2..8.9 rows=8412 loops=1)

Subplans Removed: 11 表示 12 个分区中裁掉了 11 个。如果这个数字是 0,说明谓词没能裁剪,需要检查:

  • 分区键是否被函数包裹(WHERE date(created_at) = '2026-10-01' 无法裁剪,应改写为范围)。
  • 类型是否隐式转换(created_at 是 timestamptz 而字面量是 date,可能抑制裁剪)。
  • 是否使用了 DEFAULT 分区导致无法排除。

对于 PREPARE 语句与 PL/pgSQL 参数化查询,观察 EXPLAIN 里是否出现 Subplans Removed 在 loops 阶段变化,那是执行期裁剪在起作用。可以通过 SET plan_cache_mode = force_custom_plan; 强制为参数化查询生成定制计划,让裁剪提前到计划期:

SET plan_cache_mode = force_custom_plan;
PREPARE q(timestamptz, timestamptz) AS
    SELECT count(*) FROM events WHERE created_at >= $1 AND created_at < $2;
EXPLAIN (ANALYZE, COSTS OFF) EXECUTE q('2026-10-01', '2026-10-08');

代价是每次执行都要重新规划,对高频短查询反而可能变慢,需要实测权衡。

还可以打开 SET enable_partition_pruning = off; 做对照实验,确认裁剪确实带来了收益——如果关掉裁剪后计划时间与执行时间几乎不变,说明你的查询根本没有走分区键谓词,分区对它是无效的。

裁剪失效的常见场景与修复手法:

写法是否裁剪修复
WHERE created_at >= '2026-10-01'是—
WHERE date(created_at) = '2026-10-01'否改写为 >= '2026-10-01' AND < '2026-10-02'
WHERE created_at::date = $1否参数改为 timestamptz 区间
WHERE created_at = '2026-10-01'(timestamptz 列)部分显式 AT TIME ZONE 或改用 timestamptz 字面量
WHERE id = 42(id 非分区键)否无解,需把分区键加入谓词
WHERE region IN ('cn','us')是(LIST)—

一个实用技巧是把「时间桶」作为应用查询的强制约束:ORM 层总是把查询限制在某个时间窗口内,既能裁剪又能防止全分区扫描。

裁剪与 JOIN

当分区表参与 JOIN 时,裁剪的时机取决于另一侧是否为常量。若分区表是 JOIN 的内侧且连接条件是等值,规划器可能无法在计划期裁剪,只能依赖执行期的 nested loop 参数化裁剪:

EXPLAIN (ANALYZE, COSTS OFF)
SELECT e.id, u.name
FROM users u
JOIN events e ON e.tenant_id = u.tenant_id
WHERE u.id = 7 AND e.created_at >= '2026-10-01';

此时观察 Append 节点的 Subplans Removed 会随 loops 变化,说明每个外层行都触发了独立的执行期裁剪。这比计划期裁剪多了一次判断开销,但对分区数不多的场景完全可接受。真正需要警惕的是把分区表放在 JOIN 外侧却没有任何分区键谓词——那会退化成「所有分区各扫一遍再 JOIN」。

四、在线维护:DETACH / ATTACH

声明式分区最有价值的运维能力是分区可以被独立摘除与挂载,且这两步在较新版本中支持 CONCURRENTLY,几乎不阻塞读写。

滚动删除历史分区:

ALTER TABLE events DETACH PARTITION events_2026_01 CONCURRENTLY;
DROP TABLE events_2026_01;

DETACH ... CONCURRENTLY 在 14 引入,它避免了长时间持有父表上的 ACCESS EXCLUSIVE 锁。如果不加 CONCURRENTLY,DETACH 需要短暂排他锁,在写密集表上可能造成秒级阻塞。注意 CONCURRENTLY 版本不能在事务块里执行。

反向操作是把已有的大表挂载成分区(分区表迁移的常用手法):

CREATE TABLE events_2026_11 (LIKE events INCLUDING ALL);
ALTER TABLE events ATTACH PARTITION events_2026_11
    FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');

ATTACH 会扫描目标表以验证所有行都满足分区边界,加一个匹配的 CHECK 约束可以让验证跳过全表扫描:

ALTER TABLE events_2026_11
    ADD CONSTRAINT events_2026_11_bound
    CHECK (created_at >= '2026-11-01' AND created_at < '2026-12-01');

有了这个 CHECK,ATTACH 直接信任约束,不做扫描。这在挂载 TB 级历史表时是决定性的优化。PostgreSQL 14 起对 DETACH CONCURRENTLY 也有类似的元数据优化,摘除时不再需要全表验证。

自动分区与 pg_partman

PostgreSQL 本身不自动创建新分区。当 2026-11 的第一条数据到来而分区不存在时,插入直接失败。生产上有两种应对:

  • 应用侧预建:定时任务提前一个月创建下一个分区,并在 CI 里做校验。
  • pg_partman 扩展:用 partman.create_parent 建表,再由 run_maintenance() 自动预建与滚动删除。
SELECT partman.create_parent(
    p_parent_table => 'public.events',
    p_control      => 'created_at',
    p_interval     => '1 month',
    p_premake      => 3
);

p_premake => 3 表示总是提前创建 3 个未来分区。配合 cron 或 pg_cron 每 15 分钟调用一次 partman.run_maintenance_proc(),即可实现无人值守滚动。扩展的安装与版本管理通过 CREATE EXTENSION pg_partman 完成,建议固定扩展版本并在 CI 中校验其 schema 与函数签名未变。

在 pg_cron 里注册维护任务:

SELECT cron.schedule(
    'partman-maintenance',
    '*/15 * * * *',
    $$CALL partman.run_maintenance_proc()$$
);

如果不想引入扩展,也可以用一个纯 SQL 的「补分区」函数兜底,在应用启动或定时任务里调用:先用 pg_inherits 找出当前已有的边界,再对缺失的下一个月执行 CREATE TABLE ... PARTITION OF。关键是这个函数必须幂等,并捕获「分区已存在」的重复对象异常。

五、索引、约束与限制

分区表的索引有两种形态,选择直接决定维护成本:

类型创建方式特点
分区本地索引在每个子分区上 CREATE INDEX可独立重建、可 DETACH 带走
分区全局索引父表 CREATE INDEXPostgreSQL 至今不支持全局非分区索引

重要事实:PostgreSQL 目前只支持分区本地索引(partitioned index 会向下传播为每个分区的本地索引),没有真正的全局索引。这意味着 WHERE id = 42 若不包含分区键,规划器必须到每个分区的本地索引里各查一次,开销随分区数线性增长。因此分区键应尽量成为查询的常用谓词。

在父表上建索引会自动在每个分区上建同名索引:

CREATE INDEX ON events (tenant_id, created_at DESC);

之后新增分区时,CREATE TABLE ... PARTITION OF 会自动继承索引定义;但用 ATTACH 挂载的独立表不会自动获得父表索引,需要手工在挂载前建好,否则该分区查询会退化为全表扫描。迁移脚本里务必加一步校验:

-- 找出缺少索引的分区
SELECT c.relname
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
WHERE i.inhparent = 'events'::regclass
  AND NOT EXISTS (
      SELECT 1 FROM pg_index x WHERE x.indrelid = c.oid
  );

唯一约束的限制再强调一次:分区表的 UNIQUE / PRIMARY KEY 必须包含全部分区键列。若业务需要跨分区唯一(如全局唯一订单号),只能靠应用层保证或引入独立的「唯一性登记表」。用 CREATE UNIQUE INDEX 在父表上建唯一索引同样会失败,错误信息为 unique constraint on partitioned table must include all partitioning columns。

其它常见限制:

  • 外键可以指向分区表,分区表也可以有外键,但引用分区表的外键不支持指向具体分区,且跨分区外键的级联删除代价较高。
  • 分区表上的 TRUNCATE 作用于父表时清空所有分区;TRUNCATE ONLY parent 在声明式分区下语义受限。
  • 行级触发器的定义会传播到分区,但 AFTER 触发器在分区裁剪时行为需要实测确认。
  • 分区表的统计信息是「逐分区 + 父表汇总」两套,ANALYZE 会递归到各分区;autovacuum 的每个分区独立计数,分区过多时会稀释 autovacuum worker 的注意力。

关于统计信息对计划质量的影响,可进一步参考 PostgreSQL 索引类型深度实战 。

逐分区索引重建

分区表最大的运维优势是索引可以逐分区重建,而不是一次性锁住整张表。用 CONCURRENTLY 逐分区重建,把维护窗口摊平到每个小分区上:

for part in $(psql -Atc "SELECT c.relname FROM pg_inherits i \
    JOIN pg_class c ON c.oid = i.inhrelid \
    WHERE i.inhparent = 'events'::regclass"); do
  psql -c "REINDEX INDEX CONCURRENTLY ${part}_tenant_id_created_at_idx"
done

每个分区的重建只锁该分区,其它分区的读写不受影响。对一张 36 分区的表,这种「逐个啃」的方式可以把原本数小时的停机维护压缩成后台低峰期的滚动任务。索引膨胀的判定可以结合 pgstattuple 扩展与 pg_stat_user_indexes 观察 idx_scan 与体积的比值,比值长期偏低就说明该分区索引可能已经退化。

六、生产迁移路径

把一张现有大表改造成分区表,PostgreSQL 没有原地转换的 DDL,必须走「新建 + 搬迁」流程。推荐路径:

  1. 新建分区父表 events_new,按目标策略建好分区骨架。
  2. 用 ATTACH 把现有历史表作为「冷分区」挂上去(前提是加上边界 CHECK 约束),避免数据搬迁。
  3. 增量数据通过双写或逻辑复制同步,参考 逻辑复制实战 搭建过渡期同步链路。
  4. 校验行数与校验和后,在低峰期做一次短暂的元数据切换(重命名 events ↔ events_new)。
  5. 观察一段时间后清理旧表。

切换前后的校验可以用一句 SQL 完成行数对账:

SELECT
    (SELECT count(*) FROM events)     AS old_count,
    (SELECT count(*) FROM events_new) AS new_count;

整个过程中,数据搬迁阶段往往是瓶颈。全量 INSERT INTO ... SELECT 会写满 WAL 并撑爆磁盘,更稳妥的是分批搬迁 + 定期 VACUUM,或直接使用 COPY 落盘再加载。迁移的整体流程、校验与回滚预案见 PostgreSQL 迁移指南 。

迁移完成后,务必用 EXPLAIN 复核关键查询的裁剪情况,并检查 pg_stat_user_tables 确认没有分区被遗漏成「冷门孤儿」。同时观察 DETACH/ATTACH 期间的锁等待,确认维护窗口内没有长事务被阻塞——分区维护会持有表级锁,与长事务的相互作用需要格外留意,事务与锁的关系可参考 PostgreSQL 事务隔离级别 。

一个常被忽视的坑是连接池与分区表的交互:pgBouncer 的事务级池化不会改变分区行为,但如果应用在 search_path 里依赖父表名,重命名切换后必须刷新连接池中的缓存计划。切换后建议对关键查询执行一次 DISCARD PLANS 或重启应用连接,避免命中引用旧 OID 的过期计划。连接池的配置细节需要在切换后重新预热,避免冷启动时大量连接同时编译计划造成瞬时 CPU 抖动。

回滚预案与观测

迁移必须有回滚预案:切换前保留旧表原样,切换后只重命名不删除,观察至少一个完整业务周期。回滚只需反向重命名,代价是切换窗口内的双写数据需要在旧表上补齐。

切换期间重点观测三个指标:

-- 1. 各分区的行数与体积是否均衡
SELECT relname, n_live_tup, pg_size_pretty(pg_relation_size(relid))
FROM pg_stat_user_tables
WHERE relname LIKE 'events_%'
ORDER BY n_live_tup DESC LIMIT 10;

-- 2. 是否有查询落到了非预期分区(全分区扫描)
SELECT query, calls, mean_exec_time
FROM pg_stat_statements
WHERE query LIKE '%FROM events%'
ORDER BY mean_exec_time DESC LIMIT 20;

-- 3. 计划时间是否随分区数上升
EXPLAIN (ANALYZE, TIMING OFF) SELECT 1 FROM events LIMIT 1;

若发现某个分区行数畸高,说明分区键分布倾斜;若发现某些查询的 shared_blks_read 异常大,多半是裁剪失效导致了全分区扫描。

七、常见错误速查

把生产中最常遇到的报错与处置列成一张表,便于排障时快速定位:

报错信息根因处置
no partition of relation found for row目标分区不存在或边界有空洞补建分区或加 DEFAULT
unique constraint must include all partitioning columns唯一约束未覆盖分区键把分区键加入约束列
cannot attach a temporary relation试图挂载临时表改为普通表
partition constraint is violated by some row挂载表里有越界数据清理越界行或修正边界
cannot detach partition ... concurrently在事务块内执行移出事务块单独执行
计划时间随分区数线性上升分区数过多合并粒度或引入二级分区

多数分区问题都能通过第一节的 pg_inherits 盘点查询与 EXPLAIN 的 Subplans Removed 快速定位。养成「先看裁剪、再看约束、最后看数量」的排障顺序,能覆盖九成以上的分区故障。

小结

分区是「用元数据换运维效率」的手段:它让过期数据清理从 O(n) 的 DELETE 变成 O(1) 的 DROP,让维护可以逐分区滚动,但也引入了分区数量、唯一约束、全局索引缺失等新的约束。选型上,时间滚动数据首选 RANGE,多租户离散维度选 LIST,写压力打散选 HASH。落地前先用 EXPLAIN 验证裁剪确实生效,用 CHECK 约束让 ATTACH 免扫描,用 pg_partman 兜住自动建分区,最后用锁与执行计划工具复核整条链路。分区键一旦选定,后期变更成本极高——这是设计阶段最需要反复确认的决定。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. PostgreSQL 锁与阻塞分析
  2. COPY 与批量数据加载优化
  3. pgvector 向量检索与混合查询