返回文章列表

PostgreSQL Declarative Partitioning 与大型数据生命周期

从声明式分区、分区键、分区裁剪、分区索引、ATTACH、DETACH、冷热分层与维护任务出发,理解 PostgreSQL 大型数据生命周期管理。

第 14 章:Declarative Partitioning 与大型数据生命周期

技术基线:PostgreSQL 18;同时标注 PostgreSQL 14、15、16、17、18 的重要差异。Go 示例使用 github.com/jackc/pgx/v5pgxpool

本章核心结论:分区首先是一种数据生命周期、故障域和物理布局管理手段,其次才可能带来查询性能收益。它不等于索引,不等于分片,也不是“大表”的自动答案。


1. 本章定位

本章解决四类生产问题:

  1. 如何让超大表仍可维护:按时间、租户或散列键把数据拆成可独立创建、装载、备份、归档、迁移和删除的物理表。
  2. 如何减少无关数据访问:让 Planner 或 Executor 根据分区边界执行 Partition Pruning,只访问可能包含目标行的分区。
  3. 如何管理数据生命周期:预建未来分区、隔离热数据与冷数据、按保留期 DETACHDROP 历史分区,而不是逐行删除。
  4. 如何控制分区带来的新风险:规划时间、会话内存、DDL 锁、Default Partition、跨分区唯一性、统计信息、Autovacuum、复制和恢复复杂度。

1.1 与前后章节的关系

  • 第 3~8 章提供 Page、Tuple、索引、Planner、Executor 和统计信息基础;本章把这些能力应用到一个由多个物理关系组成的逻辑表。
  • 第 9~13 章提供 MVCC、锁、VACUUM、WAL 和恢复基础;本章解释逐行 DELETE 与元数据级 DETACH/DROP 为什么具有完全不同的并发和 WAL 特征。
  • 第 15 章继续讨论在线 DDL。分区维护虽常被称为“轻量操作”,但仍可能请求强锁、排队并形成锁队列。
  • 第 16~19 章将使用本章模型实现独立维护任务、容量规划,以及区分单机分区与跨节点分片。

1.2 本章不展开的内容

  • 不把传统继承分区作为主方案;生产新系统优先使用 Declarative Partitioning。
  • 不展开 Citus、分布式 PostgreSQL 或自研路由层;本章只说明分区与分片的边界。
  • 不替代第 15 章的完整零停机 DDL 方法,也不替代第 20~23 章的备份、复制和高可用设计。
  • 不给出适用于所有机器的固定“每个分区多少行”结论。粒度必须由查询窗口、保留策略、索引大小、维护时限与规划开销共同决定。

2. 可验证的学习目标

完成本章后,你应能够:

  1. 为给定业务在 RANGELISTHASH 及多级分区之间做出可解释的选择。
  2. 写出边界无重叠、无空洞或有意保留空洞的分区 DDL,并解释 Range 下界包含、上界不包含的语义。
  3. 使用 EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY) 区分规划期裁剪、初始化期裁剪和执行期裁剪。
  4. 解释为什么参数化查询仍可能执行运行期裁剪,以及 Generic Plan 与 Custom Plan 对计划形状的影响。
  5. 判断一条查询为什么访问一个、多个或全部分区,并改写谓词以匹配分区边界。
  6. 设计分区索引、主键、唯一约束和外键,并准确说明 PostgreSQL 18 没有通用全局唯一索引的限制。
  7. 使用 ATTACH PARTITIONDETACH PARTITIONDETACH PARTITION CONCURRENTLYDROP TABLE 管理生命周期,并预测其锁行为。
  8. 在不伪造耗时的前提下,对比历史分区 DROP/DETACH 与批量 DELETE 的 Buffers、WAL、死元组和延迟分位数。
  9. 解释分区父表、叶分区、分区索引、pg_partitioned_tablepg_inheritspg_class.relpartbound 之间的关系。
  10. 为分区树建立统计信息和 Autovacuum 策略,并解释为什么需要定期手工 ANALYZE 分区父表。
  11. 识别过度分区、Default Partition 膨胀、遗漏未来分区、DDL 阻塞和全局 ID 冲突等事故模式。
  12. 使用 Go、pgx/v5 与 pgxpool 实现独立、单实例、可取消、带锁超时的未来分区维护任务,而不在普通请求路径执行 DDL。

3. 核心术语

中文名称English准确定义容易混淆的概念所属层次
声明式分区Declarative Partitioning由 PostgreSQL 根据分区键和边界管理路由、裁剪及层级关系的内建分区机制传统继承分区、分片Schema / Executor
分区表Partitioned Table没有自身 Heap 存储、作为分区树根或中间节点的虚拟关系普通表、视图Catalog / Relcache
叶分区Leaf Partition真正存储 Heap Tuple 和本地索引的普通表或外部表分区父表Storage
分区键Partition Key用于计算目标分区、证明查询与边界是否相交的列或表达式主键、索引键Schema / Planner
分区边界Partition Bound描述某个分区可接收键空间的元数据约束用户定义 CHECKCatalog
范围分区Range Partitioning按有序、互不重叠的半开区间划分数据范围索引扫描Schema
列表分区List Partitioning将明确列出的离散值集合映射到分区枚举类型Schema
哈希分区Hash Partitioning按分区键哈希结果的 modulus/remainder 划分数据哈希索引、跨节点一致性哈希Schema
多级分区Sub-partitioning叶候选节点继续声明为分区表,形成两级或更多层级分片副本Schema / Planner
默认分区Default Partition接收未匹配其他 Range/List 分区边界的行;Hash 不支持 Default错误队列、兜底表Schema / Operations
元组路由Tuple RoutingINSERT 或跨边界 UPDATE 时沿分区树选择目标叶分区应用层路由Executor
分区裁剪Partition Pruning根据分区边界排除不可能包含结果的分区索引扫描、Constraint ExclusionPlanner / Executor
规划期裁剪Planning-Time PruningPlanner 在构建计划时利用已知常量排除分区执行期裁剪Planner
初始化期裁剪Initialization PruningExecutor 初始化计划时利用执行参数排除子计划规划期裁剪Executor
动态执行期裁剪Dynamic Execution-Time Pruning参数在执行中变化时反复重新计算可用分区,例如参数化 Nested Loop 内侧Generic PlanExecutor
参数化查询Parameterized Query谓词值由 $1 等参数提供;可使用 Custom Plan 或 Generic Plan字符串拼接 SQLProtocol / Planner
分区索引Partitioned Index父表上的虚拟索引及每个叶分区上的真实本地索引集合全局索引Catalog / Index
全局唯一索引Global Unique Index单个索引跨所有分区验证唯一性;PostgreSQL 18 核心未提供通用实现父级唯一约束Index
分区级连接Partition-Wise Join将两个兼容分区表按匹配子分区分别连接,再组合结果Parallel JoinPlanner / Executor
分区级聚合Partition-Wise Aggregate在各分区先聚合,必要时再做最终聚合Parallel AggregatePlanner / Executor
附加分区ATTACH PARTITION将已有表验证后纳入分区树CREATE TABLE ... PARTITION OFDDL / Locking
分离分区DETACH PARTITION将分区从树中移除并保留为独立表DROP TABLEDDL / Lifecycle
并发分离DETACH … CONCURRENTLY通过两个内部事务和较弱父表锁降低阻塞的分离方式CREATE INDEX CONCURRENTLYDDL / Locking
待分离状态Detach PendingDETACH CONCURRENTLY 第一阶段已标记、第二阶段尚未完成的目录状态Invalid IndexCatalog / Recovery
冷热分层Hot/Cold Tiering将近期高频访问数据与历史低频访问数据放在不同分区、索引或 Tablespace缓存淘汰Storage / Operations
过度分区Over-partitioning分区数量或层级超过工作负载收益,导致规划、目录、锁和内存成本上升大表本身Architecture
数据分片Sharding将数据分布到不同 PostgreSQL 实例或节点,突破单节点资源边界单实例分区Distributed Architecture

4. 整体心智模型

PostgreSQL Declarative Partitioning 与大型数据生命周期 flow 1

4.1 数据流

  • 分区父表本身不存储行。写入父表时,Executor 根据分区键值选择叶分区;Heap Tuple、TOAST 数据和真实 Index Tuple 均写入叶分区。
  • 查询父表时,逻辑上访问整个树;实际执行多少叶分区由裁剪能力、谓词、参数、连接条件和计划决定。
  • 跨分区查询不是把所有分区“合并成一个文件”,而是生成多个子计划并通过 AppendMerge Append、并行 Append、分区级连接或聚合组合结果。

4.2 控制流

  • Planner 先读取分区元数据并尝试规划期裁剪。
  • 对计划时未知的参数,Executor 可在初始化时或参数变化时执行运行期裁剪。
  • ATTACHDETACHDROP 不是普通 DML,而是目录结构变更;它们需要表级锁,并使其他会话中的 Relcache 或缓存计划重新验证。

4.3 状态变化

  • ATTACH:独立表 → 验证列、约束、边界和索引 → 分区树成员。
  • 普通 DETACH:分区树成员 → 独立表。
  • DETACH CONCURRENTLY:正常分区 → inhdetachpending 状态 → 等待旧事务 → 独立表;中断后可用 FINALIZE 完成。
  • DROP:分区树成员及其物理文件和目录对象被删除;恢复依赖备份与 WAL,而不是“撤销文件删除”。

4.4 故障路径

  • 没有匹配分区且无 Default:写入直接失败,通常是维护任务遗漏或时间边界错误。
  • 有 Default:写入成功但可能掩盖遗漏,后续添加正式分区时 Default 扫描、数据迁移和强锁成为事故点。
  • 分区过多或谓词不能裁剪:规划时间、每会话元数据内存和锁数量上升,P99 可在执行前就恶化。
  • 长事务持有历史分区锁:DROP、普通 DETACHDETACH CONCURRENTLY 第二阶段等待,后续 DDL/DML 可能形成锁队列。

5. 使用方式

5.1 Range、List、Hash 的最小正确示例

5.1.1 Range:时间序列与生命周期

CREATE SCHEMA IF NOT EXISTS app;

CREATE TABLE app.event_log (
    tenant_id   bigint        NOT NULL,
    event_id    uuid          NOT NULL,
    occurred_at timestamptz   NOT NULL,
    event_type  text          NOT NULL,
    payload     jsonb         NOT NULL,
    ingested_at timestamptz   NOT NULL DEFAULT clock_timestamp(),
    PRIMARY KEY (tenant_id, occurred_at, event_id)
) PARTITION BY RANGE (occurred_at);

CREATE TABLE app.event_log_2026_06
    PARTITION OF app.event_log
    FOR VALUES FROM ('2026-06-01 00:00:00+00')
                 TO ('2026-07-01 00:00:00+00');

CREATE TABLE app.event_log_2026_07
    PARTITION OF app.event_log
    FOR VALUES FROM ('2026-07-01 00:00:00+00')
                 TO ('2026-08-01 00:00:00+00');

Range 边界是半开区间 [FROM, TO):下界包含,上界不包含。相邻月可共享边界值,2026-07-01 00:00:00+00 只属于七月分区。

时间分区必须统一边界语义。建议将保留策略和边界统一到 UTC;业务展示时区由应用处理。否则夏令时地区的“本地一天”可能不是固定 24 小时。

5.1.2 List:离散、稳定、低基数域

CREATE TABLE app.customer_profile (
    region_code text   NOT NULL,
    customer_id bigint NOT NULL,
    profile     jsonb  NOT NULL,
    PRIMARY KEY (region_code, customer_id)
) PARTITION BY LIST (region_code);

CREATE TABLE app.customer_profile_apac
    PARTITION OF app.customer_profile
    FOR VALUES IN ('JP', 'SG', 'AU');

CREATE TABLE app.customer_profile_emea
    PARTITION OF app.customer_profile
    FOR VALUES IN ('DE', 'FR', 'GB');

CREATE TABLE app.customer_profile_other
    PARTITION OF app.customer_profile DEFAULT;

List 适合值域明确且变化缓慢的区域、业务线、状态族。不适合“每个客户一个分区”:客户数增长会将目录对象数与规划开销绑定到业务增长。

5.1.3 Hash:均匀分散,不提供时间删除边界

CREATE TABLE app.idempotency_record (
    idempotency_key text        NOT NULL,
    response_code   integer     NOT NULL,
    response_body   jsonb       NOT NULL,
    created_at      timestamptz NOT NULL,
    PRIMARY KEY (idempotency_key)
) PARTITION BY HASH (idempotency_key);

CREATE TABLE app.idempotency_record_p0
    PARTITION OF app.idempotency_record
    FOR VALUES WITH (MODULUS 8, REMAINDER 0);
-- p1 ... p7 分别使用 remainder 1 ... 7

这里 PRIMARY KEY (idempotency_key) 合法,因为唯一键包含全部分区键。Hash 可分散写入和缩小本地索引,但按时间清理仍会触及每个 Hash 分区,不能像时间 Range 一样整分区淘汰。

5.2 多级分区

典型组合是“先按时间管理生命周期,再按租户 Hash 分散单月热点”:

CREATE TABLE app.metric_sample (
    tenant_id  bigint        NOT NULL,
    sampled_at timestamptz   NOT NULL,
    metric_id  bigint        NOT NULL,
    value      double precision NOT NULL,
    PRIMARY KEY (sampled_at, tenant_id, metric_id)
) PARTITION BY RANGE (sampled_at);

CREATE TABLE app.metric_sample_2026_06
    PARTITION OF app.metric_sample
    FOR VALUES FROM ('2026-06-01 00:00:00+00')
                 TO ('2026-07-01 00:00:00+00')
    PARTITION BY HASH (tenant_id);

CREATE TABLE app.metric_sample_2026_06_h0
    PARTITION OF app.metric_sample_2026_06
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
-- h1 ... h3

多级分区会使叶分区数相乘。若保留 36 个月、每月 16 个 Hash 子分区,就是 576 个叶分区;再为每个叶分区配置 5 个索引,会产生 2,880 个真实索引。设计前必须计算完整对象数量,而不是只看第一层。

5.3 Partition Key 的选择原则

优先级通常是:

  1. 生命周期边界:能否把同一批删除/归档的数据放入同一分区。
  2. 高频谓词:大多数查询是否显式包含与边界兼容的分区键条件。
  3. 唯一性要求:主键或唯一约束是否能包含全部分区键。
  4. 数据倾斜:单个分区是否会成为容量、I/O、锁或 Autovacuum 热点。
  5. 未来增长:租户、状态值或日期跨度增长后,分区数量是否仍可控。

错误示例:业务总按 tenant_id 点查,却只按 occurred_at 分区,且点查 SQL 不携带时间范围。即使 event_id 在每个分区都有索引,数据库仍需在许多分区中分别探测本地索引。

5.4 Partition Bound 与表达式

可使用列或表达式作为分区键,但表达式分区键会直接限制父级 UNIQUE/PRIMARY KEY 的定义。生产上优先使用简单、稳定、可直接出现在谓词中的列。

-- 可行,但会让唯一约束与谓词证明更复杂,不是默认首选。
CREATE TABLE app.daily_rollup (
    occurred_at timestamptz NOT NULL,
    tenant_id   bigint      NOT NULL,
    amount      numeric     NOT NULL
) PARTITION BY RANGE ((occurred_at AT TIME ZONE 'UTC')::date);

更常见的设计是增加一个由应用或受控生成逻辑写入的 event_date date,并明确校验其与时间戳一致;这样边界、约束和 SQL 更可读。不要通过可变时区设置使同一时间戳在不同会话落入不同逻辑日期。

5.5 Default Partition:兜底还是债务入口

CREATE TABLE app.event_log_default
    PARTITION OF app.event_log DEFAULT;

Default 适合短期防止写入中断,但必须把它视为异常缓冲区

  • 它会掩盖未来分区未创建、边界错误或脏时间数据。
  • 新建或 Attach 正式分区时,PostgreSQL 必须证明 Default 中没有属于新边界的行;没有可证明的 CHECK 约束时会扫描 Default,并对它获取 ACCESS EXCLUSIVE 锁。
  • Default 一旦很大,会把本应简单的月度维护变成扫描、迁移和锁事故。
  • 父表存在 Default 时不能使用 DETACH PARTITION ... CONCURRENTLY
  • Hash 分区不支持 Default。

生产建议:若业务能接受“快速失败”,时间序列表通常宁可不建 Default,并对 SQLSTATE 和“未来分区剩余天数”告警;若必须建 Default,则监控行数、最早/最晚键值,目标应长期为零。

5.6 Tuple Routing 与跨分区 UPDATE

INSERT INTO app.event_log (
    tenant_id, event_id, occurred_at, event_type, payload
) VALUES ($1, $2, $3, $4, $5);

应用只写根表。PostgreSQL 根据 $3 沿分区树路由。不要让普通业务代码拼接具体分区名:这会绕开统一约束、增加注入风险、让迁移和重分区困难。

更新分区键可能移动行:

UPDATE app.event_log
SET occurred_at = $1
WHERE tenant_id = $2
  AND occurred_at >= $3
  AND occurred_at <  $4
  AND event_id = $5;

若新值不再满足原分区边界,Executor 将行移动到目标分区。其代价接近删除旧版本并向新叶分区插入新版本:涉及两个 Heap、两组本地索引、更多 WAL、更多锁与触发器/FK 语义。高并发系统应尽量让分区键不可变。

5.7 查询形式与 Partition Pruning

推荐:直接使用与边界同型的半开区间

SELECT tenant_id, event_id, occurred_at, event_type
FROM app.event_log
WHERE occurred_at >= $1
  AND occurred_at <  $2
  AND tenant_id = $3
ORDER BY occurred_at, event_id;

风险:只对分区键套函数

-- 可能不能按原始 timestamptz Range 边界完成有效裁剪。
SELECT count(*)
FROM app.event_log
WHERE date_trunc('month', occurred_at) = $1;

可改为由应用计算边界并传入 [month_start, next_month_start)。这也避免数据库逐行调用函数。

为什么“不包含分区键”会扫描大量分区

分区裁剪依据的是分区边界,不是本地索引。查询若只有 event_id = $1,Planner 无法证明该 ID 不存在于某个月,因此每个月都可能包含目标行。结果可能是对每个叶分区执行一次本地 Index Scan;有 120 个月就可能产生 120 次索引探测、120 组关系锁和元数据访问。

5.8 规划期、初始化期与动态执行期裁剪

EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
SELECT count(*)
FROM app.event_log
WHERE occurred_at >= TIMESTAMPTZ '2026-06-01 00:00:00+00'
  AND occurred_at <  TIMESTAMPTZ '2026-07-01 00:00:00+00';
  • 规划期裁剪:常量在 Plan 阶段已知,无关分区通常根本不出现在最终计划中。
  • 初始化期裁剪:Generic Plan 或参数在执行初始化时才已知;EXPLAIN 可显示 Subplans Removed。被初始化期裁剪的分区仍可能在执行开始时被锁定。
  • 动态执行期裁剪:参数化 Nested Loop 的内侧参数随外侧行变化;检查各子计划的 loops(never executed)

参数化查询不会天然破坏裁剪。需要同时检查:

SHOW plan_cache_mode;

SELECT name, statement, generic_plans, custom_plans
FROM pg_prepared_statements;

plan_cache_mode = auto 通常应保留。Generic Plan 可节省重复规划成本,仍能执行初始化/执行期裁剪;但若不同参数对应的数据量差异巨大,Custom Plan 可能更准确。不要在全局层面武断强制 Custom Plan,应对目标 SQL 做基准和计划验证。

5.9 分区索引

CREATE INDEX event_log_tenant_time_idx
    ON app.event_log (tenant_id, occurred_at DESC)
    INCLUDE (event_type);

父表索引是虚拟对象;PostgreSQL 为现有叶分区创建真实索引,未来新建或 Attach 的分区也必须具备可附加的匹配索引。

分区不替代索引:

  • 裁剪回答“哪些分区不可能有数据”;
  • 索引回答“在剩余分区内如何快速找到少量行”。

若查询通常扫描整月的 70%,该月顺序扫描可能优于索引;若查询在一个月中只取某租户的少量事件,本地复合索引通常有价值。

父分区表不能直接使用 CREATE INDEX CONCURRENTLY。在线补索引的模式是:

CREATE INDEX event_log_type_time_idx
    ON ONLY app.event_log (event_type, occurred_at);

CREATE INDEX CONCURRENTLY event_log_2026_06_type_time_idx
    ON app.event_log_2026_06 (event_type, occurred_at);

ALTER INDEX app.event_log_type_time_idx
    ATTACH PARTITION app.event_log_2026_06_type_time_idx;

对所有叶分区完成并 Attach 后,父级分区索引才会变为有效。此流程必须由迁移系统跟踪完整性,不能漏掉叶分区。

5.10 Unique Constraint、Primary Key 与全局唯一业务 ID

PostgreSQL 18 在分区父表上建立 UNIQUEPRIMARY KEY 时:

  • 唯一键必须包含所有分区键列
  • 分区键不能包含表达式或函数调用;
  • 多级分区时,约束需覆盖目标父表及后代分区层级所需的全部分区键。

原因是每个真实索引只属于一个叶分区,核心 PostgreSQL 没有一个通用索引跨所有叶分区仲裁冲突。

如何保证全局唯一业务 ID

方案数据库保证强度优点代价与适用条件
将分区键纳入业务主键,如 (occurred_at, event_id)原生约束,简单调用方必须携带复合键;单独 event_id 不由数据库全局验证
按业务 ID Hash 分区,并以业务 ID 为唯一键UNIQUE(id) 包含分区键,可全局强制不利于按时间整分区淘汰;时间查询跨多个 Hash 分区
独立未分区注册表 event_identity(id PK, occurred_at),同一事务先注册再写事件保留时间 Range 生命周期与全局唯一性多写一张表和索引;注册表可能成为容量/写热点;删除与归档要设计一致性
应用生成 UUIDv7/随机 UUID,并接受碰撞概率模型概率性无中心写热点,跨系统方便;[PG18] 可用 uuidv7()不是数据库跨分区唯一证明;安全/审计域可能仍要求注册表
触发器查询所有分区查重弱且危险表面上像全局检查并发竞态、扫描成本和锁复杂,通常禁止

强一致注册表示例:

CREATE TABLE app.event_identity (
    event_id    uuid        PRIMARY KEY,
    occurred_at timestamptz NOT NULL,
    tenant_id   bigint      NOT NULL
);

BEGIN;
INSERT INTO app.event_identity (event_id, occurred_at, tenant_id)
VALUES ($1, $2, $3);

