跳至内容

16.2 时序表与时间分区

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

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

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

16.2.1 从 ch04 的分区 ADR 选择时间键

分区键首先决定数据归属

配送事件有两个候选:

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

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

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

本章 ADR 选择:

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

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

父表合同

核心定义摘自 setup.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 唯一性由未分区的:

event_registry(event_id PRIMARY KEY, ...)

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

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

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

半开日分区

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');

边界含义是:

lower <= occurred_at < upper

fixture 验证:

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 分区:

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

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

WHERE occurred_at >= :lower_timestamptz
  AND occurred_at <  :upper_timestamptz

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

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

正例:

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 的固定计划只有:

Seq Scan on delivery_event_20260308

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

反例:

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

time-wrapped-plan.sql 显示:

Append
  -> delivery_event_20260307
  -> delivery_event_20260308
  -> delivery_event_20260309

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

官方 声明式分区文档 区分规划期与执行期裁剪,也明确指出裁剪由分区边界驱动而非普通索引。

从目录验证,而不是从表名猜

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 同时采集边界、行数、 最早/最晚事件、owner 与 marker。正式验收不应只检查三张名字像日期的表。

16.2.2 写入模式、冷热生命周期与保留

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

本章确定性流程:

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 至少定义:

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?
  • 异常未来时间和损坏年份是否被悄悄接受?

一种稳健策略:

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

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

分区粒度由约束共同决定

按小时、日、周或月选择时,至少估算:

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

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

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

索引是叶分区成本

本章每张事件分区维护:

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、冻结与膨胀;本章只要求 把这些成本列入 ADR。

16.2.3 原生分区、聚合与可选时序扩展

先列需求,再选能力

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

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

原生方案的优势:

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

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

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 把 TimescaleDB 标为 deferred,而不是反对。重开条件是压缩、保留、连续聚合或 运维收益出现量化证据。

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

在 Pigsty 中,时序扩展不是一句 CREATE EXTENSION

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 扩展页 应作为目标 release 的供应入口;实际版本和 PG major/OS 可用性要在 inventory 中核对,不能从本章快照推断未来版本。

聚合真值仍来自时间合同

无论使用:

GROUP BY date_bin
materialized view
continuous aggregate
external stream processor

都必须固定:

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

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

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

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

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

然后分别比较:

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 不在本章重复在线分区化和维护细节

本章边界

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

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

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

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

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

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. 分区数量乘以索引数量的运维成本。

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

本节验收

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

应看到:

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

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


上一节:时间语义先于时序扩展 · 返回本章目录 · 下一节:空间类型与坐标参考 · 查看全书目录 · 查看索引中心

最后更新于