vault backup: 2026-05-25 20:51:57

This commit is contained in:
hhs
2026-05-25 20:51:57 +08:00
parent 383d98a4dc
commit 7e4671fe25
8 changed files with 1673 additions and 43 deletions
@@ -0,0 +1,331 @@
---
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["1. 分配 ht[1]"] --> S2["2. rehashidx = 0, 开始渐进式迁移"]
S2 --> S3["3. 每次 CRUD 操作时, 迁移 ht[0] 上 rehashidx 指向的整个 bucket 到 ht[1]"]
S3 --> S4["4. rehashidx++"]
S4 --> S5{"所有 bucket 已迁移?"}
S5 -->|"否"| S3
S5 -->|"是"| S6["5. 释放 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 时) | field→value 直接映射 |
| **Set** | 唯一存储结构(含非整数元素时) | 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 的关系