INSERT INTO app.event_log (
    tenant_id, event_id, occurred_at, event_type, payload
) VALUES ($3, $1, $2, $4, $5);
COMMIT;

注册表也需要生命周期策略。若事件删除后 ID 永远不可复用,则注册表可能长期增长;可只保留哈希指纹、使用另一种分片方式,或明确 ID 可复用窗口。

5.11 Foreign Key

PostgreSQL 18 支持分区表参与外键,但要先满足引用侧唯一性:被引用列必须由有效的主键或唯一约束保证。因此,若被引用表自身按时间分区,单列业务 ID 无法在父表上建立唯一约束,就不能直接作为标准 FK 的全局目标。

生产注意事项:

  • 外键检查可能访问多个叶索引;让引用列与分区键对齐可减少放大。
  • ON DELETE CASCADE 作用于大量分区行时仍是逐行 DML,不会自动变成 DROP PARTITION
  • DETACH 会考虑引用该分区表的外键并获取相关锁;执行前检查锁图。
  • [PG15+] 改进了分区键更新导致行跨分区移动时的外键语义,根表层面表现更接近一次更新。

5.12 ATTACH、DETACH、DETACH CONCURRENTLY 与 DROP

ATTACH:先离线准备,再短时纳入

CREATE TABLE app.event_log_2026_08_stage
    (LIKE app.event_log INCLUDING DEFAULTS INCLUDING CONSTRAINTS);

ALTER TABLE app.event_log_2026_08_stage
    ADD CONSTRAINT event_log_2026_08_bound
    CHECK (
        occurred_at >= TIMESTAMPTZ '2026-08-01 00:00:00+00'
        AND occurred_at < TIMESTAMPTZ '2026-09-01 00:00:00+00'
    );

-- 可先装载、校验、建索引,再 Attach。
ALTER TABLE app.event_log
    ATTACH PARTITION app.event_log_2026_08_stage
    FOR VALUES FROM ('2026-08-01 00:00:00+00')
                 TO ('2026-09-01 00:00:00+00');

匹配且已验证的 CHECK 约束可避免 Attach 时扫描待附加表。若存在 Default,还应先让 Default 具有可证明排除新范围的约束,否则会扫描并 ACCESS EXCLUSIVE 锁住 Default。

普通 DETACH

ALTER TABLE app.event_log
    DETACH PARTITION app.event_log_2025_06;

分区保留为独立表,父表通常需要 ACCESS EXCLUSIVE 锁。适合维护窗口或已停止访问的树。

DETACH CONCURRENTLY [PG14+]

ALTER TABLE app.event_log
    DETACH PARTITION app.event_log_2025_06 CONCURRENTLY;
  • 不能放在事务块中。
  • 父表存在 Default 时不允许。
  • 内部使用两个事务:第一阶段以 SHARE UPDATE EXCLUSIVE 标记待分离并提交,然后等待所有旧事务结束;第二阶段再次锁父表,并以 ACCESS EXCLUSIVE 锁叶分区完成分离和补充 CHECK
  • 中断后使用:
ALTER TABLE app.event_log
    DETACH PARTITION app.event_log_2025_06 FINALIZE;

同一父表同一时间最多一个待完成的并发分离。

DROP

DROP TABLE app.event_log_2025_06;

它会快速删除整分区,不逐行产生每条记录的删除版本,也无需后续 Vacuum 回收这些行。但它需要父表 ACCESS EXCLUSIVE 锁,且删除不可通过普通 SQL 查询恢复;必须确认备份、归档和保留策略。

5.13 锁行为速查

操作父表主要锁叶表/待附加表主要锁关键并发风险
CREATE TABLE ... PARTITION OFACCESS EXCLUSIVE新表创建锁阻塞父表读写,适合预建空分区且设置短 lock_timeout
ATTACH PARTITIONSHARE UPDATE EXCLUSIVE待附加表 ACCESS EXCLUSIVE;Default 也可能 ACCESS EXCLUSIVE验证扫描时间长、Default 被读时等待
普通 DETACH PARTITION通常 ACCESS EXCLUSIVE相应强锁读写父表均可能阻塞
DETACH ... CONCURRENTLY两阶段 SHARE UPDATE EXCLUSIVE第一阶段较弱,第二阶段 ACCESS EXCLUSIVE等待旧事务;不可在事务块;不可有 Default
DROP TABLE 分区ACCESS EXCLUSIVE删除目标关系短操作也可能因锁队列长时间等待
父表 CREATE INDEX递归建立叶索引每个叶表相应锁不支持父级 CONCURRENTLY,大树风险高

DDL 的“执行时间短”不代表“总延迟短”。在锁队列中排队 30 分钟后只执行 50 ms,仍是严重事故。始终设置合适的 lock_timeout,并在重试前检查 blocker,而不是无界重试。

5.14 Partition-Wise Join 与 Aggregate

SET LOCAL enable_partitionwise_join = on;
SET LOCAL enable_partitionwise_aggregate = on;

两项默认均为 off,因为计划节点数、规划 CPU、规划内存以及受 work_mem 限制的执行节点可能随扫描分区数线性增长。

Partition-Wise Join 典型条件:

  • 两侧都按兼容键分区;
  • Join 条件包含全部分区键,类型一致;
  • 子分区能一一匹配。
SELECT o.order_month, sum(o.amount)
FROM app.orders o
JOIN app.refund r
  ON r.order_month = o.order_month
 AND r.order_id = o.order_id
WHERE o.order_month >= DATE '2026-01-01'
  AND o.order_month <  DATE '2026-07-01'
GROUP BY o.order_month;

Partition-Wise Aggregate 可在每个分区先聚合;若 GROUP BY 不包含全部分区键,还需最终聚合。它适合扫描少数大分区的分析型查询,不应因“听起来更快”而全局开启。

[PG18] 改进了访问大量分区时的规划效率,允许更多场景使用 Partition-Wise Join,并降低其内存用量,同时改善分区查询成本估算。但这些改进不是取消分区数量成本;不良谓词仍可留下大量分区,且各计划节点仍有 CPU/内存代价。

5.15 分区统计信息与 Autovacuum

  • 每个叶分区是普通表,拥有自己的 pg_statisticpg_stat_all_tables、Dead Tuple、Freeze Age 和 Autovacuum 状态。
  • 父分区表还需要描述整个继承/分区树的统计信息,用于跨分区估算。
  • 叶分区发生变化不会自动触发父表 Analyze;Autovacuum 不会因为子分区变化而自动为虚拟父表完成所需的全树统计。因此应定期:
ANALYZE app.event_log;
  • 新建并批量装载分区后,应对叶分区 ANALYZE,再根据跨分区查询需要 Analyze 父表。
  • 不要全局关闭 Autovacuum。可按叶分区数据变化率设置表级阈值,但必须保留防 XID Wraparound 的保护。

诊断:

SELECT
    relid::regclass AS partition,
    n_live_tup,
    n_dead_tup,
    last_autovacuum,
    last_autoanalyze,
    autovacuum_count,
    autoanalyze_count
FROM pg_stat_all_tables
WHERE relid IN (
    SELECT relid FROM pg_partition_tree('app.event_log') WHERE isleaf
)
ORDER BY partition::text;

5.16 冷热数据、Tablespace 与归档

可让新分区位于快速 Tablespace,历史分区迁移到容量型存储:

CREATE TABLE app.event_log_2026_09
    PARTITION OF app.event_log
    FOR VALUES FROM ('2026-09-01 00:00:00+00')
                 TO ('2026-10-01 00:00:00+00')
    TABLESPACE ts_hot;

ALTER TABLE app.event_log_2025_01
    SET TABLESPACE ts_cold;

但要理解:

  • SET TABLESPACE 会搬动物理数据,需要锁、I/O、额外空间和 WAL;不是零成本元数据操作。
  • Tablespace 不是备份、复制或对象存储归档。所有 Tablespace 必须被备份方案、Replica 主机路径和故障切换节点正确提供。
  • 冷分区仍在主库中时,跨历史查询仍占用主库 CPU/I/O。真正归档可先 DETACH,使用 COPY/pg_dump 导出并验证,再删除独立表。
  • Foreign Table 可作为更冷的只读层,但会引入远端一致性、网络、Planner 估算与备份边界问题,本章不将其视为透明替代。

5.17 自动创建未来分区

维护任务应提前创建至少覆盖“当前写入窗口 + 时钟偏差 + 延迟数据窗口”的未来分区。例如按月表可保持未来 3~6 个月,具体取决于发布频率和最长延迟事件。

必须满足:

  • 独立 CronJob、systemd timer 或运维任务运行,不在 HTTP/gRPC 普通请求内动态 DDL。
  • 使用 advisory lock 保证单实例。
  • lock_timeoutstatement_timeout 和最大重试次数。
  • 只从可信日期生成对象名;DDL 标识符不能用 $1 参数代替,最好封装为数据库侧受控函数并由 Go 参数化调用。
  • 创建后验证边界、索引、约束、Tablespace、Owner、Privileges 和统计信息。
  • 对“未来可写天数不足”“Default 出现行”“维护任务连续失败”告警。

5.18 分区与分片的区别

维度单实例分区分片 Sharding
数据位置同一 PostgreSQL Cluster 内的多个关系多个 PostgreSQL Cluster/节点
资源上限仍受单节点 CPU、内存、WAL、存储和连接上限约束可横向扩展,但有跨节点协调成本
事务原生单库事务跨分片事务通常更复杂或受限
Join/聚合单 Planner/Executor 可统一优化常需路由、下推和结果合并
高可用整个 Cluster 一套 HA每个分片都需 HA,另有路由/元数据 HA
主要目标生命周期、局部索引、裁剪、维护隔离突破单节点容量与吞吐边界

把单表切成 1,000 个分区不会增加 CPU 核数、WAL 写带宽或主库数量。单节点已到资源极限时,应进入容量规划和分片设计,而不是继续增加分区数量。

5.19 七个必须明确回答的问题

  1. 千万行是否必然需要分区? 不必然。千万行、甚至更多行的窄表,在索引、缓存、VACUUM 和查询设计良好时完全可能无需分区。是否分区取决于维护窗口、数据保留、单索引尺寸、查询裁剪率、装载/删除模式和 SLO,而不是行数阈值。
  2. 分区能否替代索引? 不能。分区裁剪先缩小关系集合,本地索引再缩小每个关系中的行集合。
  3. 为什么不包含分区键会扫描大量分区? 因为边界只描述分区键范围;没有该条件,任何分区都可能命中,只能逐分区扫描或探测本地索引。
  4. 按天、月、年如何选择? 让粒度贴近最小常用删除/归档批次和典型查询窗口,同时控制总分区数与单分区索引大小。按天适合高写入量和短保留;按月是常见平衡;按年适合低数据量、低频维护,不适合每次只删除一个月。
  5. Default Partition 有什么风险? 掩盖缺失分区、无限增长、Attach 新分区时扫描和强锁、数据迁移复杂,并阻止 DETACH CONCURRENTLY
  6. 为什么 DROP 分区通常比大量 DELETE 高效? DROP 主要删除关系元数据和物理文件,不逐行写删除版本、维护每个索引、生成相同比例 WAL 或等待 Vacuum 回收;代价是强 DDL 锁和粗粒度删除。
  7. 如何保证全局唯一业务 ID? 让唯一键包含全部分区键、按 ID Hash 分区,或使用独立全局注册表;仅依赖随机 UUID 是概率保证,不是 PostgreSQL 跨分区唯一约束。

5.20 PostgreSQL 14~18 关键差异

版本与本章直接相关的变化
[PG14+]大量分区上的 UPDATE/DELETE 规划开销降低,并支持更多执行期裁剪;引入 DETACH PARTITION ... CONCURRENTLYFINALIZE
[PG15+]当大量分区中仅少数相关时继续改善规划时间;Default/List 分区被裁剪后可在更多场景避免排序;改善跨分区行移动的 FK 行为;支持对分区表执行 CLUSTER
[PG16+]缓存 Range/List 分区查找,改善路由/查找性能;VACUUM/ANALYZE 可用 BUFFER_USAGE_LIMIT 控制共享缓冲区占用。
[PG17+]支持布尔分区键 IS [NOT] UNKNOWN 裁剪;改善分区表 LIMIT 优化;EXPLAIN (MEMORY) 可辅助观察规划内存。
[PG18]改善访问大量分区时的规划效率;扩大 Partition-Wise Join 适用场景并降低内存;改善分区查询成本估算;AIO 可影响顺序扫描、Bitmap Scan 和 Vacuum 的 I/O 路径,但不能消除无效跨分区访问。

6. 底层原理

6.1 INSERT 元组路由时间线

INSERT INTO app.event_log ... 为例:

  1. Parse/Analyze:根表名称解析为分区父表 OID,检查列、类型和权限。
  2. Plan:生成 ModifyTable 写入计划。目标逻辑上是根表,但 Executor 准备分区路由状态。
  3. 计算分区键:从输入 Slot 读取 occurred_at;若是表达式键则计算表达式。
  4. 查找边界:在当前分区描述符中定位匹配子分区;多级分区继续向下查找。
  5. 无目标处理:没有匹配叶分区时,若存在 Default 则进入 Default,否则语句失败。
  6. 打开叶关系:建立对应 ResultRelInfo,检查叶分区约束、RLS、触发器和索引。
  7. 写 Heap:在叶分区生成 Tuple Version;必要时写 TOAST。
  8. 维护本地索引:每个叶索引插入 Index Tuple,唯一冲突只在该索引的可见范围内仲裁。
  9. 记录 WAL:Heap、Index、Visibility Map 等变化按正常规则产生 WAL。
  10. 提交:WAL 持久性、同步复制等待和 Commit 结果不确定性与普通表相同。

[PG16+] 对 Range/List 分区查找增加缓存优化,但这不意味着任意深度和数量的分区没有路由成本。

6.2 Partition Pruning 的证明问题

裁剪本质是集合不相交证明:

  • 分区边界:P = [2026-06-01, 2026-07-01)
  • 查询条件:Q = [2026-08-01, 2026-09-01)
  • 若 Planner 能证明 P ∩ Q = ∅,该分区可被排除。

索引不参与这个证明。即使六月分区有 event_id 索引,查询 event_id = X 也与 occurred_at 边界没有逻辑矛盾,因此不能裁剪六月。

常见破坏证明的写法:隐式类型转换、非 Immutable 函数、复杂 OR、分区键上函数、时区不一致、与另一列比较。应先检查实际表达式和类型,而不是只看 SQL 文本是否“出现了时间”。

6.3 参数化查询的三种计划路径

  1. Custom Plan:每次根据具体参数规划,参数值可用于规划期裁剪;规划成本更高。
  2. Generic Plan + 初始化期裁剪:计划结构可包含多个子计划,执行初始化时用 $1/$2 删除无关子计划;节省重复规划。
  3. 动态参数化计划:Nested Loop 内侧的分区键值来自外侧行,每次参数变化时重新裁剪;计划中可能列出多个子计划,但很多显示 (never executed)

因此,看到 Generic Plan 不应直接判断“所有分区都会扫描”。必须查看 Subplans Removed、实际 loops、Buffers 和每个子计划的执行状态。

6.4 UPDATE 跨分区移动

occurred_at 从六月改到七月:

六月叶分区旧 Tuple --标记删除/更新--> 旧版本留给 MVCC
                              |
                              +--> 七月叶分区插入新 Tuple
                                      +--> 七月本地索引插入

影响:

  • 旧分区出现 Dead Tuple,需 Vacuum。
  • 新分区产生新行和索引项。
  • 两边都产生 WAL 和 Buffer Dirty。
  • 并发事务可能看到符合其 Snapshot 的旧版本或新版本,而不会看到违反 MVCC 的“半行”。
  • 若目标分区不存在,整个语句失败。
  • 触发器、外键、逻辑复制和 CDC 消费者需按根表语义验证,不要假设只有单关系原地更新。

6.5 ATTACH 的验证与锁

ATTACH 必须确认:

  • 列名、类型、顺序与父表一致;
  • NOT NULLCHECK、父级索引/约束满足要求;
  • 所有现有行均位于新边界;
  • 新边界与其他分区不重叠;
  • Default 中不存在应迁入新分区的行。

若待附加表有匹配且有效的 CHECK,Planner/DDL 逻辑可跳过全表验证扫描。否则会在持有待附加表 ACCESS EXCLUSIVE 时扫描。Default 同理。优化 Attach 的关键不是“让 ALTER 更快”,而是在不影响父表的准备阶段先完成可证明约束与数据校验

6.6 DETACH CONCURRENTLY 的状态机

PostgreSQL Declarative Partitioning 与大型数据生命周期 flow 2

第一阶段提交后,旧事务仍可能基于旧分区树运行,因此系统必须等待它们退出。长事务会直接拉长维护时间。并发版本降低的是父表锁强度,不是取消所有等待。

6.7 DROP 与 DELETE 的物理差异

DELETE WHERE occurred_at < cutoff

  • 逐行查找、加行锁、写 Heap 删除标记;
  • 逐个维护索引可见性;
  • 产生大量 WAL、Dirty Buffer 和可能的 Full-Page Image;
  • 空间通常不会立即返还操作系统;
  • 后续 Vacuum 清理 Dead Tuple 和索引垃圾;
  • 长 Snapshot 会延迟清理。

DROP TABLE old_partition

  • 以关系为单位修改系统目录并安排删除物理文件;
  • 不逐行制造 Dead Tuple;
  • 不需要为这些行运行 Vacuum;
  • 通常耗时和 WAL 不随行数线性增长;
  • 但需要强 DDL 锁,删除粒度必须与分区边界一致。

6.8 Partition-Wise Join/Aggregate 的代价模型

传统计划可能先 Append 两侧分区,再进行一个大 Join。Partition-Wise Join 则构造多个“分区 A_i Join 分区 B_i”的子树。好处是局部数据集更小、缓存局部性更好、可并行;代价是计划节点数量增多,每个 Hash/Sort/Aggregate 节点都可能消耗 work_mem,规划搜索空间和内存也增长。

[PG18] 降低了其中部分规划和内存成本,并放宽了可用场景,但仍需以实际 EXPLAIN (ANALYZE, BUFFERS, SETTINGS, MEMORY)、进程 RSS、临时文件和 P95/P99 验证。


7. 内部数据结构和状态

7.1 关系与存储

对象pg_class.relkind是否有 Heap主要内容
分区父表/中间分区表p列定义、分区策略、约束、虚拟索引关系
叶普通表rHeap Page、Tuple、FSM、VM、TOAST(如需要)
父分区索引I分区索引层级和有效性
叶索引i真实 Index Page 与 Index Tuple

父表没有 Heap Page,因此 pg_relation_size(parent) 不代表整棵树大小。使用:

SELECT pg_size_pretty(sum(pg_total_relation_size(relid))) AS total_tree_size
FROM pg_partition_tree('app.event_log')
WHERE isleaf;

注意 pg_total_relation_size 包含索引和 TOAST;按树求和时只统计叶,避免重复理解父虚拟对象。

7.2 系统目录

pg_partitioned_table

记录分区策略:

  • partstratr/l/h
  • partnatts:分区键数量;
  • partdefid:Default 分区 OID;
  • partattrs:键列号,0 表示表达式;
  • partclasspartcollationpartexprs:操作符类、排序规则和表达式树。

pg_inherits

每个直接父子关系一行:

  • inhrelid:子关系;
  • inhparent:直接父关系;
  • inhseqno:继承顺序;
  • inhdetachpending:是否处于并发 Detach 待完成状态。

pg_class

  • relispartition:是否为某父关系的分区;
  • relpartbound:内部边界表达式;
  • reltuplesrelpages:估算与成本相关;
  • relfrozenxid/relminmxid:叶表防 Wraparound 状态。

可读化边界:

SELECT
    c.oid::regclass AS relation,
    c.relkind,
    c.relispartition,
    pg_get_expr(c.relpartbound, c.oid) AS partition_bound,
    i.inhdetachpending
FROM pg_class AS c
LEFT JOIN pg_inherits AS i
  ON i.inhrelid = c.oid
WHERE c.oid IN (
    SELECT relid FROM pg_partition_tree('app.event_log')
)
ORDER BY c.oid::regclass::text;

7.3 Relcache、Memory Context 与缓存计划

每个访问分区树的 Backend 会把相关关系、属性、分区描述和边界元数据装入本地内存。会话长期访问大量分区时,进程内存可随已触碰关系增长。DDL 改变树后,通过 Catalog Invalidation 使其他 Backend 的 Relcache 和缓存计划重新验证。

因此:

  • 过度分区不仅是一次查询的 Planning Time 问题,也是大量长连接的累计内存问题。
  • PgBouncer 降低空闲 Backend 数不等于消除每个活跃 Backend 的分区元数据成本。
  • 频繁创建/删除分区会使缓存计划反复失效;维护频率应与粒度收益平衡。

7.4 Lock 状态

查询父表通常会对实际访问或计划涉及的关系获取关系锁。初始化期裁剪发生前,某些后来被裁剪的分区仍可能在执行开始时锁定。锁诊断不能只看“实际扫描了几张表”,还应看 pg_locks 中父表和叶分区的 Relation Lock。

SELECT
    a.pid,
    a.xact_start,
    a.query_start,
    a.state,
    a.wait_event_type,
    a.wait_event,
    l.mode,
    l.granted,
    l.relation::regclass AS relation,
    a.query
FROM pg_locks AS l
JOIN pg_stat_activity AS a USING (pid)
WHERE l.relation IN (
    SELECT relid FROM pg_partition_tree('app.event_log')
)
ORDER BY l.granted, a.query_start;

7.5 WAL、LSN 与 Replica

分区 DML 的 WAL 与普通叶表 DML相同;分区 DDL 还会记录目录与关系创建/删除相关变化。物理 Standby 重放后必须得到一致的分区树和文件状态。逻辑复制按发布配置和版本行为处理分区根/叶,切换策略必须在第 22 章单独验证;不要假设订阅端自动拥有相同分区 DDL。

监测维护前后 WAL:

SELECT pg_current_wal_lsn();
-- 执行实验操作
SELECT pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), $1::pg_lsn));

7.6 统计状态

  • 叶级统计决定单分区扫描、索引和 Join 估算。
  • 父级继承统计帮助跨分区查询。
  • 新分区初始 reltuples 可能为未知/低质量值;批量装载后不 Analyze 会导致估算错误。
  • 分区数据分布差异很大时,逐叶统计反而是优势;但父级合并估算仍可能掩盖热点月份,需要对关键 SQL 做参数分布测试。

8. 场景和选型决策

