# 时空联合查询是本章收束目标

LLMS 索引： [llms.txt](/llms.txt)

---

时空查询不是“时间 WHERE + 空间 WHERE”这么简单。历史围栏场景至少有三项
同时成立：

```text
event.occurred_at 在请求时间段内
zone.valid_during 包含 event.occurred_at
zone.geometry 覆盖 event.location
```

第一项选择事件分区，第二项选择当时规则版本，第三项执行空间关系。少任何
一项，答案都可能看起来合理却在历史边界上出错。

## 16.5.1 某时段、某区域内的配送事件 {#item-16-5-1}

### 先把业务问题写完整

目标：

> 找出 2026-03-08 UTC 日内，事件发生时属于 `central` 围栏的配送事件。

完整 SQL：

```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;
```

固定结果：

```text
e002 central v1
e003 central v1
e005 central v2
e006 central v2
e008 central v2
```

`e004` 与 `e005` 位于同一点附近：

```text
e004 occurred 11:55 -> central v1 -> outside
e005 occurred 12:00 -> central v2 -> inside
```

如果查询只连接 `max(version)`，两条都会按 v2 判断，历史答案被今天的规则
重写。

### 时间范围约束放在事件时间

应用可能请求“纽约当地 3 月 8 日”。接口层先将当地日解析为两个
`timestamptz` 参数：

```text
lower = 2026-03-08 05:00:00Z
upper = 2026-03-09 04:00:00Z
```

SQL 仍是：

```sql
event.occurred_at >= :lower
AND event.occurred_at < :upper
```

不要在列上转换时区或取 date。参数计算与存储查询分层后，既保留当地日
语义，也保留分区裁剪机会。

### 围栏版本也使用半开区间

```sql
zone.valid_during @> event.occurred_at
```

`@>` 依据 range 自身端点规则。v1 的上界不包含 12:00，v2 的下界包含
12:00，因此不需要：

```sql
event.occurred_at BETWEEN valid_from AND valid_to
```

`BETWEEN` 两端都包含，会让相邻版本在换挡时刻同时命中。用 range 可以把
端点合同保存在数据中。

### 空间边界可能产生多归属

本章允许相邻围栏共享边界，`ST_Covers` 又包含边界，所以 `e003` 同时命中：

```text
central v1
east v1
```

这意味着：

```sql
count(*) FROM event_zone_membership
```

可以大于事件数。固定 12 个事件得到 14 条 membership。若聚合“各区事件数”
后求和，不能假设等于全局事件数。

需要唯一归属时，可以定义：

```text
zone priority
smallest area first
explicit ownership of shared boundary
pre-topologized non-overlapping polygons
deterministic row_number() tie-break
```

但任何规则都会改变业务含义，应版本化并进入 ADR，而不是在报表 SQL 中随机
`DISTINCT ON`。

### 视图是可复用语义，不是性能保证

本章创建：

```sql
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);
```

应用读取：

```sql
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`](/labs/ch16/app-query.sql) 以应用角色返回固定五行；
[`app-write.sql`](/labs/ch16/app-write.sql) 更新事件固定失败为 SQLSTATE
`42501`。

### 参数、权限与租户必须先过滤

真实查询还可能需要：

```sql
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 轨迹、停留、地理围栏与迟到修正 {#item-16-5-2}

### 轨迹首先是有序事件序列

最小查询：

```sql
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
);
```

稳定顺序由三项共同提供：

```text
occurred_at
source_sequence
event_id
```

单用 timestamp 可能同值；单用来源序列无法跨来源解释实际时间；event ID
用于最后确定 tie。

### 先分段，再连线

生成轨迹：

```sql
ST_MakeLine(location ORDER BY occurred_at, event_id)
```

只对已确定的 segment 安全。分段条件可能包括：

- courier/session 改变；
- 相邻事件间隔超过阈值；
- 设备重启或 sequence 回退；
- 推算速度超过物理上限；
- 位置质量从 verified 变成 missing；
- 数据跨过不可连接的业务状态。

若从 10:00 的北京点直接连到 18:00 的上海点，LineString 只画出一条直线，
并没有证明实际路径。

### 停留是派生规则

“在一个区域停留十分钟”需要同时定义：

```text
distance threshold
minimum duration
sampling gap tolerance
entry/exit boundary policy
GPS accuracy
late event correction
segment identity
```

一种简单候选：

```text
consecutive points within R meters
and max(time)-min(time) >= D
and every gap <= G
```

但稀疏采样只能证明观测点，不能证明两点之间始终停留。生产应把结果标为推断，
保存算法版本和输入范围。

### 地理围栏事件有三种生成方式

| 方式 | 特点 |
|---|---|
| 查询时计算 membership | 总能使用最新修正，查询成本高 |
| 写入时计算并存结果 | 读快，但迟到/围栏修订要更正 |
| 批/流增量派生 | 可控重算，增加状态与作业 |

本章 view 使用查询时计算，最容易证明语义。生产可以物化：

```sql
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 到达后应：

