Skip to content

哈希索引原理 ​

前面几节讲的都是 B-Tree。但在 PostgreSQL 提供的六种索引方法里,还有一类算法完全不同、只为「等值查询」而生的索引——哈希索引(hash index)。

它的设计思路非常纯粹:既然只回答「这一列等于某个值吗」,那就不必维护有序结构,用哈希表直接算地址就行。代价是它永远做不了范围查询、排序和唯一性检查。本节讲清哈希索引的桶结构、四类页、为什么它只支持 =、崩溃安全的历史变迁(PG 10 是个分水岭)、它的存储开销特征,以及哪些限制是结构性的、根本无法绕过。

前置知识

阅读本节前,建议先了解:索引排序与 NULLS FIRST / LAST

基础概念 ​

哈希索引是什么 ​

哈希索引把「索引键的哈希值」而不是「键本身」存进索引。查找时:

text
查询 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记录每个溢出页的「已用/可用」状态,供快速定位空闲页
text
                     ┌──────────────────────────────┐
   metapage (page 0) │ 版本 | 桶数 | 位图页指针 ...   │
                     └──────────────────────────────┘

   bucket 0   bucket 1   bucket 2   bucket 3   ...
   ┌───────┐  ┌───────┐  ┌───────┐  ┌───────┐
   │ 主桶页 │  │ 主桶页 │  │ 主桶页 │  │ 主桶页 │
   └───┬───┘  └───────┘  └───┬───┘  └───────┘
       │ 溢出                   │ 溢出
       ▼                        ▼
   ┌───────┐                ┌───────┐
   │溢出页  │                │溢出页  │
   └───────┘                └───────┘
       │
       ▼
   ┌───────┐
   │溢出页  │
   └───────┘

与 B-Tree 的关键结构差异

B-Tree 的叶子页是全局有序链表;哈希索引的桶之间没有任何顺序关系。同一个桶内的条目按哈希码排序以支持二分查找,但跨桶无序。这是「哈希索引不能排序」的结构性原因。

为什么只支持 = 操作符 ​

这是哈希索引最本质的限制,来自哈希函数的性质:

text
哈希函数的好性质:不同的值 → 大概率不同的哈希值
哈希函数的坏性质:哈希值的大小 与 原值的大小 无关

  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

桶的数量会增长 ​

哈希索引的桶数量不是固定的,会随行数增长而扩展:

text
初始:少量桶
     ┌──┐ ┌──┐ ┌──┐ ┌──┐
     └──┘ └──┘ └──┘ └──┘

行数增长后,某些桶溢出:
     ┌──┐ ┌──┐ ┌──┐ ┌──┐
     └┬─┘ └──┘ └┬─┘ └──┘
      ▼         ▼
    溢出页     溢出页

进一步增长后扩展桶数(增量扩展):
     ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐
     └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘

扩展发生在前台(foreground),即由执行插入的会话完成,因此插入可能突然变慢。官方明确指出:哈希索引不适合行数快速增长的表。

删除与空间回收 ​

哈希索引的空间管理比较特殊:

现象说明
延迟维护删除条目不立即合并页
VACUUM 的作用清理死条目、压缩溢出链
溢出页可回收复用会回到索引的空闲空间池
不归还操作系统已分配的页不会缩回 OS
唯一的收缩手段REINDEX

也就是说,一个曾经膨胀过的哈希索引,即使删掉大量数据,磁盘占用也不会变小——除非重建。

哈希索引 vs B-Tree 的定位 ​

维度哈希索引B-Tree
支持操作符仅 == < <= > >= 等
多列不支持支持
唯一性不支持支持
排序不支持支持
索引内容4 字节哈希码 + TID完整键值 + TID
长键的体积小大
等值查询速度与 B-Tree 接近与哈希接近
范围查询完全不可用优秀
写放大(插入)略低(条目更小)略高

一句总结:哈希索引唯一的独特优势是「长键等值查询下的索引体积」。其他任何场景,B-Tree 都不差。

语法与用法 ​

text
-- 创建哈希索引
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 索引名;

最小可运行示例:

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

详细说明 ​

用 EXPLAIN 观察哈希索引的扫描 ​

先造一张小表,对比哈希索引和 B-Tree 的执行计划差异:

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

先看哈希索引的扫描:

sql
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;
text
                                     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)

注意两点:

  1. Index Cond 里出现了 code = 'CODE-00007777',说明索引被用上了;
  2. Recheck Cond 一定存在——这是有损扫描的标志。

哈希索引能做仅索引扫描吗

不能。仅索引扫描要求索引中存在查询需要的所有列,而哈希索引只存 4 字节哈希码,没有任何原始列值,因此永远无法覆盖查询。这也意味着哈希索引的基础扫描方式是 Bitmap Index Scan + Bitmap Heap Scan。

对照 B-Tree 的计划(默认情况下优化器会优先选 B-Tree):

sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id FROM hash_demo WHERE code = 'CODE-00007777';
text
                                     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 往往不输哈希,甚至更好。

哈希索引不支持的多列 ​

尝试建多列哈希索引会直接报错:

sql
CREATE INDEX idx_hash_multi ON hash_demo USING hash (code, tag);
text
ERROR:  access method "hash" does not support multicolumn indexes

这是 PostgreSQL 的硬限制——官方文档也明确写着「Hash indexes support only single-column indexes」(哈希索引只支持单列索引)。

需要多列等值查询时,只有两条路:

sql
-- 路径一:分别建哈希索引,让优化器用 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;
text
                             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)
sql
-- 路径二:改用多列 B-Tree 索引(通常更优,可保序)
CREATE INDEX idx_btree_demo_code_tag ON hash_demo (code, tag);
text
CREATE INDEX

哈希索引不支持唯一性 ​

sql
CREATE UNIQUE INDEX uq_hash_demo_code ON hash_demo USING hash (code);
text
ERROR:  access method "hash" does not support unique indexes

哈希索引无法承担唯一性检查,原因有两层:

  1. 有损:索引里没有原值,无法在索引层判定两行是否真的相等;
  2. 无全局顺序:唯一性检查依赖「找到相邻的同键条目」,哈希表里同一键的条目虽在同一桶,但缺少跨桶的有序保证。

需要唯一性时只能用 B-Tree:

sql
CREATE UNIQUE INDEX uq_btree_demo_code ON hash_demo (code);
text
CREATE UNIQUE INDEX

哈希索引不支持排序与范围 ​

sql
-- 范围查询:哈希索引完全用不上
EXPLAIN (COSTS OFF)
SELECT id FROM hash_demo WHERE code > 'CODE-00005000';
text
                    QUERY PLAN
---------------------------------------------------
 Seq Scan on hash_demo
   Filter: (code > 'CODE-00005000'::text)
(2 rows)

即使存在哈希索引,范围查询也只能顺序扫描。排序同理:

sql
EXPLAIN (COSTS OFF)
SELECT id FROM hash_demo ORDER BY code LIMIT 10;
text
                       QUERY PLAN
---------------------------------------------------------
 Limit
   ->  Sort
         Sort Key: code
         ->  Seq Scan on hash_demo
(4 rows)

Sort 节点无法消除,因为哈希索引不提供任何顺序。

索引内容与存储结构实测 ​

哈希索引的条目只有「4 字节哈希码 + 6 字节 TID」,远小于长文本的键。用长字符串放大这个差异:

sql
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;
text
DROP TABLE
CREATE TABLE
INSERT 0 200000
CREATE INDEX
CREATE INDEX
ANALYZE
sql
SELECT
  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;
text
      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):

sql
CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT * FROM pgstattuple('idx_hash_size_url');
text
 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:

sql
SELECT * FROM pgstattuple('idx_btree_size_url');
text
 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:

sql
-- 查看当前 WAL 模式
SHOW wal_level;
text
 wal_level
------------
 replica
(1 row)

对哈希索引执行写操作后,可以直接观察 WAL 生成量:

sql
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;
text
      wal_generated
------------------------
 6 MB
text
INSERT 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):

sql
SET max_parallel_workers_per_gather = 4;

EXPLAIN (COSTS OFF)
SELECT count(*) FROM hash_demo WHERE code = 'CODE-00007777';
text
                             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 的大范围扫描:

sql
EXPLAIN (COSTS OFF)
SELECT count(*) FROM hash_demo WHERE code < 'CODE-00009000';
text
                             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):

sql
CREATE INDEX idx_hash_size_host
  ON hash_size USING hash ((split_part(url, '/', 3)));
text
CREATE INDEX

查询时必须使用完全相同的表达式:

sql
EXPLAIN (COSTS OFF)
SELECT id FROM hash_size WHERE split_part(url, '/', 3) = 'example.com';
text
                                     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% 的行)差,该桶极度溢出,扫描要遍历长链

用重复值演示溢出链的代价:

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

存在 10 万行重复值的桶:

sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM skew_demo WHERE status = 'common';
text
                                  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(代价更低)。强制用哈希索引对比:

sql
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;
text
                                  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)

用部分索引排除高重复值

官方建议:如果列里既有高重复值又有低重复值,可以用部分索引把高重复值排除掉,只索引分布均匀的部分:

sql
CREATE INDEX idx_hash_skew_rare
  ON skew_demo USING hash (status)
  WHERE status <> 'common';

用系统表查看哈希索引 ​

sql
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;
text
      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)

查看哈希索引支持的存储参数:

sql
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';
text
 amname |      name       | has_description
--------+-----------------+-----------------
 hash   | hashoptions     | t
(1 row)

哈希索引接受的存储参数只有 fillfactor(与 B-Tree 不同,它没有 deduplicate_items):