业务场景推荐方案不推荐方案原因性能代价并发代价一致性代价高可用代价运维复杂度
日志/事件,按月保留 24 个月RANGE(occurred_at),月分区,预建未来分区单表每日大批 DELETE生命周期边界与删除批次一致跨月查询有 Append;单月索引更小月度 DDL 需锁管理无额外业务一致性代价DDL/WAL 要在副本重放;备份需覆盖树
每日 TB 级、只保留 14 天日分区,必要时日内 Hash 子分区月分区单月过大、删除粒度太粗分区数约 14~几十,可控每日维护频繁分区键应不可变更频繁 DDL 需监控 Replica Lag中高
低写入审计表,保留 10 年,按年查询年或季度 Range每日分区每日分区收益小、对象数过多年内索引较大DDL 少简单备份对象少
少量固定区域隔离LIST(region)每租户一分区区域低基数、边界稳定跨区域查询多个分区区域迁移可能跨分区 UPDATE主键需包含 regionFailover 无特殊变化低中
幂等键高并发点查,需数据库全局唯一HASH(idempotency_key) + PK(key)时间 Range + 仅叶级 UNIQUE(key)Hash 键即唯一键,可原生强制时间清理触及全部 Hash 分区写热点较均匀,但仍共享 WAL强唯一单节点资源上限不变
事件按时间淘汰,同时需全局 ID时间 Range + 独立 ID 注册表触发器扫描全树查重分离生命周期和唯一仲裁每写多一次索引/表访问注册表可能热点强唯一,可事务化注册表纳入备份/复制
典型查询不带候选分区键重新选键、增加路由元数据或不分区仅靠大量本地索引无法裁剪会读放大Planning/Index Probe 放大关系锁和连接占用上升故障时恢复慢查询更多中高
热数据 NVMe、冷数据容量盘按时间分区 + Tablespace/Detach 归档期望 OS 自动识别业务冷热分区提供明确移动单元移动产生 I/O/WALSET TABLESPACE 锁与带宽竞争每个 HA 节点需相同存储布局
单节点 CPU/WAL 已饱和进入读扩展/分片设计继续增加分区数分区不增加硬件资源不能解决总吞吐上限不减少全局 WAL/连接争用分片会引入新一致性问题每分片独立 HA很高

8.1 按天、月、年选择的计算框架

不要以“行数”单指标拍板。至少收集:

  • 每日新增行数与平均/尾部行宽;
  • 每日 Heap、TOAST、各索引增长量;
  • 典型查询时间窗:1 小时、1 天、30 天还是全历史;
  • 最小删除与归档批次;
  • 允许的 DDL 频率和维护窗口;
  • 保留年限对应的总分区数;
  • 每个查询裁剪后剩余分区数;
  • 单分区 Vacuum、Analyze、备份、恢复和索引重建时限。

经验不是固定规则:让单分区足够小,以便在目标维护窗口内完成;又足够大,避免分区对象数、规划和 DDL 频率主导系统成本。


9. 高性能分析

9.1 评估前必须记录的环境

任何分区基准必须记录:PostgreSQL 版本和配置、数据量、平均/P95 行宽、分区数量与层级、每分区索引、数据倾斜、并发数、读写比例、测试持续时间、连接池大小、CPU、内存、shared_buffers、存储介质、文件系统、缓存冷热状态、查询窗口和 SLO。没有这些信息的“月分区比日分区快 30%”没有可迁移性。

9.2 CPU

收益:裁剪可减少 Filter、Tuple 解码、表达式计算和索引比较。风险:大量分区增加 Planner CPU、Relcache 查找、计划节点初始化和多个本地索引探测。PG18 改善了大量分区规划效率,但不能补救没有裁剪条件的查询。

关键指标:EXPLAIN 的 Planning/Execution Time、pg_stat_statements.total_plan_time/mean_plan_time、数据库进程 CPU、计划节点数量、每次执行剩余分区数。

9.3 内存

  • Planner/Executor 为每个剩余分区和计划节点分配状态。
  • Partition-Wise Join/Aggregate 可产生多个受 work_mem 限制的节点,总内存不是“整条查询只用一个 work_mem”。
  • 每个 Backend 会缓存访问过的分区元数据。
  • 并发 100 条跨 500 分区查询与单条查询完全不同。

[PG17+] 可结合 EXPLAIN (ANALYZE, MEMORY, ...) 观察规划期内存;仍需监测 OS RSS、OOM 和 cgroup 限制。

9.4 shared_buffers 与 OS Page Cache

较小的热分区和本地索引可能提高缓存局部性;但跨全历史扫描会逐个污染 Buffer Cache 和 OS Page Cache。按分区拆表不会自动为热数据预留缓存。需要通过访问模式、索引、Tablespace、预热策略和工作负载隔离控制。

9.5 随机 I/O、顺序 I/O 与 PG18 AIO

  • 单点查若没有分区键,可能在许多叶索引做随机 I/O。
  • 宽时间窗聚合可能对多个叶表做顺序扫描。
  • [PG18] AIO 可让部分顺序扫描、Bitmap Heap Scan 和 Vacuum 排队更多读取,提升 I/O 并行度;应监测 pg_stat_io、设备队列、吞吐和延迟。
  • AIO 不改变逻辑读放大:扫描 120 个无关分区仍是错误设计,只是 I/O 提交机制更高效。

9.6 网络往返

分区在服务器内部透明,单条跨分区 SQL不等于 100 次客户端往返。但应用逐分区循环查询会制造 N 次网络往返、N 次规划/执行和连接占用,应改为一条根表查询,让 PostgreSQL裁剪和并行。

9.7 索引维护成本

每个新分区都需要完整索引集合;索引越多,未来分区创建时间、Catalog 对象数、写放大和备份体积越大。冷热分区可采用不同额外索引,但父级索引会要求整个树保持匹配。历史只读分区可考虑删除不再需要的写路径索引,前提是查询与恢复演练验证。

9.8 WAL 与 Checkpoint

  • 批量 DELETE 产生与行数和索引维护相关的大量 WAL,推高 Checkpoint、归档和 Replica Lag。
  • DROP/DETACH 的 WAL 远小于逐行删除,但仍是 DDL,需在副本重放目录变更。
  • 新分区批量装载可能造成短期 WAL 峰值;使用 COPY、合理批次、Checkpoint 和复制监控,而不是关闭 fsyncfull_page_writes

9.9 Vacuum

分区允许逐叶 Vacuum 和 Freeze,缩小单次工作集;但叶表数量多会增加 Autovacuum 调度和启动成本。历史只读分区仍需 Freeze 到安全年龄,不能因“不会更新”就永远忽略。父表统计则需手工 Analyze。

9.10 Temporary File

跨分区 Join/Aggregate/Sort 可能产生一个大临时文件,也可能因 Partition-Wise 计划产生多个局部临时文件。监测 log_temp_filespg_stat_database.temp_bytesEXPLAIN 的 Sort Method/Hash Batches。不要只提高 work_mem;并发乘法可能造成内存事故。

9.11 吞吐量与 P95/P99

平均延迟常掩盖两个尾延迟来源:

  1. 缺失分区键的少数跨全树查询;
  2. 分区 DDL 在锁队列中阻塞大量后续请求。

基准应分别测:单分区、典型跨 3 分区、最坏全历史;冷/热缓存;Custom/Generic Plan;无 DDL与维护并发;并记录 P50/P95/P99、TPS、Planning Time、Buffers、WAL、CPU、I/O 和 Wait Event。

9.12 读、写和空间放大

  • 读放大:剩余分区数 × 每分区扫描/索引探测。
  • 写放大:Heap + 每个叶索引 + WAL;跨分区 UPDATE 近似两侧写入。
  • 空间放大:每叶表和索引的固定元数据/Page 开销、重复索引根页、Bloat、Default 临时重复、归档副本。

10. 高并发分析

10.1 必须区分的并发量

  • 应用 goroutine 数:可能远大于连接数,只代表潜在请求。
  • 连接数:PostgreSQL Backend 或池连接数量。
  • 活跃查询数:当前正在 CPU/I/O/锁等待的查询。
  • TPS:单位时间成功事务数。
  • 排队请求数:在应用 semaphore、pgxpool Acquire 或数据库锁队列中等待的请求。

分区不会自动解决连接竞争。一个请求扫描 500 分区会更久占用连接,使池 Acquire Wait 和排队请求增加。

10.2 MVCC 与长事务

每个叶分区仍遵循 MVCC。长事务可能:

  • 阻止旧分区 Dead Tuple 清理;
  • 延迟 DETACH CONCURRENTLY 等待旧事务阶段;
  • 在强 DDL 已排队时造成后续请求排队;
  • 让历史分区删除后的空间回收与 Replica 冲突更复杂。

监控 xact_startbackend_xmin、最老 Snapshot 和 idle in transaction。

10.3 锁竞争与阻塞队列

典型事故:维护任务请求 ACCESS EXCLUSIVE,被一个长查询阻塞;强锁进入队列后,后续本可与长查询兼容的请求也可能排在 DDL 后面,形成“长查询 + DDL + 请求雪崩”。

维护任务应:

SET lock_timeout = '2s';
SET statement_timeout = '30s';

超时后先诊断 blocker,再有界重试。不能持续立即重试,否则形成 DDL 重试风暴。

10.4 热点行与热点索引页

时间 Range 分区可把“当前月”写入集中到一个叶表,但并不消除热点:

  • 单调主键可能集中到 B-tree 右端页;
  • 所有写仍争用同一 WAL、Buffer Mapping、Checkpoint 和存储;
  • 单租户计数器仍是单行锁热点。

二级 Hash 子分区可分散部分本地索引和 Heap 写入,但会增加分区数,且不能解决单行更新、全局序列或主库 WAL 上限。

10.5 死锁

跨分区更新、外键和业务多行修改可能以不同顺序锁定多个叶分区中的行,仍会死锁。统一按稳定业务键排序更新,缩短事务,并仅对 SQLSTATE 40P01 重试完整事务。不要将 DDL 锁超时 55P03 与死锁混为一谈。

10.6 Backpressure 与 Admission Control

  • 普通请求使用有界 pgxpool;不要为每个 goroutine 创建连接。
  • 全历史报表、分区维护和在线业务使用不同队列/连接预算。
  • 对跨分区查询设置业务允许的最大时间窗,或路由到只读副本/分析系统。
  • 维护任务单实例、低并发;创建 24 个分区不需要 24 个 goroutine 并发 DDL。
  • 监控 pgxpool.Stat()AcquiredConnsEmptyAcquireCountEmptyAcquireWaitTime 和取消次数。

10.7 事务边界、幂等与 Commit 不确定

未来分区创建使用事务性 DDL,但连接在 COMMIT 后断开时,客户端不能武断认定“未创建”。重试必须先查询目录判断目标分区及边界是否已存在。DDL 名称和边界是幂等键;CREATE TABLE IF NOT EXISTS 只检查名称存在,不保证现有对象定义正确,因此仍需后置验证。

10.8 goroutine 与连接池

普通请求路径动态 DDL 是反模式:首次遇到新月份的许多 goroutine 可能同时请求强锁、占满连接池并重试。应由独立维护任务提前创建;请求路径若发现无分区,快速失败并告警,而不是自愈式建表。


11. 高可用分析

分区对高可用的关系主要是间接但重要:它不提供副本或故障转移,却改变 WAL、备份对象、恢复验证、维护锁和切换后的 Schema 一致性。

11.1 RPO 与 RTO

  • RPO 由同步/异步复制、WAL 归档与备份策略决定,不因分区自动改善。
  • 分区可缩短历史数据删除、局部恢复演练或索引重建的维护时间,从而间接改善运维 RTO。
  • DROP 分区是逻辑删除;恢复 RPO/RTO 取决于 PITR 或独立归档,不存在“分区回收站”。

11.2 备份与 PITR

  • 物理备份必须包含所有 Tablespace、叶分区文件和系统目录。
  • PITR 恢复到 DROP 前可找回分区,但通常需要恢复到隔离实例再导出目标数据,不能随意让生产主库整体回退。
  • DETACH 后导出冷数据时,要校验行数、边界、校验和/对象清单,并演练恢复,而不是“文件上传成功即归档完成”。

11.3 物理复制与复制延迟

  • 大量 DELETE 的 WAL 和后续 Vacuum 会加重网络、归档和 Standby Replay;整分区淘汰通常更友好。
  • CREATE INDEX、Tablespace 搬迁和批量装载仍可能造成巨大 WAL 峰值。
  • DDL 在 Standby 重放时可能与长查询产生 Recovery Conflict;历史报表副本也需控制长事务。

11.4 逻辑复制

逻辑复制的分区根发布、叶表身份、Replica Identity 与 DDL 同步需按第 22 章的具体拓扑验证。核心逻辑复制不会替你把订阅端 Schema 维护成完全相同的分区树。发布新分区前,要确认订阅端表、约束、权限和路由策略已就绪。

11.5 Planned Switchover / Unplanned Failover

切换前后必须验证:

  • 未来分区在候选新主库上已通过复制存在;
  • 所有 Tablespace 路径与挂载可用;
  • 维护调度器只有一个 Active 实例,避免旧主与新主同时执行 DDL;
  • 旧连接被关闭或重新建立,不能继续向旧主写入;
  • 若 Commit 结果不确定,维护任务通过目录复核后再重试。

11.6 脑裂与 Fencing

若两个节点都接受写入,它们可能各自创建不同分区或向同名分区写入不同数据;后续不能靠简单 Attach 合并。分区不改变脑裂原则:必须先 Fencing 旧主,再提升新主,维护任务也应受同一领导者租约或调度控制。

11.7 数据恢复验证

恢复后至少检查:

SELECT * FROM pg_partition_tree('app.event_log');

SELECT
    c.oid::regclass,
    pg_get_expr(c.relpartbound, c.oid)
FROM pg_class AS c
WHERE c.oid IN (
    SELECT relid FROM pg_partition_tree('app.event_log')
)
ORDER BY 1;

并抽样验证每个边界的最小/最大时间、父级索引有效性、Default 行数、未来分区覆盖和关键查询裁剪。


12. 三维影响矩阵

维度相关度核心收益主要风险关键指标
高性能裁剪无关数据、缩小本地索引、改善缓存局部性、快速淘汰历史过度分区、规划/内存放大、跨分区查询、重复索引Planning Time、剩余分区数、Buffers、P95/P99、temp bytes、CPU/I/O
高并发中高将部分维护和局部写入隔离到叶分区DDL 强锁、长事务、锁队列、当前分区热点、连接占用Lock Wait、blocker 年龄、Active Queries、pool wait、WAL throughput
高可用降低逐行删除 WAL、提供归档单元、缩短部分维护窗口误删恢复、Tablespace 一致性、Replica Lag、双主维护任务WAL/Replay Lag、归档验证、未来分区覆盖、恢复演练 RTO

13. 实验

统一安全说明

以下实验只能在可丢弃数据库执行。实验包含真实 DELETE、DDL、锁等待和 WAL 生成。对 DML 使用 EXPLAIN ANALYZE 会真正执行语句;即使包在 BEGIN/ROLLBACK 中,Sequence、外部系统副作用及部分观测影响也不一定完全回滚。本章不关闭 fsyncfull_page_writes、Autovacuum 或数据校验。

13.1 实验一:按月事件表与 Partition Pruning

13.1.1 实验目标

  1. 建立六个月的 Range 分区表。
  2. 对比规划期裁剪、Generic Plan 的初始化期裁剪,以及关闭裁剪后的计划。
  3. 验证分区裁剪由时间边界驱动,本地索引不能弥补缺失分区键。
  4. 建立不伪造固定耗时的性能记录模板。

13.1.2 版本与扩展

  • PostgreSQL 14~18 均可执行;输出以 PostgreSQL 18 为基线。
  • 必要扩展:无。
  • 可选扩展:pg_stat_statements,仅在实验环境已按规范预加载时使用。

13.1.3 建表与准备数据

DROP SCHEMA IF EXISTS lab14 CASCADE;
CREATE SCHEMA lab14;

CREATE TABLE lab14.event_monthly (
    tenant_id   integer     NOT NULL,
    event_id    bigint      NOT NULL,
    occurred_at timestamptz NOT NULL,
    event_type  text        NOT NULL,
    payload     text        NOT NULL,
    PRIMARY KEY (occurred_at, event_id)
) PARTITION BY RANGE (occurred_at);

CREATE TABLE lab14.event_monthly_2026_01
    PARTITION OF lab14.event_monthly
    FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00');
CREATE TABLE lab14.event_monthly_2026_02
    PARTITION OF lab14.event_monthly
    FOR VALUES FROM ('2026-02-01 00:00:00+00') TO ('2026-03-01 00:00:00+00');
CREATE TABLE lab14.event_monthly_2026_03
    PARTITION OF lab14.event_monthly
    FOR VALUES FROM ('2026-03-01 00:00:00+00') TO ('2026-04-01 00:00:00+00');
CREATE TABLE lab14.event_monthly_2026_04
    PARTITION OF lab14.event_monthly
    FOR VALUES FROM ('2026-04-01 00:00:00+00') TO ('2026-05-01 00:00:00+00');
CREATE TABLE lab14.event_monthly_2026_05
    PARTITION OF lab14.event_monthly
    FOR VALUES FROM ('2026-05-01 00:00:00+00') TO ('2026-06-01 00:00:00+00');
CREATE TABLE lab14.event_monthly_2026_06
    PARTITION OF lab14.event_monthly
    FOR VALUES FROM ('2026-06-01 00:00:00+00') TO ('2026-07-01 00:00:00+00');

-- 每月 100,000 行,共 600,000 行。根据实验机器调整,但必须记录实际值。
INSERT INTO lab14.event_monthly (
    tenant_id, event_id, occurred_at, event_type, payload
)
SELECT
    1 + (g % 1000)::integer,
    (m.month_no::bigint * 1000000) + g,
    m.month_start + ((g % 2500000) * interval '1 second'),
    CASE g % 4
        WHEN 0 THEN 'created'
        WHEN 1 THEN 'updated'
        WHEN 2 THEN 'viewed'
        ELSE 'deleted'
    END,
    repeat(md5(g::text), 4)
FROM (
    VALUES
        (1, TIMESTAMPTZ '2026-01-01 00:00:00+00'),
        (2, TIMESTAMPTZ '2026-02-01 00:00:00+00'),
        (3, TIMESTAMPTZ '2026-03-01 00:00:00+00'),
        (4, TIMESTAMPTZ '2026-04-01 00:00:00+00'),
        (5, TIMESTAMPTZ '2026-05-01 00:00:00+00'),
        (6, TIMESTAMPTZ '2026-06-01 00:00:00+00')
) AS m(month_no, month_start)
CROSS JOIN generate_series(1, 100000) AS s(g);

CREATE INDEX event_monthly_tenant_time_idx
    ON lab14.event_monthly (tenant_id, occurred_at);

ANALYZE lab14.event_monthly;

SELECT tableoid::regclass AS physical_partition, count(*)
FROM lab14.event_monthly
GROUP BY tableoid
ORDER BY physical_partition::text;

确认每个分区行数,并记录:

SELECT version();
SHOW shared_buffers;
SHOW effective_cache_size;
SHOW work_mem;
SHOW random_page_cost;
SHOW effective_io_concurrency;
SHOW io_method;  -- [PG18];旧版本无此参数

SELECT
    count(*) AS leaf_partitions,
    pg_size_pretty(sum(pg_relation_size(relid))) AS heap_size,
    pg_size_pretty(sum(pg_indexes_size(relid))) AS index_size
FROM pg_partition_tree('lab14.event_monthly')
WHERE isleaf;

13.1.4 Session A:常量查询,观察规划期裁剪

SET enable_partition_pruning = on;

EXPLAIN (
    ANALYZE,
    BUFFERS,
    WAL,
    SETTINGS,
    VERBOSE,
    SUMMARY
)
SELECT count(*)
FROM lab14.event_monthly
WHERE occurred_at >= TIMESTAMPTZ '2026-03-01 00:00:00+00'
  AND occurred_at <  TIMESTAMPTZ '2026-04-01 00:00:00+00'
  AND tenant_id = 42;

预期:计划只出现 event_monthly_2026_03。是否使用 Seq Scan、Bitmap Scan 或 Index Scan 由选择率和成本决定;“只剩一个分区”不等于“一定使用索引”。

再执行不带分区键的点查:

EXPLAIN (
    ANALYZE,
    BUFFERS,
    WAL,
    SETTINGS,
    VERBOSE,
    SUMMARY
)
SELECT *
FROM lab14.event_monthly
WHERE event_id = 3000042;

预期:六个叶分区都可能出现在计划中。主键的首列是 occurred_at,该查询甚至未必能有效利用主键;即使额外创建每叶 event_id 索引,也仍需对六个本地索引分别探测。

13.1.5 Session A:Generic Plan,观察初始化期裁剪

SET plan_cache_mode = force_generic_plan;

PREPARE event_count(timestamptz, timestamptz, integer) AS
SELECT count(*)
FROM lab14.event_monthly
WHERE occurred_at >= $1
  AND occurred_at <  $2
  AND tenant_id = $3;

EXPLAIN (
    ANALYZE,
    BUFFERS,
    WAL,
    SETTINGS,
    VERBOSE,
    SUMMARY
)
EXECUTE event_count(
    TIMESTAMPTZ '2026-05-01 00:00:00+00',
    TIMESTAMPTZ '2026-06-01 00:00:00+00',
    42
);

预期:计划通常包含可运行时裁剪的 Append,并显示类似 Subplans Removed: 5;实际只有五月分区产生 Buffer 访问。具体节点文本可能随版本、统计和成本不同。

SELECT name, generic_plans, custom_plans, statement
FROM pg_prepared_statements
WHERE name = 'event_count';

13.1.6 Session B:关闭裁剪作为对照

在独立会话执行:

SET enable_partition_pruning = off;

EXPLAIN (
    ANALYZE,
    BUFFERS,
    WAL,
    SETTINGS,
    VERBOSE,
    SUMMARY
)
SELECT count(*)
FROM lab14.event_monthly
WHERE occurred_at >= TIMESTAMPTZ '2026-03-01 00:00:00+00'
  AND occurred_at <  TIMESTAMPTZ '2026-04-01 00:00:00+00'
  AND tenant_id = 42;

预期:计划会保留所有分区,其他月份在叶节点执行 Filter 后返回零行。Buffers 和执行工作量上升。此设置只用于实验,生产不应为常规查询关闭裁剪。

13.1.7 Session C:观察活动、锁和等待

在 A/B 正在执行较大数据版本实验时:

SELECT
    pid,
    application_name,
    state,
    wait_event_type,
    wait_event,
    query_start,
    clock_timestamp() - query_start AS query_age,
    left(query, 160) AS query
