数据库基础体系 · 第 98/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

PostgreSQL 分区表:规划、裁剪、索引、维护和在线迁移

分区表不是“把一张大表自动切成几张小表”这么简单。它改变了数据存储、写入路由、查询计划、索引组织、统计信息、锁行为和维护方式。

使用分区表前,至少要回答五个问题:

  1. 分区键是否与主要查询条件、数据生命周期和写入分布一致?
  2. 优化器能否在计划阶段或执行阶段排除不相关分区?
  3. 索引是建在分区父表、每个分区,还是使用适合顺序扫描的 BRIN?
  4. 分区如何创建、扩展、归档、删除和重建?
  5. 现有非分区表如何在持续写入下迁移,且能验证和回滚?

本文以 PostgreSQL 当前稳定版本的公开语义为准。示例默认单集群、单数据库、事务型表;没有把跨数据库复制、第三方逻辑复制工具或云厂商托管能力当作 PostgreSQL 核心能力。


一、分区表的基本模型

1.1 分区表、分区父表和叶子分区

PostgreSQL 的声明式分区由三层概念组成:

  • 分区父表:定义列、类型、默认值、约束以及分区策略。
  • 分区:父表的子表,保存实际行。
  • 叶子分区:真正存储数据的表。分区还可以继续分区,形成多级分区树。

例如:

