# 在线分区化

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

---

本书对分区采用渐进式教学：

```text
ch04: decide whether partitioning is justified
ch11: rehearse attach/detach as a safe schema release
ch28: operate the full partition lifecycle
```

本节不推翻 ch04 对订单模型“暂不分区”的 ADR。我们使用独立事件 fixture，学习当证据门已经通过后，如何把预装数据表挂入/摘出分区层次，并把 lock、scan、version 和 recovery 语义说清楚。

## 11.4.1 从 ch04 的分区 ADR 选择迁移路径 {#item-11-4-1}

### 先确认旧决定为什么失效

[ch04 分区决策门](/data-types-constraints/06/) 要求至少出现一类真实证据：

- retention/归档需要按整批数据处理；
- relation/index 规模已形成可测瓶颈；
- 代表性查询能稳定携带 partition key，并证明 pruning 收益；
- 热冷分层、备份或维护窗口需要独立 physical units。

进入 ch11 时，不应把“学会 ATTACH”误解为“现在应该分区”。先更新 ADR：

```text
old evidence
new evidence and timestamp
candidate key and partition count
unique/PK/FK implications
NULL and row-movement semantics
query/pruning measurements
retention and operational owner
migration and rollback strategy
```

如果新证据仍不足，正确结论可以继续是 `not-now`。

### Regular table 不能原地变成 partitioned table

迁移通常需要新结构。常见路径：

```text
A. new parent + new partitions
   → copy/backfill
   → catch up writes
   → switch logical name/view

B. prepare an existing regular table
   → prove it fits one bound
   → ATTACH PARTITION

C. new partitioned table + logical replication/CDC
   → reconcile
   → cut over
```

路径 B 适合：

- 已经按边界离线装载的一批数据；
- 归档表重新加入 logical parent；
- 新周期分区先单独 load/validate；
- 迁移时把一段现有数据转换成 child。

它不是把任意一张巨大表“瞬间变成分区表”。父/子 column shape、constraints、indexes、owner、storage 与数据 bound 都要准备。

### Key、unique 与 FK 必须先重做语义

PostgreSQL partitioned table 的 unique/primary key 必须包含全部 partition key columns，且 key 不能含 expression。因为 uniqueness 最终由各 child 的本地 index 执行，数据库要从 partition routing 推导跨 child 不重复。

如果旧合同是：

```text
UNIQUE(order_no)
```

按 `placed_at` 分区后机械改为：

```text
UNIQUE(order_no, placed_at)
```

不再保证 `order_no` 全局唯一。两个不同月份可以重复 order number。可选设计：

- 改 API identity 为复合键，明确语义变化；
- 保留未分区 global registry；
- 选择不同 partition key；
- 接受只在 partition 内唯一；
- 不分区。

外键、idempotency key、upsert conflict target 和 driver 参数都会受影响。没有解决这些逻辑问题时，在线 DDL 技巧没有意义。

### 本章 fixture 的范围

事件表没有 global unique/FK，只演练 range bound：

```sql
CREATE TABLE shop_private.ch11_event (
    event_id bigint NOT NULL,
    occurred_at timestamptz NOT NULL,
    payload text NOT NULL
) PARTITION BY RANGE (occurred_at);
```

独立候选 `ch11_event_2025q1` 有 20,000 行。数据都落在：

```text
[2025-01-01 00:00:00+00, 2025-04-01 00:00:00+00)
```

这个简化让实验精确关注 attach/detach；它不是完整生产事件模型。

## 11.4.2 预建约束、`ATTACH` 与扫描规避 {#item-11-4-2}

### ATTACH 的两种验证路径

命令：

```sql
ALTER TABLE shop_private.ch11_event
ATTACH PARTITION shop_private.ch11_event_2025q1
FOR VALUES FROM ('2025-01-01 00:00:00+00')
             TO ('2025-04-01 00:00:00+00');
```

PostgreSQL 必须证明所有 child row 满足隐含 partition bound。若没有可用证明，它会在持有 child `ACCESS EXCLUSIVE` 时扫描该表。

预先建立并验证匹配 CHECK：

```sql
ALTER TABLE shop_private.ch11_event_2025q1
ADD CONSTRAINT ch11_event_2025q1_bound
CHECK (
    occurred_at >=
        timestamptz '2025-01-01 00:00:00+00'
    AND occurred_at <
        timestamptz '2025-04-01 00:00:00+00'
);
```

官方文档建议这样让系统跳过 attach validation scan。注意：

- constraint 必须 valid；
- expression 必须足以让系统证明相同 bound；
- time zone/type/cast/NULL 语义要一致；
- child columns 必须与 parent 完全匹配；
- child 若自身 partitioned，递归 subpartition 也要考虑；
- attach 仍会取得锁，预建 CHECK 不等于 lock-free。

