16.5 时空联合查询是本章收束目标
时空查询不是“时间 WHERE + 空间 WHERE”这么简单。历史围栏场景至少有三项 同时成立:
event.occurred_at 在请求时间段内
zone.valid_during 包含 event.occurred_at
zone.geometry 覆盖 event.location第一项选择事件分区,第二项选择当时规则版本,第三项执行空间关系。少任何 一项,答案都可能看起来合理却在历史边界上出错。
16.5.1 某时段、某区域内的配送事件
先把业务问题写完整
目标:
找出 2026-03-08 UTC 日内,事件发生时属于
central围栏的配送事件。
完整 SQL:
SELECT
event.event_id,
event.occurred_at,
zone.zone_id,
zone.version
FROM shop_ch16.delivery_event AS event
JOIN shop_ch16.geofence_version AS zone
ON zone.valid_during @> event.occurred_at
AND ST_Covers(zone.zone_geom, event.location)
WHERE event.occurred_at >=
TIMESTAMPTZ '2026-03-08 00:00:00+00'
AND event.occurred_at <
TIMESTAMPTZ '2026-03-09 00:00:00+00'
AND zone.zone_id = 'central'
ORDER BY event.occurred_at, event.event_id;固定结果:
e002 central v1
e003 central v1
e005 central v2
e006 central v2
e008 central v2e004 与 e005 位于同一点附近:
e004 occurred 11:55 -> central v1 -> outside
e005 occurred 12:00 -> central v2 -> inside如果查询只连接 max(version),两条都会按 v2 判断,历史答案被今天的规则
重写。
时间范围约束放在事件时间
应用可能请求“纽约当地 3 月 8 日”。接口层先将当地日解析为两个
timestamptz 参数:
lower = 2026-03-08 05:00:00Z
upper = 2026-03-09 04:00:00ZSQL 仍是:
event.occurred_at >= :lower
AND event.occurred_at < :upper不要在列上转换时区或取 date。参数计算与存储查询分层后,既保留当地日 语义,也保留分区裁剪机会。
围栏版本也使用半开区间
zone.valid_during @> event.occurred_at@> 依据 range 自身端点规则。v1 的上界不包含 12:00,v2 的下界包含
12:00,因此不需要:
event.occurred_at BETWEEN valid_from AND valid_toBETWEEN 两端都包含,会让相邻版本在换挡时刻同时命中。用 range 可以把
端点合同保存在数据中。
空间边界可能产生多归属
本章允许相邻围栏共享边界,ST_Covers 又包含边界,所以 e003 同时命中:
central v1
east v1这意味着:
count(*) FROM event_zone_membership可以大于事件数。固定 12 个事件得到 14 条 membership。若聚合“各区事件数” 后求和,不能假设等于全局事件数。
需要唯一归属时,可以定义:
zone priority
smallest area first
explicit ownership of shared boundary
pre-topologized non-overlapping polygons
deterministic row_number() tie-break但任何规则都会改变业务含义,应版本化并进入 ADR,而不是在报表 SQL 中随机
DISTINCT ON。
视图是可复用语义,不是性能保证
本章创建:
CREATE VIEW shop_ch16.event_zone_membership AS
SELECT ...
FROM delivery_event AS event
JOIN geofence_version AS zone
ON zone.valid_during @> event.occurred_at
AND ST_Covers(zone.zone_geom, event.location);应用读取:
SELECT event_id, zone_id, zone_version
FROM shop_ch16.event_zone_membership
WHERE occurred_at >= :lower
AND occurred_at < :upper
AND zone_id = :zone;普通 view 保存查询定义,规划器通常会展开优化;它不缓存结果,也不保证
每次选择相同计划。权限上,本章只授予 pg36_app 对父表、中心和三个视图的
SELECT,不授予任何写权限。
app-query.sql 以应用角色返回固定五行;
app-write.sql 更新事件固定失败为 SQLSTATE
42501。
参数、权限与租户必须先过滤
真实查询还可能需要:
AND event.tenant_id = :tenant
AND zone.tenant_id = :tenant
AND event.courier_id = ANY(:allowed_couriers)空间命中不能越过租户和授权边界。若使用 RLS,要验证:
- view 的 security invoker/definer 行为;
- 空间函数是否泄露错误或执行时间信息;
- 查询计划是否在权限过滤后仍可接受;
- plan cache 对不同租户选择率的影响。
本章单租户 fixture 不声称覆盖这些生产边界。
空间输入也要设限
若 API 允许用户上传任意 Polygon:
- 顶点数可能巨大;
- geometry 可能无效;
- SRID 可能错误;
- bbox 可能覆盖全球;
- 拓扑计算可消耗大量 CPU;
- WKT/GeoJSON 大小可能成为滥用入口。
接口应限制字节、顶点、对象类型、SRID、区域范围和 statement timeout,并在 受控流程中验证/规范化。不能因为 PostGIS 函数是 SQL,就把它当廉价谓词。
16.5.2 轨迹、停留、地理围栏与迟到修正
轨迹首先是有序事件序列
最小查询:
SELECT
courier_id,
event_id,
occurred_at,
location,
lag(occurred_at) OVER courier_order AS previous_at,
lag(location) OVER courier_order AS previous_location
FROM shop_ch16.delivery_event
WINDOW courier_order AS (
PARTITION BY courier_id
ORDER BY occurred_at, source_sequence, event_id
);稳定顺序由三项共同提供:
occurred_at
source_sequence
event_id单用 timestamp 可能同值;单用来源序列无法跨来源解释实际时间;event ID 用于最后确定 tie。
先分段,再连线
生成轨迹:
ST_MakeLine(location ORDER BY occurred_at, event_id)只对已确定的 segment 安全。分段条件可能包括:
- courier/session 改变;
- 相邻事件间隔超过阈值;
- 设备重启或 sequence 回退;
- 推算速度超过物理上限;
- 位置质量从 verified 变成 missing;
- 数据跨过不可连接的业务状态。
若从 10:00 的北京点直接连到 18:00 的上海点,LineString 只画出一条直线, 并没有证明实际路径。
停留是派生规则
“在一个区域停留十分钟”需要同时定义:
distance threshold
minimum duration
sampling gap tolerance
entry/exit boundary policy
GPS accuracy
late event correction
segment identity一种简单候选:
consecutive points within R meters
and max(time)-min(time) >= D
and every gap <= G但稀疏采样只能证明观测点,不能证明两点之间始终停留。生产应把结果标为推断, 保存算法版本和输入范围。
地理围栏事件有三种生成方式
| 方式 | 特点 |
|---|---|
| 查询时计算 membership | 总能使用最新修正,查询成本高 |
| 写入时计算并存结果 | 读快,但迟到/围栏修订要更正 |
| 批/流增量派生 | 可控重算,增加状态与作业 |
本章 view 使用查询时计算,最容易证明语义。生产可以物化:
event_zone_result (
event_id,
zone_id,
zone_version,
predicate_version,
computed_at,
source_checksum,
...
)不能只存 event_id, zone_id。至少要知道使用哪个围栏版本、哪套边界算法和
哪批输入。
入围/出围不是两个独立点
若连续位置从 outside 变成 inside,可派生 enter;inside 变 outside 可派生 exit。但 GPS 抖动会在边界反复切换。常见稳健化:
- 进入与退出使用不同阈值(hysteresis);
- 要求连续 N 个样本;
- 使用定位精度圆而不是无误差 Point;
- 对边界附近状态标记 uncertain;
- 限制最大采样间隔;
- 保存原始点以便重算。
ST_Covers 只定义单点关系,不自动解决状态机抖动。
迟到事件会插入历史中间
本章 e004 比 e005 早发生却后到。若系统先看到 e005 并已生成轨迹/围栏
状态,e004 到达后应:
insert raw/canonical fact
identify affected courier + time neighborhood
recompute local segment or bucket
version or retract previous derived result
emit correction evidence不能只在列表末尾追加。否则 processing order 被误当成 event order。
围栏修订也会重写历史
若业务在 3 月 10 日修订“central v2 从 3 月 8 日 12:00 生效”,至少有两种 政策:
retroactive truth:
重算历史 event-zone membership
as-known-at-the-time:
保留当时系统认知,并另存修订版本前者适合最终业务事实,后者适合审计。需要两者时,应同时建 valid time 与 system time,而不是在原行上静默覆盖。
重算范围要可证明
对于变更围栏 Polygon:
affected time = old/new valid range union
affected space = old/new bbox union
candidate events = time range AND bbox
exact changes = compare old/new predicates这正是时空联合过滤的另一个用途。先用时间与 bbox 缩小候选,再对旧/新几何 执行精确关系,可避免全表重算;但必须保留旧 geometry 或可恢复版本。
派生结果不应覆盖原始事实
建议层次:
raw attempts
-> canonical events
-> normalized/quality-assessed locations
-> zone memberships / trajectories / stays
-> aggregates and alerts每层保存:
- 输入版本或 checksum;
- 算法/规则版本;
- 计算时刻;
- 可重建路径;
- 更正/撤回身份。
把“是否在围栏内”直接写回唯一事件行且不留版本,会让历史无法审计。
16.5.3 时间裁剪、空间索引与二阶段过滤
两条独立缩小路径
联合查询的候选空间可以理解为:
all events
-> partition pruning by requested event-time range
-> spatial bbox candidates within surviving partitions
-> exact zone valid-time + ST_Covers filters
-> final rows固定联合计划:
Nested Loop
-> Index Scan geofence_version_no_overlap
Index Cond: zone_id = 'central'
-> Index Scan event_20260308_location_gist_idx
Index Cond: location @ zone_geom
Filter:
occurred_at in day8
zone.valid_during @> occurred_at
st_covers(zone_geom, location)最关键的不是 Nested Loop,而是:
only delivery_event_20260308 appears
geometry GiST supplies candidates
valid-time and exact covers remain visible filtersjoint-plan.sql 保存完整证据。
SQL 书写顺序不等于执行顺序
把时间谓词写在 WHERE 第一行不会强制数据库先执行它。PostgreSQL 规划器会 根据等价变换与成本选择路径。我们能做的是:
- 写出可推导的直接分区键范围;
- 使用有索引语义的空间谓词;
- 保持统计新鲜;
- 在真实参数分布下检查计划;
- 必要时调整模型、索引或查询边界;
- 不把关闭 planner 开关当生产提示。
“先时间后空间”是逻辑与候选设计,不是靠 SQL 行顺序控制算子。
prepared statement 也要看参数计划
应用通常使用参数:
WHERE occurred_at >= $1
AND occurred_at < $2
AND zone_id = $3PostgreSQL 可能使用 custom 或 generic plan。执行期裁剪可以根据参数移除 分区,但不同参数选择率仍可能使通用计划不理想。生产验证应包括:
EXPLAIN EXECUTE with narrow range
EXPLAIN EXECUTE with wide range
generic/custom plan behavior
plan cache and connection pool settings不要只在 psql 常量查询上验收,然后假设 ORM prepared statement 完全相同。
先过滤围栏还是先过滤事件取决于基数
本章只有两个 central 版本和七个 day8 事件,Nested Loop 很自然。现实中:
- 一个 zone + 短时间:先找 zone 再扫事件空间索引可能好;
- 许多 zone + 一个事件:对事件点查围栏索引可能好;
- 巨大 polygon:bbox 候选可能很多;
- 大半径 geography:空间选择率可能很低;
- 多租户:tenant/zone 复合过滤会改变基数。
应从业务参数分布测量,不应固定 join order。
范围排他索引兼任查找路径
geofence_version_no_overlap 原本为约束创建:
(zone_id gist_text_ops, valid_during range_ops)联合计划也用它查 zone_id。一个索引可以同时承担约束与查询,但这不保证它
覆盖所有查询。若主要模式是:
WHERE zone_id = ?
AND valid_during @> ?应在真实规模验证该复合 GiST 的选择率和代价,再决定是否需要其他索引。
大查询要显式预算
若请求:
all zones
all events
five years
global polygon时间与空间索引都无法制造高选择率。接口必须限制:
- 最大时间跨度;
- 最大区域/半径;
- zone 数;
- 返回行数与分页;
- statement timeout;
- 并发与资源组;
- 是否异步导出。
索引不是资源治理替代品。
验收逻辑结果与计划结果
先验收结果:
psql "service=pg36-admin user=pg36_app" \
-f static/labs/ch16/app-query.sql应为:
e002 central 1
e003 central 1
e005 central 2
e006 central 2
e008 central 2再验收计划:
psql "service=pg36-admin" \
-f static/labs/ch16/joint-plan.sql最后验收全量 membership:
psql "service=pg36-admin" \
-f static/labs/ch16/zone-membership.sql必须是 14 行,并保留 e003、e005、e006 的双区域命中。只对五行
central 结果做截图不足以证明边界、多归属和版本语义。
本节收束
一条可交付的时空查询结论应包含:
event-time bounds and timezone
valid-time range policy
geometry/geography and SRID
boundary predicate
multi-membership policy
logical expected rows/checksum
partition pruning evidence
spatial index candidate evidence
exact predicate evidence
representative-scale performance limits
late/revision recomputation policy这十项比“用了 PostGIS + 分区”更接近生产合同。
上一节:空间谓词与索引 · 返回本章目录 · 下一节:时空扩展的交付与观察 · 查看全书目录 · 查看索引中心