FROM pg_stat_activity
WHERE datname = current_database()
  AND pid <> pg_backend_pid()
ORDER BY query_start;

SELECT
    l.pid,
    l.mode,
    l.granted,
    l.relation::regclass AS relation
FROM pg_locks AS l
WHERE l.relation IN (
    SELECT relid FROM pg_partition_tree('lab14.event_monthly')
)
ORDER BY l.pid, l.relation;

13.1.8 明确时间线

时间Session ASession BSession C结果
T0完成建表、装载、Analyze六个叶分区就绪
T1常量范围查询查看计划/锁规划期裁剪到一个分区
T2Generic Plan + 参数查看计划/锁初始化期裁剪,检查 Subplans Removed
T3关闭裁剪执行同查询观察活动所有叶分区保留
T4不带时间的 ID 查询多分区访问,证明本地索引不等于裁剪
  • 等待步骤:正常小数据下不应产生锁等待;扩大数据后可能出现 CPU/I/O 等待。
  • 失败步骤:本实验无预期失败;若插入超出六月且无目标分区,会预期失败。
  • 提交步骤:准备数据的 INSERT 在 Autocommit 下提交;查询只读。

13.1.9 诊断字段解释

  • Planning Time:解析后生成计划的时间;分区数过多时可能显著增长。
  • Execution Time:实际执行时间,不包含客户端传输全部结果的时间。
  • Subplans Removed:初始化期裁剪掉的子计划数。
  • loops:节点被执行次数;动态裁剪需结合它判断。
  • never executed:该子计划每次都被运行期裁剪。
  • Buffers shared hit/read:来自 shared_buffers 的命中/读取,不能直接等于物理磁盘 I/O。
  • WAL:SELECT 通常无业务 WAL,但 Hint Bit 等行为和环境可能影响观测;以实际输出为准。
  • SETTINGS:列出影响计划的非默认设置,便于复现实验。

13.1.10 性能记录表

不要填入预设数字;使用实际结果:

项目裁剪开启/单月Generic + 初始化裁剪裁剪关闭不带分区键
PostgreSQL 版本
数据量/平均行宽
冷/热缓存
并发数/持续时间
P50/P95/P99
Planning Time
Execution Time
shared hit/read
WAL bytes/records
CPU
I/O 吞吐/延迟
Wait Event
实际访问分区数

13.1.11 清理

DEALLOCATE event_count;
RESET plan_cache_mode;
RESET enable_partition_pruning;
-- 保留 lab14 给后续实验;全章结束后统一 DROP SCHEMA。

13.1.12 生产安全警告

  • 不要通过全局关闭裁剪做长期对照。
  • 冷缓存测试不要在共享生产主机上使用清理 OS Cache 的命令。
  • EXPLAIN ANALYZE 会运行查询;对大范围查询可能影响生产缓存和 I/O。
  • 计划中分区节点文本会随版本和统计变化,判断依据应是实际访问、Buffers 和 loops,而不是死记某一行输出。

13.2 实验二:Default Partition 数据迁移到新分区

13.2.1 实验目标

  1. 稳定复现:Default 中存在目标范围数据时,Attach 正式分区先因锁等待,随后因数据冲突失败。
  2. 在明确阻断写入的事务中,把 Default 中数据迁入 Staging Table。
  3. 使用匹配 CHECK 约束避免 Attach 对 Staging 和 Default 的重复验证扫描。
  4. 理解为什么 Default 必须监控为异常缓冲区。

13.2.2 版本与扩展

  • PostgreSQL 14~18。
  • 必要扩展:无。

13.2.3 建表和准备数据

DROP TABLE IF EXISTS lab14.event_with_default CASCADE;

CREATE TABLE lab14.event_with_default (
    event_id    bigint      NOT NULL,
    occurred_at timestamptz NOT NULL,
    payload     text        NOT NULL,
    PRIMARY KEY (occurred_at, event_id)
) PARTITION BY RANGE (occurred_at);

CREATE TABLE lab14.event_with_default_2026_01
    PARTITION OF lab14.event_with_default
    FOR VALUES FROM ('2026-01-01 00:00:00+00')
                 TO ('2026-02-01 00:00:00+00');

CREATE TABLE lab14.event_with_default_catchall
    PARTITION OF lab14.event_with_default DEFAULT;

-- 二月正式分区尚未创建,因此这些行被路由到 Default。
INSERT INTO lab14.event_with_default (event_id, occurred_at, payload)
SELECT
    g,
    TIMESTAMPTZ '2026-02-01 00:00:00+00' + (g * interval '1 second'),
    repeat(md5(g::text), 2)
FROM generate_series(1, 200000) AS s(g);

ANALYZE lab14.event_with_default_catchall;

SELECT tableoid::regclass AS physical_partition, count(*)
FROM lab14.event_with_default
GROUP BY tableoid;

-- 先准备一个空的二月 Staging Table,并给出可验证边界。
CREATE TABLE lab14.event_with_default_2026_02_stage
    (LIKE lab14.event_with_default INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING INDEXES);

ALTER TABLE lab14.event_with_default_2026_02_stage
    ADD CONSTRAINT event_with_default_2026_02_stage_bound
    CHECK (
        occurred_at >= TIMESTAMPTZ '2026-02-01 00:00:00+00'
        AND occurred_at <  TIMESTAMPTZ '2026-03-01 00:00:00+00'
    );

13.2.4 Session A:持有 Default 的 AccessShareLock

BEGIN;

SELECT count(*)
FROM lab14.event_with_default_catchall;

-- 不提交。SELECT 的 AccessShareLock 持续到事务结束。
SELECT pg_backend_pid() AS session_a_pid, now() AS snapshot_time;

13.2.5 Session B:尝试 Attach,先等待再失败

SET lock_timeout = '20s';
SET statement_timeout = '2min';

ALTER TABLE lab14.event_with_default
    ATTACH PARTITION lab14.event_with_default_2026_02_stage
    FOR VALUES FROM ('2026-02-01 00:00:00+00')
                 TO ('2026-03-01 00:00:00+00');

该命令需要对 Default 获取 ACCESS EXCLUSIVE。只要 Session A 未提交,它就等待。

13.2.6 Session C:诊断锁链

SELECT
    blocked.pid AS blocked_pid,
    blocker.pid AS blocker_pid,
    blocked.wait_event_type,
    blocked.wait_event,
    clock_timestamp() - blocker.xact_start AS blocker_xact_age,
    left(blocked.query, 120) AS blocked_query,
    left(blocker.query, 120) AS blocker_query
FROM pg_stat_activity AS blocked
CROSS JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(blocker_pid)
JOIN pg_stat_activity AS blocker
  ON blocker.pid = b.blocker_pid
WHERE blocked.wait_event_type = 'Lock';

SELECT
    l.pid,
    l.mode,
    l.granted,
    l.relation::regclass AS relation
FROM pg_locks AS l
WHERE l.relation IN (
    'lab14.event_with_default'::regclass,
    'lab14.event_with_default_catchall'::regclass,
    'lab14.event_with_default_2026_02_stage'::regclass
)
ORDER BY l.granted, l.pid, relation::text;

重要字段:

  • pg_blocking_pids:数据库实际判定的直接 blocker PID。
  • blocker_xact_age:优先处理最老事务,而不是只看最后一条 SQL。
  • mode/granted:区分已持有锁与正在请求的锁。
  • relation:确认阻塞发生在父表、Default 还是 Staging。

13.2.7 Session A:释放锁

COMMIT;

Session B 获得 Default 强锁后会扫描它,并预期失败,因为其中存在二月行。错误文本可能随版本略变,核心语义是 Default 中有行违反更新后的 Default 分区约束。

13.2.8 正确迁移流程

此实验使用显式 ACCESS EXCLUSIVE 锁实现简单、可证明的停写窗口。生产可设计更复杂的双写或离线切换,但绝不能在迁移过程中允许新二月行继续进入 Default。

在 Session B 执行:

BEGIN;

SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '5min';

-- 先锁父表,阻止通过父表的新读写;再锁 Default,防止直接叶表访问。
LOCK TABLE lab14.event_with_default
    IN ACCESS EXCLUSIVE MODE;
LOCK TABLE lab14.event_with_default_catchall
    IN ACCESS EXCLUSIVE MODE;

-- 原子地从 Default 删除并插入 Staging。
WITH moved AS (
    DELETE FROM lab14.event_with_default_catchall
    WHERE occurred_at >= TIMESTAMPTZ '2026-02-01 00:00:00+00'
      AND occurred_at <  TIMESTAMPTZ '2026-03-01 00:00:00+00'
    RETURNING event_id, occurred_at, payload
)
INSERT INTO lab14.event_with_default_2026_02_stage (
    event_id, occurred_at, payload
)
SELECT event_id, occurred_at, payload
FROM moved;

-- 让系统可证明 Default 不含二月范围;此处会验证现有 Default 数据。
ALTER TABLE lab14.event_with_default_catchall
    ADD CONSTRAINT event_with_default_catchall_no_2026_02
    CHECK (
        occurred_at <  TIMESTAMPTZ '2026-02-01 00:00:00+00'
        OR occurred_at >= TIMESTAMPTZ '2026-03-01 00:00:00+00'
    );

-- Staging 已有匹配有效 CHECK,Default 也有排除 CHECK,避免 Attach 扫描两张表。
ALTER TABLE lab14.event_with_default
    ATTACH PARTITION lab14.event_with_default_2026_02_stage
    FOR VALUES FROM ('2026-02-01 00:00:00+00')
                 TO ('2026-03-01 00:00:00+00');

-- Attach 后内部 Partition Bound 已负责约束,可移除临时证明约束。
ALTER TABLE lab14.event_with_default_2026_02_stage
    DROP CONSTRAINT event_with_default_2026_02_stage_bound;

ALTER TABLE lab14.event_with_default_catchall
    DROP CONSTRAINT event_with_default_catchall_no_2026_02;

COMMIT;

如果在获取锁时超过 lock_timeout,事务失败并回滚。不要无界重试;先找 blocker,确认能否安全终止或等待业务窗口。

13.2.9 验证结果

SELECT
    tableoid::regclass AS physical_partition,
    count(*) AS rows,
    min(occurred_at) AS min_time,
    max(occurred_at) AS max_time
FROM lab14.event_with_default
GROUP BY tableoid
ORDER BY physical_partition::text;

SELECT count(*) AS default_rows
FROM lab14.event_with_default_catchall;

SELECT
    c.oid::regclass AS relation,
    pg_get_expr(c.relpartbound, c.oid) AS bound
FROM pg_class AS c
WHERE c.oid IN (
    SELECT relid FROM pg_partition_tree('lab14.event_with_default')
)
ORDER BY relation::text;

预期:

  • 200,000 行位于新二月分区。
  • Default 行数为 0。
  • 父表查询可正常裁剪到二月分区。
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
SELECT count(*)
FROM lab14.event_with_default
WHERE occurred_at >= TIMESTAMPTZ '2026-02-01 00:00:00+00'
  AND occurred_at <  TIMESTAMPTZ '2026-03-01 00:00:00+00';

13.2.10 明确时间线

时间Session ASession BSession C等待/失败/提交
T0BEGIN 并读 DefaultA 持有 AccessShareLock
T1保持事务发起 Attach查询锁链B 等待 Default 的 AccessExclusiveLock
T2COMMIT获锁并扫描 DefaultA 提交;B 因二月数据存在而失败
T3开始迁移事务并强锁父表/Default可观察业务请求等待B 建立受控停写窗口
T4DELETE RETURNING → INSERT StagingDML 尚未提交
T5加证明约束并 AttachAttach 成功
T6COMMIT验证新二月分区上线

13.2.11 统计与性能记录

该实验不提供固定耗时。记录:

  • Default 行数、大小、索引大小;
  • 初次 Attach 的锁等待时长与扫描失败时长;
  • 数据迁移的 Buffers、WAL、CPU、I/O;
  • 强锁持有时间;
  • 迁移期间业务 P95/P99 和 pgxpool 等待;
  • Replica Lag;
  • 添加匹配 CHECK 前后 Attach 的扫描差异。

可单独对迁移 DML在可丢弃副本上执行:

EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
WITH moved AS (
    DELETE FROM lab14.event_with_default_catchall
    WHERE occurred_at >= TIMESTAMPTZ '2026-02-01 00:00:00+00'
      AND occurred_at <  TIMESTAMPTZ '2026-03-01 00:00:00+00'
    RETURNING event_id, occurred_at, payload
)
INSERT INTO lab14.event_with_default_2026_02_stage
SELECT * FROM moved;

警告:它会真实移动数据;只能在重置后的实验环境执行。

13.2.12 清理

本实验对象可留给观察;单独清理:

DROP TABLE IF EXISTS lab14.event_with_default CASCADE;
-- 如果 Attach 失败且 Staging 仍独立:
DROP TABLE IF EXISTS lab14.event_with_default_2026_02_stage;

13.2.13 生产安全警告

  • 不要在未阻止新写入时“先搬一遍再 Attach”,否则会持续有新行落入 Default。
  • 不要直接终止 blocker,先判断是否是关键事务、备份、迁移或复制维护。
  • 强锁迁移适合明确维护窗口;在线迁移要设计双写/捕获增量、幂等和切换点。
  • Default 行数告警应接近实时;等到月初才发现数亿行已经太晚。

13.3 实验三:DETACH/DROP 历史分区与大量 DELETE 比较

13.3.1 实验目标

  1. 在两棵相同分区树上分别执行大批 DELETEDETACH CONCURRENTLY + DROP
  2. 对比 WAL、Buffers、Dead Tuple、空间回收和锁等待。
  3. 复现长事务延迟并发 Detach 的等待阶段。
  4. 验证 DROP 快不等于无锁,也不等于可随意生产执行。

13.3.2 版本与扩展

  • PostgreSQL 14+ 才支持 DETACH PARTITION ... CONCURRENTLY
  • 必要扩展:无。
  • pg_stat_statements、OS I/O 工具为可选观测,不影响实验正确性。

13.3.3 建表和准备数据

DROP TABLE IF EXISTS lab14.events_delete CASCADE;
DROP TABLE IF EXISTS lab14.events_detach CASCADE;

CREATE TABLE lab14.events_delete (
    event_id    bigint      NOT NULL,
    occurred_at timestamptz NOT NULL,
    payload     text        NOT NULL,
    PRIMARY KEY (occurred_at, event_id)
) PARTITION BY RANGE (occurred_at);

CREATE TABLE lab14.events_detach
    (LIKE lab14.events_delete
        INCLUDING DEFAULTS
        INCLUDING CONSTRAINTS
        INCLUDING INDEXES)
    PARTITION BY RANGE (occurred_at);

CREATE TABLE lab14.events_delete_2025_01
    PARTITION OF lab14.events_delete
    FOR VALUES FROM ('2025-01-01 00:00:00+00') TO ('2025-02-01 00:00:00+00');
CREATE TABLE lab14.events_delete_2025_02
    PARTITION OF lab14.events_delete
    FOR VALUES FROM ('2025-02-01 00:00:00+00') TO ('2025-03-01 00:00:00+00');
CREATE TABLE lab14.events_delete_2025_03
    PARTITION OF lab14.events_delete
    FOR VALUES FROM ('2025-03-01 00:00:00+00') TO ('2025-04-01 00:00:00+00');

CREATE TABLE lab14.events_detach_2025_01
    PARTITION OF lab14.events_detach
    FOR VALUES FROM ('2025-01-01 00:00:00+00') TO ('2025-02-01 00:00:00+00');
CREATE TABLE lab14.events_detach_2025_02
    PARTITION OF lab14.events_detach
    FOR VALUES FROM ('2025-02-01 00:00:00+00') TO ('2025-03-01 00:00:00+00');
CREATE TABLE lab14.events_detach_2025_03
    PARTITION OF lab14.events_detach
    FOR VALUES FROM ('2025-03-01 00:00:00+00') TO ('2025-04-01 00:00:00+00');

-- 每月 300,000 行。按实验环境调整,并记录实际数据和行宽。
INSERT INTO lab14.events_delete (event_id, occurred_at, payload)
SELECT
    (m.month_no::bigint * 1000000) + g,
    m.month_start + ((g % 2500000) * interval '1 second'),
    repeat(md5(g::text), 6)
FROM (
    VALUES
        (1, TIMESTAMPTZ '2025-01-01 00:00:00+00'),
        (2, TIMESTAMPTZ '2025-02-01 00:00:00+00'),
        (3, TIMESTAMPTZ '2025-03-01 00:00:00+00')
) AS m(month_no, month_start)
CROSS JOIN generate_series(1, 300000) AS s(g);

INSERT INTO lab14.events_detach
SELECT * FROM lab14.events_delete;

ANALYZE lab14.events_delete;
ANALYZE lab14.events_detach;

SELECT
    parent,
    max(row_count) AS rows,
    pg_size_pretty(sum(total_bytes)) AS total_size
FROM (
    SELECT
        'delete' AS parent,
        (SELECT count(*) FROM lab14.events_delete) AS row_count,
        pg_total_relation_size(relid) AS total_bytes
    FROM pg_partition_tree('lab14.events_delete')
    WHERE isleaf
    UNION ALL
    SELECT
        'detach',
        (SELECT count(*) FROM lab14.events_detach),
        pg_total_relation_size(relid)
    FROM pg_partition_tree('lab14.events_detach')
    WHERE isleaf
) AS x(parent, row_count, total_bytes)
GROUP BY parent;

13.3.4 DELETE 路径:真实执行并记录 WAL

在 psql Session D 中开启计时:

\timing on

SELECT pg_current_wal_lsn() AS delete_lsn_before \gset

EXPLAIN (
    ANALYZE,
    BUFFERS,
    WAL,
    SETTINGS,
    VERBOSE,
    SUMMARY
)
DELETE FROM lab14.events_delete
WHERE occurred_at >= TIMESTAMPTZ '2025-01-01 00:00:00+00'
  AND occurred_at <  TIMESTAMPTZ '2025-02-01 00:00:00+00';

SELECT
    pg_size_pretty(
        pg_wal_lsn_diff(pg_current_wal_lsn(), :'delete_lsn_before'::pg_lsn)
    ) AS delete_wal_generated;

SELECT
    relid::regclass AS relation,
    n_live_tup,
    n_dead_tup,
    last_autovacuum,
    vacuum_count,
    autovacuum_count
FROM pg_stat_all_tables
WHERE relid = 'lab14.events_delete_2025_01'::regclass;

SELECT
    pg_size_pretty(pg_relation_size('lab14.events_delete_2025_01')) AS heap_after_delete,
    pg_size_pretty(pg_indexes_size('lab14.events_delete_2025_01')) AS indexes_after_delete;

预期

  • DELETE 节点显示大量实际行和 WAL records/bytes。
  • n_dead_tup 最终上升,但统计是近似且异步更新的,可运行 ANALYZE 后再观察。
  • 关系文件通常不会因普通 Delete 立即缩小。
  • 后续 VACUUM 可回收内部空间供复用,但通常不把全部文件空间返还 OS。

不要在同一测量前后恰好让 Autovacuum 介入而不记录。可观察但不要生产式禁用 Autovacuum;实验若需控制,应选择短窗口并记录发生的 Autovacuum。

13.3.5 Session A:建立长事务,使用待 Detach 分区

BEGIN;

SELECT count(*)
FROM lab14.events_detach
WHERE occurred_at >= TIMESTAMPTZ '2025-01-01 00:00:00+00'
  AND occurred_at <  TIMESTAMPTZ '2025-02-01 00:00:00+00';

SELECT pg_backend_pid() AS session_a_pid, xact_start
FROM pg_stat_activity
WHERE pid = pg_backend_pid();

-- 保持事务,不提交。

13.3.6 Session B:并发 Detach

DETACH CONCURRENTLY 必须在 Autocommit、事务块外运行:

\timing on
SET lock_timeout = '10s';
SET statement_timeout = '10min';

SELECT pg_current_wal_lsn() AS detach_lsn_before \gset

ALTER TABLE lab14.events_detach
    DETACH PARTITION lab14.events_detach_2025_01 CONCURRENTLY;

第一阶段通常可以完成,然后命令等待 Session A 的旧事务结束。不要在 B 中执行 BEGIN

13.3.7 Session C:观察 Pending Detach 和等待

SELECT
    i.inhrelid::regclass AS child,
    i.inhparent::regclass AS parent,
    i.inhdetachpending
FROM pg_inherits AS i
WHERE i.inhparent = 'lab14.events_detach'::regclass;

SELECT
    pid,
    state,
    xact_start,
    query_start,
    wait_event_type,
    wait_event,
    pg_blocking_pids(pid) AS blocking_pids,
    left(query, 160) AS query
FROM pg_stat_activity
WHERE query ILIKE '%DETACH PARTITION%'
   OR pid IN (
       SELECT unnest(pg_blocking_pids(pid))
       FROM pg_stat_activity
       WHERE query ILIKE '%DETACH PARTITION%'
   )
ORDER BY xact_start NULLS LAST;

并发 Detach 的“等待旧事务”不一定始终表现为普通 Relation Lock blocker 文本;结合 inhdetachpending、活动事务年龄和命令状态判断。

13.3.8 Session A:提交,允许 Detach 完成

COMMIT;

Session B 随后完成。若 B 被取消或服务重启,检查 inhdetachpending,必要时执行:

ALTER TABLE lab14.events_detach
    DETACH PARTITION lab14.events_detach_2025_01 FINALIZE;

仅在确认目标确实处于 Pending 状态后执行。

13.3.9 Session B:记录 WAL 并删除独立历史表

SELECT
    pg_size_pretty(
        pg_wal_lsn_diff(pg_current_wal_lsn(), :'detach_lsn_before'::pg_lsn)
    ) AS detach_wal_generated;

-- 此时它已是独立表,可先归档并验证。
SELECT count(*) AS detached_rows
FROM lab14.events_detach_2025_01;

SELECT
    pg_size_pretty(pg_total_relation_size('lab14.events_detach_2025_01'))
    AS detached_size;

SELECT pg_current_wal_lsn() AS drop_lsn_before \gset

DROP TABLE lab14.events_detach_2025_01;

SELECT
    pg_size_pretty(
        pg_wal_lsn_diff(pg_current_wal_lsn(), :'drop_lsn_before'::pg_lsn)
    ) AS drop_wal_generated;

在真实生命周期中,DROP 前应先:导出、校验、登记归档对象、验证恢复,再按审批删除。

13.3.10 明确时间线

