Files
cs-note/hhs/Redis/02-核心数据类型/02-3-hashtable.md
T
2026-05-25 22:24:47 +08:00

15 KiB
Raw Blame History

tags, create time, author
tags create time author
Redis
底层数据结构
hashtable
dict
rehash
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() 内部流程是:

  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 可能成为热点节点。

预防与应对

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 下依然保持亚毫秒级延迟。

关联笔记