如果你翻过PostgreSQL的源码,一定会在很多模块里撞见同一个身影:HTAB、hash_create、hash_search这些符号遍布系统缓存、锁管理、共享缓冲区的实现代码。我第一次接触这套数据结构时,以为它只是一张普普通通的哈希表,往里塞数据就行。结果在排查一个问题时发现,动态哈希表的“动态”二字远比想象中复杂:桶数量会随数据量自动翻倍,条目会被搬移到新桶,共享表还会按分区拆分成多把锁。搞清楚这张表的工作原理,再回头看缓存、锁、快照这些模块的代码,很多原来“看不懂”的细节会瞬间通顺。这篇文章就从操作者的视角,把dynahash的核心结构、创建参数、增删改查动作、扩容机制和实战排坑完整过一遍。无论你是想改PostgreSQL源码,还是只是在调试时想理解内部日志,内容都适用。
1. 哈希表在PostgreSQL里的存在感:从系统缓存到锁管理器
1.1 dynahash的血统
PostgreSQL里的动态哈希表实现集中在一个文件里:src/backend/utils/hash/dynahash.c。这套代码从伯克利时代的POSTGRES继承下来,命名也保留着当年的痕迹。它解决的问题很朴素:给内核提供一个键值存储结构,既能按键快速查找,又能在数据量变大时自动扩张,不需要调用方提前精确预知数据规模。
代码里的符号名基本都以hash_开头,但核心对象叫HTAB,翻译过来就是“哈希表句柄”。你在源码里看到的hash_create负责创建表,hash_search负责查找、插入、删除,hash_seq_search负责遍历。这套接口从1990年代延续到今天,虽然实现细节改了无数轮,外部形态一直非常稳定。
1.2 它到底撑起了哪些核心模块
dynahash在PostgreSQL里不是用来处理SQL查询里的JOIN哈希的。执行器里的哈希连接用的是另一套内存哈希,别搞混。真正重度使用dynahash的是这些地方:
- 系统缓存(syscache):用于缓存表、列、规则、索引等元数据对象。
- 关系缓存(relcache):按OID缓存已打开的表关系描述。
- 锁管理器:用哈希表记录当前会话持有的锁和等待的锁。
- 共享缓冲区管理:缓冲区替换算法通过哈希表快速定位某个block对应的buffer编号。
- 事务快照状态:某些快照信息也会以哈希表形式组织。
这些模块共同的特点是:查找频繁,键值结构多样,且很多表需要多个后端进程共享访问。dynahash为它们提供了一致的“创建—查找—插入—删除”接口,内部又根据不同场景分成局部表和共享表两种内存模式。
1.3 “动态”两个字到底指什么
很多人在理解动态哈希表时,只记住了“会自动扩容”。但这里“动态”其实包含两个独立含义:
第一,桶数组本身可以扩展。你创建表时传入的nelem只是一个初始参考值,不是一个硬上限。数据多了以后,哈希表会自己增加桶数,甚至增加桶数组所占的内存段。
第二,扩容不是简单地把数组变大就完事,它伴随重哈希。桶的数量变了,同一个键算出来的桶下标也要重新计算,这就意味着有一部分条目要被搬走。对调用方来说,整个扩容过程应该是不可见的。但对源码阅读者来说,这一块恰恰是理解性能抖动和内存分配行为的关键。
2. 动手前先认全核心结构体:HASHCTL、HTAB、HASHHDR与HASHENTRY
2.1 HASHCTL:创建一张表的“控制面板”
HASHCTL是hash_create的入参结构。创建一张表之前,你需要在里面填好键长、条目长、哈希函数、分区数等信息。下面是我在开发模块时最常用的几个成员:
| 成员 | 作用 |
|---|---|
keysize | 键的字节长度,哈希表按它比较两个键是否相等 |
entrysize | 整个条目的字节长度,必须包含键和负载数据 |
hash | 自定义哈希函数指针,不填则使用内部默认函数 |
num_partitions | 分区个数,辅助并发控制 |
dir | 指向桶目录的指针,一般只在共享表复用已有目录时指定 |
这个结构体必须由调用方清零后再填充。常见写法是memset(&ctl, 0, sizeof(ctl))。漏清零导致某个随机字段被当成有效配置,是创建哈希表时最隐蔽的一个坑,后文会专门展开。
2.2 HTAB和HASHHDR的分工
HTAB是暴露给调用方的哈希表句柄。它内部包含HASHHDR *hctl和HASHSEGMENT *dir等字段。HASHHDR可以理解为表头的控制块,记录了当前桶数、最大桶下标、掩码值、装载因子、当前条目总数等统计信息。
dynahash的桶数组不是一整块连续内存,而是被拆成多个“段”。每段包含一批连续的桶下标。目录dir就像页表一样管理这些段。这样做的好处很明显:需要扩容时,不一定重新分配整块大数组,只要新增一段桶即可。对局部哈希表来说,内存分配更平稳,不会出现某一次插入突然申请一大块内存的毛刺。
2.3 HASHENTRY:一条数据在内存里的摆放方式
哈希表里的每条记录在源码层面叫哈希条目。每个条目在内存中由两部分组成:前半部分是哈希表自己维护的链指针等元数据,后半部分才是用户可见的键和负载数据。
当你调用hash_search返回一个指针时,它指向的是用户数据区的起点,而不是整个条目的起点。这也就是说,你拿到指针后要把它转换为自己的结构体类型,然后直接读写结构体字段。链指针由系统内部管理,不允许调用方修改,否则会把整个桶的链表搞坏。
这里有一个非常关键的约定:你的结构体里,键必须放在开头。因为哈希表比较键时,默认从返回的用户数据区起点开始,读keysize个字节来做比较。如果键没有放在第一条目第一位,比较就会错位,导致每次查找都命中不了。
2.4 空闲链对内存回收的影响
删除条目时,dynahash不会马上把内存归还给操作系统或内存上下文。它把被删条目挂入一张空闲链表,后面再插入新条目时优先复用。这个设计能减少重复分配的系统调用,但也带来一个直觉上反直觉的现象:你删除了大量条目,表的内存占用可能并没有降下来。
对局部哈希表来说,这个行为问题不大,因为内存上下文最终会统一释放。对共享哈希表来说,你需要意识到:不要把一个动态哈希表当成临时容器频繁增删,它的内存峰值比想象中顽固。如果业务上需要频繁删改,尽量复用条目,让空闲链真正发挥作用。
3. 创建一张动态哈希表:hash_create的参数组合
3.1 一个最小可用的例子
假设我要写一个模块,按整数ID保存一段负载数据。最直接的建表代码如下:
#include "utils/hsearch.h" typedef struct MyKey { int id; } MyKey; typedef struct MyEntry { MyKey key; /* 键必须放在第一条目位置 */ char payload[64]; } MyEntry; HASHCTL ctl; HTAB *tab; memset(&ctl, 0, sizeof(ctl)); ctl.keysize = sizeof(MyKey); ctl.entrysize = sizeof(MyEntry); tab = hash_create("my_dynahash_demo", 64, &ctl, HASH_ELEM);hash_create的第一个参数是表名,主要用于调试输出,不影响逻辑。第二个参数64是初始条目预估,让系统按这个量级分配初始桶数组。第三个参数是控制结构,第四个参数flags告诉系统哪些字段生效。这里只传了HASH_ELEM,说明是固定大小元素,键长和条目长都来自ctl。
3.2 keysize与entrysize为什么不是一回事
我见过不少人把keysize和entrysize理解成同一个值,结果建出来的表要么插入时越界,要么查找时键比较错位。
keysize决定哈希表如何比较键。它只覆盖键值本身。entrysize决定每个条目的总内存占用,除了键,还要容纳负载区以及哈希表需要的对齐要求。实际操作中,entrysize要大于等于keysize,而且一般不小于MAXALIGN(sizeof(MyEntry))这样的对齐结果。
如果你只需要维护一个键集合,不存任何负载,就让两者相等。但绝大多数场景下,entrysize都会比keysize大,因为你要在条目里携带业务数据。哈希表在新增条目时,分配的是一整块entrysize大小的内存;在比较键时,却只读前keysize字节。这是这套结构能同时满足“查找快”和“负载灵活”的根本设计。
3.3 自定义哈希函数与HASH_BLOBS
默认情况下,dynahash使用内部实现的hash_any函数。它对任意字节序列都能给出分布不错的哈希值,通用性很强。但如果你对性能有更高要求,或者你的键结构特殊,可以传入自己的哈希函数:
static uint32 my_hash(const void *key, Size keysize) { int id = *((const int *) key); return (uint32) (id * 0x9E3779B1); }创建时这样传:
ctl.hash = my_hash; tab = hash_create("my_hash_demo", 64, &ctl, HASH_ELEM | HASH_FUNCTION);自定义哈希函数必须遵守一个铁律:相等的键必须返回相同的哈希值。否则同一键在不同时间算出来的桶不同,查找自然失效。另外,哈希函数的输入是键数据在条目里的指针,不是键原来的栈地址。条目会被拷贝、搬移,所以函数内部只能依赖键值本身。
还有一个标志叫HASH_BLOBS,用于变长键。用法是把keysize设为负数,表示键长度信息存在键数据的前缀里。这个特性在源码里主要给系统缓存这类复杂场景使用,普通开发其实用得不多。我在代码里看到负数keysize时都会特别小心,因为它意味着键比较不再是简单的memcmp,而是要走变长比较逻辑。
3.4 分区数怎么定:HASH_PARTITION的秘密
如果哈希表只在单进程内使用,加分区纯属浪费。但共享哈希表会被多个后端进程并发访问,一把全局锁会严重限制并发度。dynahash提供了分区机制:把桶按照哈希值划分到num_partitions个分区里,每个分区独立加锁。
创建时这样配置:
ctl.num_partitions = 16; tab = hash_create("part_hash_demo", 128, &ctl, HASH_ELEM | HASH_PARTITION);分区的本质是“锁的粒度细化”。哈希值落入哪个分区,就只抢那把分区锁。理论上分区数越多,并发冲突越小,但每把锁都会占用内存。共享内存里挤出的每一点空间都很宝贵,所以不要无脑设大。PostgreSQL源码里常见的分区数有16和64,这是权衡后的经验值。如果你对自己的访问模式有把握,可以通过性能观测进一步调整。
别忘了:分区锁只保护桶链表的增删和查找本身,不保护你已经插入的负载数据。两个后端同时找到同一个条目并修改它的负载字段,依然存在竞态条件,需要你自己的业务锁来兜底。
4. 核心操作:hash_search带你看清查、增、删一次完成
4.1 HASHACTION的四种身份
hash_search是操作动态哈希表的头号入口。函数签名如下:
void *hash_search(HTAB *hashp, const void *keyPtr, HASHACTION action, bool *foundPtr);HASHACTION控制动作类型,日常使用最常见的有三种:
HASH_FIND:只查找,找不到返回NULL,不创建新条目。HASH_ENTER:查找,找到就返回已有条目,找不到就新建一个。HASH_REMOVE:查找并删除,找不到返回NULL。
实际使用中最容易造成误解的是HASH_ENTER。它不是一个单纯的插入动作,而是“找或插”二合一。调用一次HASH_ENTER,你既完成了查找,又可能顺带插入了一条新数据。真正的“改”则由你拿到返回指针后,直接修改指针指向的负载字段完成。
示例代码:
bool found; MyEntry *e; MyKey key; key.id = 42; e = (MyEntry *) hash_search(tab, &key, HASH_ENTER, &found); if (!found) strcpy(e->payload, "first time"); else strcat(e->payload, ".");注意:found告诉我们这条记录是这次新建的还是本来就有。新建时条目内存通常已被清零,但依然要根据found区分初始化路径,避免每次访问都覆盖已有数据。
4.2 传入哈希值更快的入口
如果调用方提前已经计算好了键的哈希值,可以用hash_search_with_hash_value。这个入口接收一个额外的hashvalue参数,表内部不再调用哈希函数,直接按传入值定位桶。
在一些高性能路径上,这个接口能省下一整轮哈希计算。更重要的是,它可以缩短锁持有的时间:先在外面把哈希值算好,再进锁区操作。系统缓存模块经常用这个接口,因为缓存键的哈希值在更上层已经被算过一遍了。
示例:
uint32 hv = my_hash(&key, sizeof(key)); e = (MyEntry *) hash_search_with_hash_value(tab, &key, hv, HASH_ENTER, &found);这里必须保证传入的hashvalue和表实际使用的哈希函数保持一致。如果你给表配了自定义哈希函数,却用默认hash_any先算了一个值传进去,结果就是每条数据都落在错误的桶里,怎么查都查不到。
4.3 遍历全表:hash_seq_search的秩序感
哈希表不是有序结构,但有时候我们必须遍历全部条目,比如统计总数据量、清理过期记录。遍历接口是hash_seq_init搭配hash_seq_search:
HASH_SEQ_STATUS status; MyEntry *e; hash_seq_init(&status, tab); while ((e = hash_seq_search(&status)) != NULL) { /* 处理条目 */ } hash_seq_terminate(&status);遍历顺序没有业务含义,不要期待它能按键排序。遍历期间对同一张表做插入操作是危险的:插入可能导致扩容,桶号发生变化,但遍历状态还是按旧桶号在走,结果就会出现漏数据甚至重复数据。如果需要边遍历边清理,删除当前条目是可以的,但删除后不要再通过原来的指针访问数据。
4.4 修改键值与hash_update_hash_key
哈希表返回的条目指针让我们可以直接修改负载区,但千万不要直接修改键字段。因为哈希表定位条目靠的是键的哈希值,键变了而桶没变,下次按新键就找不到了。
PostgreSQL在较新版本里提供了hash_update_hash_key,它能安全地完成“键更新”这个动作:删除旧键位置、计算新哈希、把条目搬移到新桶。如果你维护的模块需要重设键,优先用这个接口。老版本没有这个函数时,只能先HASH_REMOVE再HASH_ENTER,简单粗暴但会破坏原有条目的指针稳定性。
5. 动态扩容与rehash:表面看不出来,实际内部天天搬家
5.1 桶数组的段式组织
要理解扩容,先理解桶的结构。dynahash把桶数组分成多个段,每个段是一个连续的桶数组块。目录dir按段号索引,每个段里又有固定数量的桶。
初始创建时,表只分配少量段和桶。随着条目增长,系统会按需增加段。这种设计最大的好处是:扩容不一定需要一次性申请巨大的连续内存。局部哈希表在内存上下文里申请一段新数组非常轻量,不会因为某个时间点数据量暴增而卡顿。
5.2 装载因子与扩容触发条件
哈希表内部维护一个装载因子ffactor,默认值通常取2。意思是:当条目数超过桶数乘以装载因子时,哈希表认为桶的链表太长了,需要增加桶数来降低平均链表长度。
用公式理解就是:nentries > max_bucket * ffactor时触发扩容。这里的max_bucket表示当前最大桶下标,可以粗略理解成桶的数量。装载因子设得越小,表越大,空间越浪费,但查找越快;设得越大,内存紧凑,但链表变长,查找退化风险上升。PostgreSQL选2是一个兼顾空间和时间的平衡点。
5.3 掩码变化与条目的“一半搬家”
扩容时,桶数组从大约N个变成2N个。哈希函数算出的原始哈希值不变,但桶下标计算方法变了。原本取低几位掩码,现在要取高一位掩码,于是每个旧桶里的条目,要么继续留在原桶,要么搬去“原桶号+旧的桶数”这个新位置。
这个性质简化了扩容逻辑:不需要把全表条目全部重哈希,只要遍历每个旧桶,把链表里符合条件的条目拆到新桶即可。从期望上看,大概有一半的条目需要搬家。对一张几十万条记录的表来说,这一次“搬家”的开销并不小。所以大量写入时会出现阶段性的性能抖动,原因就藏在这里。
5.4 扩容时的锁与并发安全
共享哈希表在扩容时并不是无锁操作。系统需要保证其他进程不会一边访问某个桶,一边看着该桶被拆成两个桶。实现上,扩容会获取与受影响桶相关的分区锁。注意,由于扩容会改变桶的下标映射,其他正常路径上的操作同样需要持锁后重新确认桶号。
所以高并发下,哈希表性能曲线不是一条平滑直线,而是一段平滑、一次毛刺、再平滑的形态。如果你的数据库在特定写入高峰期出现了偶发的微卡顿,并且定位到是在哈希表相关模块,可以检查一下是不是扩容频次太高,进而考虑调大初始nelem。
5.5 预判规模:共享内存表无法随便扩容
如果只是本地哈希表,预估不准也没关系,最多多扩容几次。共享内存哈希表就不同了:共享内存通常是在服务器启动时统一规划的,后续很难再动态申请大块内存。共享表内部的桶数组和相关控制结构必须提前预留空间。
因此,创建共享哈希表前,一定要认真评估最大条目数。估算得太小,表会在运行中尝试扩容,但共享内存空间不足,轻则扩容失败,重则影响整个数据库启动。估算得太大,又浪费了共享内存,影响其他模块。这里没有银弹,只能结合业务峰值和模块设计反复评估。我在做某块共享状态存储时,就吃过估算偏小的亏,后来直接把nelem提高了一个数量级并把统计日志打开观察,才找到合适的值。
6. 实操中的常见坑与定位方法
6.1 键没有放在结构体第一项
这是最经典的新手错误。自定义条目时,如果先写负载字段,再写键字段,哈希表会从起点读取keysize字节,把负载当键来比较,几乎每次查找都会失败。排查时优先看条目结构体布局。
/* 错误示例 */ typedef struct MyEntry { char payload[64]; MyKey key; /* 键放后面,哈希表根本不认识 */ } MyEntry;正确定义一定是键在最前,负载在键之后。
6.2 哈希函数与keysize长度不一致
键的长度必须和哈希函数读取的数据长度一致。若键本身是int(4字节),keysize却传了sizeof(long)(8字节),比较时会把int后面的填充字节也纳入键比较。新建条目的填充字节是零,看起来没问题;一旦负载写入了非零数据,同一个键可能就再也查不到了。这种偶发性错误非常难排查,建议在初始化或单元测试阶段充分验证增删查改的闭环。
6.3 局部哈希表的生命周期与内存泄漏
局部哈希表的桶和条目默认分配在当前内存上下文里。如果你在事务级上下文里创建了一张表,却希望它跨事务存在,事务提交时上下文被重置,表会直接消失。反过来,如果你在会话级或顶层上下文里创建表,用完不调用hash_destroy,内存会一直挂在那里,积少成多就成了内存泄漏。
正确的做法是:选择明确的上下文,确定表的生命期,结束后及时hash_destroy。临时表场景下,可以把哈希表的创建包裹在临时上下文里,离开作用域时一次性释放。
6.4 共享内存哈希表初始化的崩溃点
在扩展模块或修改初始化代码时,常见的错误是过早创建共享哈希表,此时共享内存初始化还没完成,hash_create会返回异常或直接触发断言失败。另一类问题是标志位组合不完整:共享表通常需要配合特定的内存分配器或目录复用参数,不能只传HASH_ELEM了事。
排查这类问题,最直接的办法是看启动日志里ShmemInitHash相关的报错信息,它通常会明确指出哪个表初始化失败。然后回头检查nelem、entrysize以及初始化顺序,确认表没有被创建在错误的初始化阶段。
6.5 遍历状态下操作同一张表
当hash_seq_search正在遍历时,同一个后端进程又对这张表执行了HASH_ENTER,这通常不是一个好主意。插入可能触发扩容,遍历状态机和桶数组的对应关系会被破坏。轻则本次遍历缺失条目,重则出现悬指针。
如果业务非要边遍历边插入,建议分两步:先遍历收集要做的修改,再关闭遍历后统一插入。删除当前条目是相对安全的行为,但为了稳妥,删除后立即终止遍历或使用保存的键重新查找,不要继续沿用旧指针。
6.6 用hash_stats观察内部状态
代码里临时插入一句hash_stats("my_table", tab);,可以在日志里看到表当前的条目数、桶数、空闲链状态等信息。这是排查哈希表“莫名膨胀”和“性能劣化”最直接的工具。
单看条目数很多时候不够。如果你发现条目数远小于预估值,但内存占用很高,说明大量条目挂在空闲链上,这是频繁删增的结果。如果你发现装载因子正常但某些桶的链表特别长,那多半是哈希函数分布不均,按固定数取模这类简单函数最容易出这种问题。
6.7 删除条目前的指针注意事项
使用HASH_REMOVE删除条目后,返回指针在逻辑上仍然指向旧内存区域,但该条目已被放入空闲链,后续任何一次插入都可能把它复写。所以删除后不要读取或释放该指针。如果后续流程还需要数据,删除前先把内容拷到临时变量里。
我在实际开发里频繁遇到这类边界问题,逐步养成了一个习惯:把对哈希表的操作封装成独立的小函数,每个函数只做一件事,返回值和指针生命周期都写清楚,避免业务代码到处都是裸的hash_search调用。这样出问题时,定位范围一下子就缩小了。
动态哈希表看似简单,真正用起来才会发现细节多到需要一篇长文才能讲完。如果你正准备在自己的模块里引入它,我最后再给一条实操建议:第一版不要追求极端性能,先把键布局、哈希函数、分区数这几个基础参数定对,然后跑一轮增删查改和遍历的完整循环,再考虑向上的优化。这一步走稳了,后面无论做系统缓存还是共享状态管理,都能少踩很多暗坑。