第 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
| 特性 | GiST | SP-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 万点)
| 场景 | 无索引 | GiST | SP-GiST | BRIN |
|---|
| 范围查询 (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 | 必须用它验证索引是否生效 |
扩展阅读