返回文章列表
postgresql

索引類型與選擇策略

PostgreSQL 豐富的索引類型——B-Tree、Hash、GIN、GiST、BRIN,各自的適用場景與選擇策略

Aaron

PostgreSQL 的索引遠比 MySQL 豐富

MySQL(InnoDB)幾乎只有一種索引結構——B+ Tree(加上 Adaptive Hash Index 自動生成)。PostgreSQL 提供六種索引類型,針對不同的資料型態和查詢模式。這是 PostgreSQL 在複雜查詢場景下比 MySQL 更強的主要原因之一。

索引類型適用查詢資料型態常見場景
B-Tree= < > BETWEEN ORDER BY所有可排序型態絕大多數場景(預設)
Hash=所有可 hash 型態純等值查詢
GIN包含、交集Array, JSONB, tsvector, hstore全文搜尋、JSON 欄位查詢
GiST範圍重疊、最近鄰Geometry, range, ltree地理空間、範圍查詢
SP-GiST分層資料IP, phone, geometry前綴搜尋、quadtree
BRIN範圍查詢(有序資料)所有可排序型態時序資料、大表

B-Tree

預設索引類型,和 MySQL 一樣基於 B+ Tree 結構(葉節點用鏈表串接)。

CREATE INDEX idx_orders_created_at ON orders (created_at);
-- 等同於
CREATE INDEX idx_orders_created_at ON orders USING btree (created_at);

與 MySQL B-Tree 的差異

面向PostgreSQLMySQL (InnoDB)
葉節點存什麼索引值 + 指向 heap tuple 的 TIDClustered: 整行;Secondary: 索引值 + PK
Clustered Index沒有(heap table,無物理排序)有(PK = 資料的物理組織)
回表所有索引都要回 heap只有 Secondary Index 回 Clustered
覆蓋索引INCLUDE 子句 或 Visibility Map直接在 Secondary Index 讀

PostgreSQL 沒有 Clustered Index——表的資料以 heap 形式存放,插入順序隨機。所有索引(包括 PK)都是 secondary index,葉節點存的是 TID(tuple identifier = page + offset),查到後都要回 heap 取完整行。

Covering Index(INCLUDE)

PostgreSQL 12+ 支援 INCLUDE 子句,把非索引欄位附加到葉節點:

CREATE INDEX idx_orders_covering ON orders (customer_id) INCLUDE (amount, status);
-- 查詢 customer_id + amount + status 時不需要回 heap

INCLUDE 的欄位不參與索引排序,只是搭便車存在葉節點裡。搭配 Visibility Map 的 all-visible 標記,可以完全避免回 heap(Index-Only Scan)。

Hash Index

只支援等值查詢(=),不支援範圍、排序。

CREATE INDEX idx_users_email ON users USING hash (email);

PostgreSQL 10 之前 Hash Index 不寫 WAL(crash 後損壞),10 之後修復了。理論上等值查詢比 B-Tree 快(O(1) vs O(log n)),但實務中差異不大,大部分場景用 B-Tree 就夠了。

GIN(Generalized Inverted Index)

倒排索引,適合「元素包含」查詢。把複合值拆成元素,建立「元素 → 哪些行包含它」的映射。

最佳場景:

全文搜尋

-- tsvector 欄位的 GIN 索引
CREATE INDEX idx_articles_fts ON articles USING gin (to_tsvector('english', content));
SELECT * FROM articles WHERE to_tsvector('english', content) @@ to_tsquery('postgresql & mvcc');

JSONB 查詢

CREATE INDEX idx_events_data ON events USING gin (data);

-- 包含查詢
SELECT * FROM events WHERE data @> '{"type": "click"}';

-- 路徑存在
SELECT * FROM events WHERE data ? 'user_id';

GIN 支援兩種 operator class:

  • jsonb_ops(預設):支援 @> ? ?| ?&,索引較大
  • jsonb_path_ops:只支援 @>,索引較小、查詢更快

