# 空间类型与坐标参考

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

---

PostGIS 不只是“经纬度函数包”。它把空间对象、坐标参考、拓扑关系、距离
模型、索引操作符和目录元数据带进 PostgreSQL。

学习顺序应当是：

```text
业务对象
  -> 坐标参考与单位
  -> geometry / geography
  -> 合法性与边界规则
  -> 谓词
  -> 索引
```

若从“建一个 GiST”开始，很容易得到能够执行却单位错误的查询。

## 16.3.1 geometry、geography 与测量语义 {#item-16-3-1}

### 两种类型回答不同计算问题

PostGIS 的核心空间类型：

| 类型 | 计算表面 | 距离/面积单位 | 典型用途 |
|---|---|---|---|
| `geometry` | 指定 CRS 的平面坐标 | CRS 单位 | 拓扑、局部投影、丰富函数、空间索引 |
| `geography` | 地球曲面模型 | 米/平方米 | 全球经纬度上的距离、半径、面积 |

`geometry(Point, 4326)` 的坐标是经纬度角度。下面这条距离：

```sql
ST_Distance(point_a_4326, point_b_4326)
```

返回的是坐标系单位，也就是角度，不是米。把结果乘一个固定“每度米数”只在
非常有限的局部近似下成立，且经度尺度随纬度变化。

将同一点作为 `geography`：

```sql
ST_Distance(
  point_a_4326::geography,
  point_b_4326::geography
)
```

