强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

PostGIS 完全指南 / 第 6 章:空间索引深入

第 6 章:空间索引深入

6.1 为什么需要空间索引

没有空间索引时,PostgreSQL 必须对每条记录执行几何运算来判断是否满足空间条件,这等同于全表扫描(Sequential Scan)。

性能对比

-- 创建测试表:100 万个随机点
CREATE TABLE test_points AS
SELECT
    id,
    ST_SetSRID(ST_MakePoint(
        random() * 360 - 180,   -- 经度 -180 ~ 180
        random() * 180 - 90     -- 纬度 -90 ~ 90
    ), 4326) AS geom
FROM generate_series(1, 1000000) AS id;

-- 无索引查询
EXPLAIN ANALYZE
SELECT count(*) FROM test_points
WHERE ST_Intersects(geom, ST_GeomFromText('POLYGON((116 39, 117 39, 117 40, 116 40, 116 39))', 4326));
-- 预期: Seq Scan, ~500-800ms

-- 创建空间索引
CREATE INDEX idx_test_points_geom ON test_points USING GIST (geom);

-- 有索引查询
EXPLAIN ANALYZE
SELECT count(*) FROM test_points
WHERE ST_Intersects(geom, ST_GeomFromText('POLYGON((116 39, 117 39, 117 40, 116 40, 116 39))', 4326));
-- 预期: Index Scan, ~5-20ms (提升 25-100 倍)

6.2 GiST 索引原理

GiST (Generalized Search Tree) 是 PostgreSQL 的通用搜索树框架。PostGIS 在 GiST 之上实现了空间索引,内部使用 R-Tree 数据结构。

R-Tree 结构

                    ┌──────────────────┐
                    │    Root Node      │
                    │  [R1, R2, R3]     │
                    └──────┬───────────┘
              ┌────────────┼────────────┐
              ▼            ▼            ▼
       ┌──────────┐ ┌──────────┐ ┌──────────┐
       │ Node A   │ │ Node B   │ │ Node C   │
       │ [R4,R5]  │ │ [R6,R7]  │ │ [R8,R9]  │
       └────┬─────┘ └────┬─────┘ └────┬─────┘
        ┌───┴───┐    ┌───┴───┐    ┌───┴───┐
        ▼       ▼    ▼       ▼    ▼       ▼
      ┌───┐  ┌───┐ ┌───┐  ┌───┐ ┌───┐  ┌───┐
      │ E1│  │ E2│ │ E3│  │ E4│ │ E5│  │ E6│
      └───┘  └───┘ └───┘  └───┘ └───┘  └───┘
      
E = Entry (几何对象)
R = Bounding Rectangle (边界矩形)

索引工作流程

查询: ST_Intersects(geom, target)
  │
  ▼
1. 从 Root Node 开始
  │
  ▼
2. 检查每个子节点的边界矩形是否与 target 的边界矩形相交
  │ (使用 && 操作符,非常快)
  │
  ▼
3. 相交的子节点 → 递归进入
   不相交的子节点 → 跳过(剪枝)
  │
  ▼
4. 到达叶子节点 → 获取候选记录
  │
  ▼
5. 对候选记录执行精确的 ST_Intersects 计算
  │
  ▼
6. 返回满足条件的结果

6.3 创建和管理空间索引

基本创建

-- 标准 GiST 索引
CREATE INDEX idx_table_geom ON table_name USING GIST (geom);

-- 并发创建(不锁表)
CREATE INDEX CONCURRENTLY idx_table_geom ON table_name USING GIST (geom);

-- 查看索引大小
SELECT pg_size_pretty(pg_relation_size('idx_table_geom')) AS index_size;

-- 查看索引使用情况
SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE indexrelname = 'idx_table_geom';

多列索引

-- 含过滤条件的索引(部分索引)
CREATE INDEX idx_active_stores_geom
ON stores USING GIST (geom)
WHERE status = 'active';

-- 复合索引(先过滤非空间条件)
CREATE INDEX idx_stores_type_geom
ON stores (store_type) INCLUDE (geom);
-- 注意:PostgreSQL 不支持 GiST 复合索引与 B-tree 混合
-- 通常的做法是先用 B-tree 过滤,再用 GiST 过滤空间

6.4 SP-GiST 索引

SP-GiST (Space-Partitioned GiST) 使用空间分区树(如四叉树、KD 树),适合高维非均匀分布的数据。

