Skip to content

哈希索引适用场景 ​

上一节讲了哈希索引的结构与限制:它只支持单列、只支持 =、扫描必有损、索引里只存 4 字节哈希码。限制这么多,它到底还有没有用? 有,但适用面很窄。

本节把「哈希索引真正值得用的场景」一条条掰开:哪些查询形态、哪些数据类型、什么样的键长,能让它在体积或 I/O 上确实优于 B-Tree;反过来,哪些看起来「像是等值查询」的写法其实用不上它。最后给出一份可以直接照着判断的决策清单。

前置知识

阅读本节前,建议先了解:哈希索引原理

基础概念 ​

判断哈希索引是否适用的三把尺子 ​

一个索引值不值得用哈希,只取决于三件事:

尺子判断依据说明
查询形态是否只有等值条件只要出现范围、排序、前缀、<>,哈希索引立刻出局
键的长度索引键是否明显长于 4 字节键越长,哈希索引省的空间越多;短键(int、小日期)省不了
唯一性需求是否不需要唯一约束需要 UNIQUE 就只能选 B-Tree

三条同时满足,哈希索引才进入候选;任意一条不满足,直接用 B-Tree。

哈希索引省的是「索引体积」,不是「查询速度」 ​

这是最容易误解的一点。哈希索引的收益几乎全部来自存储层:

text
B-Tree 每条索引项: [完整键值(可能几十字节)] + [6 字节 TID]
哈希索引每条索引项:[4 字节哈希码]          + [6 字节 TID]

键越长 → 两者差距越大 → 哈希索引越占优
键越短 → 两者差距越小 → 哈希索引几乎没有理由

而查询速度上,哈希索引反而多了一层「有损扫描」的代价:

text
B-Tree 等值查找: 索引内直接确定命中 → 回表取行
哈希索引等值查找:索引内只能确定哈希码相同 → 回表后还要 Recheck 比对原值

结论先行

哈希索引是「省空间」的工具,不是「提速度」的工具。 在等值查询延迟上,B-Tree 通常持平甚至略优;哈希索引的价值出现在「索引体积大到影响内存命中率、备份体积、磁盘占用」的时候。

键长与体积收益的量化关系 ​

假设一张 1000 万行的表,TID 固定 6 字节,页头与行指针开销按经验折算,不同键长下的相对体积:

键类型典型键长B-Tree 索引相对体积哈希索引相对体积哈希节省比例
int44 字节1.0×1.0×≈ 0%(没意义)
bigint8 字节1.2×1.0×≈ 17%(不明显)
uuid(文本存储)36 字符3.5×1.1×≈ 69%
64 位十六进制 token64 字符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,可用选项也少得多:

text
-- 基本形式
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 列建立哈希索引。

sql
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);
text
DROP TABLE
CREATE TABLE
CREATE INDEX

确认它确实是哈希索引:

sql
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;
text
       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、一次性校验码、短信验证码的记录键,都属于这一类。

sql
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;
text
DROP TABLE
CREATE TABLE
INSERT 0 2000000
CREATE INDEX
CREATE INDEX
ANALYZE

在默认设置下,优化器会倾向选择体积更小或成本更低的那个。强制它使用哈希索引来对比:

sql
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;
text
                                    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 万行」微不足道。

这个场景的收益在体积:

sql
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;
text
       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 字符 UUID36 字节收益中等
char(36) / varchar(36)36 字节收益中等
text 存 64 位十六进制64 字节收益明显
sql
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;
text
DROP TABLE
CREATE TABLE
INSERT 0 1000000
CREATE INDEX
CREATE INDEX
VACUUM
sql
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 = 'events_demo'::regclass
ORDER BY pg_relation_size(i.indexrelid) DESC;
text
       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(带前缀路径)。
sql
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;
text
DROP TABLE
CREATE TABLE
INSERT 0 1500000
CREATE INDEX
ANALYZE
sql
EXPLAIN (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';
text
                                        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)基数低,通常只有几个到几十个值。很多人会问:给状态列建哈希索引有用吗?

答案通常是否定的,原因有两条:

  1. 桶分布极不均匀:只有几个不同值,意味着只有几个哈希值、几个桶承载全部行,每个桶都溢出成长链;
  2. 优化器多半不选它:低基数列的等值条件命中比例高,顺序扫描更便宜,索引会被忽略。

实测看看——先看建索引前后的计划选择:

sql
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;
text
DROP TABLE
CREATE TABLE
INSERT 0 1000000
CREATE INDEX
ANALYZE
sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM orders_status WHERE status = 'paid';
text
                            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%),索引路径不划算。

哪怕强行禁用顺序扫描看看索引长什么样:

sql
SET enable_seqscan = off;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM orders_status WHERE status = 'paid';
RESET enable_seqscan;
text
                                        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 万行都要回表比对。

