PostGIS 把 PostgreSQL 变成了一套完整的地理空间数据库:它引入 geometry 与 geography 两种空间类型,提供数千个空间函数,并让 GiST 索引能够加速空间谓词。它被广泛应用于位置服务、物流路径、地理围栏、行政区划统计等场景——在这些场景里,用普通数值列存经纬度再自己算距离,几乎注定会踩到坐标系统、球面几何和索引失效三个大坑。
核心认知:空间查询的成败取决于三件事——选对类型(geometry 还是 geography)、算对坐标系(SRID)、用对索引(GiST 而非 B-tree)。
一、PostGIS 安装与几何类型
1.1 安装与启用
-- Debian/Ubuntu: apt install postgresql-16-postgis-3
-- macOS: brew install postgis
CREATE EXTENSION postgis;
-- 验证版本
SELECT PostGIS_Version();
1.2 geometry 与 geography 的选择
这是 PostGIS 最重要的一次决策:
| 维度 | geometry | geography |
|---|---|---|
| 坐标系 | 平面(可投影) | 球面(WGS84 经纬度) |
| 单位 | 由 SRID 决定(米/度) | 始终为米 |
| 距离计算 | 平面欧氏距离 | 球面大圆距离 |
| 计算速度 | 快 | 慢(三角函数开销) |
| 支持函数 | 全量 | 子集 |
| 适用 | 城市级、已投影数据 | 跨洲、经纬度原始数据 |
-- geometry:适合小范围、已投影到平面坐标系的数据
CREATE TABLE parcels (
id serial PRIMARY KEY,
geom geometry(Polygon, 3857) -- Web Mercator
);
-- geography:适合经纬度原始数据,距离计算直接得到米
CREATE TABLE checkins (
id serial PRIMARY KEY,
geog geography(Point, 4326) -- WGS84
);
1.3 常见几何子类型
POINT 点(POI、设备位置)
LINESTRING 线(道路、轨迹)
POLYGON 多边形(行政区、地块、围栏)
MULTIPOINT 多点集合
MULTILINESTRING 多线集合
MULTIPOLYGON 多面集合(含岛屿的行政区)
GEOMETRYCOLLECTION 混合集合
1.4 插入与构造
-- WKT 文本构造(需要显式指定 SRID)
INSERT INTO checkins (geog) VALUES (ST_GeogFromText('SRID=4326;POINT(116.404 39.915)'));
-- 用经度、纬度直接构造(注意顺序是 经度 纬度)
INSERT INTO checkins (geog) VALUES (ST_MakePoint(116.404, 39.915)::geography);
-- GeoJSON 构造
INSERT INTO parcels (geom) VALUES (
ST_GeomFromGeoJSON('{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,1],[0,0]]]}')
);
关键:
ST_MakePoint(x, y)的参数顺序是先经度后纬度,与直觉相反,是最常见的错误来源。
二、坐标系统与 SRID
2.1 SRID 是什么
SRID(Spatial Reference System Identifier)标识一套坐标系统。最常见的两个:
4326 WGS84,经纬度,单位「度」——GPS 原始数据
3857 Web Mercator,单位「米」——在线地图瓦片(Google/OSM)
2.2 混用 SRID 的后果
-- 错误:两个不同 SRID 的几何直接比较,结果无意义
SELECT ST_Distance(
ST_GeomFromText('POINT(0 0)', 4326),
ST_GeomFromText('POINT(0 0)', 3857)
);
-- ERROR: Operation on mixed SRID geometries
即使不报错,把「度」当成「米」来算距离也是灾难:
在纬度 40° 处,1 度经度 ≈ 85km,1 度纬度 ≈ 111km
把 0.001 度当作 0.001 米,误差高达 8 万倍
2.3 投影转换
-- 从 4326 转换到 3857(在线地图用)
SELECT ST_Transform(geom, 3857) FROM parcels;
-- 查看几何的 SRID
SELECT ST_SRID(geom) FROM parcels LIMIT 1;
-- 转换后再计算距离(3857 单位为米)
SELECT ST_Distance(
ST_Transform(a.geom, 3857),
ST_Transform(b.geom, 3857)
) AS meters
FROM points a, points b WHERE a.id = 1 AND b.id = 2;
2.4 选 SRID 的实用建议
- 数据来自 GPS、跨区域 → 存 4326,用 geography 或按需投影
- 单一城市/国家、追求性能 → 投影到当地平面坐标系(如 UTM)
- 与在线地图对接 → 3857 或运行时转换
- 中国境内高精度 → 考虑 GCJ-02 偏移问题(PostGIS 不内置,需自行纠偏)
三、空间索引
3.1 GiST 索引
空间查询没有空间索引时会全表扫描,GiST 是 PostGIS 的主力索引:
-- geometry 列建 GiST
CREATE INDEX idx_parcels_geom ON parcels USING gist (geom);
-- geography 列同样
CREATE INDEX idx_checkins_geog ON checkins USING gist (geog);
3.2 GiST 为什么能加速
GiST 把每个几何对象的外包矩形(bounding box)组织成树,查询时先比较包围盒:
查询点 P → 树节点包围盒 → 命中叶子 → 精确几何判断
↑ 快速排除绝大部分对象 ↑ 只对少量候选做精确计算
因此空间谓词必须写成能用索引的形式(&&、ST_DWithin、ST_Intersects 等),而 ST_Distance(a, b) < 100 这种写法不会用索引。
3.3 索引使用验证
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM parcels
WHERE ST_Contains(geom, ST_MakePoint(116.404, 39.915));
期望看到 Index Scan using idx_parcels_geom。如果出现 Seq Scan,说明谓词没走索引。
3.4 索引与查询的对齐
-- 走索引:ST_DWithin 是索引感知的
SELECT id FROM checkins
WHERE ST_DWithin(geog, ST_MakePoint(116.404, 39.915)::geography, 1000);
-- 不走索引:先算距离再过滤,无法利用包围盒
SELECT id FROM checkins
WHERE ST_Distance(geog, ST_MakePoint(116.404, 39.915)::geography) < 1000;
ST_DWithin 与 ST_Distance < x 语义相近,但前者能用索引,性能差距可达数百倍。
3.5 SP-GiST 与覆盖索引
-- 点数据可用 SP-GiST(空间分区树)
CREATE INDEX idx_points_spgist ON points USING spgist (geom);
-- PostgreSQL 12+ 支持 INCLUDE 覆盖索引,避免回表
CREATE INDEX idx_parcels_geom_inc ON parcels USING gist (geom) INCLUDE (name);
四、空间关系与查询
4.1 空间谓词一览
| 函数 | 含义 | 是否用索引 |
|---|---|---|
&& | 包围盒相交 | 是 |
ST_Intersects | 几何相交 | 是 |
ST_Contains | A 包含 B | 是 |
ST_Within | A 在 B 内 | 是 |
ST_DWithin | 距离小于阈值 | 是 |
ST_Covers | A 覆盖 B(含边界) | 是 |
ST_Distance | 精确距离 | 否 |
4.2 附近搜索与方圆查询
-- 找出 1 公里内的所有签到点
SELECT id, ST_Distance(geog, ST_MakePoint(116.404, 39.915)::geography) AS meters
FROM checkins
WHERE ST_DWithin(geog, ST_MakePoint(116.404, 39.915)::geography, 1000)
ORDER BY meters
LIMIT 20;
4.3 地理围栏判断
-- 判断某点落在哪个行政区(面)内
SELECT p.name AS district
FROM districts d
JOIN points p ON true
WHERE ST_Contains(d.geom, p.geom)
LIMIT 1;
-- 更好的写法:先在点表上过滤,再关联
SELECT p.id, d.name
FROM points p
LEFT JOIN LATERAL (
SELECT name FROM districts d
WHERE ST_Contains(d.geom, p.geom)
LIMIT 1
) d ON true;
4.4 距离与面积计算
-- geography 直接得到米
SELECT ST_Distance(
ST_MakePoint(116.404, 39.915)::geography,
ST_MakePoint(121.473, 31.230)::geography
) AS meters; -- 北京到上海约 1067000 米
-- 面积:geography 返回平方米
SELECT ST_Area(geog) AS sq_meters FROM regions;
-- geometry 面积取决于 SRID 单位
SELECT ST_Area(ST_Transform(geom, 3857)) AS approx_sq_meters FROM regions;
注意:在 3857(Web Mercator)上算面积会随纬度产生显著畸变,高精度面积应使用等面积投影(如 Albers)或
geography。
4.5 格式化输出
-- 转 GeoJSON 给前端地图
SELECT id, ST_AsGeoJSON(geom)::jsonb AS geojson FROM parcels LIMIT 5;
-- 转 WKT
SELECT ST_AsText(geom) FROM parcels LIMIT 1;
-- 转 KML / SVG(可视化)
SELECT ST_AsKML(geom) FROM parcels LIMIT 1;
五、地理处理与分析
5.1 缓冲区
-- 生成 500 米缓冲区(geography 单位为米)
SELECT ST_Buffer(geog, 500) FROM checkins WHERE id = 1;
-- geometry 缓冲需先投影到米制坐标系
SELECT ST_Buffer(ST_Transform(geom, 3857), 500) FROM parcels;
5.2 相交与合并
-- 两个面的交集
SELECT ST_Intersection(a.geom, b.geom) FROM regions a, regions b
WHERE a.id = 1 AND b.id = 2;
-- 合并相邻地块
SELECT ST_Union(geom) FROM parcels WHERE owner = 'acme';
-- 快速合并(只做包围盒,比 ST_Union 快很多)
SELECT ST_Collect(geom) FROM parcels WHERE owner = 'acme';
5.3 最近邻查询 KNN
-- 借助 <-> 距离操作符,GiST 索引支持 KNN,无需先算距离
SELECT id, name
FROM pois
ORDER BY geom <-> ST_MakePoint(116.404, 39.915)::geometry
LIMIT 10;
KNN 是「找最近 N 个」的最优写法,它让 GiST 索引按距离顺序返回结果,避免全表计算距离再排序。
5.4 抽稀与简化
-- 简化几何(Douglas-Peucker),用于前端渲染
SELECT ST_Simplify(geom, 0.001) FROM parcels;
SELECT ST_SimplifyPreserveTopology(geom, 0.001) FROM parcels;
5.5 坐标与索引的常见组合
-- 为「按距离排序 + 距离过滤」组合建立索引
CREATE INDEX idx_pois_geom ON pois USING gist (geom);
SELECT id, geom <-> ST_MakePoint(116.404, 39.915)::geometry AS dist
FROM pois
WHERE ST_DWithin(geom::geography, ST_MakePoint(116.404, 39.915)::geography, 5000)
ORDER BY dist
LIMIT 10;
六、性能调优与踩坑
6.1 用 EXPLAIN 确认索引生效
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM parcels WHERE ST_Contains(geom, ST_MakePoint(1, 1));
-- 好:Index Scan,Rows Removed by Index Recheck 少
Index Scan using idx_parcels_geom on parcels
Index Cond: (geom ~ st_makepoint(1, 1))
-- 坏:Seq Scan,全表逐行判断
Seq Scan on parcels
Filter: st_contains(geom, st_makepoint(1, 1))
6.2 geography 的性能代价
geography 的距离运算涉及球面三角函数,比 geometry 慢数倍。混合策略是:存储用 geography,高频查询前先投影到 geometry。
-- 高频查询:预先把 geography 转成投影后的 geometry
ALTER TABLE checkins ADD COLUMN geom3857 geometry(Point, 3857);
UPDATE checkins SET geom3857 = ST_Transform(geog::geometry, 3857);
CREATE INDEX ON checkins USING gist (geom3857);
6.3 常见踩坑清单
1. 经纬度写反:ST_MakePoint(经度, 纬度),不是 (纬度, 经度)
2. 混用 SRID:4326 与 3857 直接运算 → 报错或结果无意义
3. 用 ST_Distance < x 代替 ST_DWithin → 索引失效
4. 把「度」当「米」:4326 上 0.001 不是 1 米
5. 在 3857 上算面积:高纬度严重畸变
6. 忘记建 GiST 索引:全表扫描
7. 缓冲区半径单位错:geometry 的 ST_Buffer 用 SRID 单位
6.4 索引维护
-- GiST 索引随更新会膨胀,定期检查
SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS size,
idx_scan
FROM pg_stat_user_indexes
WHERE relname IN ('parcels', 'checkins');
-- 必要时重建
REINDEX INDEX CONCURRENTLY idx_parcels_geom;
常见问题(FAQ)
geometry 还是 geography 的选择
数据是经纬度、需要跨区域精确距离且量级不大,用 geography。数据已投影到米制平面、或追求极致性能、或数据集中在小范围,用 geometry。很多系统两者并存:geography 保真存储,geometry 供高频查询。
ST_DWithin 与 ST_Distance 的差异
语义上 ST_DWithin(a, b, d) 等价于 ST_Distance(a, b) <= d,但前者能被 GiST 索引加速(先做包围盒扩张),后者必须对每行算精确距离。所有「距离小于阈值」的查询都应改写成 ST_DWithin。
空间查询仍然全表扫描的原因
常见原因有三:谓词写成了 ST_Distance(...) < x 而非 ST_DWithin;几何列没有 GiST 索引;或者查询里对几何列做了函数包装(如 ST_Transform(geom, 3857) 后再比较),导致索引无法使用。
经纬度顺序容易搞错怎么办
记住 PostGIS 遵循 x, y 顺序,即 POINT(经度 纬度)。可以用 ST_X(经度)与 ST_Y(纬度)验证,也可以用 ST_MakePoint(longitude, latitude) 显式命名来避免混淆。
空间索引对 geography 是否有效
有效。geography 列同样支持 GiST 索引,且 ST_DWithin、ST_Intersects 等谓词能利用它。只是 geography 的精确计算更慢,索引能过滤掉大部分候选,剩下的精确判断仍比 geometry 慢。
相关阅读
- PostgreSQL 索引类型全解 — GiST、SP-GiST 与空间索引原理
- PostgreSQL 扩展与规模化 — PostGIS 等扩展的安装与版本管理
- PostgreSQL 查询优化实战 — 索引扫描与位图扫描的解读
- PostgreSQL 表结构设计 — 空间列与业务列的建模
- PostgreSQL JSONB 性能与索引 — GeoJSON 存 jsonb 与存 geometry 的取舍
- PostgreSQL 专题导航
延伸阅读
- PostgreSQL 监控与诊断体系 — 空间查询的慢日志与索引使用率监控
- PostgreSQL 性能调优 — work_mem 与并行查询对空间聚合的影响
完整示例(一键复制)
-- ========== 1. 启用扩展 ==========
CREATE EXTENSION IF NOT EXISTS postgis;
-- ========== 2. 建表 ==========
CREATE TABLE pois (
id serial PRIMARY KEY,
name text,
geom geometry(Point, 4326),
geog geography(Point, 4326)
);
CREATE TABLE districts (
id serial PRIMARY KEY,
name text,
geom geometry(Polygon, 4326)
);
-- ========== 3. 空间索引 ==========
CREATE INDEX idx_pois_geom ON pois USING gist (geom);
CREATE INDEX idx_pois_geog ON pois USING gist (geog);
CREATE INDEX idx_districts_geom ON districts USING gist (geom);
-- ========== 4. 插入数据(注意经度在前) ==========
INSERT INTO pois (name, geom, geog) VALUES
('天安门', ST_SetSRID(ST_MakePoint(116.397, 39.909), 4326),
ST_SetSRID(ST_MakePoint(116.397, 39.909), 4326)::geography),
('王府井', ST_SetSRID(ST_MakePoint(116.410, 39.915), 4326),
ST_SetSRID(ST_MakePoint(116.410, 39.915), 4326)::geography);
-- ========== 5. 附近搜索:1 公里内,按距离排序 ==========
SELECT id, name,
ST_Distance(geog, ST_SetSRID(ST_MakePoint(116.404, 39.915), 4326)::geography) AS meters
FROM pois
WHERE ST_DWithin(geog, ST_SetSRID(ST_MakePoint(116.404, 39.915), 4326)::geography, 1000)
ORDER BY meters
LIMIT 20;
-- ========== 6. KNN 最近邻 ==========
SELECT id, name, geom <-> ST_SetSRID(ST_MakePoint(116.404, 39.915), 4326) AS dist
FROM pois
ORDER BY dist
LIMIT 10;
-- ========== 7. 围栏判断 ==========
SELECT p.name, d.name AS district
FROM pois p
LEFT JOIN LATERAL (
SELECT name FROM districts d WHERE ST_Contains(d.geom, p.geom) LIMIT 1
) d ON true;
-- ========== 8. GeoJSON 输出 ==========
SELECT id, name, ST_AsGeoJSON(geom)::jsonb AS geojson FROM pois;
-- ========== 9. 距离与面积 ==========
SELECT ST_Distance(
ST_SetSRID(ST_MakePoint(116.404, 39.915), 4326)::geography,
ST_SetSRID(ST_MakePoint(121.473, 31.230), 4326)::geography
) AS beijing_to_shanghai_meters;
-- ========== 10. 计划验证 ==========
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM pois
WHERE ST_DWithin(geog, ST_SetSRID(ST_MakePoint(116.404, 39.915), 4326)::geography, 1000);
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。