时间Session ASession BSession C/D等待/失败/提交
T0D 执行真实 DELETEDelete 提交并产生 Dead Tuple/WAL
T1BEGIN 并读旧分区A 保持旧事务
T2保持事务发起 DETACH ... CONCURRENTLYC 查 PendingB 第一阶段后等待 A
T3COMMITDetach 第二阶段完成C 验证A 提交,B 完成
T4读取独立表、归档模拟、DROP记录 WAL/大小独立历史表删除
  • 等待步骤:B 在旧事务结束前等待。
  • 失败步骤:正常流程无预期失败;若父表有 Default,B 会立即失败;若放在事务块也会失败。
  • 提交步骤:D 的 Delete 在 Autocommit 下提交;A 的读事务显式提交;并发 Detach 内部两事务由命令管理。

13.3.11 对比指标

指标大量 DELETEDETACH CONCURRENTLY + DROP解释
处理行数Delete 与行数相关;Detach/Drop 以关系为单位
总耗时分开记录执行与锁等待
P50/P95/P99用重复、重置后的独立数据集测量
Buffers hit/read/dirtied/writtenDelete 通常触及大量 Heap/Index Page
WAL records/FPI/bytes不预设倍数,以实际为准
CPUDelete 执行器和索引维护更重
I/ODelete + Vacuum 与 Drop 文件处理不同
Wait EventDetach 可能主要等待旧事务/锁
n_dead_tupDrop 路径不制造逐行 Dead Tuple
操作后物理大小Delete 文件通常不立即变小,Drop 移除关系
Replica Lag记录两路径对重放的实际影响

要得到 P50/P95/P99,应每次从相同快照或重新装载的独立数据库开始,执行足够轮次;不能在已删除的数据上重复跑同一语句。

13.3.12 清理

DROP TABLE IF EXISTS lab14.events_delete CASCADE;
DROP TABLE IF EXISTS lab14.events_detach CASCADE;
DROP TABLE IF EXISTS lab14.events_detach_2025_01;

全章实验结束:

DROP SCHEMA IF EXISTS lab14 CASCADE;

13.3.13 生产安全警告

  • DROP TABLE 是真实删除;对象名错误可能造成不可逆事故。使用审批、Allowlist、边界复核和 PITR 演练。
  • DETACH CONCURRENTLY 不是无锁;长事务会让它长时间处于 Pending。
  • 父表有 Default 时不能使用并发 Detach。
  • 不要为了让 DDL通过而随意终止所有长事务;先识别业务影响。
  • 不要用单次运行的 wall-clock 时间宣称方案快多少;必须同时记录 WAL、Buffers、缓存状态、CPU、I/O、等待与副本延迟。

14. Go:独立未来分区维护任务

14.1 架构约束

维护程序不是业务请求处理器。推荐部署为 Kubernetes CronJob、systemd timer 或受调度平台控制的独立任务:

普通请求进程 ──参数化 INSERT/SELECT──> app.event_log

                                            │ 预先存在的未来分区

独立 Maintainer ──单实例 + 短超时 DDL──> ensure_event_month(date)

设计原则:

  1. 请求路径不执行 CREATE TABLEATTACHDROP
  2. Go 不拼接 DDL 标识符或日期字面量;它只参数化调用受控数据库函数。
  3. 数据库函数仅允许月初日期和有限未来窗口,使用固定 Schema、固定父表和安全 search_path
  4. 整个任务持有 Session Advisory Lock;每个月创建又持有 Transaction Advisory Lock,防止同月竞态。
  5. DDL 并发固定为 1,避免锁风暴。
  6. lock_timeoutstatement_timeout 让任务快速退出,而不是堵住请求。
  7. 只对 4000140P0155P03 做有界完整事务重试;服从 Context,并使用指数退避与抖动。
  8. 单独处理 Commit 错误:先验证目录状态,不能武断认为事务未提交。
  9. 使用专用最小权限角色;不把父表 Owner 权限直接授予普通应用。

14.2 数据库侧受控函数

以下函数应由父表 Owner 创建和拥有。若使用 SECURITY DEFINER,必须固定 search_path、撤销 PUBLIC 执行权限,并只开放给维护角色。

CREATE OR REPLACE FUNCTION app.ensure_event_month(p_month date)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
SET TimeZone = 'UTC'
AS $$
DECLARE
    v_current_month date;
    v_next_month    date;
    v_name          text;
    v_start_text    text;
    v_end_text      text;
    v_existing      oid;
BEGIN
    IF p_month IS NULL THEN
        RAISE EXCEPTION USING
            ERRCODE = '22023',
            MESSAGE = 'p_month must not be null';
    END IF;

    IF p_month <> date_trunc('month', p_month)::date THEN
        RAISE EXCEPTION USING
            ERRCODE = '22023',
            MESSAGE = 'p_month must be the first day of a month';
    END IF;

    v_current_month := date_trunc('month', current_date)::date;

    -- 防止被滥用为任意历史/远期 DDL 接口;窗口应按组织策略调整。
    IF p_month < (v_current_month - interval '1 month')::date
       OR p_month > (v_current_month + interval '24 months')::date THEN
        RAISE EXCEPTION USING
            ERRCODE = '22023',
            MESSAGE = 'p_month is outside the allowed maintenance window';
    END IF;

    v_next_month := (p_month + interval '1 month')::date;
    v_name := format('event_log_%s', to_char(p_month, 'YYYY_MM'));
    v_start_text := to_char(p_month, 'YYYY-MM-DD') || ' 00:00:00+00';
    v_end_text := to_char(v_next_month, 'YYYY-MM-DD') || ' 00:00:00+00';

    -- 同一个月份即使由不同维护实例调用,也只能由一个事务创建。
    PERFORM pg_advisory_xact_lock(
        hashtextextended('app.event_log:' || p_month::text, 0)
    );

    SELECT c.oid
    INTO v_existing
    FROM pg_class AS c
    JOIN pg_namespace AS n
      ON n.oid = c.relnamespace
    WHERE n.nspname = 'app'
      AND c.relname = v_name;

    IF v_existing IS NOT NULL THEN
        IF NOT EXISTS (
            SELECT 1
            FROM pg_inherits AS i
            WHERE i.inhparent = 'app.event_log'::regclass
              AND i.inhrelid = v_existing
        ) THEN
            RAISE EXCEPTION USING
                ERRCODE = '42P07',
                MESSAGE = format('relation app.%I exists but is not a direct partition of app.event_log', v_name);
        END IF;

        RETURN false;
    END IF;

    EXECUTE format(
        'CREATE TABLE app.%I PARTITION OF app.event_log '
        'FOR VALUES FROM (%L) TO (%L)',
        v_name,
        v_start_text,
        v_end_text
    );

    RETURN true;
END;
$$;

REVOKE ALL ON FUNCTION app.ensure_event_month(date) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION app.ensure_event_month(date)
TO partition_maintainer;

安全说明

  • 函数 Owner 应与 app.event_log Owner 对齐,且维护角色只获 EXECUTE
  • SECURITY DEFINER 函数是高权限边界,必须进入 Schema Migration 审核、单元测试和安全审计。
  • 函数使用 format('%I', ...) 引用受控标识符、%L 引用受控时间字面量;输入不能改变父表或 Schema。
  • CREATE TABLE IF NOT EXISTS 只验证名称,不验证定义,因此函数先确认同名对象确实属于目标父表;Go 再验证实际边界。
  • 父级索引、约束和用户定义行级触发器会按 PostgreSQL 规则复制到新分区;维护后仍应检查索引有效性、Owner、Privileges 和 Tablespace。

14.3 可编译的 pgx/v5 示例

初始化依赖:

go mod init example.com/partition-maintainer
go get github.com/jackc/pgx/v5
export DATABASE_URL='postgres://partition_maintainer:***@db.example/app?sslmode=verify-full'
go run .
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"math/rand"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgxpool"
)

const (
	monthsAhead = 6
	maxAttempts = 3
)

type ensureResult struct {
	Created                  bool
	CommitVerifiedAfterError bool
}

type queryRower interface {
	QueryRow(context.Context, string, ...any) pgx.Row
}

func main() {
	if err := run(); err != nil {
		slog.Error("partition maintenance failed", "error", err)
		os.Exit(1)
	}
}

func run() error {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	databaseURL := os.Getenv("DATABASE_URL")
	if databaseURL == "" {
		return errors.New("DATABASE_URL is required")
	}

	cfg, err := pgxpool.ParseConfig(databaseURL)
	if err != nil {
		return fmt.Errorf("parse pool config: %w", err)
	}
	cfg.MaxConns = 2
	cfg.MinConns = 0
	cfg.MaxConnIdleTime = 2 * time.Minute
	cfg.MaxConnLifetime = 30 * time.Minute
	cfg.ConnConfig.RuntimeParams["application_name"] = "partition-maintainer"

	pool, err := pgxpool.NewWithConfig(ctx, cfg)
	if err != nil {
		return fmt.Errorf("create pool: %w", err)
	}
	defer pool.Close()

	pingCtx, cancelPing := context.WithTimeout(ctx, 5*time.Second)
	err = pool.Ping(pingCtx)
	cancelPing()
	if err != nil {
		return fmt.Errorf("ping database: %w", err)
	}

	// A session-level advisory lock makes the whole run single-instance.
	// It must stay on one acquired connection for the duration of the job.
	conn, err := pool.Acquire(ctx)
	if err != nil {
		return fmt.Errorf("acquire maintenance connection: %w", err)
	}
	defer conn.Release()

	locked, err := tryJobLock(ctx, conn)
	if err != nil {
		return fmt.Errorf("acquire advisory lock: %w", err)
	}
	if !locked {
		slog.Info("another partition maintainer is active; exiting")
		return nil
	}
	defer unlockJob(conn)

	rng := rand.New(rand.NewSource(time.Now().UnixNano()))
	now := time.Now().UTC()
	firstMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)

	// DDL is intentionally sequential. Concurrency one is a bounded policy and
	// avoids creating a lock storm against the same partitioned table.
	for i := 0; i < monthsAhead; i++ {
		month := firstMonth.AddDate(0, i, 0)
		result, err := ensureMonthWithRetry(ctx, pool, conn, month, rng)
		if err != nil {
			return fmt.Errorf("ensure month %s: %w", month.Format("2006-01"), err)
		}
		slog.Info(
			"month partition verified",
			"month", month.Format("2006-01"),
			"created", result.Created,
			"commit_verified_after_error", result.CommitVerifiedAfterError,
		)
	}

	listCtx, cancelList := context.WithTimeout(ctx, 10*time.Second)
	err = listPartitions(listCtx, conn)
	cancelList()
	if err != nil {
		return fmt.Errorf("list partitions: %w", err)
	}

	stat := pool.Stat()
	slog.Info(
		"partition maintenance completed",
		"acquired_conns", stat.AcquiredConns(),
		"idle_conns", stat.IdleConns(),
		"empty_acquire_count", stat.EmptyAcquireCount(),
		"empty_acquire_wait", stat.EmptyAcquireWaitTime(),
	)
	return nil
}

func tryJobLock(ctx context.Context, conn *pgxpool.Conn) (bool, error) {
	var locked bool
	err := conn.QueryRow(
		ctx,
		`SELECT pg_try_advisory_lock($1, $2)`,
		int32(140014),
		int32(1),
	).Scan(&locked)
	return locked, err
}

func unlockJob(conn *pgxpool.Conn) {
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	var unlocked bool
	err := conn.QueryRow(
		ctx,
		`SELECT pg_advisory_unlock($1, $2)`,
		int32(140014),
		int32(1),
	).Scan(&unlocked)
	if err != nil {
		slog.Error("release advisory lock", "error", err)
		return
	}
	if !unlocked {
		slog.Warn("advisory lock was not held when releasing")
	}
}

func ensureMonthWithRetry(
	ctx context.Context,
	pool *pgxpool.Pool,
	conn *pgxpool.Conn,
	month time.Time,
	rng *rand.Rand,
) (ensureResult, error) {
	var lastErr error

	for attempt := 1; attempt <= maxAttempts; attempt++ {
		result, err := ensureMonthOnce(ctx, pool, conn, month)
		if err == nil {
			return result, nil
		}
		lastErr = err

		if !isRetryable(err) || attempt == maxAttempts {
			break
		}

		base := 250 * time.Millisecond * time.Duration(1<<(attempt-1))
		jitter := time.Duration(rng.Int63n(int64(200 * time.Millisecond)))
		delay := base + jitter
		slog.Warn(
			"retrying partition DDL",
			"month", month.Format("2006-01"),
			"attempt", attempt,
			"delay", delay,
			"error", err,
		)

		timer := time.NewTimer(delay)
		select {
		case <-ctx.Done():
			if !timer.Stop() {
				<-timer.C
			}
			return ensureResult{}, ctx.Err()
		case <-timer.C:
		}
	}

	return ensureResult{}, fmt.Errorf("partition %s not ensured: %w", month.Format("2006-01"), lastErr)
}

func ensureMonthOnce(
	ctx context.Context,
	pool *pgxpool.Pool,
	conn *pgxpool.Conn,
	month time.Time,
) (ensureResult, error) {
	tx, err := conn.BeginTx(ctx, pgx.TxOptions{})
	if err != nil {
		return ensureResult{}, fmt.Errorf("begin transaction: %w", err)
	}
	defer func() {
		rollbackCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
		defer cancel()
		_ = tx.Rollback(rollbackCtx)
	}()

	// These constants are controlled by the program, not user input.
	if _, err := tx.Exec(ctx, `SET LOCAL lock_timeout = '2s'`); err != nil {
		return ensureResult{}, fmt.Errorf("set lock_timeout: %w", err)
	}
	if _, err := tx.Exec(ctx, `SET LOCAL statement_timeout = '30s'`); err != nil {
		return ensureResult{}, fmt.Errorf("set statement_timeout: %w", err)
	}

	var created bool
	monthDate := month.Format("2006-01-02")
	if err := tx.QueryRow(
		ctx,
		`SELECT app.ensure_event_month($1::date)`,
		monthDate,
	).Scan(&created); err != nil {
		return ensureResult{}, classifyPgError("ensure_event_month", err)
	}

	if err := tx.Commit(ctx); err != nil {
		// Commit errors can have an uncertain result. Verify catalog state before
		// deciding whether retrying is safe.
		verifyCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		// Verify through the pool, not the transaction connection: that
		// connection may have been lost at the exact commit boundary.
		if verifyErr := verifyPartition(verifyCtx, pool, month); verifyErr == nil {
			return ensureResult{
				Created:                  created,
				CommitVerifiedAfterError: true,
			}, nil
		}
		return ensureResult{}, fmt.Errorf("commit outcome uncertain: %w", err)
	}

	if err := verifyPartition(ctx, conn, month); err != nil {
		return ensureResult{}, fmt.Errorf("post-commit partition verification: %w", err)
	}
	return ensureResult{Created: created}, nil
}

func verifyPartition(ctx context.Context, q queryRower, month time.Time) error {
	partitionName := "event_log_" + month.Format("2006_01")
	var relationName string
	var bound string

	err := q.QueryRow(
		ctx,
		`SELECT
		     c.oid::regclass::text,
		     pg_get_expr(c.relpartbound, c.oid)
		 FROM pg_class AS c
		 JOIN pg_namespace AS n ON n.oid = c.relnamespace
		 JOIN pg_inherits AS i ON i.inhrelid = c.oid
		 WHERE n.nspname = 'app'
		   AND c.relname = $1
		   AND i.inhparent = 'app.event_log'::regclass`,
		partitionName,
	).Scan(&relationName, &bound)
	if errors.Is(err, pgx.ErrNoRows) {
		return fmt.Errorf("partition %s is absent", partitionName)
	}
	if err != nil {
		return classifyPgError("verify partition", err)
	}

	nextMonth := month.AddDate(0, 1, 0)
	if !strings.Contains(bound, month.Format("2006-01-02")) ||
		!strings.Contains(bound, nextMonth.Format("2006-01-02")) {
		return fmt.Errorf("partition %s has unexpected bound %q", relationName, bound)
	}
	return nil
}

func listPartitions(ctx context.Context, conn *pgxpool.Conn) error {
	rows, err := conn.Query(
		ctx,
		`SELECT
		     p.relid::text,
		     COALESCE(p.parentrelid::text, ''),
		     p.isleaf,
		     p.level,
		     COALESCE(pg_get_expr(c.relpartbound, c.oid), '')
		 FROM pg_partition_tree('app.event_log') AS p
		 JOIN pg_class AS c ON c.oid = p.relid
		 ORDER BY p.level, p.relid::text`,
	)
	if err != nil {
		return classifyPgError("query partition tree", err)
	}
	defer rows.Close()

	for rows.Next() {
		var relation string
		var parent string
		var isLeaf bool
		var level int
		var bound string
		if err := rows.Scan(&relation, &parent, &isLeaf, &level, &bound); err != nil {
			return fmt.Errorf("scan partition row: %w", err)
		}
		slog.Info(
			"partition",
			"relation", relation,
			"parent", parent,
			"is_leaf", isLeaf,
			"level", level,
			"bound", bound,
		)
	}
	if err := rows.Err(); err != nil {
		return fmt.Errorf("iterate partition rows: %w", err)
	}
	return nil
}

func isRetryable(err error) bool {
	var pgErr *pgconn.PgError
	if !errors.As(err, &pgErr) {
		return false
	}
	switch pgErr.Code {
	case "40001", // serialization_failure
		"40P01", // deadlock_detected
		"55P03": // lock_not_available / lock_timeout
		return true
	default:
		return false
	}
}

func classifyPgError(operation string, err error) error {
	var pgErr *pgconn.PgError
	if !errors.As(err, &pgErr) {
		return fmt.Errorf("%s: %w", operation, err)
	}

	return fmt.Errorf(
		"%s: sqlstate=%s constraint=%s table=%s detail=%s: %w",
		operation,
		pgErr.Code,
		pgErr.ConstraintName,
		pgErr.TableName,
		pgErr.Detail,
		err,
	)
}

14.4 代码审查要点

  • signal.NotifyContext:SIGINT/SIGTERM 取消当前 SQL、退避等待和后续月份。
  • pgxpool.ParseConfig/NewWithConfig:配置来自 DATABASE_URL,池在退出时关闭。
  • MaxConns = 2:一个连接持有 Session Advisory Lock 并串行执行 DDL;另一个仅用于 Commit 结果不确定时从新会话复核目录。
  • pool.Acquire:Session Advisory Lock 必须绑定同一物理连接;defer conn.Release() 防泄漏。
  • BeginTx + defer Rollback:每月创建是完整事务;Rollback 使用短 Background Context,避免主 Context 已取消时无法清理。
  • SET LOCAL:超时只影响当前事务,不污染归还池中的连接。
  • $1::date:日期参数化;Go 不拼 DDL。
  • errors.As + *pgconn.PgError:按 SQLSTATE 分类,不依赖错误文本。
  • maxAttempts、指数退避和随机抖动:防止多个任务同步重试形成风暴。
  • Commit 错误后通过池中的另一连接验证 Catalog:原事务连接可能恰在提交边界断开,不能依赖它,也不能盲目重试。
  • rows.Close()rows.Err():正确归还连接并检查迭代错误。
  • listPartitions:后置输出树、层级和边界,可作为审计日志;真实系统还应把结构化指标发送到监控系统。

14.5 不应放进普通请求路径的伪代码

// 禁止:大量并发请求在月初同时做 DDL。
func HandleInsert(ctx context.Context, event Event) error {
    if noPartition(event.OccurredAt) {
        // CREATE TABLE ...  // 强锁、竞态、连接池耗尽、注入风险
    }
    return insertEvent(ctx, event)
}

正确行为是参数化 INSERT;若 SQLSTATE 表示没有目标分区,快速失败、记录目标时间、触发告警。维护系统应在此之前数周创建分区。


15. 生产 Runbook

15.1 首先确认什么

先建立事实时间线,不要直接改参数:

  1. 故障起点、首次告警、最近一次分区 DDL、应用发布和统计信息更新分别在何时。
  2. 影响是写入失败、查询变慢、连接池耗尽、DDL 等待、Replica Lag,还是历史数据误删。
  3. 影响一个租户/时间窗,还是所有访问父表的请求。
  4. SQL 是否包含分区键和半开区间,实际参数类型和时区是什么。
  5. 是否刚跨日/月/年边界,未来分区是否存在,Default 是否突然有数据。
  6. 是否存在长事务、idle in transaction、备份、报表或维护任务。
  7. 当前 Primary 身份是否明确,是否刚发生 Switchover/Failover,旧维护任务是否仍在运行。

应用错误日志必须保留 SQLSTATE、Schema、Table、Constraint 和目标时间参数;不要只保留错误文本。

15.2 查看哪些指标

按同一时间轴对齐:

  • API P50/P95/P99、错误率、超时率、队列长度。
  • pgxpool AcquiredConnsEmptyAcquireCountEmptyAcquireWaitTime、取消次数。
  • pg_stat_statements 的调用量、总/平均规划时间、执行时间、Rows、Temp Blocks、WAL(版本支持时)。
  • 主机 CPU、Load、RSS、Page Fault、磁盘 IOPS/吞吐/延迟/队列深度。
  • [PG16+] pg_stat_io 的 Backend、Autovacuum、Checkpointer I/O。
  • WAL 生成速率、归档滞后、Checkpoint、Replica Send/Write/Flush/Replay Lag。
  • Autovacuum 活动、Dead Tuple、Freeze Age。
  • 父表和叶分区数量、Default 行数、Pending Detach、未来覆盖窗口。

15.3 查询哪些系统视图

分区清单、边界、大小和行数估算

SELECT
    p.level,
    p.relid::regclass AS relation,
    p.parentrelid::regclass AS parent,
    p.isleaf,
    pg_get_expr(c.relpartbound, c.oid) AS bound,
    c.reltuples::bigint AS estimated_rows,
    pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_partition_tree('app.event_log') AS p
JOIN pg_class AS c
  ON c.oid = p.relid
ORDER BY p.level, relation::text;
  • level:0 为根,1 为一级分区。
  • isleaf:是否真正存储数据。
  • bound:边界文本。
  • reltuples:Analyze 后的估算,不是精确 Count。
  • total_size:父虚拟表可能接近零;容量统计关注叶分区。

Default 是否异常积压

SELECT
    count(*) AS rows,
    min(occurred_at) AS min_key,
    max(occurred_at) AS max_key,
    pg_size_pretty(pg_total_relation_size('app.event_log_default')) AS total_size
FROM app.event_log_default;

该 Count 对巨大 Default 可能昂贵;日常监控应使用增量业务计数、近似统计或按小窗口抽样,不要每分钟全表 Count。

叶分区 Vacuum/Analyze

