15 KiB
tags, create time, author
| tags | create time | author | |||||
|---|---|---|---|---|---|---|---|
|
2026-05-25 11:00 | hhs |
hashtable(字典 / 哈希表)
概述
hashtable 是 Redis 中使用最广泛的底层结构——Hash、Set、ZSet(skiplist 模式下的 member 索引)都依赖它实现 O(1) 的精确查找。
但 Redis 的 hashtable 不是你在教科书里学的那种"满了就扩、缩了就缩"的简单版本。它支持渐进式 rehash:扩容时不一次性迁移所有 bucket,而是分摊到每次 CRUD 操作中,避免一次性阻塞。
一、dict 的整体结构
[!question] 为什么需要两个哈希表? 答案是:rehash 的时候,新旧表要共存。平时只用
ht[0],扩容时分配ht[1],逐步把数据从 0 搬到 1,搬完后交换、释放旧表。
flowchart TB
D["dict"] --> HT0["ht[0], 正在使用的哈希表"]
D --> HT1["ht[1], rehash 时的临时表"]
D --> RH["rehashidx, 当前 rehash 到哪个 bucket"]
HT0 --> B0_0["bucket 0, dictEntry 链表"]
HT0 --> B0_1["bucket 1"]
HT0 --> B0_N["bucket N"]
HT1 --> B1_0["bucket 0"]
HT1 --> B1_1["bucket 1"]
HT1 --> B1_M["bucket M"]
classDef dict fill:#e1f5fe,stroke:#2196f3
classDef table fill:#fff3e0,stroke:#ff9800
classDef bucket fill:#e8f5e9,stroke:#4caf50
class D,RH dict
class HT0,HT1 table
class B0_0,B0_1,B0_N,B1_0,B1_1,B1_M bucket
dict 与 dictht 结构
// 简化的 dict 结构
type dict struct {
ht [2]*dictht // 两张哈希表,rehash 时交替使用
rehashidx int64 // rehash 进度,-1 表示未在 rehash
pauserehash int16 // > 0 时暂停 rehash(如遍历期间)
}
type dictht struct {
table []*dictEntry // bucket 数组
size uint64 // bucket 总数(总是 2 的幂)
sizemask uint64 // size - 1,用于位运算取模
used uint64 // 已存储的 entry 数量
}
[!tip]
sizemask的妙用 因为size始终是 2 的幂,所以hash & sizemask等价于hash % size,但位运算比取模快得多。这是所有高性能哈希表的经典技巧。
dictEntry 结构
每个 bucket 是一个单向链表,节点是 dictEntry:
// Redis 源码中的 dictEntry(来自 src/dict.h)
typedef struct dictEntry {
void *key; // SDS 键
union {
void *val; // 指针:用于 Hash/Set 存储的值
uint64_t u64; // 整数:用于 ZSet 的 score 缓存
int64_t s64;
double d;
} v;
struct dictEntry *next; // 链表法解决哈希冲突
} dictEntry;
[!tip] 为什么值用
union而不是void *? 如果全部用void *,即使是整数 score 也要先malloc一块内存存整数、再把指针存进来——多一次堆分配、多 8 字节指针。union直接把整数嵌在结构体里,省掉了这次分配,对 ZSet 这种高频读写场景有明显收益。
[!question] 为什么 Redis 用链表法而不开放寻址? 开放寻址在负载因子较高时性能急剧下降(聚集效应)。链表法实现简单,且 Redis 通过渐进式 rehash 控制负载因子,链表长度通常很短(理想情况 ≤ 1)。
二、哈希函数与冲突处理
Redis 的哈希函数经历了演进:
| 版本 | 哈希函数 | 特点 |
|---|---|---|
| < 7.0 | MurmurHash2 (32/64 位) | 速度快,分布均匀 |
| 7.0+ | SipHash-1-2 | 防 HashDoS 攻击(有序碰撞),安全性更强 |
key → bucket 的映射过程
从一个 key 到最终落哪个 bucket,一共两步:
// 伪代码:key → bucket index
hash := siphash(key) // 1. 计算哈希值
idx := hash & ht.sizemask // 2. 位运算取模,等价于 hash % size
entry := ht.table[idx] // 拿到链表头,遍历查找
[!question] 为什么 bucket 数量必须是 2 的幂? 只有当
size = 2^n时,hash & (size - 1)才等价于hash % size。位运算 & 比 % 快 5~10 倍,对每个 key 的每次访问都要用到,累计效果非常显著。Redis 在扩容时总是把新表大小设为第一个 ≥ 2×used 的 2 的幂。
[!warning] HashDoS 攻击 攻击者构造大量具有相同哈希值的 key,让 hashtable 退化为链表,O(1) 变 O(N)。SipHash 是加密级哈希函数,攻击者无法预测输出,从根源上防御了这类攻击。Redis 7.0 默认启用 SipHash。
负载因子与 rehash 触发条件
负载因子 = ht[0].used / ht[0].size
| 条件 | 触发 | 说明 |
|---|---|---|
| 没有 BGSAVE/BGREWRITEAOF 时,负载因子 ≥ 1 | 扩容 | 正常扩容阈值 |
| 正在执行 BGSAVE/BGREWRITEAOF 时,负载因子 ≥ 5 | 扩容 | 提高阈值,避免 rehash 与 fork 竞争内存 |
| 负载因子 < 0.1 | 缩容 | 内存空闲太多,释放回系统 |
[!question] 为什么 BGSAVE 期间要放宽扩容阈值?
BGSAVE需要fork()子进程,操作系统使用 Copy-on-Write——fork 后如果父进程修改内存页,OS 会复制一份。如果此时大规模 rehash,大量内存页被修改,内存占用可能瞬间翻倍。提高扩容阈值可以减少这种情况。
三、渐进式 Rehash(核心机制)
[!question] 一次性 rehash 有什么问题? 假设 hashtable 有 1000 万个 key。一次性迁移意味着在某个瞬间,CPU 大量时间花在 rehash 上,Redis 主线程被阻塞——对于缓存场景,这可能意味着数百毫秒的延迟抖动。
过程详解
flowchart TB
S1["分配 ht[1]"] --> S2["rehashidx = 0, 开始渐进式迁移"]
S2 --> S3["每次 CRUD 操作时, 迁移 ht[0] 上 rehashidx 指向的整个 bucket 到 ht[1]"]
S3 --> S4["rehashidx++"]
S4 --> S5{"所有 bucket 已迁移?"}
S5 -->|"否"| S3
S5 -->|"是"| S6["释放 ht[0], ht[1] 变成 ht[0], 分配新空 ht[1]"]
classDef step fill:#e1f5fe,stroke:#2196f3
classDef check fill:#fff3e0,stroke:#ff9800
class S1,S2,S3,S4,S6 step
class S5 check
rehash 期间的读写规则
| 操作 | 行为 |
|---|---|
| 查找 (GET) | 先查 ht[0],没找到再查 ht[1] |
| 插入 (SET) | 只插入 ht[1](确保 ht[0] 只减不增) |
| 删除 (DEL) | 两张表都删 |
用伪代码理解 lookup 在 rehash 期间的行为:
func (d *dict) lookup(key string) *dictEntry {
// 如果正在 rehash,先推进一步(顺手搬一个 bucket)
if d.isRehashing() {
d.rehashStep()
}
// 先查 ht[0]
hash := siphash(key)
idx := hash & d.ht[0].sizemask
for e := d.ht[0].table[idx]; e != nil; e = e.next {
if e.key == key { return e }
}
// ht[0] 没找到且正在 rehash,再查 ht[1]
if d.isRehashing() {
idx = hash & d.ht[1].sizemask
for e := d.ht[1].table[idx]; e != nil; e = e.next {
if e.key == key { return e }
}
}
return nil // 两张表都没有
}
[!insight] "顺手" rehash 注意
rehashStep()是嵌在 CRUD 里的——每次操作"顺手"搬一个 bucket,用户无感知。这就是"渐进式"的精髓:不做大动作,小步快跑。
[!question] 哪些操作会触发 rehashStep? 并非只有写操作。任何访问 dict 的命令都会"顺手"推进一步:
- 读:
GET、HGET、SISMEMBER、ZSCORE等——查找时触发_dictRehashStep()- 写:
SET、HSET、SADD、ZADD等——插入/删除时触发- 主动触发:
SCAN、迭代器内部也会推进 rehash所以高 QPS 的线上环境,rehash 通常很快完成。但低 QPS 的场景(比如只有定时任务写入),单靠 CRUD 触发不够——这时候
serverCron的定时兜底就至关重要了。
[!tip] 周期性辅助迁移 Redis 的时间事件(serverCron,默认每 100ms)会执行
incrementallyRehash(),在空闲时也推进 rehash,避免大量只读操作时 rehash 进度停滞。如果 rehash 持续很长时间没推进,可能是 QPS 太低、操作不够频繁——这种情况 serverCron 兜底。
四、与持久化的交互
[!caution] rehash 期间做 RDB 快照 RDB 序列化时,会同时遍历
ht[0]和ht[1](如果正在 rehash)。这保证了快照的完整性,但序列化时间会略长。AOF 不受影响——每次写命令记录的是逻辑操作(
SET key value),rehash 是纯内部行为。
五、Redis 7.0+ 的 dict 优化:listpack 编码
[!question] hashtable 已经够快了,为什么还要优化? 问题不在速度,在内存。每个
dictEntry至少占 24 字节(key 指针 + value 指针 + next 指针),加上 SDS 和 redisObject,小 Hash 存 100 个短字段,元数据开销可能比数据本身还大。
Redis 7.0 引入了 listpack 编码的 dict(dictType 支持嵌入式存储):小 hashtable 的 bucket 内部直接用 listpack 存储,省掉了 dictEntry 的堆分配。编码切换仍然由 hash-max-ziplist-entries / hash-max-ziplist-value 控制。
两种编码对比
flowchart LR
subgraph HT["hashtable 编码(大 Hash)"]
direction TB
BK["bucket 数组"]
BK --> E1["dictEntry → SDS key, robj value, next ptr"]
BK --> E2["dictEntry → SDS key, robj value, next ptr"]
BK --> E3["dictEntry → ..."]
end
subgraph LP["listpack 编码(小 Hash)"]
direction TB
LPDATA["连续内存块"]
LPDATA --> L1["entry: len + field1 + value1"]
LPDATA --> L2["entry: len + field2 + value2"]
LPDATA --> L3["entry: ... + END byte"]
end
classDef htStyle fill:#ffebee,stroke:#f44336
classDef lpStyle fill:#e8f5e9,stroke:#4caf50
class BK,E1,E2,E3 htStyle
class LPDATA,L1,L2,L3 lpStyle
| 维度 | listpack 编码 | hashtable 编码 |
|---|---|---|
| 内存 | 极少(连续内存,无指针开销) | 较高(每 entry 至少 24 字节指针) |
| 查找 | O(N) 线性扫描 | O(1) 哈希查找 |
| 适用 | field 少、value 短的小 Hash | field 多或 value 较长的大 Hash |
[!tip] 什么时候切换?
HSET myhash field value执行时,Redis 检查当前 Hash 的 field 数量和 value 长度。超过阈值(默认 128 个 field 或 64 字节 value,由hash-max-listpack-entries/hash-max-listpack-value控制)自动将 listpack 转换为 hashtable,不可逆。
[!question] 切换过程会不会阻塞? 会,但通常可以忽略。
hashTypeConvertListpack()内部流程是:
- 创建一个临时 dict(hashtable 编码)
- 遍历 listpack 的所有 field-value 对,逐个插入 dict
- 释放 listpack 内存,用 dict 替换
这是一次性的 O(N) 操作。因为触发条件是 field ≤ 128(默认),所以转换代价很小——128 次插入在微秒级完成。但如果调大了
hash-max-listpack-entries(比如设成 10000),切换时的短暂阻塞就需要注意了。关键点:这个转换是单向的、不可逆的。即使你删除字段让数量降到 128 以下,编码也不会回退到 listpack。这是 Redis 对"频繁转换"的简化处理——避免在阈值附近反复翻转。
六、各类型使用 hashtable 的方式
| 数据类型 | hashtable 的角色 | 特殊之处 |
|---|---|---|
| Hash | 存储结构之一(超过 hash-max-listpack-entries / hash-max-listpack-value 阈值后;小 Hash 优先使用 listpack) |
field→value 直接映射 |
| Set | 存储结构之一(含非整数元素或超过 set-max-intset-entries 阈值时;全整数小集合优先使用 intset) |
member→NULL,只用 key |
| ZSet | skiplist 的"辅助索引" | member→score,配合 skiplist 实现 O(1) 定位 |
[!insight] 为什么 Set 用 hashtable 而不是直接存数组? Set 的核心操作是
SISMEMBER(判断元素是否存在)和SADD/SREM(增删)。hashtable 的 O(1) 查找完美匹配。如果用数组,每次SISMEMBER都要 O(N) 遍历——对大集合不可接受。
七、Big Key 与实践要点
hashtable 虽然快,但使用不当会成为性能杀手。一个 key 对应的 hashtable 特别大时("Big Key"),会引发一系列连锁问题。
什么是 Big Key?
| 类型 | Big Key 的定义 | 影响 |
|---|---|---|
| Hash/Set | field 数量达数十万 | HGETALL / SMEMBERS 一次返回大量数据,阻塞主线程 |
| ZSet | member 数量极大 | ZRANGE 0 -1 同理 |
[!warning] Big Key 的真实危害
- 读放大:
HGETALL/SMEMBERS一次遍历整个 hashtable,大 key 可能耗时数百毫秒。- 删除阻塞:释放百万级
dictEntry需要遍历所有 bucket 和链表,DEL命令可能阻塞数秒。Redis 4.0+ 的UNLINK异步释放缓解了这个问题。- rehash 卡顿:单个 bucket 链表过长时,一次 rehashStep 迁移该 bucket 耗时高。
- 内存不均:集群模式下,大 key 所在 slot 可能成为热点节点。
预防与应对
flowchart LR
A["Big Key 问题"] --> B["拆分: 按 hash 取模分桶"]
A --> C["压缩: 缩短 field/value"]
A --> D["异步删除: UNLINK 替代 DEL"]
A --> E["监控: redis-cli --bigkeys"]
classDef problem fill:#ffebee,stroke:#f44336
classDef solution fill:#e8f5e9,stroke:#4caf50
class A problem
class B,C,D,E solution
[!tip] 拆分示例 一个存储用户行为的 Hash 有 100 万个 field?拆成
user:actions:0~user:actions:15共 16 个 Hash,按crc32(user_id) % 16分桶。单个 Hash 缩小到 ~6 万 field,rehash 和遍历的开销都在可控范围。
线上诊断
发现 Big Key 不能靠猜,需要工具实锤:
| 工具 | 用法 | 特点 |
|---|---|---|
redis-cli --bigkeys |
扫描全库,按类型统计最大 key | 简单直接,但会遍历所有 key,建议从节点执行 |
redis-cli --memkeys |
按内存占用排序 | 比 bigkeys 更精准,显示实际字节数 |
MEMORY USAGE key |
查看单个 key 的内存占用 | 精确到字节,包含 dictEntry 开销 |
DEBUG OBJECT key |
查看编码方式和序列化长度 | 顺便确认编码是否符合预期 |
SLOWLOG GET |
慢查询日志 | Big Key 操作通常会出现在慢日志里 |
[!warning] 生产环境慎用 SCAN 类工具
--bigkeys和--memkeys本质上是全库 SCAN,虽然用的是增量迭代,但在 key 数量极大(亿级)时仍会增加从节点的负载压力。建议在从节点、低峰期执行。
八、总结
[!summary] 一句话带走 Redis hashtable 的核心设计哲学是**"不阻塞"**:用渐进式 rehash 把大动作拆成小步骤,用 2 的幂 bucket + 位运算保证单步足够快,用 SipHash 防住外部攻击。理解了 rehash 机制,就理解了 Redis 为什么能在百万 QPS 下依然保持亚毫秒级延迟。
关联笔记
- hhs/Redis/02-核心数据类型 — 五种数据类型的编码切换全景
- hhs/Redis/02-核心数据类型/02-1-ziplist与listpack — hashtable 的"紧凑替代品"
- hhs/Redis/04-RDB持久化 — rehash 期间的快照行为
- hhs/Redis/07-集群方案 — 集群 slot 迁移与 hashtable 的关系