# 空间谓词与索引

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

---

空间查询不应从函数名猜语义。先把业务问题归类：

```text
布尔拓扑：是否相交/覆盖/包含？
范围邻近：是否在给定距离内？
度量：具体距离或面积是多少？
排名：最近的 K 个是谁？
```

这四类问题的返回类型、边界、单位、索引方式和停止条件不同。

## 16.4.1 包含、相交、邻近与最近邻 {#item-16-4-1}

### 拓扑谓词回答关系，不回答距离

常用关系：

| 谓词 | 问题 |
|---|---|
| `ST_Intersects(a,b)` | 两者是否共享任何点？ |
| `ST_Disjoint(a,b)` | 两者是否完全不相交？ |
| `ST_Contains(a,b)` | B 是否位于 A 内部，且内部有共同点？ |
| `ST_Within(a,b)` | A 是否位于 B 内，是 contains 的反向关系 |
| `ST_Covers(a,b)` | B 是否没有任何点位于 A 外部？ |
| `ST_Touches(a,b)` | 是否只在边界接触、内部不相交？ |

对“事件属于围栏”：

```sql
ST_Covers(zone.zone_geom, event.location)
```

参数顺序是：

```text
area first, point second
```

若使用反向表达，可写 `ST_CoveredBy(point, area)`。不要因为
`ST_Intersects` 参数对称，就假设所有空间谓词都对称。

### 边界政策决定 contains 还是 covers

固定探针：

```sql
SELECT
  ST_Covers(zone_geom, location),
  ST_Contains(zone_geom, location),
  ST_Touches(zone_geom, location)
FROM ...
WHERE event_id = 'e003';
```

结果：

```text
covers=true, contains=false, touches=true
```

这不是函数争论，而是业务选择：

| 业务规则 | 更接近的表达 |
|---|---|
| 边界也算在服务区 | `ST_Covers` |
| 必须严格位于内部 | `ST_Contains` |
| 只找边界点 | `ST_Touches` |
| 任何接触都算 | `ST_Intersects` |

[`boundary-semantics.sql`](/labs/ch16/boundary-semantics.sql) 还证明同一点在
围栏扩张前后得到不同结果，说明时间版本不能被省略。

### 邻近筛选使用 `ST_DWithin`

“在中心 1 km 内”是布尔资格：

```sql
WHERE ST_DWithin(
  event.location_geog,
  hub.location::geography,
  1000
)
```