错误!为低基数列单独建哈希索引

sql
-- 不推荐:5 个值、100 万行,索引几乎不会被使用
CREATE INDEX idx_orders_status_hash ON orders_status USING hash (status);

低基数列更适合的做法是把它作为 B-Tree 复合索引的前缀列(配合高基数列),或者干脆不建索引。

场景五:只做存在性检查的等值查询 ​

登录校验、Token 有效性检查、消息去重,本质上都是「这一行存在吗」:

sql
SELECT 1 FROM sessions_demo WHERE token = $1;

这类查询的特点是:只关心命中与否,不关心返回多少列,且几乎总是命中 1 行或 0 行。哈希索引在这里的表现与 B-Tree 基本一致(都是几次缓冲区访问),差别仅在:

  • 哈希索引多一次 Recheck(可忽略);
  • B-Tree 有机会走 Index Only Scan(若查询列都在索引里),哈希索引永远不行。
sql
-- B-Tree 可覆盖:只查 token 本身,Heap Fetches: 0
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT token FROM sessions_demo
WHERE token = '7c4a8d09ca3762af61e59520943dc26494f8941b9c50f1a8bfd1c8c2d1a3f5e7';
text
                                        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 可能反超的原因。

场景六:不适合哈希索引的写法清单 ​

下面这些写法看起来能用哈希索引,实际全部无效:

sql
-- 反例 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:

sql
EXPLAIN (COSTS OFF)
SELECT * FROM sessions_demo WHERE token LIKE '7c4a%';
text
                      QUERY PLAN
-------------------------------------------------------
 Seq Scan on sessions_demo
   Filter: (token ~~ '7c4a%'::text)
(2 rows)
sql
EXPLAIN (COSTS OFF)
SELECT * FROM sessions_demo ORDER BY token LIMIT 100;
text
                   QUERY PLAN
-----------------------------------------------
 Limit
   ->  Sort
         Sort Key: token
         ->  Seq Scan on sessions_demo
(4 rows)

错误!把哈希索引当成 B-Tree 的平替

sql
-- 不推荐:以后一旦需要 ORDER BY token 或前缀匹配,这个索引完全帮不上
CREATE INDEX idx_sd_token_hash ON sessions_demo USING hash (token);

索引类型一旦选定,改能力就等于重建。如果业务对未来的查询形态没有十足把握,B-Tree 是更安全的默认值。

场景七:性能实测方法(体积 + 计划) ​

评估「这个哈希索引值不值得」,标准流程只有三步:

第 1 步:量体积

sql
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 步:看条目密度

sql
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 专用):

sql
SELECT * FROM pgstatindex('idx_sd_token_btree');
text
 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 步:比计划与缓冲区

sql
-- 哈希
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 数值。两者接近就说明「性能相当」,此时是否选哈希只看体积。

基准测试的三个纪律

  1. 必须 ANALYZE:统计信息不新鲜会让优化器做出完全错误的判断;
  2. 必须多次执行取稳定值:第一次执行要读磁盘,缓存命中后结果会明显不同;
  3. 必须固定参数:enable_indexscan、enable_bitmapscan、work_mem 等会影响计划形态,对比时要么都放开由优化器决定,要么用同一套开关。

场景八:与其他索引类型的选型矩阵 ​

哈希索引不是唯一选项。把常见候选放到一张表里:

查询诉求推荐方法备选哈希索引可用性
长字符串等值查找hash(体积优先)或 btreebtree✅ 最合适
等值 + 需要唯一约束btree(UNIQUE)—❌
等值 + 需要排序btree—❌
等值 + 范围混合btree—❌
多列等值组合btree(复合)bitmap 组合单列❌
前缀匹配 LIKE 'abc%'btree(text_pattern_ops)—❌
全文检索gingist❌
JSONB 包含查询gin(jsonb_path_ops)—❌
数组包含gin—❌
大表范围 + 相关性排序brinbtree❌
几何/范围/近邻gist / spgist—❌
短键等值(int / smallint)btree—⚠️ 无收益

一句话记法:哈希索引只覆盖「长键 + 纯等值 + 不要唯一」这一格,其他格子全部交给 B-Tree 或 GIN / BRIN / GiST。

场景九:从 B-Tree 切换到哈希索引的迁移路径 ​

如果确认要换,必须走「先建新的、再删旧的」,且全程用 CONCURRENTLY:

sql
-- 第 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;
text
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 的残骸,这种索引不会被优化器使用,但会继续消耗写入开销和空间。发现后必须手工清理:

sql
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 步:建表并灌入数据

sql
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;
text
DROP TABLE
CREATE TABLE
INSERT 0 1200000
ANALYZE

这里 long_url 平均约 110 字符,short_code 固定 7 字符,正好构成「长键 vs 短键」的对照。

