哈希索引适用场景
上一节讲了哈希索引的结构与限制:它只支持单列、只支持 =、扫描必有损、索引里只存 4 字节哈希码。限制这么多,它到底还有没有用? 有,但适用面很窄。
本节把「哈希索引真正值得用的场景」一条条掰开:哪些查询形态、哪些数据类型、什么样的键长,能让它在体积或 I/O 上确实优于 B-Tree;反过来,哪些看起来「像是等值查询」的写法其实用不上它。最后给出一份可以直接照着判断的决策清单。
前置知识
阅读本节前,建议先了解:哈希索引原理
基础概念
判断哈希索引是否适用的三把尺子
一个索引值不值得用哈希,只取决于三件事:
| 尺子 | 判断依据 | 说明 |
|---|---|---|
| 查询形态 | 是否只有等值条件 | 只要出现范围、排序、前缀、<>,哈希索引立刻出局 |
| 键的长度 | 索引键是否明显长于 4 字节 | 键越长,哈希索引省的空间越多;短键(int、小日期)省不了 |
| 唯一性需求 | 是否不需要唯一约束 | 需要 UNIQUE 就只能选 B-Tree |
三条同时满足,哈希索引才进入候选;任意一条不满足,直接用 B-Tree。
哈希索引省的是「索引体积」,不是「查询速度」
这是最容易误解的一点。哈希索引的收益几乎全部来自存储层:
B-Tree 每条索引项: [完整键值(可能几十字节)] + [6 字节 TID]
哈希索引每条索引项:[4 字节哈希码] + [6 字节 TID]
键越长 → 两者差距越大 → 哈希索引越占优
键越短 → 两者差距越小 → 哈希索引几乎没有理由而查询速度上,哈希索引反而多了一层「有损扫描」的代价:
B-Tree 等值查找: 索引内直接确定命中 → 回表取行
哈希索引等值查找:索引内只能确定哈希码相同 → 回表后还要 Recheck 比对原值结论先行
哈希索引是「省空间」的工具,不是「提速度」的工具。 在等值查询延迟上,B-Tree 通常持平甚至略优;哈希索引的价值出现在「索引体积大到影响内存命中率、备份体积、磁盘占用」的时候。
键长与体积收益的量化关系
假设一张 1000 万行的表,TID 固定 6 字节,页头与行指针开销按经验折算,不同键长下的相对体积:
| 键类型 | 典型键长 | B-Tree 索引相对体积 | 哈希索引相对体积 | 哈希节省比例 |
|---|---|---|---|---|
int4 | 4 字节 | 1.0× | 1.0× | ≈ 0%(没意义) |
bigint | 8 字节 | 1.2× | 1.0× | ≈ 17%(不明显) |
uuid(文本存储) | 36 字符 | 3.5× | 1.1× | ≈ 69% |
| 64 位十六进制 token | 64 字符 | 5.5× | 1.2× | ≈ 78% |
| 长 URL / 路径 | 100+ 字符 | 8× 以上 | 1.3× | ≈ 84% |
表格只表达趋势,真实比例取决于 fillfactor、是否有重复值、页内碎片等。但方向很明确:键长 8 字节以下,别考虑哈希索引。
「等值」与「看起来像等值」
初学者最容易踩的坑是把下面这些当成等值查询:
| 写法 | 是等值吗 | 哈希索引能用吗 |
|---|---|---|
col = 'abc' | 是 | 能 |
col IN ('a','b','c') | 等价于多个等值 | 能(每个值各走一次) |
col = ANY(ARRAY[...]) | 等价于多个等值 | 能 |
col LIKE 'abc'(无通配符) | 优化器可改写为 = | 能,但依赖优化器改写 |
col LIKE 'abc%' | 不是,是前缀区间 | 不能 |
col ~ '^abc' | 不是,是正则 | 不能 |
col = 'abc' OR col = 'def' | 是两个等值 | 能(BitmapOr) |
col <> 'abc' | 不是,是补集 | 不能 |
IN 与 = ANY 的边界
IN 列表里值很少(比如 2~5 个)时,走哈希索引与走 B-Tree 差别不大;值很多时优化器更可能整体改判为顺序扫描。哈希索引并不会让 IN 变快,它只是能服务这种形态。
不适合哈希索引的场景(总览)
先列「不要用」的清单,后面详细说明里再逐条展开:
| 场景 | 原因 |
|---|---|
范围查询(>、<、BETWEEN) | 哈希无序,结构上不支持 |
前缀匹配(LIKE 'abc%') | 需要有序区间 |
排序输出(ORDER BY) | 哈希无序 |
| 唯一性 / 主键 | 不支持唯一索引 |
| 多列等值组合 | 不支持多列索引 |
覆盖查询(INCLUDE) | 不存原值,无法覆盖 |
| 短键(int / smallint / 短 code) | 省不下空间,白担限制 |
| 低基数列作为独立索引 | 桶分布极不均,且优化器多半直接顺序扫描 |
语法与用法
哈希索引的创建语法与 B-Tree 唯一的区别就是 USING hash,可用选项也少得多:
-- 基本形式
CREATE INDEX 索引名 ON 表名 USING hash (列名);
-- 在线创建(不长时间阻塞写)
CREATE INDEX CONCURRENTLY 索引名 ON 表名 USING hash (列名);
-- 部分索引:只索引常用子集
CREATE INDEX 索引名 ON 表名 USING hash (列名) WHERE 条件;
-- 表达式哈希索引:把长表达式哈希后再索引
CREATE INDEX 索引名 ON 表名 USING hash ((表达式));
-- 存储参数:唯一可用的是 fillfactor(10~100)
CREATE INDEX 索引名 ON 表名 USING hash (列名) WITH (fillfactor = 70);
-- 重建(唯一能收缩已膨胀哈希索引的手段)
REINDEX INDEX CONCURRENTLY 索引名;
-- 删除
DROP INDEX CONCURRENTLY 索引名;这里没有的东西
UNIQUE、多列、INCLUDE、DESC、NULLS FIRST/LAST、opclass 全部不可用于哈希索引。写进去只会得到一句报错。
最简示例:为长 token 列建立哈希索引。
DROP TABLE IF EXISTS api_keys;
CREATE TABLE api_keys (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
key_token text NOT NULL,
owner_id integer NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_api_keys_token_hash ON api_keys USING hash (key_token);DROP TABLE
CREATE TABLE
CREATE INDEX确认它确实是哈希索引:
SELECT
i.indexrelid::regclass AS index_name,
am.amname AS method,
pg_get_indexdef(i.indexrelid) AS definition
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_am am ON am.oid = c.relam
WHERE i.indrelid = 'api_keys'::regclass; index_name | method | definition
-------------------------+--------+----------------------------------------------------------------------------
api_keys_pkey | btree | CREATE UNIQUE INDEX api_keys_pkey ON public.api_keys USING btree (id)
idx_api_keys_token_hash | hash | CREATE INDEX idx_api_keys_token_hash ON public.api_keys USING hash (key_token)
(2 rows)详细说明
场景一:长字符串键的等值查找
这是哈希索引最典型、也几乎是唯一有十足说服力的场景。会话 token、API Key、一次性校验码、短信验证码的记录键,都属于这一类。
DROP TABLE IF EXISTS sessions_demo;
CREATE TABLE sessions_demo (
id bigint GENERATED ALWAYS AS IDENTITY,
token text NOT NULL,
user_id integer NOT NULL
);
INSERT INTO sessions_demo (token, user_id)
SELECT md5(g::text) || md5((g * 13)::text), (random() * 50000)::int
FROM generate_series(1, 2000000) AS g;
-- 两个索引都建,用于对比
CREATE INDEX idx_sd_token_btree ON sessions_demo (token);
CREATE INDEX idx_sd_token_hash ON sessions_demo USING hash (token);
ANALYZE sessions_demo;DROP TABLE
CREATE TABLE
INSERT 0 2000000
CREATE INDEX
CREATE INDEX
ANALYZE在默认设置下,优化器会倾向选择体积更小或成本更低的那个。强制它使用哈希索引来对比:
SET enable_indexscan = off;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, user_id FROM sessions_demo
WHERE token = '7c4a8d09ca3762af61e59520943dc26494f8941b9c50f1a8bfd1c8c2d1a3f5e7';
RESET enable_indexscan; QUERY PLAN
---------------------------------------------------------------------------------------
Bitmap Heap Scan on sessions_demo (actual rows=1 loops=1)
Recheck Cond: (token = '7c4a8d09ca3762af61e59520943dc264...'::text)
Heap Blocks: exact=1
Buffers: shared hit=7
-> Bitmap Index Scan on idx_sd_token_hash (actual rows=1 loops=1)
Index Cond: (token = '7c4a8d09ca3762af61e59520943dc264...'::text)
Buffers: shared hit=2
(7 rows)注意 Recheck Cond 的存在——这就是哈希索引的固定成本。它不是免费的:多了一次原值比较,只是这个成本相比「顺序扫描 200 万行」微不足道。
这个场景的收益在体积:
SELECT
i.indexrelid::regclass AS index_name,
am.amname AS method,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS size
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_am am ON am.oid = c.relam
WHERE i.indrelid = 'sessions_demo'::regclass
ORDER BY pg_relation_size(i.indexrelid) DESC; index_name | method | size
------------------------+--------+---------
idx_sd_token_btree | btree | 226 MB
idx_sd_token_hash | hash | 93 MB
(2 rows)什么时候这个体积差真的重要
- 索引大于可用内存,每次查询都要走磁盘 I/O;
- 备份/恢复窗口受体积制约;
- 云上按存储量计费;
- 同一张表有多个长键索引,总体积累积可观。
如果以上都不成立、索引完全能缓存在内存里,那么哈希索引省下的只有磁盘占用,查询延迟基本无差别。
场景二:长 UUID 列的等值查找
UUID 是很常见的等值键。注意 UUID 的存储方式会显著影响结论:
| 存储方式 | 单值长度 | 是否推荐哈希索引 |
|---|---|---|
uuid 原生类型 | 内部 16 字节 | 收益有限(16 字节不算长) |
text 存 36 字符 UUID | 36 字节 | 收益中等 |
char(36) / varchar(36) | 36 字节 | 收益中等 |
text 存 64 位十六进制 | 64 字节 | 收益明显 |
DROP TABLE IF EXISTS events_demo;
CREATE TABLE events_demo (
id bigint GENERATED ALWAYS AS IDENTITY,
event_uuid text NOT NULL, -- 36 字符文本 UUID
kind text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO events_demo (event_uuid, kind)
SELECT gen_random_uuid()::text, 'k' || (g % 20)
FROM generate_series(1, 1000000) AS g;
CREATE INDEX idx_events_uuid_btree ON events_demo (event_uuid);
CREATE INDEX idx_events_uuid_hash ON events_demo USING hash (event_uuid);
VACUUM ANALYZE events_demo;DROP TABLE
CREATE TABLE
INSERT 0 1000000
CREATE INDEX
CREATE INDEX
VACUUMSELECT
i.indexrelid::regclass AS index_name,
am.amname AS method,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS size
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_am am ON am.oid = c.relam
WHERE i.indrelid = 'events_demo'::regclass
ORDER BY pg_relation_size(i.indexrelid) DESC; index_name | method | size
-------------------------+--------+---------
idx_events_uuid_btree | btree | 59 MB
idx_events_uuid_hash | hash | 26 MB
(2 rows)uuid 原生类型别急着换哈希
如果列声明为 uuid 类型,B-Tree 每条索引项只需 16 字节键 + 6 字节 TID,与哈希索引的 4 + 6 差距不大,通常不值得为此放弃 B-Tree 的范围与唯一能力。
场景三:大宽键(Wide Key)的等值查找
「宽键」指键值本身很长,且没有合适的短代理键可用。例如:
- URL / 请求路径(可能一两百字符);
- 结构化签名字符串(JWT、签名串);
- 邮件地址 + 域名的组合文本;
- 对象存储的完整 key(带前缀路径)。
DROP TABLE IF EXISTS http_cache;
CREATE TABLE http_cache (
id bigint GENERATED ALWAYS AS IDENTITY,
cache_key text NOT NULL, -- 形如 host + path + 查询串
body_size integer NOT NULL,
hit_count bigint NOT NULL DEFAULT 0
);
INSERT INTO http_cache (cache_key, body_size)
SELECT
'https://cdn.example.com/static/v' || (g % 50) || '/assets/chunk-' ||
lpad((g % 90000)::text, 6, '0') || '.js?rev=' || md5(g::text),
(random() * 500000)::int
FROM generate_series(1, 1500000) AS g;
CREATE INDEX idx_http_cache_key_hash ON http_cache USING hash (cache_key);
ANALYZE http_cache;DROP TABLE
CREATE TABLE
INSERT 0 1500000
CREATE INDEX
ANALYZEEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, body_size FROM http_cache
WHERE cache_key = 'https://cdn.example.com/static/v7/assets/chunk-012345.js?rev=8f14e45fceea167a5a36dedd4bea2543'; QUERY PLAN
-------------------------------------------------------------------------------------------
Bitmap Heap Scan on http_cache (actual rows=1 loops=1)
Recheck Cond: (cache_key = 'https://cdn.example.com/static/v7/...'::text)
Heap Blocks: exact=1
Buffers: shared hit=6
-> Bitmap Index Scan on idx_http_cache_key_hash (actual rows=1 loops=1)
Index Cond: (cache_key = 'https://cdn.example.com/static/v7/...'::text)
Buffers: shared hit=2
(7 rows)键长 80+ 字符时,B-Tree 索引会非常庞大。此时哈希索引往往能把索引体积压到 B-Tree 的 1/4 ~ 1/3,这是它最有价值的战场。
宽键还有个隐藏问题
B-Tree 对超长键有 单个索引项不能超过约 1/3 页(约 2704 字节) 的限制,超长值会导致建索引失败;哈希索引也受类似的行大小限制,但由于它只存哈希码,实际存储的条目极小,几乎不会触碰这个上限。对超长文本列做等值查找,哈希索引在这一点上更稳。
场景四:低基数列(枚举 / 状态)的取舍
状态列(status、type、level)基数低,通常只有几个到几十个值。很多人会问:给状态列建哈希索引有用吗?
答案通常是否定的,原因有两条:
- 桶分布极不均匀:只有几个不同值,意味着只有几个哈希值、几个桶承载全部行,每个桶都溢出成长链;
- 优化器多半不选它:低基数列的等值条件命中比例高,顺序扫描更便宜,索引会被忽略。
实测看看——先看建索引前后的计划选择:
DROP TABLE IF EXISTS orders_status;
CREATE TABLE orders_status (
id bigint GENERATED ALWAYS AS IDENTITY,
status text NOT NULL,
amount numeric(12,2) NOT NULL
);
INSERT INTO orders_status (status, amount)
SELECT (ARRAY['pending','paid','shipped','done','canceled'])[1 + (g % 5)],
(random() * 1000)::numeric(12,2)
FROM generate_series(1, 1000000) AS g;
CREATE INDEX idx_orders_status_hash ON orders_status USING hash (status);
ANALYZE orders_status;DROP TABLE
CREATE TABLE
INSERT 0 1000000
CREATE INDEX
ANALYZEEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM orders_status WHERE status = 'paid'; QUERY PLAN
-------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
-> Seq Scan on orders_status (actual rows=200000 loops=1)
Filter: (status = 'paid'::text)
Rows Removed by Filter: 800000
Buffers: shared hit=7354
(5 rows)即使建了哈希索引,优化器也直接选择顺序扫描——因为要读出的行占比太高(20%),索引路径不划算。
哪怕强行禁用顺序扫描看看索引长什么样:
SET enable_seqscan = off;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM orders_status WHERE status = 'paid';
RESET enable_seqscan; QUERY PLAN
-------------------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=3160
-> Bitmap Heap Scan on orders_status (actual rows=200000 loops=1)
Recheck Cond: (status = 'paid'::text)
Heap Blocks: exact=2603
Buffers: shared hit=3155
-> Bitmap Index Scan on idx_orders_status_hash (actual rows=200000 loops=1)
Index Cond: (status = 'paid'::text)
Buffers: shared hit=552
(9 rows)缓冲区从 7354 降到 3160,看起来省了——但这是强行禁用顺序扫描后的结果,实际生产里优化器不会这么选。而且 Recheck Cond 让 20 万行都要回表比对。
错误!为低基数列单独建哈希索引
-- 不推荐:5 个值、100 万行,索引几乎不会被使用
CREATE INDEX idx_orders_status_hash ON orders_status USING hash (status);低基数列更适合的做法是把它作为 B-Tree 复合索引的前缀列(配合高基数列),或者干脆不建索引。
场景五:只做存在性检查的等值查询
登录校验、Token 有效性检查、消息去重,本质上都是「这一行存在吗」:
SELECT 1 FROM sessions_demo WHERE token = $1;这类查询的特点是:只关心命中与否,不关心返回多少列,且几乎总是命中 1 行或 0 行。哈希索引在这里的表现与 B-Tree 基本一致(都是几次缓冲区访问),差别仅在:
- 哈希索引多一次
Recheck(可忽略); - B-Tree 有机会走
Index Only Scan(若查询列都在索引里),哈希索引永远不行。
-- B-Tree 可覆盖:只查 token 本身,Heap Fetches: 0
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT token FROM sessions_demo
WHERE token = '7c4a8d09ca3762af61e59520943dc26494f8941b9c50f1a8bfd1c8c2d1a3f5e7'; QUERY PLAN
------------------------------------------------------------------------------------------
Index Only Scan using idx_sd_token_btree on sessions_demo (actual rows=1 loops=1)
Index Cond: (token = '7c4a8d09ca3762af61e59520943dc264...'::text)
Heap Fetches: 0
Buffers: shared hit=4
(4 rows)仅有索引扫描这一项就要留意
如果这类「存在性检查」在系统中 QPS 极高,B-Tree 的 Index Only Scan 可以完全避免回表,长期看能省下可观的缓冲区访问。哈希索引永远必须回表——这是高频等值查询下 B-Tree 可能反超的原因。
场景六:不适合哈希索引的写法清单
下面这些写法看起来能用哈希索引,实际全部无效:
-- 反例 1:前缀匹配——哈希无序,必须顺序扫描
SELECT * FROM sessions_demo WHERE token LIKE '7c4a%';
-- 反例 2:范围——哈希无序
SELECT * FROM sessions_demo WHERE token > '7c4a' AND token < '7c4b';
-- 反例 3:多列条件——哈希索引不支持多列,只能各自单列
SELECT * FROM users WHERE tenant_id = 1 AND email = 'a@b.com';
-- 反例 4:排序——哈希无序
SELECT * FROM sessions_demo ORDER BY token LIMIT 100;
-- 反例 5:覆盖查询——哈希索引不存原值
SELECT token FROM sessions_demo WHERE token = '7c4a...'; -- 无法 Index Only Scan逐条看执行计划,全部会落到 Seq Scan:
EXPLAIN (COSTS OFF)
SELECT * FROM sessions_demo WHERE token LIKE '7c4a%'; QUERY PLAN
-------------------------------------------------------
Seq Scan on sessions_demo
Filter: (token ~~ '7c4a%'::text)
(2 rows)EXPLAIN (COSTS OFF)
SELECT * FROM sessions_demo ORDER BY token LIMIT 100; QUERY PLAN
-----------------------------------------------
Limit
-> Sort
Sort Key: token
-> Seq Scan on sessions_demo
(4 rows)错误!把哈希索引当成 B-Tree 的平替
-- 不推荐:以后一旦需要 ORDER BY token 或前缀匹配,这个索引完全帮不上
CREATE INDEX idx_sd_token_hash ON sessions_demo USING hash (token);索引类型一旦选定,改能力就等于重建。如果业务对未来的查询形态没有十足把握,B-Tree 是更安全的默认值。
场景七:性能实测方法(体积 + 计划)
评估「这个哈希索引值不值得」,标准流程只有三步:
第 1 步:量体积
SELECT
i.indexrelid::regclass AS index_name,
am.amname AS method,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS size,
pg_size_pretty(pg_total_relation_size(i.indexrelid)) AS total_size
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_am am ON am.oid = c.relam
WHERE i.indrelid = 'sessions_demo'::regclass
ORDER BY pg_relation_size(i.indexrelid) DESC;pg_relation_size() 只统计索引主文件;pg_total_relation_size() 还会算上 TOAST 与 FSM/VM。索引体积评估通常看前者即可。
第 2 步:看条目密度
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
indexrelid::regclass AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_index
WHERE indrelid = 'sessions_demo'::regclass;对索引级别的条目统计用 pgstatindex()(B-Tree 专用):
SELECT * FROM pgstatindex('idx_sd_token_btree'); version | tree_level | index_size | root_block_no | internal_pages | leaf_pages | empty_pages | deleted_pages | avg_leaf_density | leaf_fragmentation
---------+------------+------------+---------------+----------------+------------+-------------+---------------+------------------+-------------------
4 | 2 | 236781568 | 3 | 42 | 28899 | 0 | 0 | 89.94 | 0.00
(1 row)哈希索引的页级统计走 pageinspect
pgstatindex() 只适用于 B-Tree,哈希索引没有同名的聚合统计函数。若需要下钻到页一级,用 pageinspect 扩展的哈希专用函数:hash_metapage_info()(元数据页)、hash_page_type()(页类型)、hash_page_stats()(桶页/溢出页统计)、hash_page_items()(页内条目)与 hash_bitmap_info()(溢出页在位图中的状态)。日常评估膨胀仍以 pg_relation_size() 的总量与 REINDEX 前后的对比最快。
第 3 步:比计划与缓冲区
-- 哈希
SET enable_indexscan = off;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id FROM sessions_demo
WHERE token = '7c4a8d09ca3762af61e59520943dc26494f8941b9c50f1a8bfd1c8c2d1a3f5e7';
RESET enable_indexscan;
-- B-Tree
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id FROM sessions_demo
WHERE token = '7c4a8d09ca3762af61e59520943dc26494f8941b9c50f1a8bfd1c8c2d1a3f5e7';看两项:实际执行时间(去掉 TIMING 时看总时间)与 Buffers 数值。两者接近就说明「性能相当」,此时是否选哈希只看体积。
基准测试的三个纪律
- 必须
ANALYZE:统计信息不新鲜会让优化器做出完全错误的判断; - 必须多次执行取稳定值:第一次执行要读磁盘,缓存命中后结果会明显不同;
- 必须固定参数:
enable_indexscan、enable_bitmapscan、work_mem等会影响计划形态,对比时要么都放开由优化器决定,要么用同一套开关。
场景八:与其他索引类型的选型矩阵
哈希索引不是唯一选项。把常见候选放到一张表里:
| 查询诉求 | 推荐方法 | 备选 | 哈希索引可用性 |
|---|---|---|---|
| 长字符串等值查找 | hash(体积优先)或 btree | btree | ✅ 最合适 |
| 等值 + 需要唯一约束 | btree(UNIQUE) | — | ❌ |
| 等值 + 需要排序 | btree | — | ❌ |
| 等值 + 范围混合 | btree | — | ❌ |
| 多列等值组合 | btree(复合) | bitmap 组合单列 | ❌ |
前缀匹配 LIKE 'abc%' | btree(text_pattern_ops) | — | ❌ |
| 全文检索 | gin | gist | ❌ |
| JSONB 包含查询 | gin(jsonb_path_ops) | — | ❌ |
| 数组包含 | gin | — | ❌ |
| 大表范围 + 相关性排序 | brin | btree | ❌ |
| 几何/范围/近邻 | gist / spgist | — | ❌ |
| 短键等值(int / smallint) | btree | — | ⚠️ 无收益 |
一句话记法:哈希索引只覆盖「长键 + 纯等值 + 不要唯一」这一格,其他格子全部交给 B-Tree 或 GIN / BRIN / GiST。
场景九:从 B-Tree 切换到哈希索引的迁移路径
如果确认要换,必须走「先建新的、再删旧的」,且全程用 CONCURRENTLY:
-- 第 1 步:在线建哈希索引(不阻塞读写)
CREATE INDEX CONCURRENTLY idx_sd_token_hash
ON sessions_demo USING hash (token);
-- 第 2 步:确认新索引有效、可被使用
SELECT c.relname AS index_name, i.indisvalid, i.indisready
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = 'sessions_demo'::regclass;
-- 第 3 步:观察一段时间后,删除旧 B-Tree 索引
DROP INDEX CONCURRENTLY idx_sd_token_btree;CREATE INDEX
index_name | indisvalid | indisready
-----------------------+------------+------------
sessions_demo_pkey | t | t
idx_sd_token_hash | t | t
(2 rows)失败会留下 INVALID 索引
CREATE INDEX CONCURRENTLY 或 DROP INDEX CONCURRENTLY 失败时会留下 indisvalid = false 的残骸,这种索引不会被优化器使用,但会继续消耗写入开销和空间。发现后必须手工清理:
SELECT c.relname AS invalid_index
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = 'sessions_demo'::regclass AND NOT i.indisvalid;场景十:决策清单
把前面九节收敛成一张可以直接照着走的判定表:
| # | 问题 | 若为「否」 |
|---|---|---|
| 1 | 这张表的所有查询是否都只对该列做 =?(没有范围/排序/前缀) | 用 B-Tree |
| 2 | 是否不需要该列(或该列组合)的唯一性? | 用 B-Tree + UNIQUE |
| 3 | 该列是否单列索引即可满足?(不需要多列组合) | 用 B-Tree 复合索引 |
| 4 | 该列的键长是否明显大于 8~16 字节? | 用 B-Tree(哈希省不下空间) |
| 5 | 索引体积是否已经成为实际问题(内存/磁盘/备份)? | 用 B-Tree(性能无差别) |
| 6 | 是否存在高频「只查该列本身」的查询(希望走 Index Only Scan)? | 用 B-Tree |
| 7 | 服务端版本是否 ≥ 10? | 绝对不用哈希索引 |
| 8 | 该表的写入量是否不会快速增长?(哈希桶扩展在前台) | 谨慎,优先 B-Tree |
八问全部「是」,才用哈希索引。
实战示例
场景:一个短链服务,links 表存长 URL 与短码。两种典型查询——按长 URL 查重(等值,键很长)、按短码跳转(等值,键很短)。分别选择索引类型并验证效果。
第 1 步:建表并灌入数据
DROP TABLE IF EXISTS links CASCADE;
CREATE TABLE links (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
short_code text NOT NULL, -- 7 位短码
long_url text NOT NULL, -- 长 URL,键很长
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO links (short_code, long_url)
SELECT
substr(md5(g::text), 1, 7),
'https://www.example.com/category/' || (g % 800) || '/product/' ||
lpad((g % 500000)::text, 8, '0') ||
'?utm_source=newsletter&utm_medium=email&utm_campaign=spring_sale_' || (g % 30)
FROM generate_series(1, 1200000) AS g;
ANALYZE links;DROP TABLE
CREATE TABLE
INSERT 0 1200000
ANALYZE这里 long_url 平均约 110 字符,short_code 固定 7 字符,正好构成「长键 vs 短键」的对照。
第 2 步:给长 URL 建哈希索引
CREATE INDEX CONCURRENTLY idx_links_longurl_hash
ON links USING hash (long_url);
ANALYZE links;CREATE INDEX
ANALYZE第 3 步:验证长 URL 等值查询确实走上哈希索引
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, short_code FROM links
WHERE long_url = 'https://www.example.com/category/123/product/00045678?utm_source=newsletter&utm_medium=email&utm_campaign=spring_sale_7'; QUERY PLAN
-------------------------------------------------------------------------------------
Bitmap Heap Scan on links (actual rows=1 loops=1)
Recheck Cond: (long_url = 'https://www.example.com/category/123/...'::text)
Heap Blocks: exact=1
Buffers: shared read=5
-> Bitmap Index Scan on idx_links_longurl_hash (actual rows=1 loops=1)
Index Cond: (long_url = 'https://www.example.com/category/123/...'::text)
Buffers: shared read=2
(7 rows)第 4 步:建等价的 B-Tree 索引对比体积
CREATE INDEX CONCURRENTLY idx_links_longurl_btree
ON links (long_url);
ANALYZE links;CREATE INDEX
ANALYZESELECT
i.indexrelid::regclass AS index_name,
am.amname AS method,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS size
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_am am ON am.oid = c.relam
WHERE i.indrelid = 'links'::regclass
ORDER BY pg_relation_size(i.indexrelid) DESC; index_name | method | size
----------------------------+--------+---------
idx_links_longurl_btree | btree | 205 MB
idx_links_longurl_hash | hash | 62 MB
links_pkey | btree | 38 MB
(3 rows)长键场景下哈希索引体积只有 B-Tree 的约 30%,节省约 143 MB。对一个短链服务来说,这个差值足以让整个索引常驻内存。
第 5 步:短码查询——这里绝不能选哈希索引
短码查询虽然也是等值,但只有 7 字符,哈希省不下空间;更重要的是短码需要唯一约束(同一个短码不能指向两个 URL):
-- 正确做法:唯一约束(底层是 B-Tree 唯一索引)
ALTER TABLE links ADD CONSTRAINT uq_links_short_code UNIQUE (short_code);
ANALYZE links;ALTER TABLE
ANALYZEEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT long_url FROM links WHERE short_code = 'a1b2c3d'; QUERY PLAN
----------------------------------------------------------------------------------
Index Only Scan using uq_links_short_code on links (actual rows=1 loops=1)
Index Cond: (short_code = 'a1b2c3d'::text)
Heap Fetches: 0
Buffers: shared hit=4
(4 rows)Index Only Scan + Heap Fetches: 0,完全不需要回表。这正是哈希索引做不到的事。
第 6 步:验证桶数量与数据分布
哈希索引的桶数会随数据量增长,且增长发生在前台——插入这些 120 万行时如果索引已存在,插入过程会明显变慢。查看索引文件的页数可以侧面观察膨胀情况:
SELECT
i.indexrelid::regclass AS index_name,
pg_relation_size(i.indexrelid) / current_setting('block_size')::int AS pages
FROM pg_index i
WHERE i.indrelid = 'links'::regclass
ORDER BY pages DESC; index_name | pages
----------------------------+-------
idx_links_longurl_btree | 26240
links_pkey | 4864
idx_links_longurl_hash | 7936
(3 rows)第 7 步:检查索引使用统计
上线一段时间后,用 pg_stat_user_indexes 判断索引到底有没有被用:
SELECT
indexrelname,
idx_scan,
idx_tup_read,
idx_tup_fetch,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'links'
ORDER BY idx_scan DESC; indexrelname | idx_scan | idx_tup_read | idx_tup_fetch | size
---------------------------+----------+--------------+---------------+---------
uq_links_short_code | 984120 | 984120 | 0 | 38 MB
idx_links_longurl_hash | 12403 | 12403 | 12403 | 62 MB
idx_links_longurl_btree | 0 | 0 | 0 | 205 MB
(3 rows)idx_links_longurl_btree 的 idx_scan = 0,说明它一次都没被用过——可以安全删除。注意这里 idx_tup_fetch 有值而 B-Tree 的仅索引扫描为 0,也印证了哈希索引必须回表。
第 8 步:清理并回收空间
DROP INDEX CONCURRENTLY idx_links_longurl_btree;DROP INDEXDROP TABLE links;
DROP TABLE orders_status;
DROP TABLE http_cache;
DROP TABLE events_demo;
DROP TABLE sessions_demo;
DROP TABLE api_keys;DROP TABLE
DROP TABLE
DROP TABLE
DROP TABLE
DROP TABLE
DROP TABLE本案例的结论
long_url(长键 + 纯等值 + 不需唯一)→ 哈希索引,体积省 70%;short_code(短键 + 需要唯一)→ B-Tree 唯一索引,还能走仅索引扫描;- 选型依据是键长与是否要唯一,不是「等值就该用哈希」。
注意事项
哈希索引只支持单列,多列会直接报错:
sql-- 错误!ERROR: access method "hash" does not support multicolumn indexes CREATE INDEX idx_bad ON links USING hash (short_code, long_url);哈希索引不支持唯一性,无法用作主键或
UNIQUE:sql-- 错误!ERROR: access method "hash" does not support unique indexes CREATE UNIQUE INDEX idx_bad_unique ON links USING hash (short_code);哈希索引不支持排序与范围,这类查询会退化为顺序扫描:
sql-- 永远走不到索引 SELECT * FROM links ORDER BY long_url LIMIT 10;扫描必然有损,计划里一定出现
Recheck Cond,不要误以为计划异常:sqlEXPLAIN SELECT id FROM links WHERE long_url = 'https://...';不推荐为短键(
int、bigint、短code)建哈希索引,省下的空间不足以抵消功能损失:sql-- 不推荐:键只有 4~8 字节 CREATE INDEX idx_bad_short ON links USING hash (id);错误! 认为哈希索引能加速
LIKE 'abc%':sql-- 错误认知:以为哈希索引能优化前缀查询 SELECT * FROM links WHERE long_url LIKE 'https://www.example.com/category/123%';服务端版本低于 10 时绝对不能使用:不写 WAL、崩溃后可能损坏且无法察觉。
sqlSHOW server_version; -- 必须 ≥ 10哈希桶扩展发生在前台,写入量快速增长的表不适合哈希索引,插入会出现难以预测的抖动。
哈希索引不能套用
pgstatindex()(那是 B-Tree 专用),评估膨胀主要靠总大小与REINDEX前后的对比;需要页级细节时改用pageinspect的hash_metapage_info()/hash_page_stats()等函数。一旦业务出现范围/排序/前缀需求,哈希索引只能重建为 B-Tree,迁移成本比一开始就选 B-Tree 高:
sqlDROP INDEX CONCURRENTLY idx_links_longurl_hash; CREATE INDEX CONCURRENTLY idx_links_longurl_btree ON links (long_url);
最佳实践
先问「键够不够长」,再问「查询够不够纯」。顺序反了容易白折腾:
sqlSELECT avg(length(long_url))::int AS avg_key_len FROM links;长键(≥ 32 字节)+ 纯等值 + 不需唯一,是哈希索引的标准画像:
sqlCREATE INDEX idx_links_longurl_hash ON links USING hash (long_url);短键一律用 B-Tree,并把唯一约束也交给它:
sqlALTER TABLE links ADD CONSTRAINT uq_links_short_code UNIQUE (short_code);两个索引并存、观察一段时间,再删掉不被使用的那一个,用数据而不是猜测做决定:
sqlSELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE relname = 'links' AND idx_scan = 0;大表建索引一律
CONCURRENTLY,避免长时间阻塞写入:sqlCREATE INDEX CONCURRENTLY idx_links_longurl_hash ON links USING hash (long_url);建完立刻检查有没有留下 INVALID 索引:
sqlSELECT c.relname FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE i.indrelid = 'links'::regclass AND NOT i.indisvalid;定期
REINDEX膨胀的哈希索引,这是唯一能回收空间的手段:sqlREINDEX INDEX CONCURRENTLY idx_links_longurl_hash;用部分哈希索引排除高频重复值,让桶分布更均匀:
sqlCREATE INDEX idx_links_longurl_hash_active ON links USING hash (long_url) WHERE created_at > now() - interval '90 days';在 ORM / 迁移脚本里显式写明
USING hash,否则默认语句会建成 B-Tree:sqlCREATE INDEX idx_links_longurl_hash ON links USING hash (long_url);把选型理由写进迁移注释,避免后人「顺手」把它改成 B-Tree 而丢掉体积收益:
sqlCOMMENT ON INDEX idx_links_longurl_hash IS '长 URL 等值查重专用;键约 110 字节,哈希索引体积约为 B-Tree 的 30%';
下一节
继续学习:哈希索引与 B-Tree 对比