PostgreSQL 哈希索引先利用哈希码缩小等值查询的候选范围,再回到表中核对原始值。哈希匹配只说明“可能相等”,不能直接证明相等。两个不同值可能具有相同哈希码,不同哈希码也可能映射到同一个桶。正常复核存在时,碰撞和共享桶都不意味着查询会返回错误结果;它们是实现需要处理的普通情况,而不是可以忽略的例外。
桶里究竟保存什么
索引条目包含四字节哈希值和指向表元组的引用,不保存完整的被索引列值。桶映射把查询导向相关索引页面。桶是共享存储空间,并不是给每个不同键单独分配一个专属位置。一个桶的主页装满后,可以继续连接溢出页,查询这个桶时也需要访问这些页面。因此,“一次算出桶”不等于“永远只读取一个页面”。
PostgreSQL 可以在单个索引页内部按哈希码排序,以便加速局部搜索,但这不会提供原始列值的顺序。哈希索引不能满足键范围或有序输出,只支持单列等值访问,不能强制唯一性,也不支持 IS NULL。B-tree 仍然可以服务等值查询,同时支持更广的一组操作,不能因为键看起来适合哈希就自动排除其他候选。
由于条目中没有原始值,哈希扫描是有损候选扫描,需要用表中实际值复核。因此,碰撞可以在执行计划中表现为 Rows Removed by Index Recheck。这说明数据库正确剔除了只在哈希上匹配的候选,而不是把两个碰撞值都当成相等返回。解读这项指标时,应把候选数量与最终答案区分开。
坏掉的样子
把索引键更新描述为“只碰一个桶”,掩盖了实际工作。修改被索引列会改变元组对应的索引值,新条目可能进入另一个桶。PostgreSQL 使用行版本与延后索引清理,旧引用不一定立刻从结构中消失。数据增长还可能触发桶分裂,溢出页也会增加读写。因此不能用一个理想化的哈希表赋值过程来解释数据库的全部更新成本。
高度重复的值、快速增长的表,与近乎唯一而稳定的等值负载,表现可能很不一样。哈希查询不是无条件的恒定延迟承诺。现代 PostgreSQL 的哈希索引支持崩溃恢复,但持久性既不会消除维护成本,也不会让它突然具备唯一性约束能力。读性能、写维护和数据契约必须分别确认。
快速验证
在可丢弃的 PostgreSQL 18 数据库中,用 psql 执行下面代码。它建立二十万个采用 C 排序规则的临时文本键,使用当前服务器自己的 hashtext 找到一组碰撞,再验证等值查询和键更新。实验没有把哈希码当成跨版本稳定的业务标识,最后回滚会移除所有夹具。
\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE hash_items (key text COLLATE "C");
INSERT INTO hash_items SELECT 'key-'||g FROM generate_series(1,200000) AS g;
CREATE TEMP TABLE collision_pair AS
SELECT min(key) AS a,max(key) AS b FROM hash_items
GROUP BY hashtext(key) HAVING count(*)>1 ORDER BY hashtext(key) LIMIT 1;
SELECT a AS collision_a,b AS collision_b FROM collision_pair \gset
SELECT a,b,hashtext(a),hashtext(b) FROM collision_pair;
CREATE INDEX hash_items_key ON hash_items USING hash(key);
ANALYZE hash_items;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT key FROM hash_items WHERE key=:'collision_a';
UPDATE hash_items SET key='changed-'||key WHERE key=:'collision_a';
SELECT count(*) AS old_key_rows FROM hash_items WHERE key=:'collision_a';
SELECT count(*) AS new_key_rows FROM hash_items WHERE key='changed-'||:'collision_a';
DO $$ BEGIN
IF (SELECT hashtext(a)=hashtext(b) AND a<>b FROM collision_pair) IS DISTINCT FROM true THEN
RAISE EXCEPTION 'fixture did not establish a collision';
END IF;
IF (SELECT count(*) FROM hash_items h JOIN collision_pair p ON h.key=p.a) <> 0 OR
(SELECT count(*) FROM hash_items h JOIN collision_pair p ON h.key='changed-'||p.a) <> 1 OR
(SELECT count(*) FROM hash_items h JOIN collision_pair p ON h.key=p.b) <> 1 THEN
RAISE EXCEPTION 'hash lookup or indexed update returned wrong keys';
END IF;
END $$;
ROLLBACK;
PostgreSQL 18.6 中找到的两个键是 key-129539 与 key-7295,哈希码都是 -1960436116。等值计划使用 hash_items_key,最终只返回一行,同时报告一行被索引复核移除。两段字符串虽然哈希相同,却仍然是不同的值。这是反对“哈希码没有碰撞”这一错误假设的实际负向验证,不能用普通的无碰撞点查替代。
将第一个键改成带 changed- 前缀的新值后,旧值查到零行,新值查到一行,另一条碰撞记录仍然查到一行。最终断言分别验证这三件事,并确认原始两个值确实具有相同哈希而内容不同。仅仅看到更新命令成功,还不足以证明同桶或同哈希的其他记录没有受到错误影响。
具体碰撞键和计划形状来自这一版本的实测。如果其他环境没有找到碰撞对,脚本应停止,不能宣称已经验证碰撞复核。即使换个环境找到另一组键,语义要求仍然不变:精确相等只能返回原始值符合条件的记录;修改一个值,不得因为另一个值哈希相同而改变它。固定的结果契约比固定某个内部哈希码更重要。
把维护也放进性能评估
使用有代表性的分布一起测量页面访问、等值延迟、更新行为和索引大小。VACUUM 可以移除失效引用、回收溢出页供结构内部复用,但不会缩小哈希索引的桶数量,也不会把这部分分配直接归还操作系统。重建属于另外的维护操作,本身需要成本。较小的哈希条目确实可能有价值,但只有完整负载比较才能判断它是否抵得过复核、溢出页和写维护开销。
来源
- PostgreSQL 18:哈希索引,碰撞复核与桶维护,已核验 2026-09-10。
- PostgreSQL 18:索引类型,B-tree 与哈希索引支持的条件,已核验 2026-09-10。