SELECT
    s.relid::regclass AS relation,
    s.n_live_tup,
    s.n_dead_tup,
    s.n_mod_since_analyze,
    s.last_vacuum,
    s.last_autovacuum,
    s.last_analyze,
    s.last_autoanalyze,
    s.vacuum_count,
    s.autovacuum_count,
    age(c.relfrozenxid) AS xid_age
FROM pg_stat_all_tables AS s
JOIN pg_class AS c ON c.oid = s.relid
WHERE s.relid IN (
    SELECT relid FROM pg_partition_tree('app.event_log') WHERE isleaf
)
ORDER BY s.n_dead_tup DESC;

父级和叶级索引有效性

SELECT
    t.relid::regclass AS table_name,
    i.indexrelid::regclass AS index_name,
    i.indisvalid,
    i.indisready,
    i.indislive,
    pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size
FROM pg_partition_tree('app.event_log') AS t
JOIN pg_index AS i
  ON i.indrelid = t.relid
ORDER BY table_name::text, index_name::text;
  • indisvalid=false:Planner 不应把它作为普通有效索引使用;在线构建失败或父级尚未 Attach 完整时常见。
  • indisready:是否接受新写入维护。
  • indislive:是否仍被视为存活索引。

Pending Detach

SELECT
    inhrelid::regclass AS child,
    inhparent::regclass AS parent,
    inhdetachpending
FROM pg_inherits
WHERE inhdetachpending;

15.4 如何找到 blocker

WITH waiting AS (
    SELECT
        a.pid,
        a.usename,
        a.application_name,
        a.xact_start,
        a.query_start,
        a.wait_event_type,
        a.wait_event,
        a.query,
        unnest(pg_blocking_pids(a.pid)) AS blocker_pid
    FROM pg_stat_activity AS a
    WHERE cardinality(pg_blocking_pids(a.pid)) > 0
)
SELECT
    w.pid AS blocked_pid,
    w.application_name AS blocked_app,
    clock_timestamp() - w.query_start AS blocked_for,
    w.wait_event_type,
    w.wait_event,
    b.pid AS blocker_pid,
    b.application_name AS blocker_app,
    b.state AS blocker_state,
    clock_timestamp() - b.xact_start AS blocker_xact_age,
    left(w.query, 180) AS blocked_query,
    left(b.query, 180) AS blocker_query
FROM waiting AS w
JOIN pg_stat_activity AS b
  ON b.pid = w.blocker_pid
ORDER BY blocker_xact_age DESC NULLS LAST;

处理顺序:确认 blocker 身份和事务目的 → 判断能否让业务自行完成 → 必要时取消 Query → 最后才考虑终止 Session。pg_cancel_backend 只取消当前语句;pg_terminate_backend 会断开连接并回滚事务,风险更高。

15.5 如何找到最早出现的计划估算错误

PostgreSQL 不保存所有历史执行计划。要定位“最早何时出现”,必须结合历史监控:

  1. pg_stat_statementsmean_plan_timetotal_plan_time、执行时间或 Rows 在何时突变。
  2. 对相同 queryid 关联发布、分区创建、Attach、Analyze 和数据倾斜时间。
  3. 在安全环境对代表性参数执行 EXPLAIN ANALYZE,比较每个节点 rows= 估算与 actual rows=
  4. 若生产已启用受控 auto_explain,搜索最早估算倍率突变的日志;不要临时全局记录所有高频查询。
  5. 检查新分区是否批量装载后未 Analyze、父表是否长期未手工 Analyze。
EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY)
SELECT ...
FROM app.event_log
WHERE occurred_at >= $1
  AND occurred_at <  $2;

估算倍率可按 greatest(actual,1) / greatest(estimated,1) 观察。单个节点误差未必导致坏计划;关注它是否改变 Join 顺序、扫描方式、并行度或内存溢写。

15.6 如何区分 CPU、内存、I/O、锁、连接池、WAL、Vacuum 和复制问题

类型典型证据分区相关判断
CPUBackend CPU 高、无明显 I/O/锁等待、Planning Time 上升过多分区、复杂 Partition-Wise 计划、全树本地索引探测
内存Backend RSS 上升、OOM、临时文件或 Hash 批次大量计划节点、每分区 work_mem 节点、长连接触碰全树
I/ODataFileRead、设备延迟/队列高、Buffers read 高无裁剪跨历史、冷分区扫描、批量装载/搬 Tablespace
wait_event_type='Lock'pg_blocking_pids 非空Attach/Drop/普通 Detach/父级索引 DDL排队
连接池DB 不忙但 Acquire Wait 高,连接被长查询占用跨分区查询和 DDL 等待延长连接持有时间
WALWAL bytes/s、归档和 Checkpoint 激增大量 Delete、跨分区 Update、建索引、批量装载
VacuumDead Tuple、XID Age、Autovacuum 长时间运行当前热分区阈值不合适;Delete 路径制造大量垃圾
复制replay_lag、LSN 差、Standby I/O/Recovery ConflictDDL、Delete/Vacuum WAL、索引构建或 Tablespace 迁移

15.7 哪些命令通常可在线执行

“在线”仍要设置超时并观察:

  • EXPLAIN 不带 ANALYZE
  • 查询 pg_partition_treepg_stat_*pg_lockspg_class
  • 对单个新装载叶分区执行 ANALYZE;父表 ANALYZE 需评估 I/O。
  • 创建独立 Staging Table、离线装载和校验。
  • 叶分区 CREATE INDEX CONCURRENTLY
  • 在无 Default 且已评估长事务时执行 DETACH ... CONCURRENTLY
  • lock_timeout/statement_timeout 设为 Session 或 Transaction 局部值。

15.8 哪些命令高风险

  • DROP TABLE 历史分区。
  • CREATE TABLE ... PARTITION OF、普通 DETACH、直接 Drop 分区导致父表强锁。
  • 有巨大 Default 时 ATTACH PARTITION
  • 父表直接 CREATE INDEX,因不支持父级 CONCURRENTLY
  • ALTER TABLE ... SET TABLESPACE 搬大分区。
  • 对数亿行运行 EXPLAIN ANALYZE DELETE
  • 全局强制 plan_cache_mode、打开 Partition-Wise Join/Aggregate 或大幅提高 work_mem
  • 终止未知长事务、关闭 Autovacuum、关闭持久性参数。

15.9 临时止损

按事故类型选择最小动作:

  • 缺失未来分区:由专用维护身份在短 lock_timeout 下创建;失败则对超出窗口写入 Backpressure,而非请求内 DDL。
  • 全历史慢查询:限流、取消、缩小允许时间窗,临时路由到分析副本。
  • DDL 锁队列:先取消等待中的非必要 DDL,解除队列,再处理根 blocker。
  • Default 积压:停止继续流入,建立迁移窗口;不要立即 Attach 触发全表扫描。
  • 大 Delete 引发 WAL/Replica Lag:暂停批次,降低并发,改用后续整分区生命周期设计。
  • Pool 耗尽:Admission Control,保留健康检查/运维连接预算;不要单纯无限增大 MaxConns。

15.10 根本修复

  • 重选与高频谓词和保留策略一致的分区键。
  • 改写 SQL 为分区键半开范围,并验证参数类型。
  • 预建未来分区,建立 Default 行数和覆盖窗口告警。
  • 调整日/月/年粒度,减少不必要层级和索引。
  • 建立父表手工 Analyze 与新叶 Analyze 流程。
  • 用注册表或 Hash 分区解决全局 ID,而非触发器全树查重。
  • 将 DDL 与请求隔离,使用单实例、有界重试维护任务。
  • 对历史删除采用 Detach → 归档验证 → Drop 的审批流程。

15.11 如何验证修复

  1. pg_partition_tree 边界、层级和未来覆盖正确。
  2. Default 行数归零且持续不增长。
  3. 代表性常量、Generic Plan 和最坏参数均只访问预期分区。
  4. Planning Time、Buffers、P95/P99、Pool Wait 回到基线。
  5. 无 Pending Detach、Invalid Index 或异常强锁等待。
  6. 叶和父统计更新时间正常,估算倍率在可接受范围。
  7. WAL/Replica Lag 恢复,Standby 查询无新增冲突。
  8. 在隔离环境完成归档恢复或 PITR 演练。

15.12 监控和告警

至少设置:

  • 未来可写覆盖 < N 天/月。
  • Default rows > 0、最老 Default 行年龄和增长速率。
  • 分区数量、叶数量和索引对象数量异常增长。
  • Query Planning P95、计划/执行时间比例、访问分区数异常。
  • DDL Lock Wait、最长事务、idle in transaction
  • Pending Detach 持续超过阈值。
  • 新分区创建失败、连续重试、边界验证失败。
  • 叶分区 last_autoanalyze/last_autovacuum 过旧、XID Age 过高。
  • 父表 last_analyze 超过策略周期。
  • WAL、归档和 Replica Replay Lag 超阈值。
  • pgxpool Empty Acquire Wait、排队和取消率上升。

16. 常见错误与反模式

  1. “超过一千万行就分区”:忽略行宽、索引、查询、保留期和硬件;先用 SLO 与维护需求证明必要性。
  2. 把分区当索引:只按月分区却不建立租户/业务谓词索引,单月内仍全表扫描。
  3. 查询不带分区键:让每个叶分区分别扫描或探测索引,读放大与 Planning Time 随保留期增长。
  4. 每租户一个分区:客户数从数百增长到数十万后,Catalog、规划和每会话内存失控。
  5. 默认分区永久兜底:不监控、不迁移,最终 Attach 新分区需要扫描巨表并强锁。
  6. 请求路径动态建分区:月初并发 DDL、连接池耗尽、强锁和重试风暴。
  7. 认为 DETACH CONCURRENTLY 无等待:忽略它要等待使用旧分区树的事务,且第二阶段仍需叶表强锁。
  8. 父表直接在线建大索引:父级不支持 CREATE INDEX CONCURRENTLY,递归锁定大树。
  9. 用触发器全树查询保证唯一:并发竞态且成本随分区数增长,不能替代全局仲裁。
  10. 只 Analyze 叶、不 Analyze 父:跨分区查询缺少全树统计,估算与 Join 策略可能失真。
  11. 分区键允许频繁更新:每次跨边界移动带来双侧 Heap/Index/WAL 和更复杂 FK/CDC 行为。
  12. 按本地时区随意切日:夏令时和会话 TimeZone 导致边界、查询参数和归档窗口不一致。
  13. 冷热 Tablespace 当备份:存储层故障、Failover 或备份缺失时仍会丢失冷分区。
  14. Drop 前不验证归档恢复:有导出文件不等于可恢复,误删后才发现对象损坏或缺列。
  15. 为解决单节点上限继续细分分区:分区不增加 CPU、WAL 带宽或 Primary 数量;应进入分片/读扩展设计。

17. 模拟生产事故案例

17.1 模拟生产案例一:Default Partition 在月初吞入数亿行

1. 系统背景

事件平台每天写入约数亿条事件,根表按月 RANGE(occurred_at),保留 24 个月。为避免缺失分区导致写入中断,团队建立了 Default Partition。独立维护任务原计划每月提前创建未来三个分区。

2. 故障现象

7 月 1 日后写入未失败,但八小时后:

  • Default 从近零增长到数亿行;
  • DBA 尝试 Attach 七月分区,命令等待并造成父表请求排队;
  • 取消 blocker 后 Attach 扫描 Default,最终因存在七月行失败;
  • API P99、pgxpool Acquire Wait 和 Replica Lag 同时上升;
  • 报表查询因 Default 被包含而读取异常多数据。

3. 错误假设

  • “有 Default 就不会发生月初事故。”
  • “Attach 只是元数据操作,很快完成。”
  • “先建空七月分区,PostgreSQL 会自动把 Default 的行搬过去。”

三者都错误。Default 只把显式失败变成隐性积压;Attach 不自动迁移行,并需要验证 Default。

4. 排查过程

  1. 从应用日志确认写入没有错误,但物理 tableoid 全部指向 Default。
  2. 查询 Default 的 min/max(occurred_at),确认从 7 月 1 日开始持续流入。
  3. pg_locks 显示 Attach 请求 Default 的 ACCESS EXCLUSIVE,被长报表的 AccessShareLock 阻塞。
  4. 取消 Attach 后,业务锁队列迅速下降,确认 DDL 是放大器而非最初根因。
  5. 检查维护任务日志:上次 Failover 后 Secret 未同步,任务连续失败但告警仅发送到已废弃频道。
  6. 检查 pg_partition_tree:只覆盖到六月,没有七月及以后。
  7. 对 Replica Lag 与 WAL 关联:团队尝试的批量迁移 Delete/Insert 产生大量 WAL,进一步加剧延迟。

5. 根因

  • 直接根因:未来七月分区未创建。
  • 控制根因:Failover 后维护身份失效,且没有未来覆盖窗口告警。
  • 设计根因:Default 被当成正常写入路径,没有零行 SLO、流量熔断和迁移 Runbook。
  • 事故放大:在业务高峰直接发起 Attach,强锁进入队列;随后无计划批量迁移加重 WAL。

6. 临时止损

  1. 取消等待中的 Attach,先解除父表锁队列。
  2. 对七月事件入口施加 Backpressure,将可重放事件写入外部持久队列;不在请求中建表。
  3. 在独立 Staging Table 建七月边界、索引和约束。
  4. 分批从 Default 向 Staging 迁移历史积压,控制 WAL 和 Replica Lag。
  5. 进入短维护窗口,阻断父表新写,搬迁最后增量,为 Default 添加排除约束并 Attach。
  6. 验证 Default 归零、七月查询裁剪和 Replica 追平后恢复入口。

7. 最终修复

  • 默认预建未来六个月,维护任务每天运行并幂等验证,而不是每月只运行一次。
  • 用 Session Advisory Lock 和专用最小权限角色;Failover 后任务由唯一 Active 调度器接管。
  • 将“未来覆盖少于 90 天”设为严重告警。
  • Default 改为异常缓冲区:任何新行立即告警;评估后最终移除 Default,让遗漏快速失败。
  • 建立 Default 迁移和 Attach 锁演练,发布前在等比例数据上测 WAL、锁和 RTO。

8. 监控补充

  • 未来最大边界与当前时间差。
  • Default 行数、每分钟增长、最老/最新键值。
  • 维护任务最后成功时间、SQLSTATE 和连续失败次数。
  • DDL Lock Wait、最长事务、Pool Empty Acquire Wait。
  • WAL 生成与 Replica Replay Lag。

9. 防止复发

将分区日历纳入发布门禁和每日巡检;每次 Switchover/Failover 都验证维护身份、调度器单活、未来分区、Tablespace 和权限。Default 不再作为“无需维护”的借口。


17.2 模拟生产案例二:过度分区与无时间谓词导致 Planning P99 爆炸

1. 系统背景

订单分析系统保留五年数据。架构最初按天 Range,再按 tenant_id 做 16 路 Hash 子分区:约 29,000 个叶分区。ordersrefunds 使用相似结构,每个叶分区有四个索引。升级 PostgreSQL 18 后,团队认为“新版本已优化大量分区”,并全局开启 Partition-Wise Join/Aggregate。

2. 故障现象

  • 客户详情接口只按 order_id 查询,P99 从几十毫秒恶化到数秒。
  • Backend CPU 高,但存储 IOPS 并不高。
  • pg_stat_statements 显示 Planning Time 占总延迟大头。
  • 单个报表 Backend RSS 急剧增长,主机发生 OOM Kill。
  • pgxpool 连接被慢规划和大计划长期占用,排队请求激增。

3. 错误假设

  • “PG18 能处理几千分区,所以几万也没问题。”
  • “每个分区有 order_id 索引,点查一定只需一次索引访问。”
  • “Partition-Wise Join/Aggregate 全局打开只会更快。”

官方改进降低了部分成本,但前提仍是典型查询能裁剪到少量分区;本地索引和分区级计划都可能按分区倍增。

4. 排查过程

  1. EXPLAIN 显示无日期条件的 order_id 查询保留数万子计划。
  2. EXPLAIN ANALYZE 的实际数据访问很少,但 Planning Time 极高;证明瓶颈发生在执行前。
  3. pg_stat_statements 按 Query ID 对比发布前后,发现全局 GUC 变更后计划节点和内存进一步增加。
  4. Backend RSS、[PG17+] EXPLAIN (MEMORY) 与计划节点数量相关。
  5. pg_locks 显示单查询涉及大量关系锁;长连接进程内存也因触碰大量关系持续偏高。
  6. 关闭目标 Session 的 Partition-Wise 设置后,OOM 风险下降但无时间谓词点查仍慢。
  7. 数据模型复盘发现:订单 ID 本身可映射到创建月份,但应用未保存/传递该路由信息。

5. 根因

  • 分区粒度和二级 Hash 的乘法导致对象数量远超收益。
  • 高频点查谓词不包含时间分区键,无法裁剪。
  • 全局开启 Partition-Wise 功能增加计划节点、规划 CPU 和内存。
  • 团队把版本优化当成架构上限承诺,没有对真实最坏 SQL做压测。

6. 临时止损

  • 对在线角色关闭 Partition-Wise Join/Aggregate,仅在经验证的分析事务局部开启。
  • API 强制携带订单创建月;从订单路由缓存/小型目录表补齐时间范围。
  • 对全历史报表限流并路由到分析副本,设置最大时间窗。
  • 降低报表并发,保护连接池和主库内存;取消最坏查询。
  • 暂停新增更细子分区,不继续放大对象数。

7. 最终修复

  • 新表改为月 Range;只有极大月份才有选择地二级分区,而非每月固定 16 路。
  • 在线点查 API 使用 (order_month, order_id) 复合定位;建立路由目录支持仅有 ID 的入口。
  • 对需要 UNIQUE(order_id) 的域评估按 ID Hash 分区或独立 ID 注册表。
  • 归档超过在线查询窗口的历史数据,减少在线树规模。
  • Partition-Wise 功能改为 Query/Role 局部策略,必须通过 Planning Time、Memory、Temp 和 P99 门禁。

8. 监控补充

  • 每棵树叶分区数、索引数、月增长趋势。
  • Query ID 的 Planning P95/P99、计划/执行时间比。
  • 每 Backend RSS、进程 OOM、关系锁数量。
  • 查询实际访问分区数和最坏时间窗口。
  • pgxpool Acquire Wait 与跨全树查询并发。

9. 防止复发

在 Schema 评审中强制提交“保留期 × 一级粒度 × 二级分区数 × 索引数”的对象预算;对不含分区键的关键 SQL做自动计划测试。版本升级优化只能降低常数,不能推翻复杂度模型。


18. 面试题

18.1 核心概念题(5 道)

面试题 1:分区与索引分别解决什么问题?分区能否替代索引?

题目

请解释 Declarative Partitioning 与普通 B-tree 索引的职责边界,并说明为什么“已经按月分区”不代表月内查询不需要索引。

30 秒回答

分区先根据 Partition Bound 排除不可能包含目标行的叶表,降低需要访问的数据对象数量;索引在保留下来的叶表内定位行。Pruning 依据分区边界而不是索引定义。因此,按月分区只能排除其他月份;查询某个月内某个租户、设备或订单时,仍通常需要叶级索引。分区不能替代索引,索引也不能替代按分区 Drop 的生命周期能力。

深入回答

Planner 从父表的分区描述中构造子计划,并在规划期、执行初始化期或执行过程中根据谓词与参数裁剪叶分区。剩余叶表可采用 Sequential Scan、Bitmap Scan 或 Index Scan。分区的主要优点是生命周期管理、批量淘汰、冷热布局以及在高选择性分区键查询中缩小工作集;缺点是对象、锁、统计、计划节点和运维复杂度增加。索引的优点是叶内快速定位与排序支持;缺点是写放大、WAL、空间和 Vacuum 成本。替代方案包括不分区的大表加合适索引、BRIN、汇总表、归档库或真正的分片。生产中要同时验证 Subplans Removed、实际访问叶数、Planning Time 和叶内访问路径。

面试官真正考察什么

是否能把“分区裁剪”和“索引访问”分成两个层次,避免把所有性能问题归因于表太大。

常见错误回答

“分区相当于自动索引,所以分区键以外不需要索引。”

追问

按月分区的事件表执行 WHERE tenant_id = $1,每个叶表都有 (tenant_id) 索引,会发生什么?

追问答案

没有时间谓词时不能按月份裁剪,Planner/Executor 可能为全部月份建立并执行本地索引探测。单次探测虽快,但总成本约随叶分区数增长;替代方案是让调用方携带时间范围、维护 ID 到月份的路由目录、改用与点查键一致的分区策略,或保留非分区表。生产上要测试最坏保留期,而不是只测两个分区的开发库。


面试题 2:Range、List、Hash Partition 如何选择?

题目

分别说明 Range、List、Hash Partition 的适用场景、优缺点和常见误用。

30 秒回答

Range 适合时间、序号等连续区间和保留期管理;List 适合有限、稳定、业务语义明确的离散集合;Hash 适合按键均匀摊开写入或容量,但不适合按时间快速淘汰。选择标准不是语法偏好,而是查询谓词、数据生命周期、唯一性约束、倾斜和运维动作是否与 Partition Key 对齐。

深入回答

Range 的 Bound 是半开区间,易做日/月/年滚动、Detach/Drop 与冷热迁移;边界遗漏和时区错误是主要风险。List 可按地区、监管域、产品线隔离,易进行有针对性的维护;集合增长失控、值迁移和 Default 积压会提高复杂度。Hash 按 Modulus/Remainder 分布,能把单键负载铺到多个叶表,但无法通过删除一个 Hash 叶表表达“删除某个月”,也不能建立 Default Partition。多级分区可组合时间 Range 与租户 Hash/List,但对象数是乘法关系。替代方案包括单表、部分索引、BRIN、物理分片。生产中先列出高频 SQL、保留动作和对象预算,再决定策略。

面试官真正考察什么

是否能从访问模式和生命周期推导分区方式,而不是背诵三种语法。

常见错误回答

“Hash 一定最均匀,所以所有大表都应 Hash 分区。”

追问

日志按月淘汰,但某些大客户单月数据极多,应如何设计?

追问答案

通常以月份 Range 作为一级分区保证 Drop 粒度;只有月内单叶在容量、索引维护或并行性上确有压力时,才对特定月份或全部月份做有限 Hash 子分区。优点是兼顾时间淘汰与月内分散;缺点是叶表、索引和计划节点倍增。也可按月单层分区并通过更快存储、合适索引、批量写入和归档解决,先以压测证明二级分区必要性。


面试题 3:Planning-Time Pruning 与 Execution-Time Pruning 有何区别?

题目

请解释分区裁剪发生的三个时机,以及 EXPLAIN 中如何判断运行时裁剪。

