SwissTable 是一种高性能的开放寻址哈希映射,最初由 Google 开发,在业内越来越受欢迎。
首先,Rust 在其 HashMap 类型中采用了 SwissTable。然后,Go 开始在其 map 类型中使用 SwissTable 的自定义版本。
现在,Valkey(Redis 的一个分支)已经重写了其核心哈希表数据结构,从旧的链式实现切换到了 SwissTable。
什么是SwissTable?
SwissTable 是一种围绕“缓存行六十四字节”打磨出来的超高性能哈希表实现,核心目标只有一个:让查找动作尽量在一个缓存行里完成,从而把内存访问次数压到最低。
这个名字来自谷歌工程师在瑞士办公室提出和完善这套设计,所以叫 SwissTable。后来被大量现代语言和库吸收,例如很多高性能容器底层都借鉴了这套思路。
先把传统哈希表想清楚
普通哈希表干两件事:把键经过哈希函数算出一个数字根据这个数字找到存储位置
如果多个键落在同一个位置,就会产生冲突。
传统解决方式有两种:
链式结构冲突元素串成链表
开放寻址冲突时往后挪,找下一个空位
传统链式结构会产生很多指针:每个指针八个字节,大量随机内存访问,缓存命中率下降,中央处理器在等内存。
性能瓶颈大多来自这里。
SwissTable 的核心理念
SwissTable 的核心思想非常简单,也非常狠:
让一次查找尽量只访问一个缓存行
让桶里的多个候选元素一次性加载进缓存
利用向量化指令快速筛选
具体做法分成三层逻辑。
第一层:分组存储
它把哈希表分成一组一组的小桶,每个桶大小和缓存行对齐。通常一个桶里放多个槽位,比如八个或十六个。
第二层:控制字节
每个槽位额外存一个“控制字节”,这个字节是哈希值的一部分。查找时先比较这个小字节,快速筛掉绝大多数不匹配项。
第三层:批量比较
利用现代中央处理器的向量指令,一次性对一整组控制字节进行比较。
匹配候选一瞬间定位出来。
这一步非常关键。
传统做法是一条一条比。
SwissTable 是一排一起比。
中央处理器的并行能力被榨干。
为什么它这么快
速度来源主要有三点:
- 缓存友好:数据集中在连续内存块里
- 指针数量极少;内存碎片减少!
- 向量指令批量筛选:减少分支跳转!
查找一个键,大概率只需一次缓存行加载。如果发生冲突,也在同一个组里继续处理。
这和早期链式结构那种“跳来跳去读内存”的风格完全不同。
当数据量达到千万级别,访问路径从“指针森林”变成“直线高速公路”,性能曲线会肉眼可见地平滑。延迟稳定,抖动减少。系统看起来像从拖拉机升级到高铁。
它解决了什么问题
SwissTable 主要优化的是:高并发场景;大量小对象;存储频繁查找
它让哈希表从“功能结构”进化成“缓存结构”。设计思路围绕硬件特性展开,而不是单纯算法抽象。
这是一种工程思维升级。
普通哈希表像宿舍找人,一个人一个人敲门。SwissTable 像点名册,一整排名字一眼扫过去。
敲门效率低。扫一眼效率高。
再狠一点说:普通结构在拼算法。SwissTable 在拼硬件。
这种设计通常不支持某些特殊能力,例如:
- 渐进式重排
- 可扩展扫描稳定性
- 特定数据库级别的随机采样
通用库场景很好用。但是数据库内核场景可能需要额外改造。
这就是为什么有些系统会借鉴它的思想,而不是直接拿来用。
Valkey 8.1 版本 怎么用SwissTable?
内存就是钱,哈希表就是仓库管理员。仓库管理员动作越利索,占地越省,老板脸上的笑容越灿烂。Valkey 在 8.1 版本里换了一套全新的哈希表设计,直接把每一对键值的数据开销压缩了大约二十个字节,带过期时间的数据压缩幅度更狠,三十个字节往下砍。数据量一上亿,省下来的就是几台服务器,就是实打实的电费,就是心跳图从焦虑变成优雅。
数据密集型世界的残酷真相
很多系统的瓶颈来自存储。运算再猛,磁盘再快,只要内存占用爆表,集群就要扩容。扩容就是机器数量增加,机器一多,成本曲线往上窜。所以一个现实逻辑非常清晰:同样的数据,如果能用更少的内存装下,你的集群规模立刻缩小,维护复杂度同步下降,预算压力直接释放。
Valkey 作为一个高性能键值数据库,核心工作就是把“键”映射到“值”。这个映射依靠一种结构,叫哈希表。你可以把哈希表理解成一个巨大的抽屉柜。每个键经过一套算法,被转换成一串看似随机的数字,这串数字指向某个抽屉。打开抽屉,值就在那里。这个动作非常快,因为它跳过了逐个查找的过程,直接定位内存地址。
当数据量达到上亿级别,每一个指针的八个字节都会变成压在你服务器上的铁块。你看到的是几行代码,机器看到的是几吨数据。
旧时代的“dict”结构长什么样
在 8.1 之前,Valkey 使用的哈希表叫 dict。结构上有两个表:table 0 和 table 1。平时只用一个,当扩容进行渐进式重排时,两个表同时存在。
它采用的是“链式哈希”。当多个键算出来的地址相同,它们会挂在同一个位置,形成链表。每个元素有一个 next 指针,指向下一个元素。
查找一个键,比如 FOO,对应的值是 BAR。机器需要读内存四次。如果发生哈希冲突,每多一个冲突节点,就要多两次内存读取,一次读键,一次读 next 指针。
内存读取速度远慢于中央处理器内部缓存。一次主内存访问,延迟几十到上百个时钟周期。缓存访问,速度像闪电。
所以问题非常清楚:内存访问次数越少,性能越猛。
缓存行:六十四字节的生死线
现代处理器在加载内存时,以固定大小块加载,这个块叫缓存行。大小通常是六十四字节。如果你要的数据恰好落在同一个缓存行里,访问效率极高。如果跨缓存行,就要再加载一次。
近些年出现的高性能哈希表,比如SwissTable,设计思路围绕一个核心:尽量让查找操作在一个缓存行内完成。
Valkey 需要额外功能,例如渐进式重排、支持 SCAN 命令遍历、支持 RANDOMKEY 随机采样。
这些特性在通用开源哈希表中没有直接支持。于是结论只有一个:自己设计。
新哈希表的核心结构
8.1 的新设计把哈希表拆成一个个“桶”。每个桶正好六十四字节,一个缓存行。每个桶最多存七个元素。
键映射到同一个桶的元素,全部放在这个桶内部。桶内部还带一块元数据区域。
以前有 dictEntry 结构,现在直接把键和值嵌入到 serverObject 里。指针数量减少。内存碎片减少。一次查找操作只需两次内存访问:一次读桶,一次读对象。
发生冲突时,大概率目标仍在同一个桶内,缓存已经加载完成,查找速度飞起。
从四次访问降到两次访问,听起来像打对折,实际是延迟曲线从陡峭变平缓。那一瞬间,性能图像像心电图进入稳定节奏。
桶满了怎么办
当一个桶塞满七个元素,第七个位置变成一个指向“子桶”的指针。子桶结构相同,只是单独分配。链条理论上可以增长,只要哈希函数分布均匀,长链极少出现。
大多数数据都待在顶层桶中。局部性保持良好。缓存命中率自然高。
桶内部元素没有排序。插入时随便找个空位填进去。逻辑简单,效率优先。
元数据的精妙设计
每个桶有八字节元数据。其中一位表示是否有子桶。七位分别表示七个槽位是否已填充。剩下七字节,用来存储“二级哈希”。
哈希值总共六十四位。查找桶只需要前面五十六位。剩下八位作为二级哈希,存入桶中。
查找时先比较这八位。如果不匹配,直接跳过,无需读取完整键。误判概率是二百五十六分之一。意味着百分之九十九点六的无关元素可以在桶内快速过滤。
你感受一下这个设计的狠劲。一次内存访问省下来,就是一次时间优势。一次指针省下来,就是一次内存节约。一亿个键值对,每个省八字节,接近一千兆字节的差距。机器数量直接变化,账单直接变化。
实际效果数据
新哈希表替换后,每个键值对节省约二十字节。带过期时间的键值对节省约三十字节。
哈希、集合、有序集合这些嵌套结构,当元素数量足够大,也使用新哈希表。每个元素再省十到二十字节。
对于存储小对象并大量使用管道操作的场景,延迟与中央处理器占用同步优化。大多数场景下性能变化接近稳定。内存下降成为最核心收益。
当内存曲线下降,你的服务器像减脂成功。同样数据量,占用更少空间。扩容节奏延后。成本结构变轻。这就是工程优化带来的真实力量。
设计哲学总结
这套设计的本质是一种工程平衡:缓存局部性最大化。指针数量最小化。支持渐进式重排。支持扫描与随机抽样。在功能完整前提下追求内存效率。
没有引入额外复杂机制。没有牺牲核心功能。每个字节都有明确用途。
Java 生态里的“类 SwissTable”实现
有一些高性能集合库在做类似 SwissTable 的事情,核心特征是:
- 开放寻址
- 连续内存布局
- 控制字节或状态位
- 减少对象分配
典型代表方向包括:
- fastutil
- HPPC
- Koloboke
- Agrona
这些库使用 primitive 专用集合,例如 int 到 int 的映射。
它们避免装箱;避免为每个元素创建对象;大量使用连续数组。
在缓存友好性上,已经非常接近 SwissTable 思路。
为什么 Java 很少出现“原版 SwissTable”?
关键原因在于 Java 运行环境特性。
Java 的对象模型:
- 每个对象有对象头
- 引用是指针
- 垃圾回收器管理内存
SwissTable 原始设计依赖:
裸内存控制
精确字节布局
向量化指令操作控制字节
Java 的安全内存模型限制了这种精细控制。