第 2 步:给长 URL 建哈希索引

sql
CREATE INDEX CONCURRENTLY idx_links_longurl_hash
  ON links USING hash (long_url);

ANALYZE links;
text
CREATE INDEX
ANALYZE

第 3 步:验证长 URL 等值查询确实走上哈希索引

sql
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';
text
                                     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 索引对比体积

sql
CREATE INDEX CONCURRENTLY idx_links_longurl_btree
  ON links (long_url);

ANALYZE links;
text
CREATE INDEX
ANALYZE
sql
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 = 'links'::regclass
ORDER BY pg_relation_size(i.indexrelid) DESC;
text
        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):

sql
-- 正确做法:唯一约束(底层是 B-Tree 唯一索引)
ALTER TABLE links ADD CONSTRAINT uq_links_short_code UNIQUE (short_code);

ANALYZE links;
text
ALTER TABLE
ANALYZE
sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT long_url FROM links WHERE short_code = 'a1b2c3d';
text
                                    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 万行时如果索引已存在,插入过程会明显变慢。查看索引文件的页数可以侧面观察膨胀情况:

sql
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;
text
        index_name          | pages
----------------------------+-------
 idx_links_longurl_btree    | 26240
 links_pkey                 |  4864
 idx_links_longurl_hash     |  7936
(3 rows)

第 7 步:检查索引使用统计

上线一段时间后,用 pg_stat_user_indexes 判断索引到底有没有被用:

sql
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;
text
       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 步:清理并回收空间

sql
DROP INDEX CONCURRENTLY idx_links_longurl_btree;
text
DROP INDEX
sql
DROP TABLE links;
DROP TABLE orders_status;
DROP TABLE http_cache;
DROP TABLE events_demo;
DROP TABLE sessions_demo;
DROP TABLE api_keys;
text
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,不要误以为计划异常:

    sql
    EXPLAIN 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、崩溃后可能损坏且无法察觉。

    sql
    SHOW server_version;  -- 必须 ≥ 10
  • 哈希桶扩展发生在前台,写入量快速增长的表不适合哈希索引,插入会出现难以预测的抖动。

  • 哈希索引不能套用 pgstatindex()(那是 B-Tree 专用),评估膨胀主要靠总大小与 REINDEX 前后的对比;需要页级细节时改用 pageinspect 的 hash_metapage_info() / hash_page_stats() 等函数。

  • 一旦业务出现范围/排序/前缀需求,哈希索引只能重建为 B-Tree,迁移成本比一开始就选 B-Tree 高:

    sql
    DROP INDEX CONCURRENTLY idx_links_longurl_hash;
    CREATE INDEX CONCURRENTLY idx_links_longurl_btree ON links (long_url);

最佳实践 ​

  • 先问「键够不够长」,再问「查询够不够纯」。顺序反了容易白折腾:

    sql
    SELECT avg(length(long_url))::int AS avg_key_len FROM links;
  • 长键(≥ 32 字节)+ 纯等值 + 不需唯一,是哈希索引的标准画像:

    sql
    CREATE INDEX idx_links_longurl_hash ON links USING hash (long_url);
  • 短键一律用 B-Tree,并把唯一约束也交给它:

    sql
    ALTER TABLE links ADD CONSTRAINT uq_links_short_code UNIQUE (short_code);
  • 两个索引并存、观察一段时间,再删掉不被使用的那一个,用数据而不是猜测做决定:

    sql
    SELECT indexrelname, idx_scan FROM pg_stat_user_indexes
    WHERE relname = 'links' AND idx_scan = 0;
  • 大表建索引一律 CONCURRENTLY,避免长时间阻塞写入:

    sql
    CREATE INDEX CONCURRENTLY idx_links_longurl_hash
      ON links USING hash (long_url);
  • 建完立刻检查有没有留下 INVALID 索引:

    sql
    SELECT 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 膨胀的哈希索引,这是唯一能回收空间的手段:

    sql
    REINDEX INDEX CONCURRENTLY idx_links_longurl_hash;
  • 用部分哈希索引排除高频重复值,让桶分布更均匀:

    sql
    CREATE INDEX idx_links_longurl_hash_active
      ON links USING hash (long_url) WHERE created_at > now() - interval '90 days';
  • 在 ORM / 迁移脚本里显式写明 USING hash,否则默认语句会建成 B-Tree:

    sql
    CREATE INDEX idx_links_longurl_hash ON links USING hash (long_url);
  • 把选型理由写进迁移注释,避免后人「顺手」把它改成 B-Tree 而丢掉体积收益:

    sql
    COMMENT ON INDEX idx_links_longurl_hash IS
      '长 URL 等值查重专用;键约 110 字节,哈希索引体积约为 B-Tree 的 30%';

下一节

继续学习:哈希索引与 B-Tree 对比

参考链接 ​