CREATE TABLE orders (
    order_id   bigint       NOT NULL,
    tenant_id  bigint       NOT NULL,
    created_at timestamptz  NOT NULL,
    status     text         NOT NULL,
    amount     numeric(12,2) NOT NULL
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_2025_01
    PARTITION OF orders
    FOR VALUES FROM ('2025-01-01 00:00:00+00')
               TO   ('2025-02-01 00:00:00+00');

CREATE TABLE orders_2025_02
    PARTITION OF orders
    FOR VALUES FROM ('2025-02-01 00:00:00+00')
               TO   ('2025-03-01 00:00:00+00');

这里:

orders_2025_01 的范围:
[2025-01-01 00:00:00+00, 2025-02-01 00:00:00+00)

orders_2025_02 的范围:
[2025-02-01 00:00:00+00, 2025-03-01 00:00:00+00)

FROM 是包含边界,TO 是排除边界,即半开区间 [lower, upper)。因此,2025-02-01 00:00:00+00 只属于二月分区,不会同时属于一月分区。

分区父表本身通常不保存叶子数据。对父表执行:

INSERT INTO orders (...)
VALUES (...);

时,PostgreSQL 根据分区约束将行路由到对应的叶子分区。

如果没有任何分区接受该行,插入会失败:

ERROR: no partition of relation "orders" found for row
DETAIL: Partition key of the failing row contains (created_at) = (...)

这不是“自动创建分区”。分区规划必须由应用或运维系统提前完成。


1.2 三种主要分区策略

RANGE 分区

RANGE 分区按有序值域划分,常用于时间、递增 ID 或数值区间:

CREATE TABLE events (
    id         bigint NOT NULL,
    occurred_at timestamptz NOT NULL,
    payload    jsonb
) PARTITION BY RANGE (occurred_at);

适合:

  • 按月份、季度或日期归档;
  • 删除历史数据;
  • 查询通常带时间范围;
  • 数据随时间增长。

时间分区最重要的边界约定是统一使用半开区间:

[开始时间, 结束时间)

不要把一部分分区写成闭区间、一部分写成开区间,也不要依赖应用层对 23:59:59.999999 的手工计算。

LIST 分区

LIST 分区按离散值划分:

CREATE TABLE tenant_orders (
    order_id  bigint NOT NULL,
    tenant_id bigint NOT NULL,
    amount    numeric(12,2) NOT NULL
) PARTITION BY LIST (tenant_id);

CREATE TABLE tenant_orders_1
    PARTITION OF tenant_orders
    FOR VALUES IN (1);

CREATE TABLE tenant_orders_2
    PARTITION OF tenant_orders
    FOR VALUES IN (2, 3);

适合少量、稳定、具有明显隔离意义的类别。对于几万或几十万个租户直接创建同等数量的分区,通常会导致分区管理、规划时间、缓存和系统目录压力。

HASH 分区

HASH 分区按分区键的哈希结果分布:

CREATE TABLE user_actions (
    user_id bigint NOT NULL,
    action  text   NOT NULL,
    at      timestamptz NOT NULL
) PARTITION BY HASH (user_id);

CREATE TABLE user_actions_p0
    PARTITION OF user_actions
    FOR VALUES WITH (MODULUS 8, REMAINDER 0);

CREATE TABLE user_actions_p1
    PARTITION OF user_actions
    FOR VALUES WITH (MODULUS 8, REMAINDER 1);

分区规则是:

hash(user_id) mod MODULUS = REMAINDER

HASH 分区主要解决数据分布和并行处理问题,不天然提供“按时间删除一整块数据”的能力。后续改变分区数量也不是简单修改 MODULUS,因为行的归属可能全部变化。


1.3 DEFAULT 分区不是万能兜底

可以创建默认分区:

CREATE TABLE orders_default
    PARTITION OF orders DEFAULT;

它接收所有不属于显式分区的行。默认分区能避免新时间段写入直接失败,但也会隐藏分区规划错误。例如,应用写入了错误年份的数据,写入成功了,却进入了 orders_default

新增分区时,默认分区还会影响校验。若要执行:

CREATE TABLE orders_2025_03
    PARTITION OF orders
    FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');

PostgreSQL 需要确认默认分区中没有属于新范围的行。默认分区较大时,这可能产生扫描和锁等待。

常见做法是先给默认分区增加排除新范围的约束,再创建新分区:

ALTER TABLE orders_default
ADD CONSTRAINT orders_default_not_2025_03
CHECK (
    created_at <  timestamptz '2025-03-01 00:00:00+00'
    OR
    created_at >= timestamptz '2025-04-01 00:00:00+00'
);

这个约束必须与真实数据一致。添加约束前应先检查:

SELECT count(*)
FROM orders_default
WHERE created_at >= timestamptz '2025-03-01 00:00:00+00'
  AND created_at <  timestamptz '2025-04-01 00:00:00+00';

返回非零时,不能直接声称默认分区已经排除了该范围。


二、分区键如何规划

2.1 分区不是按“表有多大”单独决定的

分区的价值主要来自以下几个因素:

  • 查询是否能够排除大量分区;
  • 是否需要按分区做生命周期管理;
  • 数据是否可以按分区独立归档、删除或迁移;
  • 写入和维护是否会形成热点;
  • 单个分区的索引、统计信息和 VACUUM 成本是否可控。

一张 500 GB 的表,如果所有查询都扫描全表,分成 100 个分区未必有收益;一张 50 GB 的时间序列表,如果经常删除三年前的数据,按月分区仍然可能非常有价值。

分区规划可以抽象成一个选择问题:

分区键 = 查询选择性 + 生命周期边界 + 写入分布 + 约束能力

这四项之间经常冲突。

例如,按 tenant_id 分区有利于租户隔离,但如果最常见查询是按 created_at 查询,那么时间条件不能有效排除租户分区。按时间分区有利于归档,却可能使单个租户查询访问多个分区。


2.2 分区键应与常见谓词保持可推导关系

对于 RANGE 分区,优化器能否裁剪分区,核心不是“SQL 中出现了分区键”这一表面条件,而是能否根据谓词推导出某些分区不可能包含结果。

设分区 PiP_i 的键范围为:

P_i = [l_i, u_i)

查询条件为:

Q = [l_q, u_q)

只有当两个区间可能相交时,分区才需要访问:

P_i ∩ Q ≠ ∅

即:

l_i < u_q 且 l_q < u_i

例如:

SELECT count(*)
FROM orders
WHERE created_at >= timestamptz '2025-01-10'
  AND created_at <  timestamptz '2025-01-12';

对于:

orders_2025_01 = [2025-01-01, 2025-02-01)
orders_2025_02 = [2025-02-01, 2025-03-01)

计算交集:

orders_2025_01 ∩ [2025-01-10, 2025-01-12) ≠ ∅
orders_2025_02 ∩ [2025-01-10, 2025-01-12) = ∅

因此只能访问 orders_2025_01

反例是对分区键做不可推导的函数变换:

SELECT count(*)
FROM orders
WHERE date(created_at) = date '2025-01-10';

在某些场景下,优化器可能通过表达式推导完成裁剪,但不能把这种能力当作所有函数、所有类型和所有版本下的保证。更稳妥的写法是显式使用范围:

WHERE created_at >= timestamptz '2025-01-10'
  AND created_at <  timestamptz '2025-01-11'

这样既符合半开区间语义,也避免对列施加函数导致普通 B-tree 索引难以使用。


2.3 分区粒度的计算思路

假设:

  • 每天写入 RR 行;
  • 每行及索引平均占用 BB 字节;
  • 希望单个分区大小约为 SS
  • 分区覆盖天数为 dd

则可用一个粗略估计:

d ≈ S / (R × B)

这不是性能定律,因为实际大小受 TOAST、索引数量、压缩、数据分布和更新膨胀影响。但它能避免完全凭感觉选择“每小时”或“每年”。

分区太细会带来:

  • 分区数量增加;
  • 查询计划遍历更多关系;
  • 分区级索引、统计信息和 autovacuum 对象增多;
  • DDL、备份、权限和监控复杂;
  • 多分区查询可能有更多节点和调度开销。

分区太粗会带来:

  • 单个分区索引和 VACUUM 仍然很大;
  • 归档只能做大块操作;
  • 单个热点分区竞争更集中;
  • 查询即使裁剪到一个分区,仍然需要扫描很大对象。

分区数量不存在脱离工作负载的通用上限。应通过代表性查询、真实数据分布和计划观察验证。


2.4 多级分区不是免费优化

例如先按年份、再按月份:

CREATE TABLE logs (
    id bigint NOT NULL,
    occurred_at timestamptz NOT NULL,
    payload jsonb
) PARTITION BY RANGE (occurred_at);

CREATE TABLE logs_2025
    PARTITION OF logs
    FOR VALUES FROM ('2025-01-01') TO ('2026-01-01')
    PARTITION BY RANGE (occurred_at);

CREATE TABLE logs_2025_01
    PARTITION OF logs_2025
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

多级分区只有在两级边界都服务于实际管理或查询需求时才值得使用。否则,额外的分区层级会增加规划树、DDL 和故障排查复杂度。


三、分区裁剪:优化器到底做了什么

3.1 分区裁剪的定义

分区裁剪是指 PostgreSQL 根据分区边界和查询条件,跳过不可能产生结果的分区。

它与索引扫描不是一回事:

  • 分区裁剪决定“访问哪些分区”;
  • 索引决定“进入某个分区后如何找行”。

一个查询可能:

裁剪掉 99 个分区,然后在剩余 1 个分区上做 Seq Scan;

也可能:

裁剪掉 99 个分区,然后在剩余 1 个分区上做 Index Scan。

前者仍然是成功的分区裁剪,只是单个分区内不适合使用索引。


3.2 计划阶段裁剪和执行阶段裁剪

分区键是常量时,优化器通常可以在生成计划时完成裁剪:

EXPLAIN
SELECT *
FROM orders
WHERE created_at >= timestamptz '2025-01-10'
  AND created_at <  timestamptz '2025-01-12';

典型计划会只出现一月分区,或者在分区追加节点中只保留一月分区。具体计划格式依版本、统计信息和成本而不同,不能仅凭节点名称判断所有细节。

使用参数时,裁剪可能延迟到执行阶段:

PREPARE q(timestamptz, timestamptz) AS
SELECT *
FROM orders
WHERE created_at >= $1
  AND created_at <  $2;

EXPLAIN (ANALYZE, BUFFERS)
EXECUTE q(
    timestamptz '2025-01-10',
    timestamptz '2025-01-12'
);

这时计划可能仍包含一个分区追加结构,但执行时通过参数值跳过部分分区。EXPLAIN (ANALYZE) 中可以看到实际执行分区数量、Subplans Removed 或各子计划的实际循环情况;具体展示形式会随版本和计划节点变化。

参数化查询还有一个容易混淆的问题:通用计划和自定义计划。对于 prepared statement,优化器可能选择一个不依赖具体参数的通用计划,也可能为当前参数生成自定义计划。通用计划不一定能像常量查询那样充分裁剪,实际行为应通过 EXPLAIN (ANALYZE) 验证,而不能只看 SQL 文本。


3.3 裁剪失败的常见原因

对错误的列做函数转换

WHERE date(created_at) = date '2025-01-10'

优先改为:

WHERE created_at >= timestamptz '2025-01-10'
  AND created_at <  timestamptz '2025-01-11'

类型或时区语义不清晰

timestamptimestamptz 的比较涉及时区解释。分区边界、应用参数和会话时区必须具有一致的语义。不要用本地时间字符串隐式比较一个以 UTC 规划的时间分区。

谓词无法证明排除关系

例如:

WHERE lower(status) = 'paid'

它与按 created_at 分区没有关系,因此不能帮助时间分区裁剪。即使同时有:

WHERE lower(status) = 'paid'
  AND date(created_at) = date '2025-01-10'

也不应假设第二个条件一定会以理想方式裁剪。

OR 条件过于复杂

WHERE created_at <  timestamptz '2025-01-10'
   OR status = 'pending';

第二个分支没有时间范围,可能使很多分区仍有潜在匹配行。优化器是否能进一步化简取决于谓词结构和约束信息。


3.4 如何诊断裁剪

使用:

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT count(*)
FROM orders
WHERE created_at >= timestamptz '2025-01-10'
  AND created_at <  timestamptz '2025-01-12';

重点观察:

  1. 是否出现分区追加或追加扫描结构;
  2. 实际执行了多少个子计划;
  3. Buffers 是否集中在预期分区;
  4. 是否因为默认分区、约束缺失或表达式形式导致额外访问;
  5. 计划估算行数与实际行数是否严重偏差。

必须使用 ANALYZE,因为没有可靠统计信息时,计划选择和行数估算可能失真:

ANALYZE orders_2025_01;
ANALYZE orders_2025_02;

父分区表的统计信息和叶子分区的统计信息不是简单等价物。查询实际扫描叶子分区时,叶子分区上的统计信息尤其重要。


四、分区上的索引:父表索引不是全局索引

4.1 PostgreSQL 核心没有全局索引

PostgreSQL 分区表的普通索引通常是每个分区一个本地索引

CREATE INDEX orders_created_at_idx
ON orders (created_at);

这个命令会在分区层次上建立相应的分区索引结构,叶子分区拥有自己的实际索引。

它不是一个跨所有分区的单一全局 B-tree。因此:

  • 查询多个分区时,可能需要访问多个本地索引;
  • 唯一性约束需要考虑分区边界;
  • 删除一个分区时,可以一起删除其本地索引;
  • 不存在核心内置的“跨所有分区维护一个全局索引”的普通方案。

分区父表上的索引更准确地说是一个分区索引定义或分区索引树,不是把所有叶子行放在一起的全局索引。


4.2 唯一约束为什么通常必须包含分区键

考虑:

CREATE TABLE orders (
    order_id   bigint NOT NULL,
    created_at timestamptz NOT NULL
) PARTITION BY RANGE (created_at);

如果允许在每个分区分别建立:

UNIQUE (order_id)

那么不同分区可以各自存在相同的 order_id。由于没有全局索引,单个分区的唯一索引无法证明全表唯一。

因此,分区表上的 PRIMARY KEYUNIQUE 约束通常必须包含所有分区键列。例如:

ALTER TABLE orders
ADD CONSTRAINT orders_pk
PRIMARY KEY (order_id, created_at);

这保证的是 (order_id, created_at) 在各本地索引组合下唯一,而不是单独 order_id 在整个父表中唯一。

如果业务要求 order_id 全局唯一,常见选择有:

  • 使用由单一序列、UUID 或雪花算法生成的全局唯一 ID,并在应用层保证;
  • 把 ID 所属范围与分区键设计成可证明的关系;
  • 额外维护一个非分区注册表,在其中对 order_id 建唯一约束;
  • 调整数据模型,使业务唯一性包含分区键。

不能仅因为执行了:

CREATE UNIQUE INDEX ON orders (order_id);

就认为 PostgreSQL 已经提供了跨分区唯一性。


4.3 索引类型如何与分区组织配合

B-tree

B-tree 适合:

  • 等值查询;
  • 范围查询;
  • 排序;
  • 前缀匹配的部分场景。

例如:

CREATE INDEX orders_tenant_created_idx
ON orders (tenant_id, created_at);

复合索引的列顺序要结合谓词。若查询经常是:

WHERE tenant_id = 10
  AND created_at >= ...
ORDER BY created_at;

(tenant_id, created_at) 通常比 (created_at, tenant_id) 更直接地匹配“等值前缀加范围”的访问模式。

但如果表已经按 created_at 分区,每个分区内的 created_at 范围较窄,则是否还需要在索引中重复时间列,要根据分区内查询、排序和选择性验证。

GIN

GIN 适合倒排式查询,例如 jsonb 包含关系或数组成员查询:

CREATE INDEX orders_payload_gin_idx
ON orders_2025_01
USING gin (payload);

GIN 更新成本和索引体积通常高于简单 B-tree。分区后可以只为确实需要 JSON 查询的分区建立索引,或逐个分区维护。

GiST

GiST 是可扩展的通用索引框架,常用于范围类型、几何数据、全文检索等场景。例如范围重叠查询:

CREATE TABLE reservations (
    id bigint NOT NULL,
    room_id bigint NOT NULL,
    during tstzrange NOT NULL,
    EXCLUDE USING gist (
        room_id WITH =,
        during  WITH &&
    )
) PARTITION BY RANGE (lower(during));

这里还涉及约束能否跨分区成立。分区级排他约束只能在相应索引作用范围内提供保证,不能自动变成无全局索引的跨分区排他性保证。设计前必须明确冲突是否可能跨分区发生。

BRIN

BRIN 不保存每一行的完整索引项,而保存数据块范围的摘要信息。它适合物理存储顺序与列值相关的场景,例如按时间追加写入的日志表:

CREATE INDEX logs_2025_01_occurred_brin
ON logs_2025_01
USING brin (occurred_at);

BRIN 很小,但它不是 B-tree 的直接替代品。若数据反复更新导致时间值与物理位置不再相关,BRIN 的过滤能力会下降。可以通过:

SELECT brin_summarize_new_values('logs_2025_01_occurred_brin');

补充新数据块摘要;具体维护频率应结合写入方式和计划效果验证。


4.4 在线创建分区索引的边界

在父表上直接创建索引通常会涉及多个分区。普通 CREATE INDEX 可能需要较强锁,长时间阻塞写入或被长事务阻塞。

核心 PostgreSQL 不支持直接对分区父表执行:

CREATE INDEX CONCURRENTLY ON orders (tenant_id);

实现逐分区并发创建的典型方式是:

CREATE INDEX CONCURRENTLY orders_2025_01_tenant_idx
ON orders_2025_01 (tenant_id);

CREATE INDEX CONCURRENTLY orders_2025_02_tenant_idx
ON orders_2025_02 (tenant_id);

然后在父表上建立一个分区索引定义,并将已有分区索引附加到它:

CREATE INDEX orders_tenant_idx
ON ONLY orders (tenant_id);

ALTER INDEX orders_tenant_idx
ATTACH PARTITION orders_2025_01_tenant_idx;

ALTER INDEX orders_tenant_idx
ATTACH PARTITION orders_2025_02_tenant_idx;

不同版本对父索引、未完成索引和附加条件的细节可能有差异,生产执行前应在目标版本验证。每个分区索引必须具有兼容的定义;遗漏某个分区时,父索引可能仍不是完整可用的统一索引结构。

CREATE INDEX CONCURRENTLY 也有自己的限制:

  • 不能在事务块中执行;
  • 失败后可能留下无效索引,需要检查并清理;
  • 它减少了对普通读写的阻塞,但仍消耗 CPU、I/O 和 WAL;
  • 它不等于“没有锁”,索引创建仍可能因长事务或锁冲突等待。

五、插入、更新和并发行为

5.1 插入时的路由过程

向父表插入一行时,逻辑过程可以概括为:

  1. 计算分区键值;
  2. 在分区约束树中寻找匹配分区;
  3. 将行写入叶子分区;
  4. 检查该分区上的约束和索引;
  5. 产生相应的 WAL 和锁行为。

如果更新分区键:

UPDATE orders
SET created_at = timestamptz '2025-02-01'
WHERE order_id = 1
  AND created_at = timestamptz '2025-01-31';

这不是在原分区中简单修改一列,而是可能表现为从旧分区删除、向新分区插入。跨分区移动受到行级并发、触发器、外键和分区边界的共同影响。

因此,分区键通常应尽量稳定。把频繁变化的列作为分区键,会让更新更昂贵,也增加并发冲突路径。


5.2 分区表与外键、触发器

触发器可以定义在分区父表上,适用范围和触发时机需要按目标版本验证;分区叶子上的约束、触发器和索引仍是实际执行检查的重要位置。

外键设计要特别谨慎:

  • 被引用表的唯一性必须真正覆盖引用语义;
  • 分区表的唯一约束受本地索引和分区键限制;
  • 跨分区移动行可能触发删除和插入相关行为;
  • 大规模分区会增加外键检查和维护的复杂度。

不要因为“父表上看起来有主键”就假设任意形式的跨分区全局唯一性都已经成立。应在目标 PostgreSQL 版本上用实际 DDL 验证约束是否被接受,并检查它的真实作用域。


六、维护:分区表真正的运营收益

6.1 添加未来分区

新建时间分区通常可以提前完成:

CREATE TABLE orders_2025_03
    PARTITION OF orders
    FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');

生产系统应在写入该时间段之前创建好分区。否则,首个落入新范围的写入会失败,而不是触发自动建表。

创建动作受父表锁和默认分区校验影响。应先检查锁等待:

SELECT
    pid,
    wait_event_type,
    wait_event,
    state,
    query
FROM pg_stat_activity
WHERE datname = current_database();

还可以在会话级设置合理的锁等待超时:

SET lock_timeout = '3s';

超时后 DDL 会失败而不是无限等待,但业务是否能接受失败需要由发布流程处理。


6.2 删除和归档历史分区

如果历史数据不再需要在线查询,删除整个分区通常比执行大范围 DELETE 更直接:

DROP TABLE orders_2023_01;

或者先从父表分离:

ALTER TABLE orders
DETACH PARTITION orders_2023_01;

DETACH PARTITION 后,该表不再接受通过 orders 父表的路由,但数据表本身仍存在,可以:

  • 备份;
  • 挂载到归档 schema;
  • 导出到外部存储;
  • 延迟删除;
  • 单独设置权限。

如果需要降低阻塞风险,可使用目标版本支持的并发分离语义,但并发分离通常会增加事务、约束检查或后续清理要求,不能把它理解为完全无锁。

删除分区的优势是:

按表级元数据操作移除一整块数据,
避免逐行 DELETE 产生的大量 WAL、死元组和长事务。

DROP TABLE 不可通过普通事务外部回滚。若需要可恢复流程,优先 DETACH,验证归档成功后再删除。


6.3 ATTACH PARTITION 的高风险点

已有普通表可以附加为分区:

ALTER TABLE orders
ATTACH PARTITION orders_2025_03
FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');

PostgreSQL 需要确认该表中的每一行都满足分区边界。若没有等价的 CHECK 约束,可能扫描整表。

可以先添加经过验证的约束:

ALTER TABLE orders_2025_03
ADD CONSTRAINT orders_2025_03_range
CHECK (
    created_at >= timestamptz '2025-03-01'
    AND created_at <  timestamptz '2025-04-01'
);

验证数据:

SELECT count(*)
FROM orders_2025_03
WHERE created_at <  timestamptz '2025-03-01'
   OR created_at >= timestamptz '2025-04-01';

只有返回 0,这个约束才有事实基础。NOT VALID 可以延迟某些约束的全表验证,但不能掩盖分区边界错误;附加分区时必须让 PostgreSQL 能够证明分区约束成立。


6.4 VACUUM、ANALYZE 和 autovacuum

分区父表不保存所有叶子行,因此维护重点在叶子分区:

VACUUM (ANALYZE) orders_2025_01;

对于频繁更新的热分区:

  • autovacuum 应更积极;
  • 需要关注死元组、膨胀和长事务;
  • 需要更新统计信息;
  • 索引膨胀可能需要 REINDEX 或重建。

可以查看各分区的统计:

SELECT
    relname,
    n_live_tup,
    n_dead_tup,
    last_vacuum,
    last_autovacuum,
    last_analyze,
    last_autoanalyze
FROM pg_stat_user_tables
WHERE relname LIKE 'orders%';

如果不同分区的数据分布差异很大,父表级别的统一统计目标可能不够。可以对特定列提高统计目标:

ALTER TABLE orders_2025_01
ALTER COLUMN tenant_id SET STATISTICS 500;

ANALYZE orders_2025_01;

这会增加分析开销,但可能改善高度倾斜列的估算。不要把提高统计目标当作裁剪失败的替代品:统计信息影响成本估算,分区裁剪首先依赖分区边界和谓词可推导性。


6.5 监控“分区是否仍然有用”

至少监控以下问题:

  • 是否存在默认分区异常增长;
  • 是否有未来时间段没有对应分区;
  • 单个分区是否成为写入热点;
  • 分区数量是否不断增长;
  • 关键查询实际访问了多少分区;
  • 各分区的行数、膨胀和索引大小;
  • 创建、附加、分离分区时的锁等待;
  • 是否有长事务阻止 DDL 或清理。

分区表的失败通常不是“查询报错”,而是:

查询访问了所有分区;
默认分区悄悄吸收了错误数据;
新时间段写入突然失败;
某个新分区缺少索引;
某些分区统计信息严重过期。

这些问题需要计划、目录和运行时指标共同诊断。


七、把普通表迁移为分区表

迁移有两种完全不同的边界:

7.1 停写窗口迁移

如果允许短暂停写,流程可以更简单:

  1. 停止写入;
  2. 创建新的分区父表和叶子分区;
  3. 将旧表数据复制到新表;
  4. 建立索引、约束和权限;
  5. 校验行数、范围和关键聚合;
  6. 在短事务中切换名称或视图;
  7. 恢复写入。

这种方式更容易证明一致性,因为复制过程中没有并发写入。

7.2 持续写入下的 Expand-Contract

在线迁移需要把变更拆成几个状态:

Expand:
    新表、新分区、新索引、新写入同步逻辑

Backfill:
    将历史数据分批复制到新表

Catch-up:
    追平复制期间产生的增量

Cutover:
    短事务切换读写入口

Contract:
    保留旧表观察期后,再删除旧结构

这就是 Expand-Contract 的核心思想:新旧结构并存,先扩展能力,再切换使用方,最后收缩旧结构。


八、一个可验证的在线迁移示例

假设旧表为:

CREATE TABLE old_orders (
    order_id   bigint PRIMARY KEY,
    tenant_id  bigint NOT NULL,
    created_at timestamptz NOT NULL,
    status     text NOT NULL,
    amount     numeric(12,2) NOT NULL
);

目标是创建按月分区的新表。

8.1 Expand:创建新父表和分区

CREATE TABLE new_orders (
    order_id   bigint NOT NULL,
    tenant_id  bigint NOT NULL,
    created_at timestamptz NOT NULL,
    status     text NOT NULL,
    amount     numeric(12,2) NOT NULL,
    PRIMARY KEY (order_id, created_at)
) PARTITION BY RANGE (created_at);

CREATE TABLE new_orders_2025_01
    PARTITION OF new_orders
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE new_orders_2025_02
    PARTITION OF new_orders
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

这里主键改成 (order_id, created_at),是为了满足分区唯一性约束的结构限制。如果业务要求 order_id 单列全局唯一,还需要额外设计,不能由这个主键替代。

先建立目标表上的其他索引:

CREATE INDEX new_orders_tenant_created_idx
ON new_orders (tenant_id, created_at);

CREATE INDEX new_orders_status_idx
ON new_orders (status);

索引建立在父表定义上,实际会作用于各个叶子分区。生产环境若索引很大,应按前文的逐分区并发方式规划。


8.2 Expand:双写触发器

下面的示例让旧表继续作为当前写入入口,同时把变更复制到新表:

CREATE OR REPLACE FUNCTION sync_old_orders_to_new()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO new_orders (
            order_id, tenant_id, created_at, status, amount
        )
        VALUES (
            NEW.order_id, NEW.tenant_id, NEW.created_at,
            NEW.status, NEW.amount
        )
        ON CONFLICT (order_id, created_at) DO UPDATE
        SET tenant_id = EXCLUDED.tenant_id,
            status    = EXCLUDED.status,
            amount    = EXCLUDED.amount;

        RETURN NEW;

    ELSIF TG_OP = 'UPDATE' THEN
        DELETE FROM new_orders
        WHERE order_id = OLD.order_id
          AND created_at = OLD.created_at;

        INSERT INTO new_orders (
            order_id, tenant_id, created_at, status, amount
        )
        VALUES (
            NEW.order_id, NEW.tenant_id, NEW.created_at,
            NEW.status, NEW.amount
        )
        ON CONFLICT (order_id, created_at) DO UPDATE
        SET tenant_id = EXCLUDED.tenant_id,
            status    = EXCLUDED.status,
            amount    = EXCLUDED.amount;

        RETURN NEW;

    ELSIF TG_OP = 'DELETE' THEN
        DELETE FROM new_orders
        WHERE order_id = OLD.order_id
          AND created_at = OLD.created_at;

        RETURN OLD;
    END IF;

    RETURN NULL;
END;
$$;

CREATE TRIGGER old_orders_sync_to_new
AFTER INSERT OR UPDATE OR DELETE ON old_orders
FOR EACH ROW
EXECUTE FUNCTION sync_old_orders_to_new();

这个示例假设:

  • order_idcreated_at 一起能唯一定位目标行;
  • 目标分区已经覆盖所有可能写入的时间范围;
  • 触发器与旧表写入在同一事务中执行;
  • 目标表的唯一约束与触发器中的 ON CONFLICT 目标一致。

它不是无条件可复用的通用模板。特别要注意:

  1. 触发器写入目标表失败,旧表事务也会失败。
    这是保持同步的优点,也是目标表故障扩大到旧写入路径的风险。

  2. 更新分区键会跨分区移动。
    上例通过删除旧键再插入新键处理,但业务主键、外键和并发更新必须额外验证。

  3. 批量语句仍然逐行触发。
    大批量更新可能导致显著额外写放大。

  4. 触发器函数必须覆盖所有业务写法。
    例如旧表上的 INSERT ... ON CONFLICT DO UPDATE、级联删除、特殊默认值和生成列,都要验证最终行状态。

  5. DDL 不会自动同步。
    旧表增加列、修改默认值、增加约束时,目标结构和同步函数也要一起演进。

启用前,应先用测试流量验证:

INSERT INTO old_orders (...)
VALUES (...);

UPDATE old_orders
SET status = 'paid'
WHERE order_id = 1;

DELETE FROM old_orders
WHERE order_id = 1;

分别检查 old_ordersnew_orders 的最终状态,而不是只检查触发器是否创建成功。


8.3 Backfill:分批复制历史数据

双写触发器启用后,再复制旧数据:

INSERT INTO new_orders (
    order_id, tenant_id, created_at, status, amount
)
SELECT
    order_id, tenant_id, created_at, status, amount
FROM old_orders
WHERE order_id > 0
  AND order_id <= 100000
ON CONFLICT (order_id, created_at) DO UPDATE
SET tenant_id = EXCLUDED.tenant_id,
    status    = EXCLUDED.status,
    amount    = EXCLUDED.amount;

生产环境通常按主键范围、时间范围或游标分批执行,而不是一个超长事务。每批操作的原因是:

  • 限制锁和快照持续时间;
  • 限制 WAL 峰值;
  • 便于重试;
  • 减少单次失败损失;
  • 让 autovacuum 和复制延迟有恢复机会。

但分批复制存在竞态:某行可能在 SELECT 后、目标写入前被旧表更新。双写触发器会把之后的更新再写入目标表,但最终一致性仍必须通过校验和追平流程证明。

典型校验:

SELECT count(*) FROM old_orders;
SELECT count(*) FROM new_orders;

行数相同不是充分条件。还应按时间分桶比较:

SELECT date_trunc('day', created_at) AS day,
       count(*) AS cnt,
       sum(amount) AS total_amount
FROM old_orders
GROUP BY 1
ORDER BY 1;

new_orders 执行同样查询并比较结果。还应抽样比较:

SELECT o.order_id
FROM old_orders o
LEFT JOIN new_orders n
  ON n.order_id = o.order_id
 AND n.created_at = o.created_at
WHERE n.order_id IS NULL
LIMIT 100;

如果旧表仍在写,校验结果会持续变化,因此需要:

  1. 首次全量复制;
  2. 再次复制变化较大的时间段或主键范围;
  3. 记录复制开始和结束位置;
  4. 在切换前停止或冻结写入,或通过更严格的增量捕获追平;
  5. 最后一次校验。

没有 WAL 位点、变更日志或可证明的冻结边界时,“跑一次全量复制然后立刻切换”不能证明无数据丢失。


九、Cutover:在线不等于零锁

切换的关键是让应用从旧表转向新表。常见方法包括:

  • 应用配置切换表名或 schema;
  • 通过稳定视图切换底层对象;
  • 使用路由层;
  • 在数据库内部重命名对象。

无论采用哪种方法,都应设计一个短事务切换点。例如,应用原来访问稳定视图:

CREATE VIEW app_orders AS
SELECT * FROM old_orders;

切换时不能简单地依赖“先删视图、再建视图”,因为中间对象缺失会影响并发查询。可以通过预先准备两个 schema、或在数据库中使用原子对象替换策略降低窗口,具体方案取决于依赖对象、权限、视图定义和应用连接池行为。

切换前应:

  1. 确认所有目标时间范围分区已存在;
  2. 确认目标索引和约束有效;
  3. 确认双写无错误;
  4. 完成最后一次增量追平;
  5. 控制新事务进入;
  6. 在短事务中切换;
  7. 立即执行读写冒烟测试。

使用锁超时:

SET lock_timeout = '3s';

可以避免切换事务无限等待,但失败时必须有明确恢复动作。在线变更的真实含义通常是:

长时间的数据准备阶段尽量不阻塞业务,
最后仍需要一个短暂、可观测、可失败重试的切换窗口。

如果依赖对象复杂,应用层双读、灰度读和按租户切换可能比直接重命名更安全,但也会引入新旧结果比较和流量路由复杂度。


十、回滚与 Contract

切换后不要立即删除旧表。保留旧表和同步逻辑一段观察期,可以支持:

  • 发现新表查询结果错误时回切;
  • 比较新旧表的读结果;
  • 处理漏分区、漏索引和默认值差异;
  • 观察新表上的锁、I/O、计划和错误率。

但回滚不是自动成立的。若切换后只向新表写入,回切旧表前必须保证旧表也收到切换期间的变更。常见做法是:

  • 切换后暂时保持反向同步;
  • 使用应用层同时写入;
  • 暂停写入后做最终反向校验;
  • 保留旧表只读并从新表导出差异。

当确认无需回切后,才执行:

DROP TRIGGER old_orders_sync_to_new ON old_orders;
DROP FUNCTION sync_old_orders_to_new();
DROP TABLE old_orders;

删除旧表前必须检查:

  • 视图、函数、触发器、外键和应用 SQL 依赖;
  • 备份是否已覆盖新表;
  • 监控和权限是否已迁移;
  • 序列、默认值和身份列是否正确;
  • 旧表是否仍有后台任务写入。

十一、迁移方案的失败表现和诊断

11.1 新数据无法写入

错误:

no partition of relation found for row

检查:

SELECT *
FROM pg_partition_tree('new_orders');

然后确认:

SELECT min(created_at), max(created_at)
FROM old_orders;

以及当前写入时间是否落在显式分区或默认分区中。

11.2 查询扫描了所有分区

检查:

EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT ...

重点排查:

  • 查询没有分区键条件;
  • 对分区键进行了无法推导的表达式转换;
  • 参数化计划没有充分裁剪;
  • 默认分区没有排除范围;
  • 分区边界或数据类型时区语义错误;
  • 实际查询访问的是视图或函数,谓词没有正确下推。

11.3 目标表数据比旧表少

可能原因:

  • 回填范围漏了部分主键;
  • 目标分区范围不完整;
  • 触发器在某些写法下未覆盖;
  • 触发器创建晚于部分写入;
  • 复制失败后只记录了错误,没有重试;
  • 更新分区键时只更新了旧分区行,没有正确插入新分区行。

不能只比较总行数。应按时间、租户、状态等业务维度进行差异比较,并检查数据库日志中触发器和目标表约束错误。

11.4 DDL 长时间等待

常见阻塞源:

  • 长事务;
  • 未提交的批量写入;
  • 备份或维护事务;
  • 应用连接持有事务但处于 idle in transaction;
  • 其他 DDL 持有冲突锁。

诊断阻塞关系:

SELECT
    a.pid,
    a.state,
    a.query,
    pg_blocking_pids(a.pid) AS blocking_pids
FROM pg_stat_activity a
WHERE a.datname = current_database();

不要为了完成分区 DDL 直接终止未知业务会话。应先确认事务持有者、影响范围和可恢复性,再按变更窗口处理。


十二、几个容易混淆的判断

“分区后查询一定更快”

不成立。没有分区键谓词的查询仍可能访问所有分区。即使成功裁剪到一个分区,如果剩余分区仍很大,查询仍可能需要顺序扫描。

“分区数越多越好”

不成立。过多分区会增加计划、系统目录、索引、统计和维护开销。分区边界应服务于裁剪或生命周期管理,而不是追求更小的表名。

“父表上的唯一索引保证全局唯一”

通常不成立。分区本地索引无法自动检查其他分区。唯一约束必须满足 PostgreSQL 对分区键的限制,业务全局唯一性还需要额外设计。

CREATE INDEX CONCURRENTLY 完全不阻塞”

不成立。它减少了对普通读写的阻塞,但仍会占用资源、等待快照或锁,并可能产生失败的无效索引。

“在线迁移就是没有停机”

不成立。在线迁移通常是把停机时间压缩到最后的切换事务,但仍需要一致性边界、锁超时、失败重试和回滚路径。

“默认分区可以替代分区管理”

不成立。默认分区只能接收未匹配数据,不能自动按时间拆分,也可能掩盖漏建分区和脏数据。


结语

PostgreSQL 分区表的核心不是建表语法,而是让以下关系同时成立:

分区边界能够表达数据生命周期;
查询谓词能够推导出不相关分区;
本地索引与单分区内访问模式匹配;
维护操作能够按分区执行;
迁移过程能够在并发写入下证明数据没有丢失。

规划阶段决定分区键和粒度,执行计划验证裁剪,索引设计解决分区内访问,维护流程利用分区边界进行归档和清理,Expand-Contract 则把结构迁移拆成可观察、可验证、可回滚的多个状态。

只有这几部分共同闭环,分区表才不仅是存储布局变化,而是一个可以长期运行和持续演进的数据管理方案。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。