# 时序表与时间分区

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

---

分区不是“数据带时间戳以后自然要做的事”。它是一项物理设计决策：

```text
查询能否按边界排除大部分数据？
写入能否稳定路由？
唯一性与外键合同是否仍成立？
分区数量、索引数量和维护动作是否可控？
迟到数据会落到仍可写的历史分区吗？
删除一个时间段是否真的比普通 DELETE 更有价值？
```

第 4 章先建立类型、约束与分区 ADR。本节只把那套判断落实到事件时间场景，
不把“按天分区”包装成默认答案。

## 16.2.1 从 ch04 的分区 ADR 选择时间键 {#item-16-2-1}

### 分区键首先决定数据归属

配送事件有两个候选：

```text
occurred_at  事件实际发生时间
received_at  系统接收时间
```

按 `received_at` 分区的好处是写入几乎总落到最新分区，创建和冻结历史分区
容易；坏处是“某业务日发生的事件”会散布到以后到达的分区。

按 `occurred_at` 分区使业务日查询与保留自然对应，也能只扫描目标事件时间
范围；代价是迟到和重放会写旧分区，旧分区不能简单变成永久只读。

本章 ADR 选择：

```yaml
partition_key: occurred_at
partition_timezone: UTC
partition_strategy: native RANGE
partition_bounds: "[)"
```

理由不是“事件表都该这么做”，而是本 PoC 的主要查询与历史围栏连接都以事件
发生时间为准。若法规要求按接收时间保留原始消息，可让 raw ingest 与规范
事件采用不同分区键。

### 父表合同

核心定义摘自 [`setup.sql`](/labs/ch16/setup.sql)：

```sql
CREATE TABLE shop_ch16.delivery_event (
  event_id       text NOT NULL,
  occurred_at    timestamptz NOT NULL,
  received_at    timestamptz NOT NULL,
  courier_id     text NOT NULL,
  event_type     text NOT NULL,
  source_sequence bigint NOT NULL,
  location       geometry(Point, 4326) NOT NULL,
  location_geog  geography(Point, 4326)
    GENERATED ALWAYS AS (
      location::geography
    ) STORED,
  PRIMARY KEY (occurred_at, event_id)
) PARTITION BY RANGE (occurred_at);
```

主键包含 `occurred_at`，不是为了业务身份。PostgreSQL 在分区父表上建立
`UNIQUE`/`PRIMARY KEY` 时，约束列必须包含所有分区键列；这样每个叶分区的
局部唯一索引才能共同证明父表范围内不重复。

业务要求的全局 `event_id` 唯一性由未分区的：

```sql
event_registry(event_id PRIMARY KEY, ...)
```

承担。这个模式把两个合同分开：

```text
event_registry: 全局领域身份
delivery_event: 分区内物理身份与数据载荷
```

若只在每个叶分区上建 `UNIQUE(event_id)`，同一个 ID 仍可出现在不同分区。
应用重试改变 `occurred_at` 时尤其危险。

### 半开日分区

```sql
CREATE TABLE shop_ch16.delivery_event_20260308
PARTITION OF shop_ch16.delivery_event
FOR VALUES FROM ('2026-03-08 00:00:00+00')
             TO ('2026-03-09 00:00:00+00');
```

边界含义是：

```text
lower <= occurred_at < upper
```

fixture 验证：

```text
e008 2026-03-08 23:59:59Z -> delivery_event_20260308
e009 2026-03-09 00:00:00Z -> delivery_event_20260309
```

若没有可接收某值的分区，向父表写入会失败。生产应提前创建未来分区并监控
覆盖范围，不应依赖事故发生后手工补表。

### UTC 边界与当地业务日

本章日分区是 UTC 日，不等于纽约当地日。纽约 2026-03-08 当地日可能跨越
两个 UTC 分区：

```text
local 2026-03-08 00:00 America/New_York
  -> 2026-03-08 05:00Z

local 2026-03-09 00:00 America/New_York
  -> 2026-03-09 04:00Z
```

查询仍然写两个绝对边界：

```sql
WHERE occurred_at >= :lower_timestamptz
  AND occurred_at <  :upper_timestamptz
```

规划器可能保留两个相关 UTC 分区，其余分区被裁剪。这比为每个用户时区建立
分区可控得多。

### 裁剪取决于谓词与分区边界