30 秒回答

规划期裁剪使用规划时已知常量直接不生成无关子计划;执行初始化期裁剪使用启动时已知参数,可在 EXPLAIN ANALYZE 中看到 Subplans Removed;执行过程中的动态裁剪可在参数化 Nested Loop 等场景随外层值变化,部分子计划显示 loops=0(never executed)。Prepared Statement 是否使用 Custom/Generic Plan 会影响规划期可见性,但不等于 Generic Plan 一定不能执行期裁剪。

深入回答

Pruning 依赖能与分区键和 Bound 推导关系的谓词。常量谓词最适合规划期裁剪;执行参数、InitPlan 值和参数化路径可在初始化或每次参数改变时裁剪。优点是减少实际扫描、Buffer 与 I/O;缺点是大量分区仍可能带来计划构造、锁和内存开销,动态裁剪也有运行时判断成本。函数包装、隐式类型转换、不可推导表达式或缺失分区键可能阻止裁剪。替代方案是规范化参数类型、显式时间范围、生成可索引/可裁剪列或重构分区键。生产中同时采集 Planning Time、Execution Time、Subplans Removed、各子计划 loops 和实际 Buffers。

面试官真正考察什么

是否理解“计划中出现分区”与“实际执行了分区”不是同一件事,也是否会正确读计划。

常见错误回答

“Prepared Statement 使用参数,所以 PostgreSQL 无法做任何分区裁剪。”

追问

为什么同一条参数化 SQL 在前几次很快,之后 Planning Time 下降但执行变慢?

追问答案

可能从参数敏感的 Custom Plan 切换为 Generic Plan:Generic Plan 复用计划,降低重复规划成本,却无法在规划期利用具体参数;仍可能执行期裁剪,但访问路径和估算不一定适合所有参数。需用 EXPLAIN (ANALYZE, SETTINGS)plan_cache_mode 的会话级对照实验和 pg_stat_statements 观察,而不能生产全局强制一种计划。替代方案包括拆分查询形状、提供更明确范围或使用动态 SQL的受控服务器端实现。


面试题 4:分区表的索引、主键和唯一约束有什么特殊限制?

题目

为什么分区父表上的 Unique/Primary Key 通常必须包含全部 Partition Key 列?所谓“全局唯一索引限制”意味着什么?

30 秒回答

分区父级索引是虚拟结构,每个叶分区有自己的物理索引。PostgreSQL 没有一个覆盖所有叶表、可在一次索引探测中仲裁的全局唯一索引。因此父表 Unique/Primary Key 必须包含全部分区键列,使任意两个可能冲突的键必然路由到同一叶分区,由本地唯一索引检查。

深入回答

例如按 created_at Range 分区时,UNIQUE(order_id) 不能直接保证跨月唯一;UNIQUE(created_at, order_id) 只能保证组合键唯一。优势是约束检查和叶表维护局部化;代价是业务自然键若与分区键不一致,就需要架构级仲裁。可选方案包括:让业务 ID 自带时间且由可靠生成器保证不重复;按业务 ID Hash 分区;建立非分区的 ID 注册表并在同一事务先插入;在外部强一致 ID 服务分配;或接受租户/时间域内唯一。触发器扫描全树既昂贵又可能出现并发竞态,不是等价替代。生产中还要考虑注册表热点、Failover 后序列/生成器安全、幂等和恢复验证。

面试官真正考察什么

是否理解约束正确性来自冲突键被同一仲裁结构序列化,而不只是 SQL 语法。

常见错误回答

“给每个分区都建 UNIQUE(order_id) 就能保证全局唯一。”

追问

按月分区的订单表必须保证随机 UUID 业务 ID 全局唯一,怎样做最稳妥?

追问答案

若 UUID 由高质量随机生成器产生,碰撞概率极低,但“概率极低”不等于数据库强约束。需要严格保证时,可维护小型非分区 order_id_registry(order_id primary key, created_month),在同一事务先注册再写目标分区,并让所有写入口统一经过该事务。优点是数据库可证明唯一;缺点是多一次写入、WAL、潜在热点和生命周期协调。也可按 UUID Hash 分区以原生约束唯一,但会牺牲按月 Drop。选择取决于唯一性强度与生命周期哪个是硬约束。


面试题 5:Default Partition 的作用和风险是什么?

题目

Default Partition 为什么既能提升可用性,又可能把显式故障变成更难处理的隐性积压?

30 秒回答

Default 接收不匹配任何显式 Bound 的行,能避免缺分区时立即报错;但它会掩盖未来分区遗漏、错误时区和脏键。后续 Attach 与 Default 范围重叠的新分区时,数据库必须确认 Default 中不存在该范围的数据,可能扫描并强锁 Default;若有数据还会失败,而且不会自动迁移。

深入回答

Default 的优点是提供异常缓冲与兼容过渡;缺点是数据路由不再与预期叶表一致、查询可能额外扫描 Default、Attach 验证成本不可控。Default 也不适用于 Hash Partition。生产上应把它视为“隔离区”而非正常分区:监控行数、增长速率和键范围,设置接近零的 SLO,预建未来分区,并准备分批迁移、排除约束和短维护窗口。替代方案是不要 Default,让缺分区以 Check Violation 快速失败并由持久队列重放;或者由独立维护器提前创建叶表。选择取决于写入能否重试、允许的数据路由偏差和恢复 RTO。

面试官真正考察什么

是否看到 Default 对锁、Attach 验证和事故可观测性的影响。

常见错误回答

“Default 保证永远不会漏数据,因此创建后无需维护未来分区。”

追问

Default 已积压一个月数据,怎样迁移到新分区而不直接把生产表锁死?

追问答案

先建立与目标 Bound 一致的独立 Staging Table、约束和索引;在受控并发下从 Default 分批复制/搬迁大部分数据,限制 WAL 与 Replica Lag;切入短维护窗口阻断或重定向该范围新写,迁移尾差,给 Default 加可证明不相交的约束;再 Attach Staging。优点是将长数据搬运移出强锁窗口;缺点是流程复杂且需处理幂等、双写和最终校验。不可假设 Attach 会搬数据。


18.2 原理与排障题(6 道)

面试题 6:为什么缺少分区键的查询会随保留期变慢?

题目

一张按月分区、保留五年的表,WHERE business_id = $1 为什么即使每个叶表都有索引,Planning P99 也可能持续增长?

30 秒回答

因为查询不能用月份 Bound 排除叶表。Planner 需要处理许多子关系和访问路径,Executor 也可能对每个叶索引做探测;每个分区还有关系锁、统计和计划节点成本。保留月份越多,总开销越大,即使最终只返回一行。

深入回答

性能代价分为规划与执行两段:规划阶段展开分区树、构造 Append/MergeAppend 及叶访问路径并持有关系锁;执行阶段初始化并探测多个子计划。对象多还会增加 Backend 私有内存、Catalog/Relcache 压力。优点仅是每个叶索引较小,但无法抵消 O(N partitions) 的固定开销。替代方案包括把月份编码进业务 ID、路由目录先查月份、把时间范围作为 API 契约、按业务 ID Hash 分区、保留一张全局目录或不分区。生产上要用全保留期数据测 Planning P95/P99,而不是只比较 Execution Time。

面试官真正考察什么

是否能识别“本地索引乘以分区数”的读放大与规划复杂度。

常见错误回答

“索引是 O(log n),所以无论多少分区都不会慢。”

追问

如何快速证明瓶颈主要在 Planning 而非 I/O?

追问答案

查看 EXPLAIN (ANALYZE, BUFFERS, SETTINGS, SUMMARY) 的 Planning/Execution Time、计划子节点数和实际 Buffers;结合 pg_stat_statements 的规划统计(启用相应跟踪后)、Backend CPU、磁盘 IOPS 和 wait_event。若 Planning Time 高、Buffers/I/O 低、返回前 CPU 高,说明规划是主因。还可在同会话用添加时间范围的等价查询做对照,但不能用强制 GUC 得出普适结论。


面试题 7:参数化查询没有裁剪时如何排查?

题目

Go/pgx 使用 WHERE occurred_at >= $1 AND occurred_at < $2,计划似乎保留全部分区。请给出排查顺序。

30 秒回答

先确认参数类型与分区键完全可比较、谓词直接作用于分区键且范围正确;再用真实参数执行 EXPLAIN ANALYZE,区分规划期未裁剪和执行期未执行;观察 Custom/Generic Plan;检查 enable_partition_pruning、函数包装、Cast、时区及 Prepared Statement 行为。

深入回答

第一步确认 DDL:分区键表达式、Bound、列类型和 pg_partition_tree。第二步检查 SQL 是否写成 date_trunc(occurred_at)=...occurred_at::date=... 等难以反推 Bound 的表达式,或参数被推断为不合适类型。第三步看 Subplans Removedloopsnever executed,避免把计划中存在节点误判为实际扫描。第四步会话级比较 plan_cache_mode=force_custom_planforce_generic_plan,只用于诊断。第五步排除关闭 Pruning 的 GUC。替代方案是传半开时间范围、显式类型、生成列或重写 API。生产中需保持参数化以防注入,不能通过拼接字面量“修复”。

面试官真正考察什么

SQL 语义、类型系统、计划缓存与执行期裁剪的综合诊断能力。

常见错误回答

“把参数直接拼进 SQL,就会得到最优计划。”

追问

为什么 occurred_at::date = $1::date 可能比半开区间差?

追问答案

它对分区键应用表达式,Planner 未必能把结果转换为与原始 timestamptz Range Bound 等价的范围;也可能破坏普通列索引的可用性并引入 Session TimeZone 语义。推荐由应用计算明确的 UTC [start, end),使用 occurred_at >= $1 AND occurred_at < $2。如业务确需本地日期,可建立语义固定的生成列并按该列设计分区/索引,但要审查 DST 与时区规则。


面试题 8:ATTACH、DETACH、DETACH CONCURRENTLY、DROP 的锁和状态如何分析?

题目

请比较四类生命周期 DDL 的主要目的、锁风险与在线性。

30 秒回答

ATTACH 把已有表纳入分区树,需要验证 Bound,若缺少可证明约束会扫描待 Attach 表;存在 Default 时还可能验证并强锁 Default。普通 DETACH 从父树移除叶表,父表锁较强。DETACH ... CONCURRENTLY 用更弱的父表锁分阶段完成,但会等待使用旧分区树的事务,不能在事务块中运行,失败可能留下 Pending Detach,需 FINALIZE。DROP 直接删除叶表对象,元数据和文件回收快,但通常会对父表造成强锁风险。

深入回答

这些命令的优势是将生命周期动作从逐行 DML 转为关系级元数据操作,显著降低 Heap/Index 逐行维护、Dead Tuple 和常规 Vacuum 压力。缺点是 DDL 锁具有“排队放大”:一个等待中的强锁可能阻挡后续本可并发的请求。DETACH CONCURRENTLY 降低父级阻塞范围,但并非无锁,也不适用于含 Default 的某些情形;第二阶段和叶表处理仍有锁要求。替代方案是在副本/归档系统保留历史、分批 Delete、逻辑归档或维护窗口普通 Detach。生产中必须设 lock_timeout、观察长事务和 Pending 状态、准备取消与 Finalize 路径。

面试官真正考察什么

是否理解锁强度、验证扫描、事务快照和 DDL 状态机,而不只会背命令。

常见错误回答

“带 CONCURRENTLY 就绝不会等待或阻塞。”

追问

DETACH CONCURRENTLY 被取消后如何判断是否已完成?

追问答案

不能仅凭客户端错误判断。查询 pg_inheritspg_partition_treepg_class.relispartition 与继承项 Pending Detach 状态,确认叶表是否仍属于父树;若处于未完成状态,按官方语义执行 ALTER TABLE ... DETACH PARTITION ... FINALIZE,同时检查约束与应用访问路径。Commit/网络结果不确定时应基于目录状态做幂等复核,而不是盲目重复 DDL。


面试题 9:为什么 Drop 分区通常比批量 Delete 高效?何时仍应 Delete?

题目

从 Tuple、Index、WAL、Vacuum、锁和复制角度比较两者。

30 秒回答

Delete 逐行产生新 MVCC 状态、更新每个索引、写大量 WAL,留下 Dead Tuple 并需要 Vacuum,且长事务会延迟空间回收;Drop/Detach 以整关系为单位移除数据对象,通常工作量与关系数而不是行数相关,因此更适合按边界淘汰整块历史。

深入回答

逐行 Delete 的优点是可精确删除任意子集、能触发既有行级业务语义;缺点是写放大、锁持续时间、Replica Lag、Bloat、Checkpoint 和 Vacuum 压力。Drop 的优点是快、空间回收直接、无逐行扫描;缺点是边界必须与保留策略一致,DDL 锁和误删影响巨大,行级触发器/CDC 语义与 Delete 不同,PITR/备份恢复也需验证。可先 Detach 做隔离与归档,再延迟 Drop,以获得回滚窗口。仍应 Delete 的场景包括 GDPR 针对单用户删除、部分范围纠错、分区内少量数据清理。生产上先验证归档、Replica Lag、锁窗口和审计要求。

面试官真正考察什么

是否能把性能收益与语义差异、恢复风险一起分析。

常见错误回答

“Drop 不写 WAL,所以副本完全不受影响。”

追问

为什么“先 Detach、观察一段时间、再 Drop”常比直接 Drop 安全?

追问答案

Detach 先让父表查询不可见,但保留独立表,可做校验、导出、临时恢复访问或重新 Attach;直接 Drop 的逻辑回滚路径只剩备份/PITR。代价是仍占空间、需要权限隔离并防止应用误访问。应规定隔离期、校验和、归档清单、最终删除审批与恢复演练。


面试题 10:分区统计与 Autovacuum 有哪些陷阱?

题目

为什么所有叶分区都有最新 last_autoanalyze,跨分区查询仍可能估算错误?

30 秒回答

Autovacuum/Autoanalyze 主要按叶关系变化触发;叶表变化不会自然按同样阈值触发父级分区表的 Analyze。跨分区 Planner 既依赖叶统计,也可能依赖父级统计,因此需要在批量装载、Attach/Detach 或分布变化后主动 ANALYZE 父表,并单独监控父级统计新鲜度。

深入回答

叶统计描述局部分布,父级统计帮助估算全树条件和 Join。小叶可能因固定阈值或低变化比例长期不触发 Analyze;新 Attach 表若未提前 Analyze,会以差统计加入计划。过度分区还让 Worker 调度、Catalog 查询和维护窗口碎片化。优点是叶级 Vacuum 可隔离工作集;缺点是维护对象多且父/叶状态分裂。替代方案包括较粗粒度分区、装载后显式 Analyze、为特定叶调节表级 Autovacuum 参数、扩展统计或汇总表。生产上监控 n_mod_since_analyzelast_autoanalyze、父级 last_analyze、估算/实际比以及 XID Age,绝不能因分区而关闭 Autovacuum。

面试官真正考察什么

是否理解统计触发机制与物理叶表的维护边界。

常见错误回答

“父表没有数据文件,所以不需要 Analyze。”

追问

新月分区离线装载并 Attach 前应做什么?

追问答案

在独立表上完成数据校验、Bound 对应的 Check Constraint、索引和 ANALYZE;确认没有越界行后 Attach,以减少验证扫描并让它带着可用统计进入树。Attach 后根据跨分区查询需要再 Analyze 父表。若数据仍持续变化,要恢复合适的 Autovacuum 设置;不能把离线装载时临时关闭的参数遗留到生产。


面试题 11:Partition-Wise Join/Aggregate 为什么默认不应全局开启?

题目

解释它们可能加速什么,又为什么会增加 Planning Time 和内存。

30 秒回答

当两侧按兼容键和边界分区时,Partition-Wise Join 可把大 Join 拆成叶级 Join;Aggregate 可先在分区内聚合,减少中间数据。但它们可能为每个分区创建独立计划节点、Hash/Sort 和内存结构,规划 CPU与运行时内存随分区数增长,所以默认关闭,应该按查询或角色经压测启用。

深入回答

收益来自数据局部性、并行机会和减少全局中间结果,尤其适合可裁剪、边界对齐的大型分析。成本包括更大的计划树、更多 work_mem 消费点、Temporary File、Backend RSS、锁对象和计划不稳定性;分区不对齐时收益有限。PostgreSQL 18 改进了大量分区规划和更多 Partition-Wise Join 场景,但不是取消复杂度。替代方案是常规 Parallel Hash Join、预聚合、物化视图、分析副本或数仓。生产中用最坏分区数和并发度测 Planning P99、Peak Memory、Temp、CPU、执行时长,局部 SET LOCAL 而非全局打开。

面试官真正考察什么

是否理解查询优化开关存在资源乘法,能进行吞吐而非单查询视角评估。

常见错误回答

“把 Join 拆小一定更省内存,所以应该永久开启。”

追问

单条报表变快 30%,但主机更容易 OOM,应如何决策?

追问答案

评估的是并发吞吐和 SLO,不是单查询最佳值。限制报表并发、路由分析副本、局部启用、降低单查询并行度或重写为分阶段聚合;记录 Backend Peak Memory 与 Temp。若总资源成本和故障半径仍过高,应回退常规计划。任何 work_mem 调整都要乘以计划节点、并发查询和并行 Worker 数量估算。


18.3 架构设计题(4 道)

面试题 12:如何为五年事件数据选择按天、月还是年分区?

题目

给出一个可操作的决策方法,而不是固定阈值。

30 秒回答

先从保留/归档最小粒度、单分区数据量与索引维护时间、典型查询窗口、叶表总数和运维频率共同决定。天适合短窗口、高精度淘汰但对象多;月通常是通用折中;年对象少但单叶巨大、淘汰不灵活。用“保留期 ÷ 粒度 × 子分区数 × 每叶索引数”做对象预算,再用真实工作负载压测。

深入回答

如果合规要求每日删除,月分区仅靠 Drop 无法满足;若查询多为整月且保留十年,日分区可能造成数千叶和大量索引;年分区虽简单,但单次 Vacuum、Reindex、归档和热点索引可能过重。还要考虑行宽、每日峰值、存储、CPU、内存、Replica 带宽、备份窗口和 SLO。可采用月分区加短期日级 Staging、月内二级分区、滚动合并或冷库迁移。生产设计要给出上限告警和未来扩展路径,不能把当前数据量线性外推为永久安全。

面试官真正考察什么

容量建模、生命周期和运维可控性的平衡能力。

常见错误回答

“每天超过一百万行就按天,否则按月。”

追问

数据量季节性波动 20 倍,固定月分区是否仍合理?

追问答案

可能合理,因为 Bound 不要求每个叶同样大;先确认峰值月是否超出索引构建、Vacuum、备份和查询 SLO。极端峰值可只对子树做更细子分区,但会形成非均匀拓扑,维护器和监控需理解。另一方案是统一按周或按天。选择应以峰值压测和对象预算为证据,不应为了视觉均匀制造大量空分区。


面试题 13:如何同时满足按月淘汰和业务 ID 全局唯一?

题目

设计一个写入与查询架构,并说明失败模式。

30 秒回答

保留 events 按月 Range 以支持生命周期,同时建立非分区 event_id_registry(event_id primary key, event_month, state) 作为全局唯一和路由仲裁。单事务先插 Registry,再插目标分区;点查先由 Registry 得到月份后精准访问。写入必须幂等,Commit 结果不确定时先查 Registry 状态,不盲重试。

深入回答

Registry 的优点是数据库级全局唯一、点查可裁剪、生命周期与业务 ID 解耦;缺点是额外写放大、WAL、索引增长和潜在热点。可用随机/UUIDv7 ID 分散 B-tree 插入特征,并按保留策略决定 Registry 是否长期保留或只保留去重窗口。事务失败时两表原子回滚;网络在 Commit 后断开时,客户端需以 Idempotency Key/ID 查询确认。替代方案是按 ID Hash 分区、ID 中编码月份、强一致外部 ID 服务,或接受概率唯一。HA 中需保证 Registry 与数据表同一 Primary/事务域,否则会引入分布式一致性问题。

面试官真正考察什么

约束、路由、幂等和 Failover 下提交不确定性的综合设计。

常见错误回答

“UUID 不会重复,所以不用约束,也不用处理 Commit 超时。”

追问

Registry 成为写热点怎么办?

追问答案

先用指标确认是索引页、WAL、锁还是容量问题;随机键通常不会形成单一右端页热点,但仍有 WAL 和单 Primary 上限。可批量写、优化行宽、减少无用索引、按业务域拆分 Registry,或将唯一 ID 由可证明无冲突的分配算法生成,从而降低数据库仲裁需求。若跨分片保持强全局唯一,需专用分配服务或全局协调,代价是可用性和延迟;不能用应用层先查后插替代唯一约束。


面试题 14:如何设计热、温、冷分区与历史归档?

题目

请覆盖 Tablespace、备份、Replica、恢复和数据删除流程。

30 秒回答

热分区放低延迟存储并保留完整索引;温分区可移到容量型 Tablespace、降低索引集合和查询并发;冷数据先 Detach,生成带校验和的归档并做恢复演练,再延迟 Drop。Tablespace 只是文件位置,不是备份;物理副本通常仍复制其变化,Failover 节点必须具备一致的 Tablespace 映射和文件系统。

深入回答

生命周期状态可定义为 Active → Read Mostly → Detached Quarantine → Archived → Dropped。每次状态转换都记录边界、行数、最小/最大键、Schema、校验和和对象清单。Move Tablespace 会产生大量 I/O、锁与复制压力,应安排窗口并限流;冷分区若仍在线查询,须维护必要统计和索引。归档格式需考虑未来 Schema、时区和权限恢复。替代方案是对象存储+查询引擎、独立归档 PostgreSQL、逻辑复制到分析平台。RPO/RTO 要分别说明:主库 PITR 能否恢复误 Drop,归档能否在目标时间重建可查询表。生产删除必须经过双人审批与恢复抽检。

面试官真正考察什么

是否把存储分层与可恢复性、HA 拓扑和审计闭环联系起来。

常见错误回答

“把旧分区移到便宜 Tablespace 后就等于完成备份。”

追问

Failover 后新 Primary 缺少冷 Tablespace 挂载会怎样?

追问答案

数据库可能无法访问相应关系,启动、查询或恢复流程失败,具体表现取决于缺失文件和访问路径;这不是可接受的“只影响冷数据”假设。所有候选节点必须有一致 Tablespace 路径、权限、容量和监控,或不要让 HA 主集群依赖仅单机可见的存储。Switchover 演练应实际读取冷热分区,而不只检查节点角色。


面试题 15:什么时候应从分区演进到分片?

题目