sql
CREATE INDEX idx_hash_fillfactor ON hash_size USING hash (url) WITH (fillfactor = 60);
text
CREATE INDEX
sql
SELECT c.relname, c.reloptions
FROM pg_class c
WHERE c.relname = 'idx_hash_fillfactor';
text
       relname        |    reloptions
----------------------+------------------
 idx_hash_fillfactor  | {fillfactor=60}
(1 row)

实战示例 ​

场景:一张用户会话表,主键是 64 位十六进制 token,查询模式只有 WHERE token = ?,且表很大。对比哈希索引与 B-Tree 的体积与查询表现。

第 1 步:建表并灌入 300 万行

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

第 2 步:基线——无索引的 token 查询

sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, user_id, expires_at FROM sessions
WHERE token = 'c4ca4238a0b923820dcc509a6f75849b3c59dc048e8850243be8079a5c74d079';
text
                            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 步:建立哈希索引

sql
CREATE INDEX CONCURRENTLY idx_sessions_token_hash
  ON sessions USING hash (token);
ANALYZE sessions;
text
CREATE INDEX
ANALYZE
sql
SET 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;
text
                                    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 索引做对照

sql
CREATE INDEX CONCURRENTLY idx_sessions_token_btree
  ON sessions (token);
ANALYZE sessions;
text
CREATE INDEX
ANALYZE
sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT id, user_id, expires_at FROM sessions
WHERE token = 'c4ca4238a0b923820dcc509a6f75849b3c59dc048e8850243be8079a5c74d079';
text
                                 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 步:对比索引体积(哈希索引的核心优势)

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 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;
text
         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 前缀批量查询」,哈希索引完全帮不上忙:

sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM sessions WHERE token LIKE 'c4ca4238%';
text
                                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 可直接走索引):

sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS)
SELECT count(*) FROM sessions WHERE token >= 'c4ca4238' AND token < 'c4ca4239';
text
                                        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 步:检查是否有无效索引残留

sql
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;
text
 invalid_index
---------------
(0 rows)

第 8 步:清理

sql
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,崩溃或主从切换后可能损坏。

    sql
    SELECT 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 没有收缩手段。

    sql
    REINDEX INDEX CONCURRENTLY idx_sessions_token_hash;
  • 哈希索引的桶扩展发生在前台,插入可能突然变慢,不适合快速增长的表。

  • 建索引前必须 ANALYZE,优化器需要统计信息才会考虑使用它。

  • fillfactor 是哈希索引唯一的存储参数,没有 deduplicate_items。

    sql
    CREATE INDEX idx_x ON t USING hash (col) WITH (fillfactor = 70);
  • 哈希索引不保证跨桶有序,桶内虽按哈希码排序可二分,但不能用于任何范围诉求。

最佳实践 ​

  • 只在「单一列 + 只有等值查询 + 键很长」三个条件同时满足时才考虑哈希索引:

    sql
    CREATE INDEX idx_token_hash ON sessions USING hash (token);
  • 长 UUID / 长 hash / 长 URL 列是哈希索引的最佳场景,索引体积能省一半以上:

    sql
    CREATE INDEX idx_event_uuid_hash ON events USING hash (event_uuid);
  • 先用 B-Tree 建索引,只有在体积成为实际问题时才换成哈希索引。等值查询性能两者相近:

    sql
    CREATE INDEX idx_token_btree ON sessions (token);
  • 需要唯一性时只能用 B-Tree,哈希索引在结构上就做不到:

    sql
    CREATE UNIQUE INDEX uq_token ON sessions (token);
  • 大表建哈希索引用 CONCURRENTLY,避免长时间锁写:

    sql
    CREATE INDEX CONCURRENTLY idx_sessions_token_hash
      ON sessions USING hash (token);
  • 排除高重复值的部分索引,保持桶内条目数少:

    sql
    CREATE INDEX idx_status_hash_rare
      ON big_table USING hash (status)
      WHERE status <> 'common';
  • 定期检查哈希索引是否真的被使用,未被使用就删掉:

    sql
    SELECT indexrelid::regclass, idx_scan
    FROM pg_stat_user_indexes
    WHERE relname = 'sessions' AND idx_scan = 0;
  • 索引膨胀后 REINDEX 收缩,这是唯一能回收哈希索引空间的手段:

    sql
    REINDEX INDEX CONCURRENTLY idx_sessions_token_hash;
  • 在建索引前确认服务端版本 ≥ 10,否则不要在生产使用哈希索引:

    sql
    SHOW server_version;
  • 不要为将来可能的范围/排序需求提前选哈希索引——需求一旦变化,只能重建为 B-Tree:

    sql
    DROP INDEX CONCURRENTLY idx_token_hash;
    CREATE INDEX CONCURRENTLY idx_token_btree ON sessions (token);

下一节

继续学习:哈希索引适用场景

参考链接 ​