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 < upperfixture 验证:
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'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 BY | continuous 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 validationPigsty 当前 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 防护。
本章必须保留的判断力
即使篇幅有限,也不能删掉:
- event/ingest/valid time 分离;
- 选择分区键的 ADR;
[)边界;- 全局
event_id与分区主键分离; - 直接谓词与包裹谓词的计划对照;
- 迟到写旧分区的成本;
- 原生能力与扩展收益的证据门槛;
- 分区数量乘以索引数量的运维成本。
可以删的是某一扩展的参数百科或某一版本的命令清单。基础判断一旦省掉,读者 会把工具选择误当成时间模型。
本节验收
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如果逻辑行数正确但计划扫描全部分区,先修谓词和边界;如果行路由错误,先修 分区定义或事件时间。不要用更多索引掩盖语义错误。
上一节:时间语义先于时序扩展 · 返回本章目录 · 下一节:空间类型与坐标参考 · 查看全书目录 · 查看索引中心