跳至内容
16.5 时空联合查询是本章收束目标

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 v2

e004e005 位于同一点附近:

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:00Z

SQL 仍是:

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_to

BETWEEN 两端都包含,会让相邻版本在换挡时刻同时命中。用 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 只定义单点关系,不自动解决状态机抖动。

迟到事件会插入历史中间

本章 e004e005 早发生却后到。若系统先看到 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 filters

joint-plan.sql 保存完整证据。

SQL 书写顺序不等于执行顺序

把时间谓词写在 WHERE 第一行不会强制数据库先执行它。PostgreSQL 规划器会 根据等价变换与成本选择路径。我们能做的是:

  • 写出可推导的直接分区键范围;
  • 使用有索引语义的空间谓词;
  • 保持统计新鲜;
  • 在真实参数分布下检查计划;
  • 必要时调整模型、索引或查询边界;
  • 不把关闭 planner 开关当生产提示。

“先时间后空间”是逻辑与候选设计,不是靠 SQL 行顺序控制算子。

prepared statement 也要看参数计划

应用通常使用参数:

WHERE occurred_at >= $1
  AND occurred_at <  $2
  AND zone_id = $3

PostgreSQL 可能使用 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 行,并保留 e003e005e006 的双区域命中。只对五行 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 + 分区”更接近生产合同。


上一节:空间谓词与索引 · 返回本章目录 · 下一节:时空扩展的交付与观察 · 查看全书目录 · 查看索引中心

最后更新于