--- 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 的关系