Array 包含

CREATE INDEX idx_products_tags ON products USING gin (tags);
SELECT * FROM products WHERE tags @> ARRAY['electronics', 'sale'];

GIN 的代價

寫入慢——每次 INSERT 要更新大量倒排列表。GIN 用 pending list 緩解:新的 entry 先寫到 pending list,等累積到 gin_pending_list_limit(預設 4MB)再批次合併到索引。代價是查詢時要額外掃描 pending list。

GiST(Generalized Search Tree)

平衡搜尋樹,支援「重疊」「包含」「最近鄰」等空間概念的查詢。

地理空間(PostGIS)

CREATE INDEX idx_places_location ON places USING gist (location);
-- 最近鄰查詢
SELECT * FROM places ORDER BY location <-> ST_MakePoint(121.5, 25.0) LIMIT 10;

Range 型態

CREATE INDEX idx_events_period ON events USING gist (period);
-- 重疊查詢
SELECT * FROM events WHERE period && '[2026-01-01, 2026-03-01)'::daterange;

GiST vs GIN

面向GINGiST
查詢速度快(精確命中)中等(可能 false positive)
寫入速度
索引大小
支援排序不支援支援(KNN)
最佳場景全文搜尋、JSONB、array地理空間、range

BRIN(Block Range Index)

輕量級索引,只記錄每一段 page range(預設 128 pages)中的最小值和最大值。

CREATE INDEX idx_logs_created_at ON logs USING brin (created_at);

適用條件

資料的物理順序和欄位值的順序高度相關。例如時間戳欄位按時間順序寫入的 log 表——前面的 page 存的都是舊資料,後面存新資料。BRIN 只需要知道「page 100-227 的 created_at 在 2026-01-01 到 2026-01-15 之間」,查詢時可以直接跳過不相關的 range。

BRIN 的優勢

  • 索引極小(一張 100GB 的表,BRIN 索引可能只有幾 MB)
  • 維護成本極低(不像 B-Tree 每筆 INSERT 都要更新)
  • 適合 append-only 的大表(log、事件、時序資料)

不適合

  • 資料物理順序和邏輯順序不相關(隨機 INSERT 的表)
  • 需要高選擇性的等值查詢(BRIN 只能做粗過濾)

進階索引功能

Partial Index(部分索引)

只對符合條件的行建索引:

CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';

如果 99% 的訂單是 completed,只有 1% 是 pending,這個索引只有 B-Tree 的 1% 大小,查 pending 訂單時卻一樣快。

Expression Index(表達式索引)

對表達式建索引:

CREATE INDEX idx_users_lower_email ON users (lower(email));
SELECT * FROM users WHERE lower(email) = '[email protected]';

MySQL 8.0 也支援 functional index,但 PostgreSQL 從很早就有了。

Multicolumn Index 與順序

和 MySQL 一樣遵守最左前綴原則,但 PostgreSQL 額外支援對每個欄位指定排序方向:

CREATE INDEX idx_orders_multi ON orders (customer_id ASC, created_at DESC);
-- 適合 ORDER BY customer_id ASC, created_at DESC

MySQL 8.0 才支援 descending index,PostgreSQL 一直都有。

索引維護

REINDEX

索引和表一樣會膨脹(dead index entry)。VACUUM 會清理 heap 的 dead tuple,但索引的回收效果有限。嚴重膨脹時需要 REINDEX:

REINDEX INDEX idx_orders_created_at;
-- 或重建所有索引
REINDEX TABLE orders;

-- PG 12+ 支援 CONCURRENTLY(不阻塞)
REINDEX INDEX CONCURRENTLY idx_orders_created_at;

監控

-- 索引使用率
SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY idx_scan ASC;

-- 未使用的索引(idx_scan = 0)
SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;

-- 索引大小
SELECT indexrelname, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;