空间索引是怎么救命的:GiST、R-Tree 与 PostGIS 查询优化实践
一个没有空间索引的 GIS 数据库和一个有的,查询时间差的是"分钟"和"毫秒"的量级。这篇文章讲清中间发生了什么。
先看一组真实数据
PostGIS 官方博客记录过一个空间连接的优化过程:
优化前:
SELECT Count(*), a.name
FROM admin0 a JOIN popplaces p ON ST_Intersects(a.geom, p.geom)
GROUP BY a.name;Seq Scan on popplaces p (cost=0.00..291.42 rows=7342 width=32)258 个国家、7342 个地点,跑了 180 万次相交测试,耗时 1.8 秒量级。
加索引后,SQL 一字未改:
CREATE INDEX popplaces_geom_x ON popplaces USING GIST (geom);HashAggregate (cost=4185.41..4187.99 rows=258 width=18)
-> Nested Loop (cost=0.15..4176.73 rows=1737 width=10)
-> Seq Scan on admin0 a (cost=0.00..98.58 rows=258 width=34320)
-> Index Scan using popplaces_geom_x on popplaces p (cost=0.15..15.80 rows=1 width=32)
Index Cond: (geom && a.geom)
Filter: st_intersects(a.geom, geom)内层从"顺序扫描 300 成本"变成"索引扫描 15 成本",便宜了 20 倍,整体快 10 倍。
注意 SQL 完全没变。 变的只是执行计划。这就是索引的全部意义。
两阶段过滤:索引的底层逻辑
空间索引的核心思路是把昂贵的计算推迟到尽可能晚。
几何体可以极其复杂——一条海岸线多边形可能有 10 万个顶点,判断两个这样的多边形是否相交,计算代价很高。空间索引用两阶段策略绕过这个开销:
阶段一:索引过滤(廉价)
每个要素被归约为它的最小外接矩形(MBR)——只有四个数字。
这些矩形被组织成层次树(R-tree:矩形聚合进父矩形,递归进行)。查询时沿树下降,剪掉那些父矩形根本不可能与查询窗口相交的整条分支,得到一个很小的候选集。
阶段二:几何精筛(精确)
只对候选集执行真正的几何谓词(ST_Intersects、ST_Contains 等)。
关键认知:索引本身从不给出最终答案,它给的是一个廉价的候选名单。"索引就是包围盒"不是缺陷,这正是它的设计意图。
为什么 PostGIS 用 GiST 而不是原生 R-Tree
PostGIS 早期版本用过 PostgreSQL 原生的 R-Tree 索引,但从 0.6 版本起完全废弃,改为基于 GiST 的 R-Tree 方案。原生 R-Tree 存在两个硬伤:
1. 不能处理大于 8KB 的要素 —— 而一个复杂多边形超过 8KB 是常态。GiST 通过"用包围盒代替要素本身"的有损技巧绕过了这个限制
2. 不 null-safe —— 对包含 NULL 几何的列建索引会直接失败
PostGIS 的 GiST 索引本质上是几何包围盒上的 R-tree,且自调优——不需要你手动调参数来适配数据分布。
正确的查询写法:双谓词
PostGIS 官方手册反复强调一个模式:索引谓词 + 空间函数谓词,两者都要写。
-- ❌ 只有空间函数:函数不能用索引
SELECT * FROM table_name
WHERE ST_Contains(the_geom, 'POLYGON((0 0, 0 10, 10 10, 10 0, 0 0))');
-- ✅ 索引谓词先行,再精确判断
SELECT * FROM table_name
WHERE the_geom && 'POLYGON((0 0, 0 10, 10 10, 10 0, 0 0))'::geometry
AND ST_Contains(the_geom, 'POLYGON((0 0, 0 10, 10 10, 10 0, 0 0))');&& 运算符测试包围盒相交,能走 GiST 索引;ST_Contains 做精确判断。前者负责快,后者负责对。
为什么不能省掉 &&
有人会问:ST_Contains 本身不是已经精确判断了吗?为什么还要 &&?
因为 ST_Contains(geom, 'POLYGON(...)') 里,几何列出现在函数参数位置。PostgreSQL 无法把这种谓词转换成索引可用的形式,只能对每一行都执行完整的几何计算——等价于全表扫描。
而 geom && constant 右边是常量,左边是裸列,这是索引能识别的标准形式。
半径查询:ST_DWithin 的正确用法
"找出所有在某点 100 米内的要素"是超高频需求。朴素写法会杀掉性能:
-- ❌ ST_Distance 不能用索引,等于全表逐个算距离
SELECT * FROM geotable
WHERE ST_Distance(geom, 'POINT(1000 1000)') < 100;正确做法是把半径测试和包围盒测试组合:
-- ✅ ST_DWithin 内部构造包围盒搜索矩形,能走索引
SELECT * FROM geotable
WHERE ST_DWithin(geom, 'POINT(1000 1000)', 100);ST_DWithin 的工作方式是:先构造一个足够大的搜索矩形,然后对索引筛出的子集做精确距离测试。这是 PostGIS 里最容易被低估的一个函数。
索引没被用上的四个原因
用 EXPLAIN ANALYZE 验证,看到 Seq Scan 就是有问题。
原因 1:统计信息陈旧
ANALYZE your_table;这不是可选的。 查询规划器需要准确统计信息才能判断用索引是否划算。大批量导入后总是要跑 ANALYZE,否则规划器会因为估算失准而放弃索引。
原因 2:谓词被函数包裹
-- ❌ geom 被函数包裹,索引失效
WHERE ST_Intersects(ST_Buffer(geom, 100), query_geom)
-- ✅ 先用裸列过滤,再算
WHERE geom && query_geom
AND ST_Intersects(ST_Buffer(geom, 100), query_geom)原因 3:SRID 不匹配
GiST 索引依赖包围盒,而包围盒是在特定 SRID 下计算的。列的 SRID 与查询 envelope 的 SRID 不一致时,索引无法使用或给出错误结果。
SELECT ST_SRID(geom) FROM table LIMIT 1;如果返回 NULL 或混乱值,先修数据,不要在查询里临时猜坐标系。
原因 4:数据分布使索引不划算
如果查询条件本身就要返回表中大部分行,规划器选顺序扫描是完全正确的决定,不是 bug。这时该考虑的是改查询逻辑,而不是强行用索引。
数据入库的基本检查
正式提供空间服务之前,先跑这几条诊断:
-- 1. 确认 SRID 和几何类型一致
SELECT ST_SRID(geom) AS srid, GeometryType(geom) AS geom_type, COUNT(*)
FROM your_table
GROUP BY ST_SRID(geom), GeometryType(geom);
-- 2. 检查几何有效性(无效几何会让空间运算结果不可预测)
SELECT COUNT(*) FROM your_table WHERE NOT ST_IsValid(geom);
-- 3. 建索引
CREATE INDEX IF NOT EXISTS your_table_geom_gix ON your_table USING GIST (geom);
-- 4. 必须 ANALYZE
ANALYZE your_table;ST_IsValid 这一步经常被跳过,后果很隐蔽。 一个自相交的多边形会让 ST_Contains 之类的函数返回不可预期的结果。修复用:
UPDATE your_table SET geom = ST_MakeValid(geom) WHERE NOT ST_IsValid(geom);不同容器的空间索引
空间索引的思想一致,但实现载体不同:
GeoPackage 的实际优势值得强调:R-tree 存在 .gpkg 文件内部,随数据一起走。GDAL 在写 GeoPackage 时默认自动创建 R-tree,可以用 gpkgAddSpatialIndex 显式添加,用下面这条验证:
SELECT * FROM gpkg_extensions WHERE extension_name = 'gpkg_rtree_index';用 ogr2ogr 转 GeoPackage 时索引会自动构建。
Shapefile 的 .qix 是独立文件,这个设计在版本管理、数据同步、邮件传输场景下都会带来麻烦——很容易出现"数据传了、索引没传"的情况,之后查询就莫名其妙地慢。如果你的数据要到处流转,考虑转成 GeoPackage。
大表补充手段:BRIN 索引
PostGIS 空间数据通常天然有序(按采集顺序或行政区分块插入)。这种情况下 BRIN 索引能用极小的存储空间换取不错的过滤效果:
CREATE INDEX idx_brin ON your_table USING BRIN (geom);BRIN 存的是"数据块的取值范围摘要",查询时先跳过明显不相关的块。代价是精度低于 GiST,但对"只查最新一批数据"这类场景很合适。
小结
空间索引的全部价值可以概括成一句话:用廉价的包围盒比较,把昂贵的几何计算从"全表"推迟到"候选集"。
写空间查询时记住两条:
1. 永远先写 &&(或 ST_DWithin),再写空间函数
2. 建完索引一定要 ANALYZE,否则规划器不知道它存在
做到这两点,绝大多数 PostGIS 性能问题都不会发生。
参考资料
PostGIS 官方文档 — https://postgis.net/docs/
PostGIS FAQ(索引章节)— https://postgis.net/docs/manual/faq.html
PostGIS Planet 博客(空间连接优化实录)— https://planet.postgis.net/
GeoPackage 规范 — https://www.geopackage.org/