### 实测 ATTACH 的锁

[partition_lab.py](/labs/ch11/partition_lab.py) 在一个 transaction 中执行 ATTACH 后故意暂不 commit，observer 查询 holder 的 `pg_locks`。

PostgreSQL 18.6 结果：

| Relation | Granted mode |
|---|---|
| `ch11_event` parent | `ShareUpdateExclusiveLock` |
| `ch11_event_2025q1` child | `AccessExclusiveLock` |

这与 PostgreSQL 18 `ALTER TABLE` 文档一致：

```text
parent: SHARE UPDATE EXCLUSIVE
attached table: ACCESS EXCLUSIVE
default partition (if any): ACCESS EXCLUSIVE
```

预验证 CHECK 降低的是持有 child 强锁期间的扫描工作，不改变 child 需要强锁这一事实。若 child 仍被报表长读，ATTACH 仍可能等锁或阻塞。

### 本地证据能证明到哪里

实验保存：

```text
bound CHECK convalidated=true before attach
child row count=20,000
exact ATTACH lock modes
child relfilenode unchanged
parent count=20,000 after attach
pg_inherits edge=1
relispartition=true
```

relfilenode 未变说明数据没有 copy/rewrite，但不能单独证明“没有 validation scan”。“匹配 valid CHECK 可避免扫描”来自官方语义；运行证据证明我们确实提供了该 CHECK 和目标 bound。

不要用一个极快 elapsed time 声称 scan 被证明跳过：数据可能在 cache 中、表可能太小、计时噪声也可能掩盖差异。

### Default partition 的隐藏扫描

如果 parent 已有 DEFAULT partition，添加一个新显式 range 时，PostgreSQL 还必须证明 DEFAULT 中没有属于新 range 的行。没有排除性 CHECK 时会：

```text
scan default partition
while holding ACCESS EXCLUSIVE on it
```

若 DEFAULT 自身 partitioned，会递归检查。发布计划要么：

- 预先为 DEFAULT 添加排除新 range 的 valid CHECK；
- 先迁出该 range 的行；
- 在可接受窗口完成扫描；
- 重新设计是否需要 DEFAULT。

只优化待 attach child 而忘记 DEFAULT，是常见的“测试环境快、生产突然锁住”原因。

### Partitioned index 的在线路径不同

不能直接对 partitioned parent 使用：

```sql
CREATE INDEX CONCURRENTLY ON partitioned_parent (...);
```

官方推荐的渐进路径：

```text
CREATE INDEX ON ONLY parent          # invalid parent index
  → CREATE INDEX CONCURRENTLY child1
  → ALTER INDEX parent_idx ATTACH PARTITION child1_idx
  → repeat for every child
  → parent index becomes valid when complete
```

每个 child index 要验证定义、opclass、collation、validity 与 ownership。对 unique/PK 还要满足 partition key 规则。生产实施还要把这套检查扩展到全部现存分区，并验证新增分区不会漏建索引。

## 11.4.3 `DETACH`、并发能力与锁等级必须按版本说明 {#item-11-4-3}

### 普通 DETACH 与 CONCURRENTLY

```sql
ALTER TABLE parent
DETACH PARTITION child;
```

普通形式对 parent 请求 `ACCESS EXCLUSIVE`。PostgreSQL 14 引入：

```sql
ALTER TABLE parent
DETACH PARTITION child CONCURRENTLY;
```

并发形式对 parent 使用较低的 `SHARE UPDATE EXCLUSIVE`，内部有两个 transaction：

1. parent 与 child 取得 `SHARE UPDATE EXCLUSIVE`，标记正在 detach 并 commit；
2. 等待使用该 partitioned table 的旧 transaction 结束；
3. 再取得 parent `SHARE UPDATE EXCLUSIVE` 和 child `ACCESS EXCLUSIVE`；
4. 完成 detach，并建立等价 CHECK。

这解释了两个边界：

- 它仍可能等待长 transaction；
- 最终仍要短时取得 child `ACCESS EXCLUSIVE`。

“CONCURRENTLY”不是不等待、不锁或固定秒数完成。

### 事务与结构限制

`DETACH PARTITION ... CONCURRENTLY`：

- 不能在 transaction block 中运行；
- parent 有 DEFAULT partition 时不允许；
- interrupted/pending detach 可能需要 `FINALIZE`；
- 目标版本必须 PostgreSQL 14+；
- FK、subpartition 和 concurrent activity 仍需按目标版本复核。

因此 migration runner 需要像 concurrent index 一样提供独立 non-transactional command。

本章的 Python runner 用两个 psql top-level command：

```text
SET ROLE pg36_owner
ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY
```