才得到以米为单位的地表距离语义。PostGIS 官方
[空间查询章节](https://postgis.net/docs/using_postgis_query.html)
说明 geometry 的平面计算与 geography 的大地计算差异。

### 不是所有列都要存两份

本章将规范值保存在 geometry：

```sql
location geometry(Point, 4326) NOT NULL
```

再生成 geography：

```sql
location_geog geography(Point, 4326)
  GENERATED ALWAYS AS (
    location::geography
  ) STORED
```

好处：

- 只有一个可写坐标事实；
- geography 与 geometry 不会因应用漏更新而漂移；
- geography 可建独立 GiST，米制查询不必每次临时转换；
- 生成表达式和类型可以从目录审计。

代价：

- 多一列存储；
- 写入要计算生成值；
- 多一个索引意味着更多 WAL、磁盘和缓存；
- schema 将业务允许的 CRS 固定为 4326。

若米制查询很少，可以只存 geometry 并在查询中转换；若大多数查询都在一个
适当局部投影内，也可以统一使用投影 geometry。要由查询和单位合同决定，
不是机械地“双列最保险”。

### typmod 把对象类型与 SRID 放进 schema

比较：

```sql
location geometry
```

和：

```sql
location geometry(Point, 4326)
```

后者让数据库拒绝非 Point 或非 4326 的值，使表结构本身表达坐标合同。本章
围栏同样固定：

```sql
zone_geom geometry(Polygon, 4326)
```

如果业务允许 MultiPolygon，应明确写：

```sql
geometry(MultiPolygon, 4326)
```

或在接入时将 Polygon 规范化为 MultiPolygon。不要直到某个区域含离岛才临时
修改客户端。

### X/Y 是坐标轴，不是自动的“纬/经”

EPSG:4326 常见 WKT/GeoJSON 使用：

```text
X = longitude
Y = latitude
```

本章点：

```sql
ST_SetSRID(
  ST_MakePoint(-74.00000, 40.71000),
  4326
)
```

即经度 `-74`、纬度 `40.71`。把两者颠倒仍可能落在各自合法数值范围内，
数据库不一定能发现。

接入合同应写清：

```yaml
format: longitude,latitude
x: longitude
y: latitude
srid: 4326
longitude_range: [-180, 180]
latitude_range: [-90, 90]
```

并用已知控制点做端到端验证，而不是只做数值范围检查。

### geography 不是“更准确”的万能开关

geography 很适合：

- “距离配送中心 1 km 内”；
- 跨较大区域的地表距离；
- 用经纬度数据直接得到米制结果。

但 geometry 仍常用于：

- `ST_Covers`、`ST_Intersects` 等拓扑；
- 本地工程坐标和高精度投影；
- 更广的 PostGIS 函数集合；
- 需要明确平面模型的地图与分析。

问题不是哪种类型高级，而是哪种计算模型与业务问题一致。

### 本章距离证据

[`distance-semantics.sql`](/labs/ch16/distance-semantics.sql) 对三个中心寻找最近
事件，并用 geography 计算米：

| hub | 最近事件 | 距离（四舍五入米） |
|---|---|---:|
| airport | `e007` | 0 |
| central | `e001` | 0 |
| east | `e004` | 423 |

三者都在 1 km 内。这里的 `423` 只验证单位与查询链，不是测绘级距离承诺。

## 16.3.2 SRID、投影、单位与坐标转换 {#item-16-3-2}

### SRID 是坐标参考身份

同样一对数：

```text
(500000, 4500000)
```

在不同 CRS 中代表完全不同位置和单位。SRID 让 PostGIS 知道坐标属于哪个
参考系统，并能查找转换定义。

检查：

```sql
SELECT
  ST_SRID(location),
  GeometryType(location)
FROM shop_ch16.delivery_event;
```

本章所有事件、中心和围栏都是 4326。

### `ST_SetSRID` 只贴标签

```sql
ST_SetSRID(geom, 4326)
```

不会改变任何坐标数值。它适用于“这些数本来就是 4326，只是对象没有声明”
的场景。

真正转换：

```sql
ST_Transform(geom, target_srid)
```

会依据源 CRS 与目标 CRS 重新计算坐标。PostGIS
[`ST_Transform` 文档](https://postgis.net/docs/ST_Transform.html)
明确区分转换坐标与仅修改 SRID 标签。

危险反例：

```sql
-- 原始数值其实是 Web Mercator，却被错误贴成 WGS84
ST_SetSRID(mercator_numbers, 4326)
```

这不是近似误差，而是数据身份损坏。以后再 `ST_Transform` 只会把错误输入
转换得更复杂。

### 混合 SRID 应当显式失败

本章故意执行：

```sql
SELECT ST_Intersects(
  ST_SetSRID(ST_MakePoint(0, 0), 4326),
  ST_SetSRID(ST_MakePoint(0, 0), 3857)
);
```

固定结果：

```text
SQLSTATE XX000
Operation on mixed SRID geometries
```

[`srid-mismatch.sql`](/labs/ch16/srid-mismatch.sql) 把错误作为验收证据。不要
捕获这个错误后自动 `ST_SetSRID` 到另一边；系统无法仅凭数值知道哪边身份
正确。

### CRS 决定单位与失真

常见选择：

| 选择 | 优点 | 风险 |
|---|---|---|
| EPSG:4326 geometry | 交换广泛，保存经纬度自然 | 平面距离是角度 |
| EPSG:4326 geography | 米制地表距离直接 | 函数/性能模型与 geometry 不同 |
| 本地投影 geometry | 局部距离、面积和形状可控 | 适用区域有限，需转换治理 |
| EPSG:3857 geometry | Web 地图显示生态常见 | 距离/面积失真，非通用测量 CRS |

Web Mercator 适合瓦片显示，不应仅因为前端地图使用它，就把业务距离也定义在
3857 平面上。

选投影需要：

- 业务覆盖区域；
- 容许失真；
- 距离、面积、方向还是拓扑；
- 数据供应者的 CRS；
- 跨区/跨国查询；
- 权威测绘要求。

这通常要由 GIS 专业人员与业务共同评审，而不是数据库管理员猜一个 EPSG
编号。

### 转换表达式与索引要一致

若查询反复写：

```sql
WHERE ST_DWithin(
  ST_Transform(location, :local_srid),
  :query_point,
  1000
)
```

原始 `location` GiST 通常不能直接服务这个转换表达式。选项包括：

- 存储/生成规范投影列并建索引；
- 建表达式索引；
- 先用原 CRS 的安全包围盒缩小候选，再精确转换；
- 使用 geography 的米制谓词。

表达式索引必须与查询表达式结构、SRID 和函数可索引条件一致。每行临时转换
还会增加 CPU。不能只验证结果，不看计划。

### 坐标转换也要版本化

CRS 定义、网格文件和转换路径可能随 PROJ/PostGIS/操作系统包变化。高精度
业务应在证据中保存：

```text
PostGIS_Full_Version()
PROJ version
source and target SRID
transformation method/grid availability
control points and expected tolerance
```

本章没有外部权威坐标或网格文件，因此只固定 PostGIS 版本、SRID 和合成
控制点，不声称测绘精度。

## 16.3.3 点、线、面、边界与有效几何 {#item-16-3-3}

### 对象维度决定问题

| 对象 | 本章/配送中的例子 | 典型问题 |
|---|---|---|
| Point | 配送事件、中心 | 在哪里、离多远 |
| LineString | 轨迹、道路 | 长度、相交、沿线位置 |
| Polygon | 围栏 | 覆盖、包含、面积 |
| Multi* | 多片区域、分段轨迹 | 多个几何组成一个业务对象 |

用两列经纬度只能表示 Point，且无法让数据库知道它们共同构成一个空间对象。
PostGIS 类型让约束、函数和索引围绕整个对象工作。

### Polygon 有内部、边界和外部

点对面的关系至少有三种：

```text
interior
boundary
exterior
```

所以：

```sql
ST_Contains(polygon, point)
ST_Covers(polygon, point)
```

不是同义词。本章 `e003` 在边界：

```text
ST_Covers   = true
ST_Contains = false
ST_Touches  = true
```

PostGIS
[`ST_Covers`](https://postgis.net/docs/ST_Covers.html)
说明它允许 B 的点位于 A 的内部或边界，并会自动利用包围盒比较。

业务要先决定：

- 边界订单属于两个区、任一区还是某个优先区；
- 边界容差如何处理 GPS 噪声；
- 围栏之间是否允许重叠；
- 点恰好在洞边界如何解释；
- 版本换挡时边界与时间边界谁先判定。

SQL 只能实现已选规则。

### 有效几何是谓词的前提

自相交 Polygon、未闭合 ring、错误洞关系等无效几何可能让拓扑谓词产生意外
结果。写入时至少验证：

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

本章还固定：

```sql
CHECK (NOT ST_IsEmpty(zone_geom))
CHECK (ST_SRID(zone_geom) = 4326)
```

必要时用：

```sql
ST_IsValidReason(geom)
```

定位原因。`ST_MakeValid` 可以尝试修复，但修复可能改变对象类型、拆成多个面
或改变业务边界，不能在生产接入中无审计地自动覆盖原值。

### 空、NULL 与未知不同

```text
NULL geometry  -> 未提供/未知
EMPTY geometry -> 已知为空的空间对象
```

它们在函数和聚合中的行为不同。本章业务事件和围栏都要求 `NOT NULL` 且
非空，避免把“没有位置”误当成“位于任何区域之外”。如果设备可能不上传定位，
应另设质量状态：

```text
location_status = missing / invalid / approximate / verified
```

不要用 `(0,0)` 作为缺失哨兵；它是几内亚湾中的真实坐标。

### 轨迹不是无序点集

若把多个 Point 组成 LineString，顺序必须来自稳定合同：

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

还应分段：

- courier/session；
- 最大时间间隔；
- 设备重启；
- 不合理速度跳变；
- SRID/质量状态。

跨越长间隔直接连线会制造从 A 到 B 的虚假直线。轨迹的 LineString 是一种
派生产品，应可从原始事件和版本化规则重建。

### 经纬度数据也有许可证与版本

真实边界、道路、地址或兴趣点通常来自外部数据源。上线前要保存：

```text
provider and dataset
version / snapshot date
license and attribution
allowed redistribution/use
source CRS
transformation chain
import checksum
update and rollback policy
```

本章坐标全部为项目自造的合成数据，
[`fixture-manifest.json`](/labs/ch16/fixture-manifest.json) 明确
`external_geodata=false`。这让实验离线可复现，也意味着它不能证明真实地图
数据质量。

### 建表前的空间合同

```yaml
business_object: delivery event
geometry_type: Point
canonical_srid: 4326
coordinate_order: longitude, latitude
geometry_role: topology and index
geography_role: meter distance
null_policy: forbidden for canonical event
validity_policy: ST_IsValid and non-empty
boundary_policy: ST_Covers
external_dataset: none
```

这份合同完整后，下一节的谓词与索引才有确定含义。

### 本节验收

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

psql "service=pg36-admin" \
  -f static/labs/ch16/distance-semantics.sql
```

再检查生成列：

```sql
SELECT
  attname,
  attgenerated,
  format_type(atttypid, atttypmod)
FROM pg_attribute
WHERE attrelid =
      'shop_ch16.delivery_event'::regclass
  AND attname IN ('location', 'location_geog');
```

`verify.sql` 要求 `location_geog.attgenerated = 's'`，所有 geometry 与
geography 的 SRID 都是 4326，围栏全部非空且有效。任一项漂移，时空查询
即使返回“看起来正确”的五行也不能通过。

---

[上一节：时序表与时间分区](../02/) · [返回本章目录](../) · [下一节：空间谓词与索引](../04/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
