332 lines
15 KiB
Markdown
332 lines
15 KiB
Markdown
---
|
||
tags: [Redis, 底层数据结构, hashtable, dict, rehash]
|
||
create time: 2026-05-25 11:00
|
||
author: 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,搬完后交换、释放旧表。
|
||
|
||
```mermaid
|
||
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 结构
|
||
|
||
```go
|
||
// 简化的 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`:
|
||
|
||
```c
|
||
// 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,一共两步:
|
||
|
||
```go
|
||
// 伪代码: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 主线程被阻塞——对于缓存场景,这可能意味着数百毫秒的延迟抖动。
|
||
|
||
### 过程详解
|
||
|
||
```mermaid
|
||
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 期间的行为:
|
||
|
||
```go
|
||
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` 控制。
|
||
|
||
### 两种编码对比
|
||
|
||
```mermaid
|
||
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()` 内部流程是:
|
||
> 1. 创建一个临时 dict(hashtable 编码)
|
||
> 2. **遍历** listpack 的所有 field-value 对,逐个插入 dict
|
||
> 3. 释放 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 的真实危害
|
||
> 1. **读放大**:`HGETALL` / `SMEMBERS` 一次遍历整个 hashtable,大 key 可能耗时数百毫秒。
|
||
> 2. **删除阻塞**:释放百万级 `dictEntry` 需要遍历所有 bucket 和链表,`DEL` 命令可能阻塞数秒。Redis 4.0+ 的 `UNLINK` 异步释放缓解了这个问题。
|
||
> 3. **rehash 卡顿**:单个 bucket 链表过长时,一次 rehashStep 迁移该 bucket 耗时高。
|
||
> 4. **内存不均**:集群模式下,大 key 所在 slot 可能成为热点节点。
|
||
|
||
### 预防与应对
|
||
|
||
```mermaid
|
||
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 的关系
|