B+ 树索引覆盖不了所有查询场景。全文搜索要倒排、地理空间要 R 树、时序大表要块范围摘要,这些都是 B-tree 不擅长的领域。PostgreSQL 不像 MySQL 只提供 B-tree 和有限的全文/空间索引,而是内置了 GiST、GIN、BRIN、SP-GiST 四种额外索引类型,每种针对特定数据特征优化。
本文梳理 PostgreSQL 的五种索引类型及其适用场景,然后展开代价估计器如何基于统计信息选择执行计划,最后落到索引运维:怎么查索引使用率、什么时候该 REINDEX、部分索引和表达式索引能解决什么问题。MVCC 与 VACUUM 的存储层机制是另一条线,见 PostgreSQL MVCC 与 VACUUM。本文也会和 MySQL 索引原理与失效分析 做对比,两者的索引设计取舍差异很明显。
前置知识
了解 B+ 树索引的基本结构与聚簇索引/二级索引概念,参见 MySQL 索引原理与失效分析
了解 PostgreSQL 的堆表组织与 MVCC,参见 PostgreSQL MVCC 与 VACUUM
一、索引类型总览
PostgreSQL 除了标准的 B-tree 索引外,还提供了 GiST、GIN、BRIN、SP-GiST 等高级索引类型,每种针对特定查询场景优化。
| 索引类型 | 全称 | 核心数据结构 | 最佳场景 | 空间开销 |
|---|---|---|---|---|
| B-tree | B-tree | B+ 树 | 等值、范围、排序、前缀匹配 | 中等 |
| GiST | Generalized Search Tree | 可定制树 | 地理空间、范围、最近邻搜索 | 中等 |
| GIN | Generalized Inverted Index | 倒排索引 | 数组、JSONB、全文搜索 | 较大 |
| BRIN | Block Range Index | 块范围摘要 | 有序大表的范围查询 | 极小 |
| SP-GiST | Space-Partitioned GiST | 空间分区树 | 电话号码、路由等非平衡结构 | 中等 |
与 MySQL 的索引对比:MySQL 的 InnoDB 是聚簇索引组织表,主键即数据,二级索引叶子存主键值需要回表。PostgreSQL 是堆表组织,所有索引都是二级索引,叶子节点存的是指向堆表的 CTID(行物理位置),所有索引平等,没有”聚簇索引优先”的概念。这也意味着 PostgreSQL 的索引选择更灵活,可以为同一张表建多种类型的索引应对不同查询。
二、B-tree 索引
PostgreSQL 的默认索引类型,适用于等值查询、范围查询、排序和前缀匹配:
-- 创建 B-tree 索引(默认类型)CREATE INDEX idx_users_name ON users (name);
-- 支持的查询模式SELECT * FROM users WHERE name = 'Alice'; -- 等值SELECT * FROM users WHERE name > 'Alice'; -- 范围SELECT * FROM users WHERE name LIKE 'Ali%'; -- 前缀SELECT * FROM users ORDER BY name; -- 排序SELECT * FROM users WHERE name = 'Alice' AND age > 20; -- 多列B-tree 覆盖了 90% 的索引场景,但在以下情况无能为力:
LIKE '%abc'(后缀匹配,无法用 B-tree 前缀)- 数组包含查询(
@>) - 全文搜索(
@@) - 地理空间范围查询(
ST_DWithin)
这些场景需要其他索引类型。但要注意,PostgreSQL 的 B-tree 有一个 MySQL 没有的能力:它可以配合可见性映射(VM)做 Index-Only Scan。VM 标记”全部元组对所有人可见”的页,索引扫描时可以直接从索引取值,跳过堆表回查。VM 的维护依赖 VACUUM,详见 MVCC 与 VACUUM 篇。
三、GiST 索引
GiST 是一种可扩展的索引框架,它不定义具体的数据结构,而是提供一套通用的树形搜索接口,由操作符类(Operator Class)决定具体行为。PostGIS 的地理空间索引就基于 GiST。
-- 地理空间索引(需要 PostGIS 扩展)CREATE INDEX idx_locations_geo ON locations USING gist (geom);
-- 范围类型索引CREATE INDEX idx_reservations_period ON reservations USING gist (during);
-- 查询示例:查找与某区域重叠的记录SELECT * FROM reservationsWHERE during && '[2026-04-01, 2026-04-30]'::daterange;
-- 查找附近的位置(5000 米内)SELECT * FROM locationsWHERE ST_DWithin(geom, ST_MakePoint(116.4, 39.9)::geography, 5000);GiST 的核心思想是近似加精确验证:索引存储每个子树的”近似边界”(Bounding Box),搜索时先通过近似边界快速排除不可能匹配的子树,再对候选结果做精确验证。这种”粗筛加精验”的模式让 GiST 能支持非规则形状的查询,代价是索引节点要存储边界信息,空间开销比 B-tree 大。
3.1 适用场景:范围查询
PostgreSQL 原生支持范围类型(int4range、daterange、tsrange 等),GiST 索引可以加速范围重叠查询。典型场景是预订系统:查某时间段内有冲突的预订、排班冲突检测、价格区间筛选。
-- 预订冲突检测:查找与目标时间段重叠的预订SELECT * FROM room_bookingsWHERE stay_period && '[2026-04-10, 2026-04-15]'::daterange;这种查询用 B-tree 无法高效处理,因为 B-tree 只能做点查询或单向范围,无法直接判断两个区间是否重叠。GiST 的范围操作符(&& 重叠、@> 包含、<< 之前)直接在索引层过滤。
3.2 适用场景:最近邻搜索
GiST 支持 KNN(K-Nearest Neighbors)查询,可以按距离排序返回最近的 K 条记录:
-- 查找最近的 10 家咖啡店SELECT name, ST_Distance(geom, ST_MakePoint(116.4, 39.9)::geography) AS distFROM cafesORDER BY geom <-> ST_MakePoint(116.4, 39.9)::geographyLIMIT 10;<-> 操作符让 GiST 在索引层按距离排序,不需要先取全部再排序,对于地图 LBS 查询非常高效。
四、GIN 索引
GIN(Generalized Inverted Index)是倒排索引,适用于包含多个元素的数据类型:数组、JSONB、全文搜索。它为每个元素值维护一个包含所有包含该元素的行 ID 列表。
-- 数组索引CREATE INDEX idx_articles_tags ON articles USING gin (tags);
-- 查询:包含特定标签的文章SELECT * FROM articles WHERE tags @> ARRAY['postgresql', 'database'];
-- JSONB 索引CREATE INDEX idx_events_data ON events USING gin (data);
-- 查询:JSONB 中包含特定键值对SELECT * FROM events WHERE data @> '{"type": "login"}'::jsonb;
-- 全文搜索索引CREATE INDEX idx_docs_content ON documents USING gin (to_tsvector('english', content));
-- 全文搜索查询SELECT * FROM documentsWHERE to_tsvector('english', content) @@ to_tsquery('english', 'postgresql & index');4.1 适用场景:全文搜索
全文搜索是 GIN 最典型的用例。PostgreSQL 内置全文搜索引擎,用 tsvector(分词后的词素列表)和 tsquery(查询表达式)做匹配。GIN 索引加在 to_tsvector 表达式上,可以快速定位包含特定词的文档。
-- 中文全文搜索需要 zhparser 或 jieba 等分词扩展-- zhparser 只注册名为 zhparser 的 parser,还需自己建文本搜索配置(PostgreSQL 内置只有 english/german/simple 等配置,没有 chinese)CREATE EXTENSION zhparser;CREATE TEXT SEARCH CONFIGURATION chinese (PARSER = zhparser);-- 配置 token 到词典的映射,否则分出的词素不进索引ALTER TEXT SEARCH CONFIGURATION chinese ADD MAPPING FOR n,v,a,i,e,l WITH simple;
CREATE INDEX idx_posts_content ON posts USING gin (to_tsvector('chinese', content));
-- 查询:包含"索引"和"优化"的文章SELECT * FROM postsWHERE to_tsvector('chinese', content) @@ to_tsquery('chinese', '索引 & 优化')ORDER BY ts_rank(to_tsvector('chinese', content), to_tsquery('chinese', '索引 & 优化')) DESC;相比 MySQL 的 FULLTEXT 索引,PostgreSQL 的全文搜索更灵活:支持多种语言分词、自定义词典、权重排序、模糊匹配。MySQL 的全文索引只在 MyISAM 上成熟,InnoDB 的全文索引在 5.6 后才支持,且功能远不如 PostgreSQL 完善。如果业务有全文搜索需求且不打算引入 Elasticsearch,PostgreSQL 的 GIN 是个现实的选择。
GIN 索引的构建速度较慢(需要收集所有元素后批量构建),但查询速度极快。对于频繁写入的表,可以使用 fastupdate = on(默认开启),将新条目暂存到待处理列表中,查询时合并,延迟索引更新以提升写入性能。但 fastupdate 会让查询变慢(要合并待处理列表),需要权衡。定期 VACUUM 会自动合并待处理列表。
五、BRIN 索引
BRIN(Block Range Index)是一种超轻量级索引,它不存储每行的索引值,而是存储每个连续数据块范围的摘要信息(最小值、最大值)。适用于物理有序的大表,如按时间追加写入的日志表。
-- 创建 BRIN 索引(指定块范围大小)CREATE INDEX idx_logs_time ON logs USING brin (created_at) WITH (pages_per_range = 32);
-- 查询:时间范围查询SELECT * FROM logsWHERE created_at BETWEEN '2026-04-01' AND '2026-04-30';
-- BRIN 索引大小对比SELECT indexname, pg_size_pretty(pg_relation_size(indexname::regclass)) AS sizeFROM pg_indexes WHERE tablename = 'logs';
-- 示例输出:-- indexname | size-- ------------------------+---------- idx_logs_time_btree | 214 MB <- B-tree-- idx_logs_time_brin | 288 kB <- BRIN(小 700 倍!)5.1 适用场景:时序大表
BRIN 的代价是精度低,它只能排除确定不包含目标值的块范围,但无法精确定位行,仍需在块范围内线性扫描。对于物理有序的数据,这种”粗筛”已经能排除 99% 以上的数据块。
典型场景是日志表、监控数据、交易流水这类按时间单调递增写入的大表。这类表动辄上亿行,建 B-tree 索引可能占几个 GB,而 BRIN 只要几 MB,查询性能却接近。原因是数据物理有序,BRIN 的每个块范围摘要几乎不重叠,一次范围查询只需扫描少数几个块范围。
BRIN 依赖数据的物理有序性。如果表频繁 UPDATE 导致数据页重排,或者数据本身无序(如随机写入),BRIN 的过滤效果会急剧下降,退化为全表扫描。判断是否适合 BRIN 的标准:查询列的值是否与物理存储位置相关。时序数据天然满足,但随机 UUID 主键就不适合。
5.2 BRIN 与 B-tree 的选择
| 维度 | B-tree | BRIN |
|---|---|---|
| 空间开销 | 中等(每行一条索引) | 极小(每块范围一条摘要) |
| 查询精度 | 精确定位 | 粗筛块范围 |
| 适用数据 | 任何数据 | 物理有序数据 |
| 写入性能 | 每行更新索引 | 每块范围更新摘要 |
| 维护成本 | 高(索引膨胀) | 低(几乎不膨胀) |
对于 TB 级的时序表,B-tree 索引可能占几十 GB 且维护代价高,BRIN 是更务实的选择。
六、SP-GiST 索引
SP-GiST(Space-Partitioned GiST)适用于非平衡的数据结构,如电话号码前缀树、路由表等:
-- 电话号码前缀索引CREATE INDEX idx_phones_number ON phones USING spgist (phone_number);
-- 路由前缀匹配SELECT * FROM routing_tableWHERE ip_range >>= '192.168.1.0/24'::inet;SP-GiST 的核心是空间分区:每个节点按某个维度将空间划分为不重叠的分区,查询时只需进入匹配的分区,不需要遍历所有子树。这种结构天然适合前缀匹配、IP 路由这类层级数据。实际业务中 SP-GiST 用得较少,但在电话号码归属地查询、IP 路由表这些特定场景下没有替代品。
七、索引类型选择决策
八、部分索引与表达式索引
PostgreSQL 的两个索引能力是 MySQL 长期欠缺的(MySQL 8.0 才支持函数索引,但功能不如 PostgreSQL 灵活)。
8.1 部分索引
部分索引只索引满足 WHERE 条件的行,不索引全表。适合状态字段只有少数值需要查询的场景:
-- 订单表有 1 亿行,其中"待支付"状态只有 10 万行-- 建全表索引浪费空间,建部分索引只索引待支付行CREATE INDEX idx_orders_pending ON orders (created_at)WHERE status = 'pending';
-- 查询时会自动使用部分索引SELECT * FROM orders WHERE status = 'pending' AND created_at > '2026-04-01';部分索引的好处是空间小、维护代价低。查询时只要 WHERE 条件匹配索引条件,优化器就会用部分索引。它特别适合”活跃数据只占总量很小比例”的场景:待处理订单、未读消息、异常记录。
8.2 表达式索引
表达式索引对函数或表达式的结果建索引,而不是直接对列建索引:
-- 大小写不敏感的用户名查询CREATE INDEX idx_users_lower_name ON users (lower(username));
-- 查询时会使用表达式索引SELECT * FROM users WHERE lower(username) = 'alice';
-- JSONB 路径索引CREATE INDEX idx_events_type ON events ((data->>'type'));
-- 查询 JSONB 路径SELECT * FROM events WHERE data->>'type' = 'login';表达式索引的代价是写入时要计算表达式、索引存储的是计算结果。如果查询频繁用某个函数过滤,表达式索引能把函数调用从查询时挪到写入时,大幅提升查询速度。注意查询时必须用完全一致的表达式,优化器才能匹配索引。
九、代价估计器
PostgreSQL 的查询优化器(Planner)基于**代价估计(Cost Estimation)**选择执行计划。代价不是真实的时间,而是一个无量纲的相对值,用于比较不同执行路径的优劣。
9.1 代价模型
PostgreSQL 的代价模型由几个核心参数组成:
seq_page_costRequirednumber
default: 1.0
random_page_costRequirednumber
default: 4.0
cpu_tuple_costRequirednumber
default: 0.01
cpu_index_tuple_costRequirednumber
default: 0.005
cpu_operator_costRequirednumber
default: 0.0025
9.2 扫描代价
-- 顺序扫描代价 = 顺序读页数 × seq_page_cost + 行数 × cpu_tuple_cost-- 假设表有 10000 页、500000 行-- Seq Scan Cost = 10000 × 1.0 + 500000 × 0.01 = 15000
-- 索引扫描代价 = 随机读页数 × random_page_cost + 索引条目数 × cpu_index_tuple_cost-- + 堆表行数 × cpu_tuple_cost-- 假设 B-tree 深度 3、返回 100 行-- Index Scan Cost = 3 × 4.0 + 100 × 0.005 + 100 × 0.01 = 13.5优化器比较两种扫描的代价,选低的。上面这个例子,索引扫描代价远低于顺序扫描,优化器会选 Index Scan。但如果查询返回的行数很多(比如 30% 以上),索引扫描的随机 I/O 代价会超过顺序扫描,优化器会转而选 Seq Scan。
对于 SSD,random_page_cost 应调低到 1.1 到 1.5,因为 SSD 的随机读性能远好于机械硬盘,默认 4.0 会高估随机 I/O 代价,导致优化器不必要地回避索引扫描:
-- SSD 环境下调低随机读代价ALTER SYSTEM SET random_page_cost = 1.1;SELECT pg_reload_conf();9.3 Join 代价
PostgreSQL 支持三种 Join 策略,每种有不同的代价计算:
| Join 方式 | 代价模型 | 适用场景 |
|---|---|---|
| Nested Loop | 外表行数 × 内表扫描代价 | 小表驱动大表,有索引 |
| Hash Join | 构建哈希表代价 + 探测代价 | 等值连接,中等大小表 |
| Merge Join | 两侧排序代价 + 合并代价 | 等值连接,数据已排序 |
-- 强制使用特定 Join 方式(仅调试用)SET enable_nestloop = off;SET enable_hashjoin = off;-- 此时优化器只能选 Merge Join
-- 恢复默认RESET enable_nestloop;RESET enable_hashjoin;调试执行计划时,临时关掉某种 Join 方式可以观察优化器的替代选择,判断它选的 Join 是否合理。
9.4 统计信息
代价估计的准确性取决于统计信息。PostgreSQL 通过 ANALYZE 命令收集表的统计信息,存储在 pg_statistic 系统表中:
-- 查看表的统计信息SELECT attname, n_distinct, null_frac, avg_widthFROM pg_statsWHERE tablename = 'users';
-- 示例输出:-- attname | n_distinct | null_frac | avg_width-- ---------+------------+-----------+------------ id | -1 | 0 | 8-- name | -0.25 | 0 | 16-- city | 100 | 0.05 | 12
-- n_distinct < 0 表示比例(-0.25 = 25% 不同值)-- n_distinct > 0 表示绝对数量-- 手动触发统计信息收集ANALYZE users;
-- 增加统计信息精度(默认 100,最大 10000)ALTER TABLE users ALTER COLUMN city SET STATISTICS 500;ANALYZE users;统计信息的精度直接影响优化器的选择。default_statistics_target 默认为 100,意味着对每列最多采样 100 × 300 = 30000 行来构建直方图。对于数据分布不均匀的列,增大统计目标可以避免优化器误判。
9.5 EXPLAIN 实战
-- 基本执行计划EXPLAIN SELECT * FROM users WHERE city = 'Beijing';
-- 带实际执行时间EXPLAIN ANALYZE SELECT * FROM users WHERE city = 'Beijing';
-- 带 I/O 统计(最常用)EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM users WHERE city = 'Beijing';
-- 示例输出:-- QUERY PLAN-- ----------------------------------------------------------------- Index Scan using idx_users_city on users-- (cost=0.29..8.31 rows=1 width=72)-- (actual time=0.015..0.016 rows=1 loops=1)-- Index Cond: (city = 'Beijing'::text)-- Buffers: shared hit=4-- Planning Time: 0.089 ms-- Execution Time: 0.032 ms解读要点:
| 字段 | 含义 |
|---|---|
cost=0.29..8.31 | 启动代价..总代价(估计值) |
rows=1 | 预估返回行数 |
width=72 | 预估每行平均字节数 |
actual time=0.015..0.016 | 实际启动时间..实际总时间(毫秒) |
rows=1 (actual) | 实际返回行数 |
shared hit=4 | 命中 Shared Buffers 的页数(无磁盘 I/O) |
shared read=0 | 需要从磁盘读取的页数 |
当 EXPLAIN 的预估行数与实际行数差距很大时(如预估 1 行实际 10000 行),说明统计信息失真,需要执行 ANALYZE 或增大统计目标。这是查询性能突然下降的最常见原因。优化器基于错误的行数估计,可能选了 Nested Loop 而非 Hash Join,导致查询慢几个数量级。
EXPLAIN 的解读逻辑和 MySQL 的 EXPLAIN 类似,都是看预估行数与实际行数是否匹配。区别在于 PostgreSQL 的 EXPLAIN 输出代价数值和 Buffer 命中信息,比 MySQL 的 EXPLAIN 更详细。MySQL 的 EXPLAIN 解读见 MySQL 索引原理与失效分析。
十、踩坑与运维
10.1 索引使用率监控
PostgreSQL 的索引不是建了就一定被用。统计信息失真、数据分布变化、查询条件不匹配,都可能让优化器放弃索引。监控索引使用率是运维的基本功:
-- 查看各索引的使用情况SELECT schemaname, relname, indexrelname, idx_scan AS scans, idx_tup_read AS tuples_read, idx_tup_fetch AS tuples_fetched, pg_size_pretty(pg_relation_size(indexrelid)) AS sizeFROM pg_stat_user_indexesORDER BY idx_scan ASC;
-- 示例输出:-- relname | indexrelname | scans | tuples_read | size-- ----------+----------------------+-------+-------------+-------- orders | idx_orders_status | 0 | 0 | 45 MB <- 从未使用-- orders | idx_orders_created | 15234 | 1200000 | 50 MBidx_scan = 0 的索引从未被查询使用,是建了没用的”僵尸索引”。这种索引白白占用磁盘空间、拖慢写入(每次 INSERT/UPDATE 都要维护它)。排查清楚后可以删除:
-- 删除从未使用的索引(先确认确实不需要)DROP INDEX CONCURRENTLY idx_orders_status;CONCURRENTLY 关键字让删除操作不阻塞写入,代价是执行时间稍长。生产环境删索引务必用 CONCURRENTLY。
10.2 索引膨胀与 REINDEX
PostgreSQL 的 Append-Only MVCC 下,索引也会积累死条目(指向已被 VACUUM 回收的堆表元组的索引条目)。普通 VACUUM 会清理这些死索引条目,但如果表频繁更新且索引列变化频繁,索引页会碎片化,导致索引扫描变慢。
判断索引是否膨胀,可以对比索引的实际大小与预期大小:
-- 查看索引大小和扫描次数SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS size, idx_scanFROM pg_stat_user_indexesWHERE relname = 'orders';
-- 如果索引大小远超预期(例如 B-tree 深度异常、叶子页稀疏),考虑 REINDEXPostgreSQL 12+ 支持 REINDEX CONCURRENTLY,不阻塞读写:
-- 并发重建单个索引(推荐)REINDEX INDEX CONCURRENTLY idx_orders_created;
-- 并发重建某表所有索引REINDEX TABLE CONCURRENTLY orders;CONCURRENTLY 的原理是先建一个新索引(与原索引同名加 _ccnew 后缀),同步完成后原子切换。代价是需要额外磁盘空间、构建时间更长。如果不用 CONCURRENTLY,REINDEX 会锁表,生产环境大表上几乎不可用。
定期 REINDEX 的频率取决于表的更新频率。频繁更新的表建议每月跑一次 REINDEX CONCURRENTLY,低频更新的表每季度一次即可。更省力的方案是配 pg_repack,它在重建表的同时重建所有索引,一步到位。
10.3 索引类型选错
最常见的索引选错是把全文搜索、JSONB 查询这种本该用 GIN 的场景用了 B-tree。B-tree 无法高效处理 @>、@@ 这类操作符,查询会退化为全表扫描。
判断索引是否匹配查询的方法:跑 EXPLAIN,看 Index Cond 是否出现期望的索引名。如果 EXPLAIN 显示 Seq Scan 而非 Index Scan,且查询条件用了 @>、@@、<-> 等操作符,大概率是索引类型建错了。
-- 错误:JSONB 查询用 B-tree,不生效CREATE INDEX idx_events_data_btree ON events (data); -- B-tree 无法索引 JSONB 内部SELECT * FROM events WHERE data @> '{"type": "login"}'::jsonb; -- 全表扫描
-- 正确:JSONB 查询用 GINCREATE INDEX idx_events_data_gin ON events USING gin (data);SELECT * FROM events WHERE data @> '{"type": "login"}'::jsonb; -- 走 GIN 索引10.4 统计信息失真导致索引不被选
另一个常见坑是统计信息过时导致优化器放弃索引。典型场景:表数据量大幅变化后没跑 ANALYZE,优化器以为表很小,选了 Seq Scan 而非 Index Scan。
-- 手动更新统计信息ANALYZE orders;
-- 或针对单列更新ANALYZE orders (status);
-- 对数据分布不均的列,提高统计精度ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;ANALYZE orders;AutoVacuum 会自动跑 ANALYZE,但触发阈值(默认 10% 变更)对大表可能不够及时。批量导入数据后建议手动跑一次 ANALYZE,让优化器立刻看到新数据分布。
参考资料
- PostgreSQL Documentation: Indexes - 官方索引类型与使用说明
- PostgreSQL Documentation: BRIN Indexes - BRIN 块范围索引原理与适用场景
- PostgreSQL Documentation: GIN Indexes - GIN 倒排索引与 fastupdate 机制
- PostgreSQL Documentation: Planner Cost Constants - 代价估计器参数与统计信息
- The Internals of PostgreSQL, Chapter 3 - B-tree 与各类索引的内部结构讲解
- MySQL 8.0 Reference Manual: Optimization - MySQL 索引与 EXPLAIN,用于对比
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






