# 分区决策门

LLMS 索引： [llms.txt](/llms.txt)

---

分区把一个逻辑关系拆成多组物理存储。它能让按生命周期整批删除、冷热分层和特定查询裁剪非常有效，也会把唯一键、外键、索引、统计信息和运维对象成倍展开。正因为改造晚了有成本，团队常想“先分了再说”；本节用决策门阻止这种没有收益证据的确定复杂度。

## 4.6.1 先证明生命周期、体量或裁剪需求再决定分区 {#item-4-6-1}

分区解决的典型问题是：

- 按月/日保留期需要快速 `DROP` 或 `DETACH PARTITION`，避免海量 DELETE 与 VACUUM；
- 热查询稳定命中少数分区，planner 能裁剪其余分区；
- 单表/索引维护窗口、冷热存储或批量加载已经不可接受；
- 数据分布天然按 list/hash 隔离，并有清楚路由与对象数量上限。

“以后数据会很多”不在其中。行数本身也不是充分证据：一亿条窄 append-only 记录和一千万条宽、频繁更新记录的物理问题不同；内存、索引、查询选择性和保留策略都会改变拐点。官方给出的只能是宽泛经验——通常要表非常大才值得——不是一个可复制的固定阈值。

### 决策输入

至少收集：

```text
current heap/index/TOAST size
daily/monthly growth
retention and legal hold
largest maintenance window
representative slow queries
predicates that can carry partition key
candidate key cardinality and null behavior
expected partition count over 3 years
backup/restore and failover objectives
```

查询裁剪必须用计划证明：

```sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT ...
FROM candidate_partitioned_table
WHERE placed_at >= $1
  AND placed_at <  $2;
```

看实际 scanned partitions、planning time、execution buffers；不要只看“有 Partition Pruning 字样”。prepared statement、parameter、函数包装和 join 条件都可能影响静态/运行时裁剪。`enable_partition_pruning` 还必须开启。

### 生命周期比“查询更快”更强

按月保留三年是可操作合同：36 个活跃分区、每月建立一个、过期时 detach/archive/drop。相比之下，“大多数查询最近数据”若没有 predicate 和计划样本，只是一句愿望。

本章订单没有保留期、法定删除批次、生产增长和慢查询数据。普通表的 PK/unique/FK 清楚且样本极小，因此不满足进入门。先不分区不是缺少架构，而是证据导向的物理决策。

## 4.6.2 分区键与主键、唯一约束必须共同设计 {#item-4-6-2}

PostgreSQL 分区表上的 unique/PK 由各 partition 的本地索引实现。为了保证不同 partition 之间不重复，unique/PK 的列必须包含全部 partition key columns，而且 partition key 不能是 expression/function。

假设按 `placed_at` 月分区：

```sql
CREATE TABLE shop.sales_order_p (...)
PARTITION BY RANGE (placed_at);
```

当前约束：

```text
PRIMARY KEY (order_id)
UNIQUE (order_no)
UNIQUE (customer_id, request_key)
```

不能原样成为 partitioned parent 的全局约束，因为它们都不含 `placed_at`。可选方向各有语义代价：

1. 改成 `(order_id, placed_at)` 等复合键；
2. 接受 only-per-partition uniqueness；
3. 另建未分区 registry 表维护全局键；
4. 由应用/trigger 维护跨 partition 唯一性，并承担并发正确性；
5. 换一个能同时服务生命周期与唯一性的 partition key；
6. 不分区。

把 `placed_at` 机械加入每个 unique 不等于问题解决。订单号原本承诺全局唯一，加入时间后两个 partition 可拥有同一个 order_no；除非 API 唯一性合同也改为 `(order_no, placed_at)`，语义已经变了。

### NULL 与草稿

v1 的 draft order `placed_at IS NULL`。range partitioning 对 NULL 没有普通 range 归属，需要 default partition 或不同 key。若用 `created_at` 路由，保留期可能与业务 placed/paid 生命周期不一致。分区键必须同时满足：

- 每行插入时可用、稳定；
- 业务生命周期/删除批次；
- 高频查询 predicate；
- unique/PK 与 FK 形状；
- 更新是否会导致跨 partition row movement。

一个“时间列存在”远不足以当分区键。

## 4.6.3 外键、引用方式与未来在线改造代价 {#item-4-6-3}

若 order PK 从 `(order_id)` 变成 `(order_id, placed_at)`，引用它的 order item 与 payment 通常也要携带 `placed_at`：

```text
sales_order_item(order_id, order_placed_at) -> sales_order(...)
payment(order_id, order_placed_at)          -> sales_order(...)
```

这增加键宽、索引宽、写入参数和更新路由；draft 的 NULL 又使引用更复杂。保留一个未分区 key registry 可避免传播时间键，却新增一张强一致写入热点与生命周期协调表。两种都不是免费。

