外观
实测对比:等值查询 B-tree/Hash/GIN 谁快 30%
问题背景:等值查询不等于只能选 B-tree
很多团队在 PostgreSQL 里形成了肌肉记忆:字段要加速查询,就 CREATE INDEX ... USING btree。这在大多数 OLTP 场景确实稳妥,但当查询从普通标量等值扩展到 JSONB、数组、全文检索,或者表非常大、写入非常密集时,B-tree、Hash 索引、GIN 索引的成本结构并不一样。
本文只回答一个具体问题:PostgreSQL 等值查询里,B-tree、Hash、GIN 到底谁更快,所谓“快 30%”出现在什么条件下?
结论先说:普通整数、文本主键式等值查询,B-tree 通常是综合最优;纯等值且字段不参与范围、排序、唯一约束时,Hash 索引在部分版本和数据分布下可能降低索引体积并获得小幅收益;GIN 不适合普通标量等值,它的价值在 JSONB、数组、全文检索等“包含/成员”查询,错误使用反而更慢、更大、写入更重。
三类索引的适用边界
B-tree:默认选项,不只是等值
B-tree 同时支持 =、<、>、BETWEEN、ORDER BY、前缀匹配和唯一约束。优化器对它的支持最成熟,统计信息、并行执行、索引-only-scan 等路径都很稳定。对于用户 ID、订单号、状态码这类高频过滤字段,B-tree 往往不是单次点查最快的理论结构,而是综合风险最低的选择。
Hash 索引:只做等值,不做范围排序
Hash 索引只处理 = 等值操作,不能用于排序、范围条件,也不能支持唯一约束。PostgreSQL 10 之后,Hash 索引已经具备 WAL 崩溃恢复能力,不再是早年“重启后可能损坏”的状态,但它的适用面仍窄。适合验证的典型场景是:大表、宽文本键、查询几乎全是等值、且不需要索引排序。
GIN:倒排索引,面向多值元素
GIN 即 Generalized Inverted Index,适合 JSONB、数组、tsvector、pg_trgm 等类型。它不是把一行映射到一个键值,而是把元素倒排到包含它的行。因此,GIN 擅长“某个 JSONB key/value 是否存在”“数组是否包含某元素”“文本是否包含关键词”,但拿它去索引普通整数等值,通常没有意义。
索引选型流程图
构造可复现实验数据
下面的 SQL 可以直接在测试库执行。为了避免污染公共环境,建议使用独立 schema 或临时数据库。数据包含 500 万行普通标量字段,以及一个 JSONB 字段,用于区分普通等值与半结构化包含查询。
sql
-- 初始化测试环境
DROP TABLE IF EXISTS idx_lab;
CREATE TABLE idx_lab (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
user_code text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 生成 500 万行测试数据:user_id 有适度重复,user_code 近似唯一
INSERT INTO idx_lab (user_id, user_code, payload)
SELECT
(random() * 4999999)::bigint,
'u_' || lpad(g::text, 10, '0'),
jsonb_build_object(
'tenant_id', (g % 100000) + 1,
'tags', jsonb_build_array('tag_' || ((g % 20) + 1), 'online'),
'vip', (g % 97 = 0)
)
FROM generate_series(1, 5000000) AS g;
-- 保证统计信息最新
VACUUM (ANALYZE) idx_lab;建立三类索引。注意:GIN 在这里分别用于 JSONB 场景;不要期待普通 user_id 上的 GIN 能替代 B-tree。
sql
-- 普通标量字段:B-tree 与 Hash 对比
CREATE INDEX idx_lab_user_id_btree
ON idx_lab USING btree (user_id);
CREATE INDEX idx_lab_user_id_hash
ON idx_lab USING hash (user_id);
CREATE INDEX idx_lab_user_code_btree
ON idx_lab USING btree (user_code);
CREATE INDEX idx_lab_user_code_hash
ON idx_lab USING hash (user_code);
-- JSONB 包含查询:GIN 是主力
CREATE INDEX idx_lab_payload_gin
ON idx_lab USING gin (payload);
-- 再次收集统计信息
ANALYZE idx_lab;查看索引体积:
sql
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE relname = 'idx_lab'
ORDER BY pg_relation_size(indexrelid) DESC;在常见 64 位环境、默认 fillfactor 下,整数列的 B-tree 与 Hash 体积可能接近;宽文本列上两者差异更明显,但也受版本、页大小、数据分布影响。因此,不要把网上的固定百分比当成你的生产结论。
实测一:普通标量等值查询
先测试 user_id 等值查询。为了让结果稳定,先挑选一个真实存在的值。
sql
-- 找一个测试值
SELECT user_id
FROM idx_lab
ORDER BY id
LIMIT 1;将下面的 :user_id 替换成查询结果,例如 123456。
sql
-- 强制使用 B-tree
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT *
FROM idx_lab
WHERE user_id = 123456;
-- 临时禁止 Hash 索引路径,仅用于实验;会话结束后自动恢复
SET enable_indexscan = on;
SET enable_bitmapscan = on;
SET enable_hashindexscan = off;
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM idx_lab
WHERE user_id = 123456;
-- 恢复 Hash 索引开关
RESET enable_hashindexscan;更可靠的方法是用 pg_stat_statements 或客户端压测工具反复执行。SQL 层可以用 EXPLAIN 观察逻辑读,但不要只执行一次:
sql
-- 连续执行 10 万次,用应用程序替换随机值更接近真实压力
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM idx_lab
WHERE user_id = (SELECT user_id FROM idx_lab TABLESAMPLE BERNOULLI(0.001) LIMIT 1);在笔者类似规格的冷缓存/暖缓存混合测试中,单行命中的 B-tree 与 Hash 差异通常只有几个百分点;当缓冲池命中率高、数据已在内存中时,差异更容易被网络、客户端处理和噪声淹没。只有在宽键、B-tree 层级较深、Hash 桶页分布理想,并且查询 100% 为等值时,Hash 才可能出现约 10% 到 30% 的平均延迟优势。但这个收益并不稳定,不能脱离执行计划直接承诺。
实测二:JSONB 包含查询必须看 GIN
下面比较顺序扫描与 GIN 索引扫描:
sql
-- 没有命中 GIN 或表较小时,可能走顺序扫描
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM idx_lab
WHERE payload @> '{"tenant_id": 8888}';
-- JSONB 典型包含查询:GIN 应发挥作用
SET enable_seqscan = off; -- 仅实验使用,不建议在生产会话中长期修改
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM idx_lab
WHERE payload @> '{"tenant_id": 8888}';
RESET enable_seqscan;数组包含查询同样适合 GIN:
sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, payload
FROM idx_lab
WHERE payload -> 'tags' ? 'tag_7';这里如果改用 B-tree,需要提前设计表达式索引,例如 ((payload->>'tenant_id')::bigint)。它对固定路径、固定类型的等值过滤可能更小、更快,但牺牲了 JSONB 任意键查询灵活性。GIN 的优势是通用性,代价是索引体积和写入放大。
写入代价:不要只看 SELECT 延迟
索引不是免费的。每多一个索引,INSERT、UPDATE、HOT UPDATE、VACUUM 都可能受到影响。可用下面方式粗略对比写入成本:
sql
SELECT
relname,
n_tup_ins,
n_tup_upd,
n_tup_hot_upd,
idx_tup_fetch
FROM pg_stat_user_tables
WHERE relname = 'idx_lab';
SELECT
indexrelname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'idx_lab';GIN 由于存在 pending list 与批量合并机制,批量写入时的表现与普通 B-tree 不同。对写入特别重的 JSONB 表,可以评估 gin_pending_list_limit 或 fastupdate,但不要盲目关闭:
sql
-- 查看当前参数
SHOW gin_pending_list_limit;
-- 针对单个索引调整,仅在明确压测后使用
ALTER INDEX idx_lab_payload_gin SET (fastupdate = on);选型决策:什么时候能拿到那 30%
如果目标是稳定的生产性能,建议按下面规则决策:
- 标量等值、范围、排序混合出现:选 B-tree。 一个索引覆盖多类 SQL,减少索引数量,通常比追求点查理论极限更重要。
- 纯等值、宽键、无唯一约束、无排序需求:可以压测 Hash。 只有当你的数据、版本、缓存命中率下,P95/P99、索引体积、写入成本都更好,再考虑上线。
- JSONB、数组、全文检索:选 GIN 或表达式 B-tree。 任意结构包含查询用 GIN;固定路径高频等值,可额外建立更窄的表达式 B-tree。
- 低选择性字段不要迷信索引。 当一个条件命中表中很大比例的数据,顺序扫描或 bitmap heap scan 可能比索引回表更便宜。
避坑总结
坑 1:只看一次 EXPLAIN 就宣称 Hash 快 30%
常见现象是第一次查询刚好多了磁盘 I/O,第二次全部命中 shared buffers,于是把缓存差异误认为索引差异。修正方法是:先 VACUUM (ANALYZE),用 pg_stat_statements 统计多次执行的总耗时、均值和 P95;同时记录 BUFFERS: shared hit/read,在相同缓存状态下对比。
坑 2:在生产库全局关闭 enable_hashindexscan 或 enable_seqscan
很多测试文章会写 SET enable_seqscan = off,这只适合临时实验。若在应用连接池或全局配置中修改,可能强迫优化器选择灾难性计划。修正方法是只在当前会话设置,测试完立即 RESET;生产上优先通过统计信息、成本参数、索引设计影响计划。
坑 3:给普通整数列盲目建 GIN
GIN 不是“更高级的 B-tree”。普通 bigint = bigint 谓词无法像 JSONB 包含查询那样从倒排索引中获益,甚至可能因为操作符不匹配而根本用不上。修正方法是明确谓词类型:标量等值用 B-tree/Hash,jsonb @> jsonb、数组包含、全文匹配再考虑 GIN。
坑 4:忽略索引体积和写入放大
多个索引并存时,SELECT 可能只快了一点,INSERT 却明显变慢,VACUUM 时间和磁盘占用也上升。修正方法是上线前同时测量查询延迟、表与索引大小、批量写入耗时、更新后的 HOT 比例;未被使用或收益很小的索引应及时下线。
坑 5:把 Hash 索引当成 B-tree 的完全替代品
Hash 不支持范围、排序、唯一约束,也不适合 ORDER BY 下推。业务一旦新增 >=、BETWEEN、ORDER BY ... LIMIT,就还得补 B-tree。修正方法是在索引评审时列出全部 SQL 模式,而不是只根据当前一条等值语句决策。
最终,PostgreSQL 索引选型没有“某类索引永远快 30%”。B-tree 胜在通用和稳定,Hash 只在纯等值窄场景中有条件胜出,GIN 则在半结构化与多值查询中不可替代。真正可靠的性能收益,必须来自相同数据、相同缓存、相同并发压力下的执行计划与端到端压测。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。