GiST 索引原理
GIN 解决的是「一个列里存多个值」。但还有一类问题它回答不了:「这两个区间有没有重叠」「哪个门店离我最近」「这个多边形内部有没有点」。这类问题里的值不是一个可枚举的集合,而是一个占据空间、可以互相重叠的区域。
GiST(Generalized Search Tree,通用搜索树)就是为这类「区域型」数据准备的索引框架。它和 GIN 的关系有点像「R 树(空间索引)与倒排索引」的关系:GIN 把值拆成键,GiST 把值抽象成一个可以包住它的边界框。本节讲清 GiST 为什么是框架而不是索引、有损索引与回表复检的机制、KNN 这个独有能力、操作符类总览与 btree_gist 的重要性,最后给出 GiST 与 GIN 的选型对照。
前置知识
阅读本节前,建议先了解:fastupdate 与 gin_pending_list_limit
基础概念
GiST 是「框架」,不是「索引」
B-Tree、哈希、GIN 都是具体的索引实现:它们已经知道自己要索引什么、怎么比较、怎么分裂。GiST 不同,它只提供树的结构(平衡、页分裂、并发控制、WAL 日志、搜索流程),至于「两个键怎么算重叠」「怎么选分裂点」「怎么把值压成边界框」,全部交给操作符类去实现。
B-Tree / GIN GiST
┌──────────────────────┐ ┌──────────────────────┐
│ 结构 + 语义 全都在内核里 │ │ 结构(平衡树、并发、WAL)│ ← GiST 提供
└──────────────────────┘ ├──────────────────────┤
│ 语义(比较、合并、分裂) │ ← 操作符类提供
└──────────────────────┘这个设计带来一个直接结果:你可以用 SQL 定义自己的 GiST 操作符类,让别人写的 GiST 支持你的数据类型。PostGIS 的几何索引就是基于 GiST 实现的。
官方文档的说法
"GiST stands for Generalized Search Tree. It is a balanced, tree-structured access method, that acts as a base template in which to implement arbitrary indexing schemes." —— 它是模板,B-Tree、R 树都能在它之上实现。
GiST 与 B-Tree 的根本区别:没有全序
B-Tree 的前提是「任意两个键都能比较大小」,因此它能维护一个全局有序的键序列,支持 <、=、> 以及范围扫描。
GiST 放弃了「全序」这个前提。它只要求操作符类实现一个「合并」操作(union)——把一组键抽象成一个能包含它们的父键。树的每个内部节点存的是「子树里所有键的合并结果」,而不是「某个中间值」。
B-Tree(全序,内部节点是分割点) GiST(无全序,内部节点是包围盒)
[ 50 ] ┌───────────────────┐
/ \ │ 包围整个子树的区域 │
[ 20 ] [ 80 ] └───────────────────┘
/ \ / \ / \
[10] [30] [70] [90] ┌────────┐ ┌────────┐
│ 区域 A │ │ 区域 B │
└────────┘ └────────┘
查询 x > 60: 查询「与区域 Q 重叠的项」:
沿路径判断即可,边界清晰 只需访问与 Q 有交集的子树,可能多个R 树式索引的核心价值就在最后那一步:如果某个子树的外包框与查询区域没有交集,整棵子树都可以跳过。这是「空间剪枝」。
支持三类 B-Tree 做不到的谓词
| 谓词类别 | 操作符 | 含义 | 典型场景 |
|---|---|---|---|
| 重叠 | && | 两个区域有交集 | 时间段冲突检测 |
| 包含 | @> <@ | 一个区域包含 / 被包含另一个 | 点在多边形内 |
| 相对位置 | << >> &< &> |&> <<| | 严格在左 / 右 / 不延伸至右 … | 空间排序 |
| 相等(区域意义) | ~= | 同一个区域 | 几何去重 |
| 最近邻(排序) | <-> | 距离,用于 ORDER BY | 附近的门店、相似向量 |
&& 与 @> 的语义随类型变化
同样是 @>,在数组上表示「包含这些元素」,在范围类型上表示「包含这个点 / 这个区间」,在几何类型上表示「几何包含」。它支持哪些操作符完全由操作符类决定,EXPLAIN 里的 Index Cond 是唯一的验证方式。
有损索引与回表复检
这是 GiST 最需要理解的一点,也是「GiST 索引建了但查询还是慢」的常见原因。
GiST 内部节点存的是抽象后的键:点会被存成边界框,多边形会被存成外接矩形,长文本会被存成签名(signature)。抽象一定是有损的——一个边界框里可能装着很多根本不在查询区域内的实际值。
查询某个小圆区域 Q:
┌──────────────────────────────────┐
│ 内部节点的边界框 │
│ ┌─────┐ │
│ │ Q │ ← 查询区域 │
│ └─────┘ │
│ • • │
│ • • ← 这些点框在 │
│ • • 边界框内, │
│ • 但不一定在 Q 内 │
└──────────────────────────────────┘
索引只能回答「可能在」,不能回答「一定在」
→ 必须回表(或对索引项本身)复检真实值这个「可能命中」在 EXPLAIN 里表现为两个标志:
| 计划节点 | 含义 |
|---|---|
Recheck Cond: (...) | 索引只给出候选,条件需要重新求值 |
Rows Removed by Index Recheck: N | 复检后被剔除的假阳性行数 |
QUERY PLAN
-------------------------------------------------------------
Index Scan using idx_events_period on events
Index Cond: (during && '[...)'::tstzrange)
Rows Removed by Index Recheck: 1243 ← 假阳性
(3 rows)有损不等于劣质
有损+复检是 GiST 用空间换正确性的方式。Rows Removed by Index Recheck 只要不是「与结果集同数量级」,索引就是有效的。真正需要警觉的是它远大于 actual rows——说明索引的选择性几乎为零,此时应该改用更精确的索引(比如 GIN)或调整查询条件。
KNN:GiST 独有的杀手特性
GiST 的 distance 接口让它可以做有序扫描(ordered scan)。这意味着数据库不需要「先取全部候选、再算距离、再排序」,而是沿着距离递增的顺序返回行,取够 LIMIT 就停。
无 KNN(普通索引 / 全表):
扫描全部行 → 每行算距离 → 排序 → 取前 10
代价 ∝ 表大小
有 KNN(GiST + ORDER BY <->):
按距离从近到远遍历树 → 取到第 10 行即停
代价 ∝ 需要的行数使用方式很直白:
SELECT id, name
FROM stores
ORDER BY location <-> point '(116.4, 39.9)'
LIMIT 10;ORDER BY 里的 <-> 会被识别成「按距离排序」,配合 GiST 变成有序索引扫描。GIN 不支持这个能力——它是倒排结构,没有「距离」的概念。
操作符类总览
| 操作符类 | 适用类型 | 索引操作符 | 支持 KNN |
|---|---|---|---|
box_ops | box | && @> <@ << >> &< &> ~= | ✅ <-> |
point_ops | point | << >> <@ ~= | ✅ <-> |
poly_ops | polygon | && @> <@ << >> &< &> ~= | ✅ <-> |
circle_ops | circle | && @> <@ << >> &< &> ~= | ✅ <-> |
range_ops | 任意范围类型 | && @> <@ << >> &< &> -|- = | ❌ |
multirange_ops | 任意多范围类型 | 同上(多范围版本) | ❌ |
inet_ops | inet / cidr | << <<= >> >>= = <> < <= > >= && | ❌ |
tsvector_ops | tsvector | @@ | ❌ |
trgm_ops | text 等 | LIKE ILIKE ~ % | ❌ |
btree_gist | 各种标量类型 | B-Tree 全部操作符(= < > 等) | ❌ |
cube_ops | cube | && @> <@ << >> … | ✅ |
ltree_ops | ltree | @> <@ ~ ? | ❌ |
inet_ops 不是默认操作符类
出于历史原因,inet / cidr 类型的默认 GiST 操作符类不是 inet_ops。要用必须显式指定:
CREATE INDEX ON my_table USING gist (my_inet_column inet_ops);btree_gist 扩展:被低估的关键组件
btree_gist 是一个 contrib 扩展,它给 GiST 加上了「B-Tree 语义」——让 integer、text、timestamp、uuid 这类标量类型也能用 GiST 索引,并支持 =、<、>、<=、>=、<>。
安装:
CREATE EXTENSION IF NOT EXISTS btree_gist;CREATE EXTENSION它有两个不可替代的用途。
用途一:把标量列和范围列放进同一个 GiST 索引。
GiST 支持多列索引(这是它相对 GIN 的优势之一),但前提是每一列都有 GiST 操作符类。范围列天然有(range_ops),而 room_id integer 这种标量列默认没有——btree_gist 补上了这一环:
-- 按会议室 + 时间段索引,一条索引同时过滤两个条件
CREATE INDEX idx_bookings_room_period
ON bookings USING gist (room_id, during);用途二:EXCLUDE 排他约束的必要前提。
EXCLUDE 约束要求「用索引来表达不允许的组合」,而用于排他约束的索引必须是 GiST(不支持 GIN)。要让「同一会议室的时间段不能重叠」这条业务规则由数据库强制保证,就必须同时用上 btree_gist(管 room_id 的等值)和 range_ops(管 during 的重叠):
ALTER TABLE bookings
ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&);ALTER TABLE没有 btree_gist 会直接报错
ALTER TABLE bookings
ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&);ERROR: data type integer has no default operator class for access method "gist"
HINT: You must specify an operator class for the index or define a default operator class for the data type.写入代价
GiST 的写入比 B-Tree 贵,原因也是「没有全序」:
| 步骤 | 做什么 | 代价 |
|---|---|---|
penalty | 决定新键插入哪个子树(选「扩大后体积最小」的那个) | 每个候选子树都要算 |
picksplit | 页满时决定怎么把键分成两半 | 组合优化,开销不小 |
union | 更新路径上所有父节点的包围盒 | 可能波及多个祖先 |
compress | 索引项压缩(若操作符类提供) | 与值的复杂度相关 |
相比之下 B-Tree 的「找到位置、插入、必要时分裂」非常直白。所以 GiST 适合读多写少、且范围查询能真正受益的场景。写入极密集的表要谨慎评估。
GiST 的并发与维护
- 支持
CREATE INDEX CONCURRENTLY与REINDEX INDEX CONCURRENTLY。 - 支持
fastupdate?不支持——那是 GIN 的存储参数。GiST 没有 pending list,写入是同步的。 - GiST 也会因为更新/删除产生膨胀,需要
VACUUM;严重时REINDEX INDEX CONCURRENTLY。
GiST 与 GIN 选型对照
这是本系列最实用的一张表。两者都支持 &&、@> 这类操作符,但在实现路线上完全相反:
| 维度 | GiST | GIN |
|---|---|---|
| 结构 | 平衡树 + 包围盒 | 倒排表 + 行号列表 |
| 查询精度 | 有损,需要回表复检 | 无损,直接给出精确行号 |
| 查询速度 | 中等(多扫一些假阳性) | 快(尤其多键同时命中) |
| 索引体积 | 小(只存抽象后的键) | 大(存所有键的倒排) |
| 写入速度 | 快(只更新包围盒路径) | 慢(要更新多个键的倒排列表) |
| 更新代价 | 低 | 高(fastupdate 缓解但仍有 pending list) |
| KNN 最近邻 | 支持(ORDER BY <->) | 不支持 |
| 多列索引 | 支持 | 不支持 |
EXCLUDE 约束 | 支持 | 不支持 |
| 典型场景 | 空间数据、范围重叠、KNN、排他约束 | 全文检索、JSONB / 数组、模糊匹配 |
一句话选型规则:
- 需要 KNN 排序 / 多列组合 /
EXCLUDE约束 → 只能用 GiST。 - 需要 精确、高频、复杂的多条件匹配(全文、JSONB) → 用 GIN。
- 两者都支持且只是简单的
&&/@>:写多读少选 GiST,读多写少选 GIN。
经验值
范围重叠查询(时间段、数值区间)上,GiST 索引体积通常只有 GIN 的 1/3 到 1/2,写入快好几倍,而查询因为复检可能慢 2~5 倍。如果查询 QPS 不高但写入量大,GiST 几乎总是更优的选择。
该用 GiST 的典型场景
| 场景 | 索引 | 关键操作符 |
|---|---|---|
| 会议室 / 车辆 / 房间预订冲突 | gist (room_id, during) | && |
| 「附近的门店 / 用户」排序 | gist (location) | <-> + LIMIT |
| 地理围栏(点是否在某区域) | gist (area) | <@ |
| 价格 / 时间区间重叠统计 | gist (price_range) | && |
| 排除约束(同一资源不重叠) | gist (resource_id, during) | EXCLUDE ... WITH =, && |
| IP 网段包含查询 | gist (network inet_ops) | << >> |
| 梯形 / 层级标签路径 | gist (path) | @> <@ |
语法与用法
-- 最简形式
CREATE INDEX 索引名 ON 表名 USING gist (列名);
-- 显式指定操作符类(inet、trgm 等必须这样写)
CREATE INDEX 索引名 ON 表名 USING gist (列名 操作符类名);
-- 多列(GiST 支持,GIN 不支持)
CREATE INDEX 索引名 ON 表名 USING gist (列1, 列2);
-- 表达式索引
CREATE INDEX 索引名 ON 表名 USING gist (lower(列名) gist_trgm_ops);
-- 部分索引
CREATE INDEX 索引名 ON 表名 USING gist (列名) WHERE 条件;
-- 在线建索引(不阻塞读写)
CREATE INDEX CONCURRENTLY 索引名 ON 表名 USING gist (列名);
-- KNN 查询写法
SELECT ... ORDER BY 列名 <-> 查询值 LIMIT N;
-- 排他约束(必须是 GiST)
ALTER TABLE 表名 ADD CONSTRAINT 约束名
EXCLUDE USING gist (列1 WITH =, 列2 WITH &&);最小可运行示例:
CREATE TABLE demo_gist (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
during tsrange NOT NULL
);
CREATE INDEX idx_demo_gist_during ON demo_gist USING gist (during);CREATE TABLE
CREATE INDEX详细说明
观察有损索引的复检行为
下面这个例子能直接在 EXPLAIN 里看到 Rows Removed by Index Recheck。
DROP TABLE IF EXISTS gist_demo;
CREATE TABLE gist_demo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
during tsrange NOT NULL,
label text NOT NULL
);
-- 构造大量互相重叠的区间
INSERT INTO gist_demo (during, label)
SELECT
tsrange(
timestamp '2026-01-01' + ((g % 10000) || ' minutes')::interval,
timestamp '2026-01-01' + ((g % 10000 + 120) || ' minutes')::interval
),
'row-' || g
FROM generate_series(1, 200000) AS g;
CREATE INDEX idx_gist_demo_during ON gist_demo USING gist (during);
ANALYZE gist_demo;DROP TABLE
CREATE TABLE
INSERT 0 200000
CREATE INDEX
ANALYZE查询一个很窄的区间:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM gist_demo
WHERE during && tsrange(timestamp '2026-01-01 05:00', timestamp '2026-01-01 05:05'); QUERY PLAN
---------------------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=512
-> Bitmap Heap Scan on gist_demo (actual rows=3 loops=1)
Recheck Cond: (during && '["2026-01-01 05:00:00","2026-01-01 05:05:00")'::tsrange)
Rows Removed by Index Recheck: 11
Heap Blocks: exact=14
Buffers: shared hit=470
-> Bitmap Index Scan on idx_gist_demo_during (actual rows=14 loops=1)
Index Cond: (during && '["2026-01-01 05:00:00","2026-01-01 05:05:00")'::tsrange)
Buffers: shared hit=42
(9 rows)三个数字连起来读:
Bitmap Index Scan actual rows = 14 ← 索引给出的候选(有损)
Rows Removed by Index Recheck = 11 ← 复检剔除的假阳性
Bitmap Heap Scan actual rows = 3 ← 真正命中的行
14 - 11 = 3 ✅ 对得上这 11 行假阳性的来源就是范围类型的内部表示:GiST 对 tsrange 会把它归约成「下界 → 上界」的边界框,长度/包含关系被抽象掉了,所以相邻但不重叠的区间也会被判定为「可能重叠」。
复检不是 bug
Recheck Cond 是 GiST 的固有机制,不是配置错误。只有当 Rows Removed by Index Recheck 远大于 actual rows(例如 1 万 vs 10)时,才说明有损程度过高,索引收益被复检吃掉了。
用 enable_seqscan 验证索引的真实价值
小表上优化器经常忽略索引。要确认 GiST 索引确实有用,可以临时关掉全表扫描对比:
SET enable_seqscan = off;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM gist_demo
WHERE during && tsrange(timestamp '2026-01-01 05:00', timestamp '2026-01-01 05:05');
RESET enable_seqscan;SET
QUERY PLAN
---------------------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=512
-> Bitmap Heap Scan on gist_demo (actual rows=3 loops=1)
Recheck Cond: (during && '["2026-01-01 05:00:00","2026-01-01 05:05:00")'::tsrange)
Rows Removed by Index Recheck: 11
Heap Blocks: exact=14
Buffers: shared hit=470
-> Bitmap Index Scan on idx_gist_demo_during (actual rows=14 loops=1)
Index Cond: (during && '["2026-01-01 05:00:00","2026-01-01 05:05:00")'::tsrange)
Buffers: shared hit=42
(9 rows)
RESETBuffers: shared hit=512 对 20 万行的表来说非常低(表本身远超 512 页),说明空间剪枝生效了。
btree_gist 的安装与效果
CREATE EXTENSION IF NOT EXISTS btree_gist;
SELECT extname, extversion FROM pg_extension WHERE extname = 'btree_gist';CREATE EXTENSION
extname | extversion
------------+------------
btree_gist | 1.8
(1 row)装上之后,标量列就能参与 GiST 索引:
CREATE TABLE gist_multi (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id integer NOT NULL,
during tsrange NOT NULL
);
CREATE INDEX idx_gist_multi ON gist_multi USING gist (room_id, during);
ANALYZE gist_multi;CREATE TABLE
CREATE INDEX
ANALYZEEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id FROM gist_multi
WHERE room_id = 7
AND during && tsrange(timestamp '2026-03-01 09:00', timestamp '2026-03-01 10:00'); QUERY PLAN
------------------------------------------------------------------------------------
Bitmap Heap Scan on gist_multi (actual rows=0 loops=1)
Recheck Cond: ((room_id = 7) AND (during && '["2026-03-01 09:00:00","2026-03-01 10:00:00")'::tsrange))
-> Bitmap Index Scan on idx_gist_multi (actual rows=0 loops=1)
Index Cond: ((room_id = 7) AND (during && '["2026-03-01 09:00:00","2026-03-01 10:00:00")'::tsrange))
(4 rows)注意 Index Cond 里同时出现了两个条件——这是 GiST 多列索引与 btree_gist 组合的直接证据。若用 GIN,这两列必须建两个索引再靠 BitmapAnd 合并。
EXCLUDE 排他约束的完整示例
DROP TABLE IF EXISTS bookings_ex;
CREATE TABLE bookings_ex (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id integer NOT NULL,
during tsrange NOT NULL,
EXCLUDE USING gist (room_id WITH =, during WITH &&)
);DROP TABLE
CREATE TABLEINSERT INTO bookings_ex (room_id, during)
VALUES (1, tsrange(timestamp '2026-04-01 09:00', timestamp '2026-04-01 11:00'));INSERT 0 1插入一个与同一会议室重叠的区间:
INSERT INTO bookings_ex (room_id, during)
VALUES (1, tsrange(timestamp '2026-04-01 10:00', timestamp '2026-04-01 12:00'));ERROR: conflicting key value violates exclusion constraint "bookings_ex_room_id_during_excl"
DETAIL: Key (room_id, during)=(1, ["2026-04-01 10:00:00","2026-04-01 12:00:00")) conflicts with existing key (room_id, during)=(1, ["2026-04-01 09:00:00","2026-04-01 11:00")).换个会议室或换个不重叠的时间段就通过:
INSERT INTO bookings_ex (room_id, during)
VALUES (2, tsrange(timestamp '2026-04-01 10:00', timestamp '2026-04-01 12:00')),
(1, tsrange(timestamp '2026-04-01 11:00', timestamp '2026-04-01 13:00'));INSERT 0 2注意第二个值是「首尾相接」(11:00 起,前一个到 11:00 止)。tsrange 默认是左闭右开 [),所以两者不算重叠,可以插入。如果希望相接也算冲突,必须用 [2026-04-01 09:00, 2026-04-01 11:00] 这样的闭区间写法。
排他约束只能靠 GiST
用 GIN 建 EXCLUDE 约束会直接报错:
ALTER TABLE bookings_ex
ADD CONSTRAINT no_overlap_gin
EXCLUDE USING gin (room_id WITH =, during WITH &&);ERROR: access method "gin" does not support exclusion constraintsKNN 能力的最小验证
KNN 需要 GiST 与 ORDER BY <-> 配合。用一个点类型的小表快速验证:
DROP TABLE IF EXISTS knn_demo;
CREATE TABLE knn_demo (
id integer PRIMARY KEY,
name text NOT NULL,
location point NOT NULL
);
INSERT INTO knn_demo (id, name, location)
SELECT g, 'p-' || g, point((g % 100)::float8, (g / 100)::float8)
FROM generate_series(1, 10000) AS g;
CREATE INDEX idx_knn_demo_loc ON knn_demo USING gist (location);
ANALYZE knn_demo;DROP TABLE
CREATE TABLE
INSERT 0 10000
CREATE INDEX
ANALYZEEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, name FROM knn_demo
ORDER BY location <-> point '(50, 50)'
LIMIT 5; QUERY PLAN
-----------------------------------------------------------------------------------------
Limit (actual rows=5 loops=1)
Buffers: shared hit=12
-> Index Scan using idx_knn_demo_loc on knn_demo (actual rows=5 loops=1)
Order By: (location <-> '(50,50)'::point)
Buffers: shared hit=12
(5 rows)关键证据是 Index Scan ... Order By: (location <-> ...) 以及 actual rows=5——只读了 5 行,而不是把 10000 行全部算一遍距离再排序。KNN 的详细实战见 GiST 与最近邻搜索。
GiST 的膨胀与维护
GiST 在大量 UPDATE / DELETE 后同样会膨胀,且没有 pending list 这种「收尾合并」机制,只能靠 VACUUM 清理 dead entries:
SELECT
c.relname AS index_name,
pg_size_pretty(pg_relation_size(c.oid)) AS index_size,
idx_scan
FROM pg_class c
JOIN pg_stat_user_indexes s ON s.indexrelid = c.oid
WHERE c.relname = 'idx_gist_demo_during'; index_name | index_size | idx_scan
-------------------------+------------+----------
idx_gist_demo_during | 4864 kB | 3
(1 row)膨胀严重时重建:
REINDEX INDEX CONCURRENTLY idx_gist_demo_during;REINDEX重建后同样要校验有效性
SELECT indexrelid::regclass AS invalid_index
FROM pg_index
WHERE NOT indisvalid; invalid_index
---------------
(0 rows)GiST 的存储参数
GiST 的存储参数比 GIN 少得多,常用的只有两个:
| 参数 | 默认 | 作用 |
|---|---|---|
fillfactor | 90 | 页内预留空间,降低后续更新引起的页分裂 |
buffering | off | 离线建索引时用「缓冲构建」加速,代价是建完需要额外一遍清理 |
-- 更新极频繁的 GiST 索引降低 fillfactor
ALTER INDEX idx_gist_multi SET (fillfactor = 70);
-- 大表离线建索引时开启 buffering
CREATE INDEX idx_gist_big ON gist_multi USING gist (room_id, during) WITH (buffering = on);ALTER INDEX
CREATE INDEX注意
buffering 只在非并发建索引时有效,CREATE INDEX CONCURRENTLY 中会被忽略。且它会延长建索引时间(多一轮外排序),只在表远大于内存时才有收益。
实战示例
用「门店 + 地理围栏 + 附近搜索」的完整流程串一遍 GiST 的主要能力。
第 1 步:准备扩展与表
CREATE EXTENSION IF NOT EXISTS btree_gist;
DROP TABLE IF EXISTS stores;
CREATE TABLE stores (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
brand text NOT NULL,
city text NOT NULL,
location point NOT NULL,
open_hours tsrange NOT NULL,
service_area box NOT NULL
);CREATE EXTENSION
DROP TABLE
CREATE TABLE第 2 步:灌入数据
INSERT INTO stores (brand, city, location, open_hours, service_area)
SELECT
'brand-' || (g % 20),
'city-' || (g % 8),
point((g % 1000)::float8 / 10, (g / 1000)::float8 / 10),
tsrange(timestamp '2026-01-01 08:00', timestamp '2026-01-01 22:00'),
box(
point((g % 1000)::float8 / 10 - 0.5, (g / 1000)::float8 / 10 - 0.5),
point((g % 1000)::float8 / 10 + 0.5, (g / 1000)::float8 / 10 + 0.5)
)
FROM generate_series(1, 50000) AS g;INSERT 0 50000第 3 步:建立三种 GiST 索引
-- KNN 用:单个点列
CREATE INDEX idx_stores_location ON stores USING gist (location);
-- 多列组合:品牌等值 + 服务区域重叠
CREATE INDEX idx_stores_brand_area ON stores USING gist (brand, service_area);
-- 营业时间重叠
CREATE INDEX idx_stores_hours ON stores USING gist (open_hours);
ANALYZE stores;CREATE INDEX
CREATE INDEX
CREATE INDEX
ANALYZE第 4 步:KNN —— 找离用户最近的 5 家门店
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, brand, city
FROM stores
ORDER BY location <-> point '(50, 20)'
LIMIT 5; QUERY PLAN
----------------------------------------------------------------------------------------
Limit (actual rows=5 loops=1)
Buffers: shared hit=13
-> Index Scan using idx_stores_location on stores (actual rows=5 loops=1)
Order By: (location <-> '(50,20)'::point)
Buffers: shared hit=13
(5 rows)第 5 步:多列 GiST —— 品牌 + 服务区域包含某点
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, location
FROM stores
WHERE brand = 'brand-7'
AND service_area @> point '(50, 20)'; QUERY PLAN
-------------------------------------------------------------------------------------------
Bitmap Heap Scan on stores (actual rows=3 loops=1)
Recheck Cond: ((brand = 'brand-7'::text) AND (service_area @> '(50,20)'::point))
Rows Removed by Index Recheck: 1
Heap Blocks: exact=4
Buffers: shared hit=28
-> Bitmap Index Scan on idx_stores_brand_area (actual rows=4 loops=1)
Index Cond: ((brand = 'brand-7'::text) AND (service_area @> '(50,20)'::point))
Buffers: shared hit=24
(8 rows)第 6 步:营业时间重叠查询
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM stores
WHERE open_hours && tsrange(timestamp '2026-01-01 21:00', timestamp '2026-01-01 23:00'); QUERY PLAN
------------------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=1183
-> Bitmap Heap Scan on stores (actual rows=50000 loops=1)
Recheck Cond: (open_hours && '["2026-01-01 21:00:00","2026-01-01 23:00:00")'::tsrange)
Rows Removed by Index Recheck: 0
Heap Blocks: exact=1129
Buffers: shared hit=1169
-> Bitmap Index Scan on idx_stores_hours (actual rows=50000 loops=1)
Index Cond: (open_hours && '["2026-01-01 21:00:00","2026-01-01 23:00:00")'::tsrange)
Buffers: shared hit=32
(9 rows)这个例子里所有门店营业时间完全相同,所以索引给出 5 万个候选、复检掉 0 个——索引在这个查询上毫无选择性。这正好说明了判断标准:actual rows 接近表行数时,索引不解决问题,要靠更精确的过滤条件。
第 7 步:查看索引体积与使用情况
SELECT
indexrelid::regclass AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan
FROM pg_stat_user_indexes
WHERE relname = 'stores'
ORDER BY pg_relation_size(indexrelid) DESC; index_name | index_size | idx_scan
---------------------------+------------+----------
idx_stores_location | 3488 kB | 1
idx_stores_brand_area | 4264 kB | 1
idx_stores_hours | 1640 kB | 1
(3 rows)对比 GIN:同样 5 万行的 point 数据,GiST 索引通常是「表体积的 30%~60%」,而 GIN 常常接近甚至超过表体积。
注意事项
- GiST 是有损索引。
Recheck Cond与Rows Removed by Index Recheck是正常机制,不是错误。 - 只有部分操作符类支持 KNN。
range_ops、inet_ops、btree_gist不支持<->;point_ops、box_ops、poly_ops、circle_ops、cube_ops支持。用错操作符类时ORDER BY <->会退化成排序而不是索引扫描。 ORDER BY 列 <-> 值是 KNN 的唯一触发形式。把它包进函数(如ORDER BY distance(location, p))或写成ORDER BY (location <-> p) DESC之后再加别的排序键,KNN 优化都可能失效——必须直接出现在ORDER BY的第一位。inet_ops必须显式指定,它是inet/cidr的非默认操作符类。- 用
EXCLUDE必须装btree_gist(涉及标量列时),且EXCLUDE只能建立在 GiST 上。 - GiST 支持多列,GIN 不支持。需要「等值 + 范围」组合过滤时,GiST 单索引往往优于 GIN 的双索引
BitmapAnd。 - GiST 没有
fastupdate,写入是同步的。批量导入时不必做「导入后合并」的动作,但也拿不到「追加写」的红利。 fillfactor是 GiST 上最值得调的参数。更新频繁的表降到70能显著减少页分裂。buffering = on与CONCURRENTLY互斥,并发建索引时该参数被忽略。- 表达式索引必须逐字复刻。
CREATE INDEX ... USING gist (lower(name) gist_trgm_ops)只有在查询里写lower(name) LIKE ...时才会命中。
最佳实践
先确认操作符类,再谈优化。用错操作符类的表现是「索引建了但
EXPLAIN里没有Index Cond」:sqlSELECT opcname, amname FROM pg_opclass o JOIN pg_am a ON a.oid = o.opcmethod WHERE amname = 'gist' ORDER BY opcname;textopcname | amname --------------------+-------- box_ops | gist btree_bool_ops | gist btree_date_ops | gist ... point_ops | gist poly_ops | gist range_ops | gist需要 KNN 就别犹豫,用 GiST。GIN 无法替代,B-Tree 只能做单维排序。
需要
EXCLUDE排他约束时必须用 GiST +btree_gist,这是唯一方案。「等值列 + 范围列」优先用一个多列 GiST 索引,而不是建两个索引让优化器
BitmapAnd:sqlCREATE INDEX idx_bookings_room_period ON bookings USING gist (room_id, during);多列顺序有讲究
与 B-Tree 一样,选择性高的列放前面。
gist (room_id, during)与gist (during, room_id)在「先按房间过滤再判重叠」的查询上性能差异明显。定期检查
Rows Removed by Index Recheck与结果集的比例。假阳性远大于真实命中时,考虑换 GIN、加部分索引或收紧查询条件。写入密集的 GiST 索引降低
fillfactor:sqlALTER INDEX idx_bookings_room_period SET (fillfactor = 70);范围类型统一用「左闭右开」
[)语义。默认的tsrange/int4range就是[),能让「首尾相接不算重叠」这条业务规则与数据库语义天然一致;要改成闭区间必须显式写。大表建 GiST 索引时提高
maintenance_work_mem,并考虑buffering:sqlSET maintenance_work_mem = '512MB'; CREATE INDEX CONCURRENTLY idx_stores_location ON stores USING gist (location); RESET maintenance_work_mem;清理无用 GiST 索引。GiST 的写入代价高于 B-Tree,留着不用的索引是纯亏损:
sqlSELECT indexrelid::regclass AS unused_index, pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_stat_user_indexes WHERE relname = 'stores' AND idx_scan = 0 ORDER BY pg_relation_size(indexrelid) DESC;textunused_index | size -----------------+--------- (0 rows)重建后用
indisvalid校验,避免CONCURRENTLY失败留下的无效索引。
下一节
继续学习:GiST 与几何 / 范围类型