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
e004 与 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: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 同时命中:
这意味着:
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
但稀疏采样只能证明观测点,不能证明两点之间始终停留。生产应把结果标为推断,
保存算法版本和输入范围。
地理围栏事件有三种生成方式
本章 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 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 行,并保留 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 + 分区”更接近生产合同。
上一节:空间谓词与索引 · 返回本章目录 · 下一节:时空扩展的交付与观察 ·
查看全书目录 · 查看索引中心