-- 创建 SP-GiST 索引
CREATE INDEX idx_points_spgist ON test_points USING SPGIST (geom);

-- 适用场景对比
-- GiST: 通用,支持所有空间操作
-- SP-GiST: 大量随机分布的点数据,KNN 查询更快

GiST vs SP-GiST

特性GiSTSP-GiST
数据结构R-Tree四叉树/KD树
支持的类型所有几何类型主要是 Point
插入性能良好优秀
查询性能优秀优秀(点查询)
索引大小中等中等偏小
并发创建支持支持

6.5 BRIN 索引

BRIN (Block Range Index) 适合物理有序的大表,索引体积极小。

-- 创建 BRIN 索引
CREATE INDEX idx_events_brin ON events USING BRIN (geom);

-- BRIN 索引大小对比
SELECT
    pg_size_pretty(pg_relation_size('idx_events_gist')) AS gist_size,
    pg_size_pretty(pg_relation_size('idx_events_brin')) AS brin_size;
-- 典型结果: GIST 80MB, BRIN 256KB

BRIN 适用场景

条件说明
数据按空间顺序插入如按时间排序的 GPS 轨迹
表非常大数十亿行
查询范围较大不适合精确的点查询
存储空间有限BRIN 索引比 GiST 小 100-1000 倍
-- 调整 BRIN 索引的块范围大小
CREATE INDEX idx_events_brin ON events USING BRIN (geom)
WITH (pages_per_range = 32);  -- 默认 128,更小的值更精确但索引更大

6.6 索引操作符

PostGIS 定义了多个索引操作符,理解它们对编写高效查询至关重要。

边界框操作符

操作符说明索引支持
&&边界框相交✅ GiST
@被包含于✅ GiST
~包含✅ GiST
~=边界框相等✅ GiST
&&&3D 边界框相交✅ GiST

距离操作符

操作符说明索引支持
<->2D 距离(用于 KNN 排序)✅ GiST
<#>覆盖距离✅ GiST
-- && 操作符:边界框相交过滤
SELECT name FROM districts
WHERE geom && ST_GeomFromText('POLYGON((116 39, 117 39, 117 40, 116 40, 116 39))', 4326);

-- @ 操作符:被包含于
SELECT name FROM pois
WHERE geom @ ST_GeomFromText('POLYGON((116 39, 117 39, 117 40, 116 40, 116 39))', 4326);

-- <-> 操作符:KNN 最近邻
SELECT name FROM stores
ORDER BY geom <-> ST_SetSRID(ST_MakePoint(116.4074, 39.9042), 4326)
LIMIT 5;

6.7 查询计划分析

EXPLAIN ANALYZE 解读

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT name FROM stores
WHERE ST_DWithin(
    geom::geography,
    ST_SetSRID(ST_MakePoint(116.4074, 39.9042), 4326)::geography,
    3000
);

关键指标

Index Scan using idx_stores_geom on stores
  Index Cond: (geom && st_expand(...))    ← 索引过滤
  Filter: st_dwithin(geom::geography, ...) ← 精确过滤
  Rows Removed by Filter: 12               ← 被精确过滤移除的行数
  Buffers: shared hit=15                    ← 缓冲区命中
  Planning Time: 0.150 ms
  Execution Time: 0.890 ms
指标含义优化目标
Index Scan vs Seq Scan是否使用索引必须是 Index Scan
Rows Removed by Filter索引过滤后被精确过滤移除的行越少越好
Buffers shared hit缓存命中越高越好
Execution Time执行时间越低越好

常见问题排查

-- 问题 1: 查询没有使用索引
-- 原因: 使用了 Geography 类型但索引是 Geometry
-- 解决: 确保索引类型与查询类型一致

-- 检查索引类型
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'stores';

-- 问题 2: 索引选择性差
-- 原因: 查询范围太大,优化器选择全表扫描
-- 解决: 缩小查询范围,或强制使用索引

-- 强制使用索引(谨慎使用)
SET enable_seqscan = off;
EXPLAIN ANALYZE SELECT ...;
RESET enable_seqscan;

-- 问题 3: 统计信息过时
-- 解决: 运行 ANALYZE
ANALYZE stores;

6.8 索引维护

重建索引

-- 索引膨胀时需要重建
REINDEX INDEX CONCURRENTLY idx_stores_geom;