正例：

```sql
SELECT event_id
FROM shop_ch16.delivery_event
WHERE occurred_at >=
        TIMESTAMPTZ '2026-03-08 00:00:00+00'
  AND occurred_at <
        TIMESTAMPTZ '2026-03-09 00:00:00+00';
```

[`time-pruned-plan.sql`](/labs/ch16/time-pruned-plan.sql) 的固定计划只有：

```text
Seq Scan on delivery_event_20260308
```

这已经是成功的分区裁剪。叶表只有七行，顺序扫描是合理选择；“没有使用
B-tree”不影响裁剪已经生效。

反例：

```sql
WHERE (occurred_at AT TIME ZONE 'UTC')::date
      = DATE '2026-03-08'
```

[`time-wrapped-plan.sql`](/labs/ch16/time-wrapped-plan.sql) 显示：

```text
Append
  -> delivery_event_20260307
  -> delivery_event_20260308
  -> delivery_event_20260309
```

逻辑答案一样，物理工作不同。不要用“给表达式建索引”代替分区裁剪；索引可
减少每张叶表内的扫描，却不一定让规划器排除叶表。

官方
[声明式分区文档](https://www.postgresql.org/docs/18/ddl-partitioning.html)
区分规划期与执行期裁剪，也明确指出裁剪由分区边界驱动而非普通索引。

### 从目录验证，而不是从表名猜

```sql
SELECT
  child.relname,
  pg_get_expr(child.relpartbound, child.oid)
FROM pg_inherits AS inheritance
JOIN pg_class AS child
  ON child.oid = inheritance.inhrelid
WHERE inheritance.inhparent =
      'shop_ch16.delivery_event'::regclass;
```

[`partition-catalog.sql`](/labs/ch16/partition-catalog.sql) 同时采集边界、行数、
最早/最晚事件、owner 与 marker。正式验收不应只检查三张名字像日期的表。

## 16.2.2 写入模式、冷热生命周期与保留 {#item-16-2-2}

### 写入链先处理身份，再路由事实

本章确定性流程：

```text
ingest_attempt
  -> validate event_id payload consistency
  -> choose canonical attempt
  -> insert event_registry
  -> insert delivery_event parent
  -> PostgreSQL routes by occurred_at
```

顺序很重要。如果先向分区事件表写入，再尝试全局注册，两个并发事务可能把
同一业务 ID 写进不同叶表。生产可使用单事务、注册表 `INSERT ... ON
CONFLICT`、显式状态机或消息 inbox/outbox 协调，但必须让全局身份争用发生在
可证明唯一的位置。

### 为迟到写入保留窗口

按事件时间分区时，“旧”不等于“不再写”。生产 ADR 至少定义：

```yaml
normal_lateness: 15m
accepted_lateness: 7d
manual_backfill: ticketed
partition_read_only_after: 14d
retention_after: 400d
```

数字取决于业务，重要的是分开：

- 正常迟到：自动接受并更新聚合；
- 允许迟到：接受但触发更正或告警；
- 超窗回补：需要显式作业、审计和容量窗口；
- 冻结：应用角色不再写，但受控管理员可能回补；
- 保留到期：可删除或归档。

若把“昨天的分区”每天 00:00 立刻设只读，`e001` 这类迟到事件会在正常链路
失败。

### 不要让 DEFAULT 分区变成垃圾桶

DEFAULT 分区可以避免缺分区导致写入失败，但会引入新的责任：

- 为什么正常日期落入 default？
- 补建正式分区时怎样迁移而不长时间阻塞？
- default 上的约束是否允许 attach 新分区？
- 查询是否意外长期扫描 default？
- 异常未来时间和损坏年份是否被悄悄接受？

一种稳健策略：

```text
提前创建 N 个未来分区
监控 max upper bound 与当前时间的距离
没有 DEFAULT，缺口立即失败并报警
异常事件进入独立 quarantine
```

另一种策略可以保留受控 DEFAULT，但必须把行数、年龄和迁移作业作为一等
监控。不存在普适答案。

### 分区粒度由约束共同决定

按小时、日、周或月选择时，至少估算：

```text
每分区行数与字节
高频查询时间跨度
保留/归档的最小动作单位
迟到回补范围
每分区索引数
总分区数与规划开销
autovacuum/analyze 节奏
备份、恢复与副本应用成本
```

例如一年按日 365 张、每张 4 个索引，已经有约 1,460 个叶索引；若按小时，
一年约 8,760 张表和数万个索引。小分区不自动更快，空或微小分区也有目录、
锁、统计与规划成本。

本章用三张日分区只为让边界和计划可见，不构成生产粒度建议。

### 索引是叶分区成本

本章每张事件分区维护：

```text
primary key (occurred_at, event_id)
B-tree     (courier_id, occurred_at, event_id)
GiST       (location geometry)
GiST       (location_geog geography)
```

三个叶表一共 12 个索引对象。分区越多，DDL、REINDEX、统计、磁盘 inode、
缓存和故障面越大。

父分区索引能管理对应的叶索引集合，但 PostGIS、不同历史策略或在线构建流程
仍可能需要逐叶控制。无论自动还是手工创建，都要从 `pg_index` 验证
`indisvalid/indisready/indislive`，不能只看 DDL 命令返回成功。

### 热、温、冷不是表空间颜色

可以按生命周期决定：

| 状态 | 可能动作 |
|---|---|
| 热 | 正常写、完整索引、频繁 analyze |
| 温 | 低频回补、限制写角色、保留关键索引 |
| 冷 | detach/归档、压缩、外部存储或只读集群 |
| 到期 | 经审批删除并保留删除证据 |

但每个动作都要回答恢复路径。`DROP TABLE old_partition` 很快，却会同时删除
数据和局部索引；只有备份、归档与法规合同允许时才是正确保留策略。

PostgreSQL 支持 `DETACH PARTITION`，可让数据先脱离父表再归档或处理。在线
动作的锁、并发、约束验证和版本差异应在接近生产的环境验证，本章 PoC 不演示
线上表迁移。

### 删除与回补会影响 vacuum

时间序列通常“追加为主”，不等于没有 MVCC 成本：

- 重复处理可能执行冲突更新；
- 迟到修正会更新旧聚合；
- 围栏重算可能写结果表；
- 保留若使用大批 `DELETE` 会产生死元组和 WAL；
- 索引页仍会分裂、膨胀或缓存失衡。

分区级删除可以避免海量行删除，但活跃分区仍需 vacuum/analyze。后续
[第 28 章](/vacuum-freeze-bloat/) 专门处理 vacuum、冻结与膨胀；本章只要求
把这些成本列入 ADR。

## 16.2.3 原生分区、聚合与可选时序扩展 {#item-16-2-3}

### 先列需求，再选能力

“这是时序数据，所以安装 TimescaleDB”不是决策。先问：

| 需求 | PostgreSQL 原生基线 | 何时考虑专用扩展 |
|---|---|---|
| 时间范围裁剪 | `RANGE` 分区 | 分片/自动 chunk 管理明显减负 |
| 普通时间聚合 | `date_bin`、GROUP BY | continuous aggregate 有量化收益 |
| 预计算 | 物化视图、增量任务 | 自动刷新与失效模型更合适 |
| 保留 | detach/drop 分区 | policy 自动化降低运维风险 |
| 压缩/列式收益 | 外部归档、其他扩展/方案 | 压缩率与查询代价已实测 |
| 高写入 | 批量、COPY、schema/索引优化 | chunk 并行与架构收益已验证 |

原生方案的优势：

- 能力随 PostgreSQL 一起交付；
- 依赖与升级边界较小；
- SQL、备份和故障模型更接近核心数据库；
- 可以先建立可信基线。

扩展的优势可能包括自动 chunk、保留策略、压缩、时间函数和连续聚合，但也
增加：

```text
package supply
shared_preload_libraries (when required)
restart coordination
extension version matrix
backup/restore compatibility
major upgrade path
licensing and feature-tier review
replica node consistency
```

“少写运维脚本”有价值，但必须与新依赖成本一起衡量。

### 本章为何推迟 TimescaleDB

fixture 只有 12 行、三天数据。它能证明：

- 事件时间分区合同；
- 半开边界；
- 裁剪正反例；
- 聚合语义；
- 迟到和历史围栏连接。

它不能证明：

- 压缩比；
- continuous aggregate 刷新成本；
- 大规模 chunk 规划；
- 写吞吐或副本延迟；
- 自动保留比受控分区作业更可靠。

所以 [`spatiotemporal-adr.md`](/labs/ch16/spatiotemporal-adr.md) 把
TimescaleDB 标为 deferred，而不是反对。重开条件是压缩、保留、连续聚合或
运维收益出现量化证据。

### 可选扩展仍要完整走交付链

在 Pigsty 中，时序扩展不是一句 `CREATE EXTENSION`：

```text
Download / package availability
  -> Install on every L1 node
  -> Config / preload if required
  -> restart or rolling change
  -> CREATE EXTENSION in target database
  -> catalog and functional validation
```

Pigsty 当前
[TimescaleDB 扩展页](https://pigsty.io/ext/e/timescaledb/)
应作为目标 release 的供应入口；实际版本和 PG major/OS 可用性要在 inventory
中核对，不能从本章快照推断未来版本。

### 聚合真值仍来自时间合同

无论使用：

```text
GROUP BY date_bin
materialized view
continuous aggregate
external stream processor
```

都必须固定：

- event time 还是 processing time；
- bucket origin 和 timezone；
- `[)` 边界；
- 迟到水位线；
- 更正是否回写旧桶；
- 去重在哪一层发生；
- 结果版本与重建方式。

扩展能自动化计算，不会替业务决定这些语义。

### 用 A/B 迁移而不是信仰迁移

若要引入时序扩展，建议保留原生基线：

```text
same frozen/replayed input
same semantic query set
same expected aggregate checksum
native path vs extension path
```

然后分别比较：

```text
ingest throughput
query latency distribution
storage and WAL
compression/decompression
background job impact
replica lag
backup and restore
operational actions and failure recovery
```

只有语义结果一致后，性能数字才可比较。

## 16.2.4 不在本章重复在线分区化和维护细节 {#item-16-2-4}

### 本章边界

本章从空 schema 创建三张固定分区，目的是教学验证，不处理已有大表的在线
分区化。以下主题需要单独的迁移设计：

- 在写入不中断时建立新分区父表；
- 双写、触发器或逻辑复制；
- 历史数据分批回填；
- `ATTACH PARTITION` 前的约束证明；
- 索引并发构建与父索引 attach；
- 外键、序列、权限、RLS 和依赖对象迁移；
- 切流、回退、校验和与旧表退役。

它们属于迁移、锁和运维章节，而不是时空语义入门。这里不提供一条貌似通用的
“在线改分区”命令，以免读者在生产大表上照抄。

### 分区维护也不应塞进应用请求

不要让第一条新日期写入在业务事务里执行 `CREATE TABLE`。DDL 会涉及锁、
catalog、权限、审计和副本传播。更合适的是受控作业：

```text
discover current coverage
  -> propose future partitions
  -> create with exact owner/tablespace/options
  -> create/attach indexes
  -> analyze
  -> verify bounds and privileges
  -> emit evidence
```

删除旧分区也需要独立审批、备份/归档确认和 active worker 防护。

### 本章必须保留的判断力

即使篇幅有限，也不能删掉：

1. event/ingest/valid time 分离；
2. 选择分区键的 ADR；
3. `[)` 边界；
4. 全局 `event_id` 与分区主键分离；
5. 直接谓词与包裹谓词的计划对照；
6. 迟到写旧分区的成本；
7. 原生能力与扩展收益的证据门槛；
8. 分区数量乘以索引数量的运维成本。

可以删的是某一扩展的参数百科或某一版本的命令清单。基础判断一旦省掉，读者
会把工具选择误当成时间模型。

### 本节验收

```bash
psql "service=pg36-admin" \
  -f static/labs/ch16/partition-catalog.sql

psql "service=pg36-admin" \
  -f static/labs/ch16/time-pruned-plan.sql

psql "service=pg36-admin" \
  -f static/labs/ch16/time-wrapped-plan.sql
```

应看到：

```text
delivery_event_20260307 rows=1
delivery_event_20260308 rows=7
delivery_event_20260309 rows=4

direct predicate -> only 20260308
wrapped predicate -> Append over all three
```

如果逻辑行数正确但计划扫描全部分区，先修谓词和边界；如果行路由错误，先修
分区定义或事件时间。不要用更多索引掩盖语义错误。

---

[上一节：时间语义先于时序扩展](../01/) · [返回本章目录](../) · [下一节：空间类型与坐标参考](../03/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