请解释两者边界,并设计渐进式扩展路线。

30 秒回答

分区仍在同一个 PostgreSQL Cluster/Primary 内,共享 CPU、内存、WAL、Checkpoint、故障域和写入上限;分片把数据与负载分散到多个独立 Primary。若瓶颈是生命周期和单表维护,优先分区;若单 Primary 的写吞吐、容量、WAL、恢复时间或故障域已无法满足,应进入分片,而不是继续增加叶表。

深入回答

判断依据包括峰值 CPU、WAL 生成与复制带宽、存储 IOPS/容量、Checkpoint、Vacuum、备份恢复 RTO、连接和单节点故障影响。分片收益是水平扩展和故障域隔离;代价是跨分片事务、全局唯一、Join/Aggregate、重平衡、路由、备份和 Failover 复杂度。渐进路线可先在单节点按未来 Shard Key 建模、消除跨租户事务、建立路由层和全局 ID 策略,再按租户/Hash 迁移到多个 Cluster。替代方案包括读副本、缓存、分析平台、垂直扩容和工作负载隔离。生产上必须定义每个分片的 RPO/RTO、Fencing、再均衡和热点租户迁移机制。

面试官真正考察什么

是否能区分逻辑数据组织与物理扩展边界,并量化复杂度转折点。

常见错误回答

“把表分成一万个分区就等于分片,能够使用一万台机器。”

追问

按租户分片后,跨租户报表怎么办?

追问答案

在线事务路径避免跨分片全局查询;通过 CDC/逻辑复制将数据汇聚到分析系统,或由查询协调层并行下推后做受控归并。前者一致性有延迟但可扩展,后者实时性高但扇出、部分失败和资源控制复杂。应明确报表的一致性等级、最大扇出、超时、分页和结果去重,不能让任意 API 同步扫描所有分片。


19. 练习与参考答案

19.1 理论题(5 道)

理论题 1:判断是否需要分区

一张审计表有 3,000 万行、总计 45 GB,每天新增 20 万行,查询大多按主键点查,历史永久保留,现有查询与 Vacuum 均满足 SLO。是否应仅因“超过千万行”立即分区?请列出决策证据。

参考答案

不应仅按行数决定。当前访问以主键点查为主,没有按时间淘汰,分区未提供明显生命周期收益;若查询不带分区键,反而可能增加 Planning 和多索引探测。需要收集行宽、索引大小、缓存命中、P95/P99、写入峰值、Vacuum/Freeze 时长、备份与恢复 RTO、未来增长、DDL 窗口和硬件余量。若单表仍满足 SLO,可保持单表并优化索引、Autovacuum、归档和容量告警。只有出现明确的维护、保留、冷热或单关系操作瓶颈,并且分区键与访问模式一致时再迁移。

理论题 2:判断约束是否合法

表按 occurred_at Range 分区。以下父级约束哪些可直接建立,为什么?

UNIQUE (event_id)
UNIQUE (occurred_at, event_id)
PRIMARY KEY (tenant_id, event_id)
PRIMARY KEY (occurred_at, tenant_id, event_id)

参考答案

在普通列 Range 分区前提下,父级 Unique/Primary Key 必须包含全部 Partition Key 列,因此第二和第四个包含 occurred_at,可满足结构性要求;第一和第三缺少 occurred_at,不能在父级形成跨叶全局唯一约束。仍需检查分区键是否使用表达式及版本具体限制。合法不等于业务语义正确:UNIQUE(occurred_at,event_id) 只保证组合唯一,若同一 ID 在不同时间可重复,仍不满足全局唯一。需要全局唯一时采用 Registry、调整分区键或外部强一致 ID 方案。

理论题 3:解释计划

查询计划中出现 60 个叶分区节点,但 Subplans Removed: 58,剩余两个节点中一个 loops=1,另一个 loops=0。是否扫描了 60 个分区?

参考答案

没有。计划文本包含候选节点不代表执行访问。58 个在执行初始化阶段被移除;loops=0 的节点没有执行;只有 loops=1 的节点实际运行。仍要注意规划阶段可能处理了较多分区并产生 Planning Time、锁和内存成本。应结合每节点 Buffers、Actual Rows、Planning/Execution Time 和参数计划类型判断,而不能只数计划文本节点。

理论题 4:分析 Default 风险

团队主张:“Default Partition 能保证写入永不失败,因此应永久保留且无需告警。”请反驳。

参考答案

Default 只能接收未匹配显式 Bound 的行,不能保证其他约束、磁盘、权限、复制或事务都不失败;Hash 分区也不支持 Default。它会隐藏缺分区、错误时区和越界数据,使路由偏离预期。后续 Attach 重叠 Bound 时可能扫描并强锁 Default,存在数据还会失败,并且不会自动迁移。正确做法是预建未来分区、对 Default 行数和增长即时告警、建立迁移 Runbook;若业务可重放,甚至移除 Default 以快速失败。

理论题 5:评估 Partition-Wise Aggregate

一个报表跨 1,000 个月/子分区执行高基数 Group By。为什么启用 Partition-Wise Aggregate 既可能更快,也可能更危险?

参考答案

它可在每个叶内先做 Partial Aggregate,降低跨叶中间数据并利用并行性;但可能为大量叶创建独立 Hash/Sort/聚合节点,每个节点都有内存与临时文件风险,计划树和 Planning Time 也增大。并发报表时资源成本会乘以连接数和 Parallel Worker。应在真实分区数、基数、数据倾斜和并发下比较 Execution、Planning、Peak Memory、Temp、CPU 和吞吐,并以 SET LOCAL 局部启用。替代方案是预聚合、物化视图、分析副本或数仓。


19.2 实验题(3 道)

实验题 1:比较分区粒度与 Planning Time

构造相同总数据量、分别按日和按月分区的两棵事件表,比较三类查询:单日、单月、无时间谓词点查。要求给出可复现方法和记录项。

参考答案

  1. 在同一 PostgreSQL 版本、配置和硬件建立两棵等价表;总行数、行宽、索引和数据分布一致。
  2. 日表至少覆盖两年以上,使叶数有明显差异;月表覆盖同范围。
  3. 分别 ANALYZE 所有叶和父表,记录 pg_partition_tree 叶数和总大小。
  4. 使用参数化半开区间执行:单日、单月、仅 event_id。每类查询先冷缓存再热缓存,重复足够次数,并记录客户端 P50/P95/P99。
  5. 保存 EXPLAIN (ANALYZE, BUFFERS, WAL, SETTINGS, VERBOSE, SUMMARY),比较 Planning Time、Execution Time、Subplans Removed、实际叶数、Buffers、CPU/I/O/Wait Event。
  6. 预期不是固定胜负:单日查询日分区可能裁剪更精细;单月查询月分区对象更少;无时间点查日分区通常放大得更严重。最终结论必须结合保留粒度和对象运维成本。
  7. 清理实验对象;禁止在生产创建数千测试分区。

实验题 2:在线建立父级分区索引

为已有 24 个大叶分区的父表增加 (tenant_id, occurred_at) 索引,设计避免长时间递归强锁的流程。

参考答案

推荐流程:

  1. 先确认磁盘、WAL、Replica Lag、长事务和索引构建预算,设置适度 lock_timeout
  2. 在父级创建 ON ONLY 的分区索引,使其初始未完整有效;具体语法先在目标版本测试。
  3. 对每个叶表逐个或低并发执行 CREATE INDEX CONCURRENTLY,索引名固定由受控程序生成,不拼接用户输入。
  4. 每个叶索引完成后检查 pg_index.indisvalid/indisready、大小和重复定义;失败的 Invalid Index 按 Runbook 清理重建。
  5. 逐个 ALTER INDEX parent_index ATTACH PARTITION leaf_index;全部 Attach 后验证父级索引有效。
  6. 对代表性 SQL执行 Explain,监控 WAL、CPU、I/O、Replica Lag 和 DDL Lock Wait。
  7. 优点是把长扫描拆为叶级 Concurrent Build;缺点是步骤多、需状态机和幂等恢复。不要假设父级可直接 CREATE INDEX CONCURRENTLY 递归完成。

实验题 3:验证维护器的 Commit 结果不确定处理

基于本章 Go 维护器,设计故障注入,证明客户端在 Commit 返回网络错误时不会盲目重复造成错误状态。

参考答案

  1. 在测试环境使用代理或容器网络,在数据库提交完成后、客户端收到结果前中断连接;不要在生产故障注入。
  2. 维护动作由固定数据库函数按月份幂等创建,并受 Session Advisory Lock 串行化。
  3. 客户端 Commit 返回错误后,将状态标记为 Unknown,而非 Failed;重新建立连接查询 pg_partition_tree、Bound、索引和 Tablespace,判断目标月份是否已完整存在。
  4. 若完整存在,按成功收敛;若不存在且无 Pending/半成品,再按有限重试执行;若部分状态存在,进入人工/自动修复分支。
  5. 记录 SQLSTATE、连接错误、目标月份、目录复核结果和重试次数。验证并发运行两个维护器时只有一个持有 Advisory Lock。
  6. 预期结果是最终只有一个合法分区,边界无重叠、索引完整;不能把“Commit 报错”直接解释为“事务未提交”。

19.3 排障题(2 道)

排障题 1:月初写入开始报错

00:00 UTC 后,插入按月分区表大量失败,应用只记录了 SQLSTATE 23514,没有 Default Partition。请给出止损和根因排查。

参考答案

23514 是 Check Violation;在该场景首先怀疑没有匹配的分区,但仍须查看服务端错误字段和目标键值确认。步骤:

  1. 限制重试并启用 Backpressure,避免所有 goroutine/连接反复失败;将可重放事件写入持久队列。
  2. 查询 pg_partition_treepg_get_expr(relpartbound, oid),确认当前和未来 Bound;检查 Session TimeZone 与应用参数。
  3. 查看独立维护任务最后成功时间、权限、Advisory Lock、Failover 后调度器单活与 Secret。
  4. 若确实缺分区,在专用运维路径创建边界、索引和约束;设 lock_timeout,先清除长事务/DDL 队列风险。普通请求路径不得自行 DDL。
  5. 创建后用边界前后测试值、tableoid 和 Explain 验证,再逐步重放队列。
  6. 根因修复是预建未来窗口、边界覆盖告警、Failover 后维护器验证和月初演练。盲目加 Default 会把失败转成潜在积压,不是自动根治。

排障题 2:查询 Execution 很快但端到端 P99 很高

一张有 20,000 个叶分区的表,数据库 Explain 显示 Execution Time 20 ms,应用 P99 3 s,磁盘不忙。如何定位?

参考答案

  1. 先把端到端拆为 Pool Acquire、网络、Planning、Execution、Row Decode;检查 pgxpool EmptyAcquireWaitTime、Acquired/Idle/Total Conns 与 Context Timeout。
  2. 查看 Explain 的 Planning Time、计划节点数、Subplans Removed;无分区键时很可能规划数秒而执行很短。
  3. pg_stat_activity 看 active Backend、Wait Event 和查询开始时间;用系统指标看 Backend CPU/RSS,而不是只看 IOPS。
  4. 检查是否全局打开 Partition-Wise Join/Aggregate,是否从月分区扩成日×Hash,是否有 Generic Plan/类型问题。
  5. 临时止损:要求时间范围、限制跨全树并发、关闭在线角色的高成本 Partition-Wise 开关、把报表路由副本。
  6. 根本修复:减少叶数、改变分区粒度、增加路由目录或重构分区键,并把 Planning P95/P99 纳入发布门禁。单纯增大连接池会放大 CPU/内存竞争。

19.4 系统设计题(1 道)

系统设计题:全球多租户事件平台的分区与生命周期

设计一个 PostgreSQL 事件平台:峰值 150,000 行/秒,事件平均 500 B;在线查询最近 90 天,合规要求原始数据保留 3 年;常见查询是租户+时间范围,偶尔按全局 event_id 点查;单租户流量最大可占 15%;要求同 Region RPO 接近 0、跨 Region 灾备 RPO 5 分钟、核心写入月初不能因缺分区中断。请给出数据模型、分区、唯一性、维护、HA、归档、监控和扩展路线。

参考答案

1. 先验证单 Primary 边界

150,000 行/秒 × 500 B 仅为原始载荷约 75 MB/s,尚未计 Heap Header、对齐、索引、WAL、Full Page Image、Replica 和峰值;必须通过真实 Schema、批次、索引数、压缩与硬件压测。若单 Region 单 Primary 无法满足 WAL/CPU/存储/RTO,直接进入按租户或 Hash 分片,而不是仅靠分区。

2. 表与分区

CREATE TABLE events (
    tenant_id    bigint        NOT NULL,
    event_id     uuid          NOT NULL,
    occurred_at  timestamptz   NOT NULL,
    event_type   text          NOT NULL,
    payload      jsonb         NOT NULL,
    PRIMARY KEY (tenant_id, occurred_at, event_id)
) PARTITION BY RANGE (occurred_at);

一级按 UTC 日或周/月选择:90 天在线高吞吐若单日大小仍可维护,可按日;三年全挂在线会形成约 1,095 叶,尚需测 Planning 与对象预算。更稳妥是只让最近 90 天在 OLTP 树中保持日级,较老数据 Detach 后进入归档 PostgreSQL/对象存储;不要在一个无限增长父树挂满所有历史。若单日热点叶过大,可在日内按 tenant_id Hash 做有限子分区,但需证明收益并设置对象上限。

常用叶级索引可为 (tenant_id, occurred_at DESC)、必要时 (tenant_id, event_type, occurred_at DESC);避免给 JSONB 无差别建大 GIN。BRIN 可用于大而时间相关的冷叶。分区不是索引替代品。

3. 全局 Event ID

因为点查只给 event_id 且时间 Range 分区无法原生 UNIQUE(event_id),建立 event_registry(event_id primary key, tenant_id, occurred_at, state)。同一事务注册并写事件;点查先路由到租户/日期。若业务允许由 UUIDv7/强 ID 服务保证唯一,也仍需明确它是概率/算法保证而非父表全局索引。Registry 可按业务要求保留三年或更久,需容量和热点压测。

4. 写入与幂等

Go 使用 pgxpool、有界 Worker 和批量 Copy/Batch;每批有 Context 超时、Admission Control 和持久队列。请求路径不执行 DDL。以 event_id/Idempotency Key 处理重试;Commit 错误后查询 Registry 判定 Unknown 结果。对 40001/40P01 只重试完整事务,指数退避和抖动,防止风暴。

5. 自动维护

独立维护器每天预建至少未来 30~90 天,使用 Session Advisory Lock、固定 Security Definer 函数、严格月份/日期白名单和目录复核。Default 的两种策略:

  • 首选无 Default,让漏分区快速失败到持久队列,确保错误可见;
  • 若业务必须持续接收,Default 仅作异常隔离区,任何行立即 P1 告警,并有自动迁移 Runbook。

维护器在 Planned Switchover/Unplanned Failover 后验证单活、权限、未来覆盖和 Tablespace。

6. 生命周期与归档

热:最近数天置于低延迟存储;温:90 天内保留在线、降低非必要索引;冷:到期叶先 DETACH CONCURRENTLY,校验行数/边界/Checksum,导出对象存储或归档库并完成抽样恢复,隔离期后 Drop。三年到期执行不可逆删除审批。Tablespace 不是备份;所有 HA 候选节点需一致挂载。

7. HA 与灾备

同 Region 用同步物理复制满足接近零 RPO,但需明确同步副本故障时是阻塞写入还是降级;采用 Patroni/等价控制面、DCS、Fencing 和连接重定向。跨 Region 异步物理/逻辑复制满足目标 RPO 5 分钟,持续监控 Replay Lag 与 WAL 保留。每个分片独立定义 RTO/RPO。PITR 和归档恢复必须演练误 Drop 分区;应用处理旧连接与 Commit 结果不确定。

8. 监控

未来 Bound 覆盖天数、Default 行数、叶数/索引数、Planning P95/P99、实际访问叶数、写入 P99、WAL/s、Checkpoint、Replica Lag、Autovacuum/Analyze、新分区创建状态、DDL Lock Wait、最长事务、Pool Acquire Wait、Backend RSS、归档校验和恢复成功率。

9. 扩展路线

先按未来 Shard Key——通常 tenant_id——保证事务局部性。单 Primary 接近 CPU/WAL/容量/RTO上限时,按一致性 Hash 或租户目录分片;15% 大租户支持独占分片。跨租户分析通过 CDC 进入分析平台,不在 OLTP 同步扇出。分片会增加全局 ID、再均衡、备份、Failover 和跨分片查询复杂度,应以容量模型而非表行数触发。


20. 本章检查清单

分区模型与约束

  • 我能够根据查询谓词、保留粒度和对象预算选择 Range、List 或 Hash,而不是按行数套模板。
  • 我能够解释 Partition Key、Partition Bound、Default Partition 和 Tuple Routing 的关系。
  • 我能够设计多级分区,并计算“一级分区 × 二级分区 × 索引数”的对象膨胀。
  • 我能够解释跨分区 UPDATE 为何可能变成旧叶删除与新叶插入。
  • 我能够判断父级 Unique/Primary Key 是否包含全部分区键,并说明全局唯一索引限制。
  • 我能够为全局业务 ID 比较 Registry、Hash 分区、ID 编码和外部 ID 服务。
  • 我能够分析 Foreign Key 在分区路由、跨叶更新和生命周期 DDL 中的影响。

计划与性能

  • 我能够区分 Planning-Time、初始化期和动态 Execution-Time Pruning。
  • 我能够从 Subplans Removedloopsnever executed、Buffers 和 Planning Time 判断实际访问分区。
  • 我能够解释为什么查询不包含分区键时会产生计划和索引探测放大。
  • 我能够证明分区不能替代叶级索引。
  • 我能够在真实保留期下测 Planning P50/P95/P99,而不是只测两个叶表。
  • 我能够评估 Partition-Wise Join/Aggregate 的计划节点、内存、Temp 和并发成本。
  • 我能够分析过度分区对 Catalog、Relcache、锁、Backend 内存和运维任务的影响。
  • 我能够结合 CPU、内存、shared_buffers、OS Page Cache、I/O、AIO、WAL、Checkpoint、Vacuum 和网络分析分区性能。

生命周期、并发与高可用

  • 我能够比较批量 Delete、Detach、Detach Concurrently 和 Drop 的语义、锁、WAL 与恢复代价。
  • 我能够在 Attach 前准备可证明 Bound 的约束,并识别 Default 验证风险。
  • 我能够识别等待中的强 DDL 锁对后续请求形成的队列放大。
  • 我能够查找长事务、Blocker、Pending Detach 和失败的 DDL 状态。
  • 我能够制定热、温、冷、Detached、Archived、Dropped 的状态机和校验门禁。
  • 我能够说明 Tablespace 不是备份,并验证 Failover 节点的路径与数据可访问性。
  • 我能够分析 Drop/Detach 对物理复制、逻辑复制、PITR、RPO 和 RTO 的影响。
  • 我能够在 Planned Switchover 和 Unplanned Failover 后验证维护器单活和未来分区覆盖。

统计、维护与 Go

  • 我能够分别检查叶级 Autovacuum/Analyze 与父表统计新鲜度。
  • 我能够在离线装载、Attach/Detach 和分布显著变化后安排合适的 Analyze。
  • 我能够实现不在普通请求路径运行的独立分区维护任务。
  • 我能够在 Go 中使用 context.Context、pgx/v5、pgxpool、参数化 SQL 和有界并发。
  • 我能够使用 Session Advisory Lock 防止多个维护器并发执行 DDL。
  • 我能够用 errors.As*pgconn.PgError 和 SQLSTATE 分类错误,而不匹配错误文本。
  • 我能够对 4000140P01 做有限完整事务重试,并处理 Commit 结果不确定。
  • 我能够把连接池 Acquire Wait、活跃连接、排队请求和数据库活跃查询区分开。

生产处置

  • 我能够复现实验中的分区裁剪、Default Attach 等待/失败和 Detach 等待。
  • 我能够在事故中先取消危险 DDL 队列、限流和保护 Replica,而不是继续重试。
  • 我能够为未来边界、Default 增长、叶数、Planning Time、DDL 锁和 Replica Lag 设置告警。
  • 我能够判断问题应由分区、索引、归档、读副本、分析平台还是分片解决。

21. 官方资料与继续阅读

以下资料均以 PostgreSQL 18 为主基线;访问日期:2026-06-20。

PostgreSQL 18 官方文档

  1. Declarative Partitioning:https://www.postgresql.org/docs/18/ddl-partitioning.html
  2. CREATE TABLE(Partition Bound、Default、约束):https://www.postgresql.org/docs/18/sql-createtable.html
  3. ALTER TABLE(Attach/Detach/Finalize 与锁):https://www.postgresql.org/docs/18/sql-altertable.html
  4. Query Planning 配置(Partition Pruning、Partition-Wise Join/Aggregate、Plan Cache):https://www.postgresql.org/docs/18/runtime-config-query.html
  5. Routine Vacuuming 与统计:https://www.postgresql.org/docs/18/routine-vacuuming.html
  6. ANALYZEhttps://www.postgresql.org/docs/18/sql-analyze.html
  7. pg_partitioned_tablehttps://www.postgresql.org/docs/18/catalog-pg-partitioned-table.html
  8. pg_inheritshttps://www.postgresql.org/docs/18/catalog-pg-inherits.html
  9. pg_classhttps://www.postgresql.org/docs/18/catalog-pg-class.html
  10. 分区信息函数 pg_partition_tree 等:https://www.postgresql.org/docs/18/functions-admin.html
  11. 显式锁与锁冲突矩阵:https://www.postgresql.org/docs/18/explicit-locking.html
  12. EXPLAINhttps://www.postgresql.org/docs/18/sql-explain.html
  13. pg_stat_activityhttps://www.postgresql.org/docs/18/monitoring-stats.html
  14. Tablespace:https://www.postgresql.org/docs/18/manage-ag-tablespaces.html

PostgreSQL 14—18 Release Notes

  1. PostgreSQL 14:https://www.postgresql.org/docs/release/14.0/
  2. PostgreSQL 15:https://www.postgresql.org/docs/release/15.0/
  3. PostgreSQL 16:https://www.postgresql.org/docs/release/16.0/
  4. PostgreSQL 17:https://www.postgresql.org/docs/release/17.0/
  5. PostgreSQL 18:https://www.postgresql.org/docs/release/18.0/

pgx/v5 官方资料

  1. pgx GitHub:https://github.com/jackc/pgx
  2. pgxpool API:https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool
  3. pgconn.PgError API:https://pkg.go.dev/github.com/jackc/pgx/v5/pgconn