-- 查看索引膨胀率
SELECT
    schemaname, tablename, indexname,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
    pg_size_pretty(pg_total_relation_size(indexrelid)) AS total_size
FROM pg_stat_user_indexes
JOIN pg_index USING (indexrelid)
WHERE idx_scan > 0;

VACUUM 与空间索引

-- 大量 DELETE/UPDATE 后需要 VACUUM
VACUUM (VERBOSE, ANALYZE) stores;

-- 设置自动 VACUUM 参数
ALTER TABLE stores SET (
    autovacuum_vacuum_scale_factor = 0.05,
    autovacuum_analyze_scale_factor = 0.02
);

6.9 高级索引策略

策略 1:分层索引

-- 对于多级行政区划,按级别创建部分索引
CREATE INDEX idx_districts_province ON districts USING GIST (geom)
WHERE level = '省';

CREATE INDEX idx_districts_city ON districts USING GIST (geom)
WHERE level = '市';

CREATE INDEX idx_districts_county ON districts USING GIST (geom)
WHERE level = '区县';

策略 2:覆盖索引

-- 使用 INCLUDE 子句避免回表
CREATE INDEX idx_stores_covering ON stores USING GIST (geom)
INCLUDE (name, address, store_type);
-- 注意: GiST 的 INCLUDE 支持在 PostgreSQL 15+ 中可用

策略 3:双向索引

-- 当表 A 和表 B 之间有频繁的双向空间查询时
-- 在两个表上都创建索引
CREATE INDEX idx_a_geom ON table_a USING GIST (geom);
CREATE INDEX idx_b_geom ON table_b USING GIST (geom);

-- JOIN 查询会自动选择较小表做驱动表
SELECT a.name, b.name
FROM table_a a
JOIN table_b b ON ST_Intersects(a.geom, b.geom);

6.10 性能基准测试

测试脚本

-- 创建测试数据
DROP TABLE IF EXISTS bench_points;
CREATE TABLE bench_points AS
SELECT
    id,
    ST_SetSRID(ST_MakePoint(
        100 + random() * 30,   -- 经度 100-130
        20 + random() * 30     -- 纬度 20-50
    ), 4326) AS geom
FROM generate_series(1, 1000000) AS id;

-- 测试 1: 无索引
DROP INDEX IF EXISTS idx_bench_geom;
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM bench_points
WHERE geom && ST_GeomFromText('POLYGON((116 39, 117 39, 117 40, 116 40, 116 39))', 4326);

-- 测试 2: GiST 索引
CREATE INDEX idx_bench_geom ON bench_points USING GIST (geom);
ANALYZE bench_points;
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM bench_points
WHERE geom && ST_GeomFromText('POLYGON((116 39, 117 39, 117 40, 116 40, 116 39))', 4326);

-- 测试 3: SP-GiST 索引
DROP INDEX idx_bench_geom;
CREATE INDEX idx_bench_spgist ON bench_points USING SPGIST (geom);
ANALYZE bench_points;
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM bench_points
WHERE geom && ST_GeomFromText('POLYGON((116 39, 117 39, 117 40, 116 40, 116 39))', 4326);

预期结果(100 万点)

场景无索引GiSTSP-GiSTBRIN
范围查询 (1° × 1°)~500ms~5ms~6ms~50ms
KNN 最近 5 个~500ms~1ms~1ms~500ms
包含判断~500ms~3ms~4ms~50ms
索引大小N/A~32MB~28MB~128KB

6.11 空间索引与并行查询

PostgreSQL 支持并行查询,空间索引扫描也可以并行化:

-- 查看并行设置
SHOW max_parallel_workers_per_gather;
SHOW parallel_tuple_cost;
SHOW parallel_setup_cost;

-- 启用并行查询(对于大表)
SET max_parallel_workers_per_gather = 4;

-- 查看查询计划是否使用并行
EXPLAIN ANALYZE
SELECT count(*) FROM bench_points
WHERE geom && ST_GeomFromText('POLYGON((116 39, 117 39, 117 40, 116 40, 116 39))', 4326);

6.12 本章小结

要点说明
GiST (R-Tree)通用空间索引,首选方案
SP-GiST大量随机点数据的替代方案
BRIN物理有序大表的轻量方案
<-> 操作符KNN 最近邻查询的索引加速
&& 操作符边界框过滤,利用索引
EXPLAIN ANALYZE必须用它验证索引是否生效

扩展阅读