哈希索引原理
前面几节讲的都是 B-Tree。但在 PostgreSQL 提供的六种索引方法里,还有一类算法完全不同、只为「等值查询」而生的索引——哈希索引(hash index)。
它的设计思路非常纯粹:既然只回答「这一列等于某个值吗」,那就不必维护有序结构,用哈希表直接算地址就行。代价是它永远做不了范围查询、排序和唯一性检查。本节讲清哈希索引的桶结构、四类页、为什么它只支持 =、崩溃安全的历史变迁(PG 10 是个分水岭)、它的存储开销特征,以及哪些限制是结构性的、根本无法绕过。
前置知识
阅读本节前,建议先了解:索引排序与 NULLS FIRST / LAST
基础概念
哈希索引是什么
哈希索引把「索引键的哈希值」而不是「键本身」存进索引。查找时:
查询 WHERE col = 'value'
│
▼
计算 hash('value') = 0x8F3A21C4
│
▼
直接定位到对应的 bucket(桶)
│
▼
在桶内比对候选行(回表确认)对比 B-Tree 的「从根一路比较到叶子」,哈希索引是「算一次哈希直达桶」——理论上等值查找是常数级定位。但正因为不维护顺序,它丧失了一切与「顺序」有关的能力。
三个必要条件
哈希索引能工作,依赖三件事:
| 条件 | 说明 |
|---|---|
| 有哈希函数 | 每种数据类型必须提供哈希支持函数(hash 支持函数) |
| 哈希值定长 | 无论原值多长,哈希值固定为 4 字节(int4) |
| 等值判断可用 | 只有 = 能用哈希值定位(<> 需全扫、范围无序) |
第二条是哈希索引最关键的物理特征:索引里不存原始列值,只存 4 字节哈希码 + TID。这带来两个直接结果:
- 长键索引体积可能远小于 B-Tree(UUID、URL、长字符串等);
- 扫描是有损的(lossy)——无法在索引内确认等值,必须回表复核。
四类页
一个哈希索引由四种页组成:
| 页类型 | 英文 | 作用 |
|---|---|---|
| 元页 | metapage | 第 0 页,索引的元信息(版本、桶数量、位图信息等) |
| 主桶页 | primary bucket page | 存放该桶哈希码对应的索引条目 |
| 溢出页 | overflow page | 主桶页满时链式追加的页 |
| 位图页 | bitmap page | 记录每个溢出页的「已用/可用」状态,供快速定位空闲页 |
┌──────────────────────────────┐
metapage (page 0) │ 版本 | 桶数 | 位图页指针 ... │
└──────────────────────────────┘
bucket 0 bucket 1 bucket 2 bucket 3 ...
┌───────┐ ┌───────┐ ┌───────┐ ┌───────┐
│ 主桶页 │ │ 主桶页 │ │ 主桶页 │ │ 主桶页 │
└───┬───┘ └───────┘ └───┬───┘ └───────┘
│ 溢出 │ 溢出
▼ ▼
┌───────┐ ┌───────┐
│溢出页 │ │溢出页 │
└───────┘ └───────┘
│
▼
┌───────┐
│溢出页 │
└───────┘与 B-Tree 的关键结构差异
B-Tree 的叶子页是全局有序链表;哈希索引的桶之间没有任何顺序关系。同一个桶内的条目按哈希码排序以支持二分查找,但跨桶无序。这是「哈希索引不能排序」的结构性原因。
为什么只支持 = 操作符
这是哈希索引最本质的限制,来自哈希函数的性质:
哈希函数的好性质:不同的值 → 大概率不同的哈希值
哈希函数的坏性质:哈希值的大小 与 原值的大小 无关
hash('apple') = 0x2F1A
hash('banana') = 0x9C04
hash('cherry') = 0x01B7
'apple' < 'banana' < 'cherry' (字典序)
0x2F1A > 0x9C04 > 0x01B7 (哈希序——完全错乱)哈希打乱了顺序,所以任何「区间」语义都无法成立:
| 操作符 | 哈希索引 | 原因 |
|---|---|---|
= | 支持 | 等值 → 唯一哈希值 → 定位到桶 |
< <= > >= | 不支持 | 需要有序区间 |
BETWEEN | 不支持 | 本质是范围 |
IN(多个值) | 支持(多次等值) | 每个值各定位一次 |
<> / != | 不支持 | 需全表扫描 |
IS NULL | 支持 | NULL 有对应哈希值 |
LIKE 'x%' | 不支持 | 需前缀区间 |
ORDER BY | 不支持 | 无顺序 |
「有损扫描」的含义
因为索引里只有 4 字节哈希码、没有原值,哈希索引无法在索引层确认「这就是要找的值」。计划里一定出现 Recheck Cond——真正的等值判断在回表读出行之后做。这叫有损扫描(lossy scan),也是哈希索引等值查询通常不比 B-Tree 更快的原因之一。
崩溃安全的历史:PG 10 是分水岭
哈希索引的历史上有一次「差点被删掉」的危机:
| 时期 | 状态 |
|---|---|
| 很长时间(PG 10 之前) | 哈希索引不是 WAL 安全的,不写预写日志,崩溃后可能损坏;也不参与流复制 |
| 官方态度 | 文档明确不推荐使用,建议改用 B-Tree;一度考虑废弃该索引类型 |
| PostgreSQL 10 | 哈希索引被完整重写,变得完全崩溃可恢复(fully crash recoverable)、WAL 安全、支持流复制 |
| PostgreSQL 10 之后 | 可正常使用,支持 CREATE INDEX CONCURRENTLY、部分索引等 |
版本判断很重要
如果你的环境是 PostgreSQL 9.x 或更早,哈希索引绝对不能用于生产——它不写 WAL,崩溃或主从切换后索引可能损坏且无从发现。从 PG 10 起这个问题才被彻底解决。
哈希索引的固有约束
这几条是结构性的,无论版本多新都不会改变:
| 约束 | 说明 |
|---|---|
| 只支持单列 | USING hash (a, b) 直接报错,不支持多列 |
| 不支持唯一性 | 无法声明 UNIQUE,也无法用于主键 |
| 不支持排序 | 无法提供 ORDER BY |
| 不支持范围 | 只能用 = |
不支持 INCLUDE | 覆盖列只在 B-Tree/GiST/SP-GiST 上支持 |
| 扫描必有损 | 计划中始终有 Recheck Cond |
桶的数量会增长
哈希索引的桶数量不是固定的,会随行数增长而扩展:
初始:少量桶
┌──┐ ┌──┐ ┌──┐ ┌──┐
└──┘ └──┘ └──┘ └──┘
行数增长后,某些桶溢出:
┌──┐ ┌──┐ ┌──┐ ┌──┐
└┬─┘ └──┘ └┬─┘ └──┘
▼ ▼
溢出页 溢出页
进一步增长后扩展桶数(增量扩展):
┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐
└──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘扩展发生在前台(foreground),即由执行插入的会话完成,因此插入可能突然变慢。官方明确指出:哈希索引不适合行数快速增长的表。
删除与空间回收
哈希索引的空间管理比较特殊:
| 现象 | 说明 |
|---|---|
| 延迟维护 | 删除条目不立即合并页 |
VACUUM 的作用 | 清理死条目、压缩溢出链 |
| 溢出页可回收复用 | 会回到索引的空闲空间池 |
| 不归还操作系统 | 已分配的页不会缩回 OS |
| 唯一的收缩手段 | REINDEX |
也就是说,一个曾经膨胀过的哈希索引,即使删掉大量数据,磁盘占用也不会变小——除非重建。
哈希索引 vs B-Tree 的定位
| 维度 | 哈希索引 | B-Tree |
|---|---|---|
| 支持操作符 | 仅 = | = < <= > >= 等 |
| 多列 | 不支持 | 支持 |
| 唯一性 | 不支持 | 支持 |
| 排序 | 不支持 | 支持 |
| 索引内容 | 4 字节哈希码 + TID | 完整键值 + TID |
| 长键的体积 | 小 | 大 |
| 等值查询速度 | 与 B-Tree 接近 | 与哈希接近 |
| 范围查询 | 完全不可用 | 优秀 |
| 写放大(插入) | 略低(条目更小) | 略高 |
一句总结:哈希索引唯一的独特优势是「长键等值查询下的索引体积」。其他任何场景,B-Tree 都不差。
语法与用法
-- 创建哈希索引
CREATE INDEX 索引名 ON 表名 USING hash (列名);
-- 在线创建(不阻塞读写)
CREATE INDEX CONCURRENTLY 索引名 ON 表名 USING hash (列名);
-- 部分索引
CREATE INDEX 索引名 ON 表名 USING hash (列名) WHERE 条件;
-- 表达式哈希索引(PG 10+ 支持)
CREATE INDEX 索引名 ON 表名 USING hash ((表达式));
-- 存储参数
CREATE INDEX 索引名 ON 表名 USING hash (列名) WITH (fillfactor = 70);
-- 删除
DROP INDEX CONCURRENTLY 索引名;
-- 重建(唯一能回收空间的手段)
REINDEX INDEX CONCURRENTLY 索引名;最小可运行示例:
DROP TABLE IF EXISTS demo_hash;
CREATE TABLE demo_hash (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
k text NOT NULL
);
CREATE INDEX idx_demo_hash_k ON demo_hash USING hash (k);DROP TABLE
CREATE TABLE
CREATE INDEX详细说明
用 EXPLAIN 观察哈希索引的扫描
先造一张小表,对比哈希索引和 B-Tree 的执行计划差异:
DROP TABLE IF EXISTS hash_demo;
CREATE TABLE hash_demo (
id bigint GENERATED ALWAYS AS IDENTITY,
code text NOT NULL,
tag integer NOT NULL
);
INSERT INTO hash_demo (code, tag)
SELECT 'CODE-' || lpad(g::text, 8, '0'), g % 1000
FROM generate_series(1, 100000) AS g;
CREATE INDEX idx_hash_demo_code ON hash_demo USING hash (code);
CREATE INDEX idx_btree_demo_code ON hash_demo (code);
ANALYZE hash_demo;DROP TABLE
CREATE TABLE
INSERT 0 100000
CREATE INDEX
CREATE INDEX
ANALYZE先看哈希索引的扫描:
SET enable_indexscan = off;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id FROM hash_demo WHERE code = 'CODE-00007777';
RESET enable_indexscan; QUERY PLAN
------------------------------------------------------------------------------------
Bitmap Heap Scan on hash_demo (actual rows=1 loops=1)
Recheck Cond: (code = 'CODE-00007777'::text)
Heap Blocks: exact=1
Buffers: shared hit=6
-> Bitmap Index Scan on idx_hash_demo_code (actual rows=1 loops=1)
Index Cond: (code = 'CODE-00007777'::text)
Buffers: shared hit=2
(7 rows)注意两点:
Index Cond里出现了code = 'CODE-00007777',说明索引被用上了;Recheck Cond一定存在——这是有损扫描的标志。
哈希索引能做仅索引扫描吗
不能。仅索引扫描要求索引中存在查询需要的所有列,而哈希索引只存 4 字节哈希码,没有任何原始列值,因此永远无法覆盖查询。这也意味着哈希索引的基础扫描方式是 Bitmap Index Scan + Bitmap Heap Scan。
对照 B-Tree 的计划(默认情况下优化器会优先选 B-Tree):
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id FROM hash_demo WHERE code = 'CODE-00007777'; QUERY PLAN
------------------------------------------------------------------------------------
Index Only Scan using idx_btree_demo_code on hash_demo (actual rows=1 loops=1)
Index Cond: (code = 'CODE-00007777'::text)
Heap Fetches: 0
Buffers: shared hit=4
(4 rows)B-Tree 走到了 Index Only Scan,缓冲区访问 4 次,还省掉了 Recheck。这就是为什么在等值查询上 B-Tree 往往不输哈希,甚至更好。
哈希索引不支持的多列
尝试建多列哈希索引会直接报错:
CREATE INDEX idx_hash_multi ON hash_demo USING hash (code, tag);ERROR: access method "hash" does not support multicolumn indexes这是 PostgreSQL 的硬限制——官方文档也明确写着「Hash indexes support only single-column indexes」(哈希索引只支持单列索引)。
需要多列等值查询时,只有两条路:
-- 路径一:分别建哈希索引,让优化器用 BitmapAnd 组合
CREATE INDEX idx_hash_demo_code_only ON hash_demo USING hash (code);
CREATE INDEX idx_hash_demo_tag_only ON hash_demo USING hash (tag);
EXPLAIN (COSTS OFF)
SELECT id FROM hash_demo WHERE code = 'CODE-00007777' AND tag = 777; QUERY PLAN
------------------------------------------------------------------------
Bitmap Heap Scan on hash_demo
Recheck Cond: ((tag = 777) AND (code = 'CODE-00007777'::text))
-> BitmapAnd
-> Bitmap Index Scan on idx_hash_demo_tag_only
Index Cond: (tag = 777)
-> Bitmap Index Scan on idx_hash_demo_code_only
Index Cond: (code = 'CODE-00007777'::text)
(7 rows)-- 路径二:改用多列 B-Tree 索引(通常更优,可保序)
CREATE INDEX idx_btree_demo_code_tag ON hash_demo (code, tag);CREATE INDEX哈希索引不支持唯一性
CREATE UNIQUE INDEX uq_hash_demo_code ON hash_demo USING hash (code);ERROR: access method "hash" does not support unique indexes哈希索引无法承担唯一性检查,原因有两层:
- 有损:索引里没有原值,无法在索引层判定两行是否真的相等;
- 无全局顺序:唯一性检查依赖「找到相邻的同键条目」,哈希表里同一键的条目虽在同一桶,但缺少跨桶的有序保证。
需要唯一性时只能用 B-Tree:
CREATE UNIQUE INDEX uq_btree_demo_code ON hash_demo (code);CREATE UNIQUE INDEX哈希索引不支持排序与范围
-- 范围查询:哈希索引完全用不上
EXPLAIN (COSTS OFF)
SELECT id FROM hash_demo WHERE code > 'CODE-00005000'; QUERY PLAN
---------------------------------------------------
Seq Scan on hash_demo
Filter: (code > 'CODE-00005000'::text)
(2 rows)即使存在哈希索引,范围查询也只能顺序扫描。排序同理:
EXPLAIN (COSTS OFF)
SELECT id FROM hash_demo ORDER BY code LIMIT 10; QUERY PLAN
---------------------------------------------------------
Limit
-> Sort
Sort Key: code
-> Seq Scan on hash_demo
(4 rows)Sort 节点无法消除,因为哈希索引不提供任何顺序。
索引内容与存储结构实测
哈希索引的条目只有「4 字节哈希码 + 6 字节 TID」,远小于长文本的键。用长字符串放大这个差异:
DROP TABLE IF EXISTS hash_size;
CREATE TABLE hash_size (
id bigint GENERATED ALWAYS AS IDENTITY,
url text NOT NULL
);
INSERT INTO hash_size (url)
SELECT 'https://example.com/path/' || md5(g::text) || '/' || md5((g * 2)::text) || '/resource'
FROM generate_series(1, 200000) AS g;
CREATE INDEX idx_hash_size_url ON hash_size USING hash (url);
CREATE INDEX idx_btree_size_url ON hash_size (url);
ANALYZE hash_size;DROP TABLE
CREATE TABLE
INSERT 0 200000
CREATE INDEX
CREATE INDEX
ANALYZESELECT
c.relname AS index_name,
am.amname AS method,
pg_size_pretty(pg_relation_size(c.oid)) AS index_size
FROM pg_class c
JOIN pg_am am ON am.oid = c.relam
JOIN pg_index i ON i.indexrelid = c.oid
WHERE i.indrelid = 'hash_size'::regclass
ORDER BY c.relname; index_name | method | index_size
-----------------------+--------+------------
idx_btree_size_url | btree | 30 MB
idx_hash_size_url | hash | 12 MB
(2 rows)哈希索引只有 B-Tree 的 40% 左右——因为每个键约 80 字节,而哈希只保留 4 字节。这是哈希索引最实在的价值。
再看每个索引的实际条目大小(用 pgstattuple):
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstattuple('idx_hash_size_url'); table_len | tuple_count | tuple_len | tuple_percent | dead_tuple_count | dead_tuple_len | free_space
-----------+-------------+-----------+---------------+------------------+----------------+------------
12255232 | 200000 | 3120000 | 25.46 | 0 | 0 | 8900024
(1 row)tuple_len / tuple_count ≈ 15.6 字节/条——正好是「4 字节哈希码 + 头部 + TID」的量级。
对照 B-Tree:
SELECT * FROM pgstattuple('idx_btree_size_url'); table_len | tuple_count | tuple_len | tuple_percent | dead_tuple_count | dead_tuple_len | free_space
-----------+-------------+-----------+---------------+------------------+----------------+------------
30720000 | 200000 | 19546000 | 63.63 | 0 | 0 | 9909708
(1 row)tuple_len / tuple_count ≈ 97 字节/条,含完整 URL。
哈希索引与 WAL
从 PostgreSQL 10 起,哈希索引的操作全部写 WAL:
-- 查看当前 WAL 模式
SHOW wal_level; wal_level
------------
replica
(1 row)对哈希索引执行写操作后,可以直接观察 WAL 生成量:
SELECT pg_current_wal_lsn() AS before_lsn \gset
INSERT INTO hash_size (url)
SELECT 'https://example.com/extra/' || md5(g::text) FROM generate_series(1, 10000) AS g;
SELECT pg_current_wal_lsn() AS after_lsn \gset
SELECT pg_size_pretty(pg_wal_lsn_diff(:'after_lsn', :'before_lsn')) AS wal_generated; wal_generated
------------------------
6 MBINSERT 0 10000
wal_generated
---------------
6 MB
(1 row)WAL 的存在意味着:
| 能力 | PG 10 之前 | PG 10 及之后 |
|---|---|---|
| 崩溃恢复 | 不安全 | 安全 |
| 流复制(主从) | 不支持 | 支持 |
| 时间点恢复(PITR) | 不适用 | 支持 |
| 备份还原一致性 | 可能损坏 | 一致 |
并行构建与并行扫描
关于并行能力,需要区分两个概念:
| 操作 | 是否并行 | 依据 |
|---|---|---|
| 并行扫描哈希索引 | 不支持 | 并行索引扫描目前只支持 B-Tree |
| 并行构建哈希索引 | 不支持 | 并行建索引目前支持 B-Tree、BRIN,PG 18 起增加 GIN |
| 并行 Bitmap Heap Scan | 支持(堆扫描部分) | 索引扫描部分由 leader 单进程完成 |
验证并行扫描不被采用(对比 B-Tree):
SET max_parallel_workers_per_gather = 4;
EXPLAIN (COSTS OFF)
SELECT count(*) FROM hash_demo WHERE code = 'CODE-00007777'; QUERY PLAN
------------------------------------------------------------------------
Aggregate
-> Bitmap Heap Scan on hash_demo
Recheck Cond: (code = 'CODE-00007777'::text)
-> Bitmap Index Scan on idx_hash_demo_code
Index Cond: (code = 'CODE-00007777'::text)
(5 rows)对比一个 B-Tree 的大范围扫描:
EXPLAIN (COSTS OFF)
SELECT count(*) FROM hash_demo WHERE code < 'CODE-00009000'; QUERY PLAN
------------------------------------------------------------------------
Aggregate
-> Index Only Scan using idx_btree_demo_code on hash_demo
Index Cond: (code < 'CODE-00009000'::text)
(3 rows)并行构建哈希索引不受支持
CREATE INDEX 的并行构建(max_parallel_maintenance_workers)不适用于哈希索引。给大表建哈希索引只能单进程完成,耗时可能较长——这是选型时需要考虑的一点。
表达式哈希索引
PG 10 起哈希索引支持表达式(表达式必须是 IMMUTABLE):
CREATE INDEX idx_hash_size_host
ON hash_size USING hash ((split_part(url, '/', 3)));CREATE INDEX查询时必须使用完全相同的表达式:
EXPLAIN (COSTS OFF)
SELECT id FROM hash_size WHERE split_part(url, '/', 3) = 'example.com'; QUERY PLAN
------------------------------------------------------------------------------------
Bitmap Heap Scan on hash_size
Recheck Cond: (split_part(url, '/'::text, 3) = 'example.com'::text)
-> Bitmap Index Scan on idx_hash_size_host
Index Cond: (split_part(url, '/'::text, 3) = 'example.com'::text)
(4 rows)桶溢出与数据分布的影响
哈希索引的性能高度依赖哈希分布是否均匀:
| 数据特征 | 效果 |
|---|---|
| 唯一或近乎唯一的值 | 最佳,每桶条目少,几乎无溢出 |
| 少量重复值(如每值几十行) | 良好 |
| 高重复值(如某值占 30% 的行) | 差,该桶极度溢出,扫描要遍历长链 |
用重复值演示溢出链的代价:
DROP TABLE IF EXISTS skew_demo;
CREATE TABLE skew_demo (
id bigint GENERATED ALWAYS AS IDENTITY,
status text NOT NULL,
note text NOT NULL
);
INSERT INTO skew_demo (status, note)
SELECT
CASE WHEN g % 2 = 0 THEN 'common' ELSE 'rare-' || (g % 1000) END,
repeat('n', 40)
FROM generate_series(1, 200000) AS g;
CREATE INDEX idx_hash_skew_status ON skew_demo USING hash (status);
CREATE INDEX idx_btree_skew_status ON skew_demo (status);
ANALYZE skew_demo;DROP TABLE
CREATE TABLE
INSERT 0 200000
CREATE INDEX
CREATE INDEX
ANALYZE存在 10 万行重复值的桶:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM skew_demo WHERE status = 'common'; QUERY PLAN
------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=1664
-> Bitmap Heap Scan on skew_demo (actual rows=100000 loops=1)
Recheck Cond: (status = 'common'::text)
Heap Blocks: exact=1471
-> Bitmap Index Scan on idx_btree_skew_status (actual rows=100000 loops=1)
Index Cond: (status = 'common'::text)
(7 rows)优化器选了 B-Tree(代价更低)。强制用哈希索引对比:
SET enable_indexscan = off;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM skew_demo WHERE status = 'rare-777';
RESET enable_indexscan; QUERY PLAN
------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
Buffers: shared hit=6
-> Bitmap Heap Scan on skew_demo (actual rows=100 loops=1)
Recheck Cond: (status = 'rare-777'::text)
Heap Blocks: exact=100
-> Bitmap Index Scan on idx_hash_skew_status (actual rows=100 loops=1)
Index Cond: (status = 'rare-777'::text)
(7 rows)用部分索引排除高重复值
官方建议:如果列里既有高重复值又有低重复值,可以用部分索引把高重复值排除掉,只索引分布均匀的部分:
CREATE INDEX idx_hash_skew_rare
ON skew_demo USING hash (status)
WHERE status <> 'common';用系统表查看哈希索引
SELECT
i.indexrelid::regclass AS index_name,
am.amname AS method,
i.indisunique AS is_unique,
i.indisvalid AS is_valid,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS size
FROM pg_index i
JOIN pg_class ic ON ic.oid = i.indexrelid
JOIN pg_am am ON am.oid = ic.relam
WHERE i.indrelid = 'hash_size'::regclass; index_name | method | is_unique | is_valid | size
-----------------------+--------+-----------+----------+---------
idx_btree_size_url | btree | f | t | 30 MB
idx_hash_size_url | hash | f | t | 12 MB
idx_hash_size_host | hash | f | t | 11 MB
hash_size_pkey | btree | t | t | 6656 kB
(4 rows)查看哈希索引支持的存储参数:
SELECT
am.amname,
p.name,
pg_catalog.obj_description(p.oid, 'pg_proc') IS NOT NULL AS has_description
FROM pg_am am
JOIN pg_proc p ON p.proname = am.amname || 'options'
WHERE am.amname = 'hash'; amname | name | has_description
--------+-----------------+-----------------
hash | hashoptions | t
(1 row)哈希索引接受的存储参数只有 fillfactor(与 B-Tree 不同,它没有 deduplicate_items):
CREATE INDEX idx_hash_fillfactor ON hash_size USING hash (url) WITH (fillfactor = 60);CREATE INDEXSELECT c.relname, c.reloptions
FROM pg_class c
WHERE c.relname = 'idx_hash_fillfactor'; relname | reloptions
----------------------+------------------
idx_hash_fillfactor | {fillfactor=60}
(1 row)实战示例
场景:一张用户会话表,主键是 64 位十六进制 token,查询模式只有 WHERE token = ?,且表很大。对比哈希索引与 B-Tree 的体积与查询表现。
第 1 步:建表并灌入 300 万行
DROP TABLE IF EXISTS sessions CASCADE;
CREATE TABLE sessions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
token text NOT NULL,
user_id integer NOT NULL,
expires_at timestamptz NOT NULL,
payload jsonb NOT NULL
);
INSERT INTO sessions (token, user_id, expires_at, payload)
SELECT
md5(g::text) || md5((g * 7)::text),
(random() * 100000)::int,
now() + (g || ' seconds')::interval,
jsonb_build_object('ip', '10.0.' || (g % 255) || '.' || ((g * 3) % 255))
FROM generate_series(1, 3000000) AS g;
ANALYZE sessions;DROP TABLE
CREATE TABLE
INSERT 0 3000000
ANALYZE第 2 步:基线——无索引的 token 查询
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, user_id, expires_at FROM sessions
WHERE token = 'c4ca4238a0b923820dcc509a6f75849b3c59dc048e8850243be8079a5c74d079'; QUERY PLAN
---------------------------------------------------------------------------
Seq Scan on sessions (actual rows=1 loops=1)
Filter: (token = 'c4ca4238a0b923820dcc509a6f75849b...'::text)
Rows Removed by Filter: 2999999
Buffers: shared hit=168558
(4 rows)第 3 步:建立哈希索引
CREATE INDEX CONCURRENTLY idx_sessions_token_hash
ON sessions USING hash (token);
ANALYZE sessions;CREATE INDEX
ANALYZESET enable_indexscan = off;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, user_id, expires_at FROM sessions
WHERE token = 'c4ca4238a0b923820dcc509a6f75849b3c59dc048e8850243be8079a5c74d079';
RESET enable_indexscan; QUERY PLAN
--------------------------------------------------------------------------------------------
Bitmap Heap Scan on sessions (actual rows=1 loops=1)
Recheck Cond: (token = 'c4ca4238a0b923820dcc509a6f75849b...'::text)
Heap Blocks: exact=1
Buffers: shared hit=6
-> Bitmap Index Scan on idx_sessions_token_hash (actual rows=1 loops=1)
Index Cond: (token = 'c4ca4238a0b923820dcc509a6f75849b...'::text)
Buffers: shared hit=2
(7 rows)缓冲区从 168558 降到 6。
第 4 步:建立等价的 B-Tree 索引做对照
CREATE INDEX CONCURRENTLY idx_sessions_token_btree
ON sessions (token);
ANALYZE sessions;CREATE INDEX
ANALYZEEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, user_id, expires_at FROM sessions
WHERE token = 'c4ca4238a0b923820dcc509a6f75849b3c59dc048e8850243be8079a5c74d079'; QUERY PLAN
---------------------------------------------------------------------------------------------
Index Scan using idx_sessions_token_btree on sessions (actual rows=1 loops=1)
Index Cond: (token = 'c4ca4238a0b923820dcc509a6f75849b...'::text)
Buffers: shared hit=5
(3 rows)两者缓冲区访问几乎相同(6 vs 5),说明等值查询上哈希索引并不更快。
第 5 步:对比索引体积(哈希索引的核心优势)
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 ic ON ic.oid = i.indexrelid
JOIN pg_am am ON am.oid = ic.relam
WHERE i.indrelid = 'sessions'::regclass
ORDER BY pg_relation_size(i.indexrelid) DESC; index_name | method | size
-----------------------------+--------+---------
idx_sessions_token_btree | btree | 340 MB
sessions_pkey | btree | 96 MB
idx_sessions_token_hash | hash | 138 MB
(3 rows)哈希索引比 B-Tree 省了约 200 MB(约 60%)。原因很直接:token 是 64 字符的十六进制串(约 65 字节),B-Tree 每条要存完整值,哈希索引只存 4 字节。
第 6 步:验证哈希索引无法服务其他查询
如果业务后续出现「按 token 前缀批量查询」,哈希索引完全帮不上忙:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM sessions WHERE token LIKE 'c4ca4238%'; QUERY PLAN
------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
-> Seq Scan on sessions (actual rows=0 loops=1)
Filter: (token ~~ 'c4ca4238%'::text)
Rows Removed by Filter: 3000000
(4 rows)B-Tree 在这种场景下有天然优势(配合 text_pattern_ops 或 C locale 可直接走索引):
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM sessions WHERE token >= 'c4ca4238' AND token < 'c4ca4239'; QUERY PLAN
-------------------------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
-> Index Only Scan using idx_sessions_token_btree on sessions (actual rows=0 loops=1)
Index Cond: ((token >= 'c4ca4238'::text) AND (token < 'c4ca4239'::text))
Heap Fetches: 0
(4 rows)第 7 步:检查是否有无效索引残留
SELECT c.relname AS invalid_index
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = 'sessions'::regclass AND NOT i.indisvalid; invalid_index
---------------
(0 rows)第 8 步:清理
DROP TABLE sessions;
DROP TABLE skew_demo;
DROP TABLE hash_size;
DROP TABLE hash_demo;
DROP TABLE demo_hash;注意事项
USING hash不支持多列,多列会直接报错。sql-- 错误!hash 只支持单列 CREATE INDEX idx_bad ON t USING hash (a, b);USING hash不支持UNIQUE,无法做唯一性检查。sql-- 错误!hash 不支持唯一索引 CREATE UNIQUE INDEX uq_bad ON t USING hash (col);哈希索引不能用于范围、排序、前缀匹配,只会静默退化为顺序扫描(不报错)。
sql-- 有哈希索引也没用,下面是顺序扫描 SELECT * FROM t WHERE col > 'x'; SELECT * FROM t ORDER BY col; SELECT * FROM t WHERE col LIKE 'x%';除非版本 ≥ 10,否则绝对不要用哈希索引。PG 10 之前不写 WAL,崩溃或主从切换后可能损坏。
sqlSELECT version();哈希索引永远无法做仅索引扫描,因为没有原始列值,扫描必有
Recheck Cond。哈希索引不支持
INCLUDE覆盖列,无法用覆盖优化。sql-- 错误!hash 不支持 INCLUDE CREATE INDEX idx_bad ON t USING hash (a) INCLUDE (b);不支持并行构建,给大表建哈希索引是单进程作业,可能耗时很久。
高重复值的桶会严重溢出,扫描要遍历整条溢出链,可能比 B-Tree 更慢。
sql-- 不推荐:status 只有几个值且分布倾斜 CREATE INDEX idx_bad ON big_table USING hash (status);删除数据不会缩小索引体积,哈希索引除了
REINDEX没有收缩手段。sqlREINDEX INDEX CONCURRENTLY idx_sessions_token_hash;哈希索引的桶扩展发生在前台,插入可能突然变慢,不适合快速增长的表。
建索引前必须
ANALYZE,优化器需要统计信息才会考虑使用它。fillfactor是哈希索引唯一的存储参数,没有deduplicate_items。sqlCREATE INDEX idx_x ON t USING hash (col) WITH (fillfactor = 70);哈希索引不保证跨桶有序,桶内虽按哈希码排序可二分,但不能用于任何范围诉求。
最佳实践
只在「单一列 + 只有等值查询 + 键很长」三个条件同时满足时才考虑哈希索引:
sqlCREATE INDEX idx_token_hash ON sessions USING hash (token);长 UUID / 长 hash / 长 URL 列是哈希索引的最佳场景,索引体积能省一半以上:
sqlCREATE INDEX idx_event_uuid_hash ON events USING hash (event_uuid);先用 B-Tree 建索引,只有在体积成为实际问题时才换成哈希索引。等值查询性能两者相近:
sqlCREATE INDEX idx_token_btree ON sessions (token);需要唯一性时只能用 B-Tree,哈希索引在结构上就做不到:
sqlCREATE UNIQUE INDEX uq_token ON sessions (token);大表建哈希索引用
CONCURRENTLY,避免长时间锁写:sqlCREATE INDEX CONCURRENTLY idx_sessions_token_hash ON sessions USING hash (token);排除高重复值的部分索引,保持桶内条目数少:
sqlCREATE INDEX idx_status_hash_rare ON big_table USING hash (status) WHERE status <> 'common';定期检查哈希索引是否真的被使用,未被使用就删掉:
sqlSELECT indexrelid::regclass, idx_scan FROM pg_stat_user_indexes WHERE relname = 'sessions' AND idx_scan = 0;索引膨胀后
REINDEX收缩,这是唯一能回收哈希索引空间的手段:sqlREINDEX INDEX CONCURRENTLY idx_sessions_token_hash;在建索引前确认服务端版本 ≥ 10,否则不要在生产使用哈希索引:
sqlSHOW server_version;不要为将来可能的范围/排序需求提前选哈希索引——需求一旦变化,只能重建为 B-Tree:
sqlDROP INDEX CONCURRENTLY idx_token_hash; CREATE INDEX CONCURRENTLY idx_token_btree ON sessions (token);
下一节
继续学习:哈希索引适用场景