对 geography，距离参数以米为单位。对 geometry，距离参数使用 CRS 单位。
PostGIS
[`ST_DWithin`](https://postgis.net/docs/ST_DWithin.html)
说明该函数包含可利用索引的包围盒比较，然后执行距离判断。

不要写成：

```sql
WHERE ST_Distance(a, b) <= 1000
```

再期待同样的索引路径。`ST_Distance` 必须为候选计算具体值；`ST_DWithin`
针对阈值问题设计，规划器可使用对应空间操作符缩小候选。

### 距离值与距离资格分开

常见响应同时需要资格与显示距离：

```sql
SELECT
  event_id,
  ST_Distance(location_geog, :hub_geog) AS meters
FROM shop_ch16.delivery_event
WHERE ST_DWithin(location_geog, :hub_geog, 1000)
ORDER BY meters, event_id;
```

先用 `ST_DWithin` 过滤，再为较小集合计算/排序距离。SQL 表达式仍可能被
优化器重排，但逻辑合同清楚，且索引条件可见。

距离函数还涉及：

- geography 使用 spheroid 还是 sphere；
- 2D 还是 3D；
- 误差容忍；
- 坐标精度与 GPS 噪声；
- 是否按路网距离而非直线距离。

本章只验证 2D 地表直线距离，不做路线规划。

### 最近邻是排名问题

找最近的 K 个：

```sql
SELECT hub_id, hub_name
FROM shop_ch16.delivery_hub
ORDER BY location <-> :query_geometry, hub_id
LIMIT 2;
```

`<->` 是距离排序操作符，适配的索引访问方法/operator class 可以执行 KNN
路径。它与 `ST_DWithin` 不同：

```text
ST_DWithin -> 资格：所有半径内对象
<-> LIMIT K -> 排名：最近 K 个，不保证在某半径内
```

生产常组合：

```sql
WHERE ST_DWithin(location, :point, :radius)
ORDER BY location <-> :point, hub_id
LIMIT :k;
```

`radius` 控制业务资格，`k` 控制返回深度。

### 稳定 tie-breaker 不能省

多个对象可能与查询点等距。实验和分页都要追加：

```sql
ORDER BY distance, event_id
```

否则同距离对象的顺序未定义。空间索引也不会替你创造业务唯一顺序。

### 最近点不等于最近路径

两点直线很近，可能隔着河流、围墙或单行路网。若问题是配送 ETA/路径，应
引入：

- 权威路网；
- 拓扑连接；
- 交通规则和时间版本；
- 路径算法；
- 地图匹配；
- 实际行驶数据校准。

PostGIS 点距离只能作为几何近似，不能自动变成路由引擎。

## 16.4.2 包围盒过滤与精确计算 {#item-16-4-2}

### 空间索引保存可搜索近似

复杂 Polygon 可能有成千上万个顶点。每行都执行精确拓扑会很贵。常见路径：

```text
bounding box candidate filter
  -> exact geometry predicate
```

包围盒是包住对象的轴对齐矩形。两个对象的包围盒不相交，则对象必不相交；
包围盒相交，只说明它们可能相交。

因此：

```text
bbox reject = 可以安全排除
bbox match  = 仍需精确判断
```

### 命名谓词会加入索引友好的初筛

PostGIS 对一组常见命名谓词自动加入包围盒条件，例如本章使用的：

```text
ST_Covers
ST_DWithin
```

固定 GiST 计划中可以看见：

```text
Index Cond:
  location_geog && _st_expand(query_geography, 1500)

Filter:
  st_dwithin(location_geog, query_geography, 1500, true)
```

`Index Cond` 缩小候选，`Filter` 执行精确距离。本章联合计划中：

```text
Index Cond:
  location @ zone.zone_geom

Filter:
  st_covers(zone.zone_geom, location)
```

内部操作符展示可能随版本和计划格式变化，工程上应关注“索引候选 +
精确谓词”结构，而不是把某一行文本当 API。

PostGIS 官方
[空间索引与查询](https://postgis.net/docs/using_postgis_query.html)
列出会自动利用空间索引的函数，并解释两阶段比较。

### 手工 `&&` 只回答包围盒

```sql
WHERE geometry_a && geometry_b
```

只测试二维包围盒重叠。它适合：

- 明确只需要视窗候选；
- 分阶段调试；
- 为后续自定义精确计算生成候选。

它不等于 `ST_Intersects`。对凹多边形、带洞区域或长斜线，包围盒会包含大量
实际不相交对象。

不要为了“更快”把精确谓词删掉，除非业务合同本来就只需要 bbox。

### lossy/recheck 是正常行为

GiST 等索引可能返回需要 heap recheck 的候选。看到计划中的：

```text
Recheck Cond
Rows Removed by Filter
```

不表示索引错误，而是近似索引和精确关系的正常分工。应观察：

- 候选数量与最终命中数量；
- recheck 比例；
- 几何复杂度；
- 选择率估计；
- heap page 命中；
- 查询半径和区域大小。

若一个巨大 Polygon 的 bbox 覆盖整座城市，空间索引无法凭 bbox 排除很多
点。可考虑细分几何、预计算层级网格或业务分区，但任何近似都要保留精确
复核或明确误差合同。

### 无效几何会破坏前提

官方 `ST_Covers` 文档提醒不要对无效 geometry 期待可靠结果。索引只会让
错误候选更快地产生。接入时：

```sql
CHECK (ST_IsValid(zone_geom))
```

并保留 `ST_IsValidReason` 证据，比查询时临时修复更可控。

### 扩张半径与单位必须一致

geometry 上：

```sql
ST_Expand(point_4326, 1000)
```

会按度扩张 1000，不是 1000 米，几乎覆盖全球。不要把 geography 的米参数
直觉套到 geometry 函数。

本章 geometry SP-GiST probe 使用：

```sql
ST_DWithin(location, point_4326, 0.02)
```

这里 `0.02` 是度，仅用于证明 operator class 路径；业务 1 km 查询使用
geography 和 `1000` 米。

### 二阶段也适用于跨类型方案

若业务必须在局部投影做高精度计算，可以：

```text
cheap canonical-CRS bbox
  -> smaller candidate set
  -> ST_Transform
  -> exact projected calculation
```

但粗筛边界必须是保守的，不能漏掉真值。跨 CRS 的安全包围盒设计需要处理
投影非线性和区域边缘，不能简单转换两个角点就默认安全。

## 16.4.3 GiST/SP-GiST 计划与选择率验证 {#item-16-4-3}

### 访问方法不是单独的“空间索引类型”

PostgreSQL 索引能力由：

```text
access method + operator class + data type + operator/query
```

共同决定。本章目录：

| 对象 | access method | operator class |
|---|---|---|
| 围栏 geometry | GiST | `gist_geometry_ops_2d` |
| 事件 geometry | GiST | `gist_geometry_ops_2d` |
| 事件 geography | GiST | `gist_geography_ops` |
| 中心 Point | SP-GiST | `spgist_geometry_ops_2d` |
| 围栏有效期约束 | GiST | `gist_text_ops, range_ops` |

因此“建了 GiST”信息不完整。还要知道列、opclass、维度和目标查询。

### GiST 与 SP-GiST 的直觉边界

**GiST** 是通用搜索树框架，PostGIS 常用它管理几何包围盒，也支持 geography
和 KNN 等相应 operator class 能力。

**SP-GiST** 将空间递归划分，适合某些可分区的数据结构与分布。本章用
`spgist_geometry_ops_2d` 为三个 Point 建索引，并只证明 `ST_DWithin`
产生该索引路径。

不要从 access method 名字推导所有能力。本章未声称这个 SP-GiST opclass
服务 `<->` KNN；最近邻是否走索引必须对目标版本、类型、opclass 和实际
查询看计划。

### 建索引

```sql
CREATE INDEX event_20260308_location_gist_idx
ON shop_ch16.delivery_event_20260308
USING gist (
  location shop_ch16_ext.gist_geometry_ops_2d
);

CREATE INDEX event_20260308_geog_gist_idx
ON shop_ch16.delivery_event_20260308
USING gist (
  location_geog shop_ch16_ext.gist_geography_ops
);

CREATE INDEX delivery_hub_location_spgist_idx
ON shop_ch16.delivery_hub
USING spgist (
  location shop_ch16_ext.spgist_geometry_ops_2d
);
```

本地实验把 PostGIS 安装到 `shop_ch16_ext`，所以 opclass 与操作符都显式
schema 限定。生产可选择 `public` 或受控扩展 schema，但搜索路径、迁移工具
和 ORM 必须与之兼容。

### 目录验收

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

[`index-catalog.sql`](/labs/ch16/index-catalog.sql) 固定 13 个管理索引，并
验证：

```text
access method
all operator classes
indisvalid
indisready
indislive
index bytes
object marker
```

其中：

```text
3 geography GiST
4 geometry GiST (3 event + 1 geofence)
1 geometry SP-GiST
1 mixed text/range GiST exclusion
4 B-tree
```

主键自动索引另由 34 个关系对象白名单验收，不混进“本章主动选择的 13 个
索引”计数。

### 为什么计划探针关闭顺序扫描

fixture 只有 3 个中心、4 个围栏和 12 个事件。正常成本模型选择 Seq Scan
很合理。为了证明路径存在，探针执行：

```sql
SET enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, ...);
```

固定结果：

```text
spatial-gist-plan
  -> event_20260308_geog_gist_idx

spatial-spgist-plan
  -> delivery_hub_location_spgist_idx

joint-plan
  -> geofence_version_no_overlap
  -> event_20260308_location_gist_idx
```

这只证明：

```text
query/operator/opclass/index are compatible
```

不证明：

```text
planner should choose it at realistic scale
index is faster
estimated selectivity is accurate
cache/WAL/write cost is acceptable
```

### 生产选择率验证

生产候选应在代表性数据上运行：

```sql
EXPLAIN (
  ANALYZE,
  BUFFERS,
  WAL,
  SETTINGS,
  VERBOSE
) ...
```

对比：

```text
estimated rows vs actual rows
index candidates vs exact matches
heap/index blocks
cache warm/cold
radius/区域大小分布
不同租户与城市的数据倾斜
并发下延迟
```

空间选择率高度依赖数据分布。城市中心密集、郊区稀疏；巨大围栏和小围栏的
bbox 过滤能力不同。一个平均值无法代表所有查询。

### 统计与维护

装载或大批更新后：

```sql
ANALYZE shop_ch16.delivery_event;
ANALYZE shop_ch16.geofence_version;
```

还要观察：

- autovacuum/analyze 是否覆盖每个叶分区；
- 历史分区统计是否陈旧；
- PostGIS 列统计目标是否足够；
- 索引膨胀与重建窗口；
- 写放大与 WAL；
- 副本 replay 延迟；
- 新分区是否漏建空间索引。

父表有索引声明不等于每个未来分区都满足预期，自动化应从目录持续核对。

### 本节反例清单

以下说法都不足以作为上线结论：

```text
“EXPLAIN 里出现 GiST，所以很快”
“用了 geography，所以最准确”
“SP-GiST 比 GiST 新，所以更好”
“ST_Distance 能算距离，所以能用索引筛半径”
“包围盒命中就是空间相交”
“12 行强制 Index Scan 比 Seq Scan 快”
```

正确结论必须包含语义、类型/SRID、operator class、计划、代表性规模和实际
测量。

---

[上一节：空间类型与坐标参考](../03/) · [返回本章目录](../) · [下一节：时空联合查询是本章收束目标](../05/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