它们在同一 session 执行，但 DETACH 不在显式或 implicit multi-statement transaction 内。

### 版本事实不能倒推

本书支持 PG14–18，因此该语法在主体矩阵中可用。若维护 PG13：

```text
CONCURRENTLY / FINALIZE unavailable
```

不能运行时 fallback 成普通 DETACH 后仍报告相同 lock semantics。应：

- 明确跳过并报告 unsupported；
- 选择有维护窗的 ordinary detach；
- 或先升级目标 major。

本章 manifest 保存 `server_version_num=180006`，review 要求 `>=140000`；它不把 18.6 的 timing 当成 PG14–17 保证。

## 11.4.4 产出一次可回退的分区化发布 {#item-11-4-4}

### 完整演练

实验状态：

```text
before:
  child standalone
  valid bound CHECK
  rows=20,000
  parent rows=0
  relispartition=false

attach:
  capture parent/child locks before commit
  commit
  parent rows=20,000
  relispartition=true

detach concurrently:
  top-level PG14+ command
  child rows=20,000
  parent rows=0
  relispartition=false
  filenode unchanged

rollback rehearsal:
  verify data/bound still valid
  reattach

final:
  parent rows=20,000
  child attached
  filenode unchanged throughout
```

这里“回退”是结构性 reattach，因为 detach 后没有允许 child 与 parent 的写路径发生分叉。若 detach 后：

- child 单独接收写入；
- parent 在同一 bound 又建立新 partition；
- constraint 被修改；
- indexes/privileges/schema 发生漂移；

reattach 就不再是机械 undo，需要 reconciliation 和新的 lock/scan 评审。

### 发布 runbook

生产 attach 前：

```text
1 verify target database/primary/role/search_path
2 verify parent/child identity and exact column shape
3 freeze or control writers to standalone child
4 validate bound CHECK and count violations=0
5 build/validate child indexes and constraints
6 handle DEFAULT exclusion
7 measure relation/index size and active transactions
8 set lock/statement timeout
9 observe service SLI, WAL, lag, disk
10 ATTACH
11 verify pg_inherits, relispartition, rows, pruning and privileges
12 keep standalone recovery plan until observation completes
```

detach 前：

```text
1 identify PG major and DEFAULT restriction
2 decide ordinary vs concurrently
3 check long transactions/snapshots
4 define routing after detach
5 execute top-level command
6 inspect pending/final state
7 verify row count and new CHECK
8 archive/copy/drop only as separate destructive action
```

### Detach 不等于删除

`DETACH` 保留 table 和数据，适合：

- 归档前 `COPY`/backup；
- 低频报表；
- 数据压缩/汇总；
- 独立验证；
- 在明确边界下重新 attach。

`DROP TABLE partition` 则删除数据对象。不要在同一个“一键 retention”里把 detach、archive verification 和 drop 混成无法暂停的动作。

### API 不应暴露 child identity

应用继续访问 logical parent：

```sql
SELECT ...
FROM event
WHERE occurred_at >= $1
  AND occurred_at < $2;
```

不要让业务 URL、job payload 或 ORM model 绑定 `event_2025q1`。否则每次 attach/detach 都变成 application contract 变更，物理生命周期无法独立演进。

## 本节验收问题

1. ch04 ADR 的进入门是否真的被新证据触发；
2. partition key 是否同时满足 lifecycle、query、NULL 与稳定性；
3. global unique/PK/FK 语义是否重新设计；
4. regular→partitioned 是否有新结构和切换路径；
5. candidate child column shape 是否与 parent 完全一致；
6. matching bound CHECK 是否 valid 且可被系统证明；
7. ATTACH 的 parent/child/default locks 是否按目标版本写明；
8. DEFAULT partition 的排除扫描是否处理；
9. partitioned index 是否使用 per-child concurrent build/attach；
10. DETACH CONCURRENTLY 是否 PG14+ 且位于 transaction block 外；
11. pending detach/FINALIZE 与长 transaction 是否进入故障预案；
12. detach 后写入是否可能让 reattach 失去可逆性；
13. archive verification 与 destructive drop 是否分开授权；
14. 应用是否只依赖 logical parent。

## 参考资料

- [PostgreSQL 18：Table Partitioning](https://www.postgresql.org/docs/18/ddl-partitioning.html)
- [PostgreSQL 18：ALTER TABLE ATTACH/DETACH](https://www.postgresql.org/docs/18/sql-altertable.html)
- [PostgreSQL 14 Release Notes：Concurrent Detach](https://www.postgresql.org/docs/14/release-14.html)

---

[上一节：索引与约束的在线化路径](../03/) · [返回本章目录](../) · [下一节：数据回填与流量切换](../05/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