PostgreSQL 已支持针对 partitioned table 的外键，但被引用键仍要满足 partitioned unique/PK 限制。应用 ORM “支持分区”也不能绕过数据库这一事实。

### 普通表不能原地变成分区表

官方文档明确：不能把 regular table 直接切换成 partitioned table，反之亦然。常见迁移需要：

1. 新建 partitioned parent 与 partitions；
2. 建立等价列、约束、索引、权限、trigger 与注释；
3. backfill 历史数据；
4. 捕获 backfill 期间增量（短暂停写、dual-write、trigger 或逻辑复制）；
5. 验证行数、checksum、FK 与查询计划；
6. 短锁窗口切换名称/view/service；
7. 保留前滚/回退与旧表清理门。

`ATTACH PARTITION` 可复用已经装载的普通表，但需要证明 partition constraint；没有匹配 CHECK 时会扫描验证并持有相应锁。partitioned index 也有自己的并发创建/attach 流程。ch11 会演练安全发布，ch28 再处理完整生命周期；本章只估算设计后果。

### 现在不分区，也要为未来保留边界

不应把应用 SQL 绑定具体 child table；所有读写面向逻辑 relation/view。业务标识不要编码当前 partition 名。持续记录时间分布、表/索引体积和保留期，让未来迁移有数据。

但不要为了“方便未来”现在就把 partition key 传播到所有 API：这会立刻锁定尚未证明的设计。可演进的关键是清楚接口与可验证迁移，不是提前暴露物理细节。

## 4.6.4 产出“现在分区 / 暂不分区”的可复查 ADR {#item-4-6-4}

[`partition-adr.md`](/labs/ch04/partition-adr.md)记录：

```text
ADR-004
decision: ch04-v1 remains unpartitioned
status: accepted
review chapter: ch26
```

主要理由不是“数据还小”一句话，而是：

- 没有体量、增长、保留期或慢查询证据；
- 当前 order_id/order_no/request key 要求全局唯一；
- order item/payment 通过 FK 引用订单；
- 候选 `placed_at` 对 draft 为 NULL；
- 预分区会立即增加对象、维护和恢复复杂度。

### 触发复查

任一条件由真实证据满足时复查：

1. heap/index 已使单表维护窗口不可接受；
2. 有稳定、可按候选 key 整批执行的过期/归档政策；
3. representative query 携带 key，计划证明 pruning 收益；
4. 普通表无法满足写入、备份恢复或冷热分层目标。

复查包必须包含增长率、关系/索引大小、保留期、慢查询计划、候选键、预期 partition count，以及一次迁移/回退演练。结论可以仍是“暂不分区”；ADR 的价值是让新证据能推翻旧决定。

### 数据库验收

`verify-v1.sql` 不只在文档中说“不分区”，还检查五表都没有 `pg_partitioned_table` entry：

```sql
SELECT
    c.oid::regclass,
    c.relkind
FROM pg_catalog.pg_class AS c
WHERE c.oid IN (
  'shop.customer'::regclass,
  'shop.product'::regclass,
  'shop.sales_order'::regclass,
  'shop.sales_order_item'::regclass,
  'shop.payment'::regclass
);
```

预期 `relkind='r'`，状态摘要输出：

```text
partition_decision=not-now
```

如果有人私自把某表换成 partitioned hierarchy，verify 失败，迫使代码与 ADR 一起评审。

### ADR 模板

```text
context
measured evidence
candidate keys and alternatives
PK/UK/FK consequences
query/lifecycle benefits
object and operation costs
decision and owner
review triggers/date
migration and rollback outline
```

不要记录“PostgreSQL 支持 range partition”这类产品事实；记录为什么这个模型在这个时点选择什么，以及什么证据会让决定失效。

### 本节验收

- 没有使用单一行数阈值替代体积、生命周期与计划证据；
- 候选 partition key 同时审查 NULL、稳定性、查询与保留期；
- 能解释为什么 PG partitioned unique 必须包含全部 key；
- order_no、request key 与 child FK 的语义后果已列出；
- 知道 regular→partitioned 不是原地 ALTER，迁移需新结构和切换；
- ADR 有 owner、反对方案、复查触发条件和数据库 verify；
- “暂不分区”被当成可复查的积极决定。

## 参考资料

- [PostgreSQL 18：table partitioning](https://www.postgresql.org/docs/18/ddl-partitioning.html)
- [PostgreSQL 18：partition pruning](https://www.postgresql.org/docs/18/ddl-partitioning.html#DDL-PARTITION-PRUNING)
- [PostgreSQL 18：CREATE TABLE 的 partition/unique 限制](https://www.postgresql.org/docs/18/sql-createtable.html)
- [PostgreSQL 18：ALTER TABLE / ATTACH PARTITION](https://www.postgresql.org/docs/18/sql-altertable.html)

---

[上一节：类型与约束的物理代价](../05/) · [返回本章目录](../) · [下一节：实战：把逻辑模型落成可靠物理模式](../07/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