```text
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 生效”，至少有两种
政策：

```text
retroactive truth:
  重算历史 event-zone membership

as-known-at-the-time:
  保留当时系统认知，并另存修订版本
```

前者适合最终业务事实，后者适合审计。需要两者时，应同时建 valid time 与
system time，而不是在原行上静默覆盖。

### 重算范围要可证明

对于变更围栏 Polygon：

```text
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 或可恢复版本。

### 派生结果不应覆盖原始事实

建议层次：

```text
raw attempts
  -> canonical events
  -> normalized/quality-assessed locations
  -> zone memberships / trajectories / stays
  -> aggregates and alerts
```

每层保存：

- 输入版本或 checksum；
- 算法/规则版本；
- 计算时刻；
- 可重建路径；
- 更正/撤回身份。

把“是否在围栏内”直接写回唯一事件行且不留版本，会让历史无法审计。

## 16.5.3 时间裁剪、空间索引与二阶段过滤 {#item-16-5-3}

### 两条独立缩小路径

联合查询的候选空间可以理解为：

```text
all events
  -> partition pruning by requested event-time range
  -> spatial bbox candidates within surviving partitions
  -> exact zone valid-time + ST_Covers filters
  -> final rows
```

固定联合计划：

```text
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，而是：

```text
only delivery_event_20260308 appears
geometry GiST supplies candidates
valid-time and exact covers remain visible filters
```

[`joint-plan.sql`](/labs/ch16/joint-plan.sql) 保存完整证据。

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

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

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

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

### prepared statement 也要看参数计划

应用通常使用参数：

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

PostgreSQL 可能使用 custom 或 generic plan。执行期裁剪可以根据参数移除
分区，但不同参数选择率仍可能使通用计划不理想。生产验证应包括：

```text
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` 原本为约束创建：

```text
(zone_id gist_text_ops, valid_during range_ops)
```

联合计划也用它查 `zone_id`。一个索引可以同时承担约束与查询，但这不保证它
覆盖所有查询。若主要模式是：

```sql
WHERE zone_id = ?
  AND valid_during @> ?
```

应在真实规模验证该复合 GiST 的选择率和代价，再决定是否需要其他索引。

### 大查询要显式预算

若请求：

```text
all zones
all events
five years
global polygon
```

时间与空间索引都无法制造高选择率。接口必须限制：

- 最大时间跨度；
- 最大区域/半径；
- zone 数；
- 返回行数与分页；
- statement timeout；
- 并发与资源组；
- 是否异步导出。

索引不是资源治理替代品。

### 验收逻辑结果与计划结果

先验收结果：

```bash
psql "service=pg36-admin user=pg36_app" \
  -f static/labs/ch16/app-query.sql
```

应为：

```text
e002 central 1
e003 central 1
e005 central 2
e006 central 2
e008 central 2
```

再验收计划：

```bash
psql "service=pg36-admin" \
  -f static/labs/ch16/joint-plan.sql
```

最后验收全量 membership：

```bash
psql "service=pg36-admin" \
  -f static/labs/ch16/zone-membership.sql
```

必须是 14 行，并保留 `e003`、`e005`、`e006` 的双区域命中。只对五行
central 结果做截图不足以证明边界、多归属和版本语义。

### 本节收束

一条可交付的时空查询结论应包含：

```text
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 + 分区”更接近生产合同。

---

[上一节：空间谓词与索引](../04/) · [返回本章目录](../) · [下一节：时空扩展的交付与观察](../06/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
