Administrator
发布于 2025-09-16 / 161 阅读
8

Redis之Hash的底层实现

在 Redis 的五大基本数据类型中,Hash(哈希)凭借其键值对嵌套存储的特性,成为存储对象类数据的最优选择(如用户信息、商品属性等)。与直接使用 String 存储 JSON 字符串相比,Hash 支持对单个字段的精准操作(如HSET/HGET/HINCRBY),且能有效节省内存空间。要理解 Hash 的高效性,核心在于掌握其底层两种实现结构 ——ziplist(压缩列表)hashtable(哈希表) 的设计逻辑与转换机制。

一、Redis Hash 的核心定位与基本操作

在深入底层前,先明确 Hash 的上层特性:Redis Hash 本质是 “键(key)- 哈希表(hash-table) ” 的映射,每个 Hash 内部包含多个 “字段(field)- 值(value) ” 对,且 field 和 value 均支持二进制安全(可存储任意字符,如图片二进制流)。

常见操作示例:

  • HSET user:100 name "zhangsan" age "25":向 Hash 中添加多个 field-value 对

  • HGET user:100 name:获取指定 field 的值

  • HLEN user:100:统计 Hash 中 field 的总数

  • HSCAN user:100 0 MATCH age* COUNT 10:迭代查询 Hash 中的字段(避免阻塞)

这些操作的性能差异,完全由底层存储结构决定 —— 当 Hash 数据量较小时,使用 ziplist 追求内存最优;数据量增大后,自动切换为 hashtable 保证操作效率。

二、底层实现一:ziplist(压缩列表)—— 小数据量的内存最优解

ziplist 是 Redis 为节省内存设计的紧凑型线性数据结构,不仅用于 Hash,还用于 List、Sorted Set 的底层实现。当 Hash 满足以下两个条件时,会优先使用 ziplist 存储:

  • Hash 中所有 field-value 对的总字节数 ≤ 64KB(可通过hash-max-ziplist-value配置);

  • Hash 中 field 的总数 ≤ 512(可通过hash-max-ziplist-entries配置)。

  1. ziplist 的结构设计

ziplist 并非传统意义上的 “列表”,而是通过连续内存块存储数据,避免了指针开销(传统链表每个节点需额外存储前后指针)。其整体结构如下(从左到右):

字段

长度(字节)

作用说明

zlbytes

4

记录整个 ziplist 的总字节数,用于快速计算内存大小或销毁时释放内存

zltail

4

记录最后一个节点(entry)距离 ziplist 起始地址的偏移量,用于快速定位尾节点

zllen

2

记录节点总数(entry count),若超过 65535 则需遍历整个列表统计

entry1 ~ entryN

可变

存储实际的 field-value 对(Hash 中每个 entry 对应一个 field 或一个 value)

zlend

1

标记 ziplist 的结束,固定值为0xFF(255)

其中,entry(节点) 是 ziplist 的核心,每个 entry 的结构又分为三部分:

  • prevlen:记录前一个 entry 的长度(字节),用于从后向前遍历(如HDEL后的节点调整);

  • encoding:记录当前 entry 的编码方式(如字符串 / 整数、长度范围),用于解析后续数据;

  • data:存储实际的 field 或 value 数据(根据 encoding 解析)。

  • ziplist 在 Hash 中的存储逻辑

Hash 的 field 和 value 在 ziplist 中采用 “field1 → value1 → field2 → value2 → ...” 的顺序交替存储,而非 “field-value” 作为一个整体节点。例如执行HSET user:100 name zhangsan age 25后,ziplist 的 entry 顺序为:

entry(name) → entry(zhangsan) → entry(age) → entry(25)

这种设计的优势在于:

  • 连续存储减少内存碎片,且无需额外存储 “field-value 关联关系”(通过顺序即可隐含对应);

  • 遍历 Hash 时,只需按 “2 个 entry 为一组” 解析,即可得到完整的 field-value 对。

ziplist 的优缺点

优点:内存利用率极高(无指针开销、紧凑存储),适合小数据量场景;

缺点

  • 插入 / 删除操作需移动内存(连续内存块特性导致),数据量越大,移动开销越高;

  • 查找操作需遍历整个列表(无索引),时间复杂度为 O (n),不适合大数据量。

三、底层实现二:hashtable(哈希表)—— 大数据量的效率保障

当 Hash 的 field 数量超过hash-max-ziplist-entries,或任意一个 field-value 对的字节数超过hash-max-ziplist-value时,Redis 会自动将底层结构从 ziplist 转换为 hashtable。

hashtable(也称为 “字典”)是 Redis 中最核心的底层结构之一(Redis 的全局键空间本身就是一个大 hashtable),其设计目标是通过哈希函数实现 O (1) 级别的查找、插入、删除操作,适合大数据量场景。

  • hashtable 的整体结构

Redis 的 hashtable 采用 “数组 + 链表” 的经典哈希表设计(解决哈希冲突),整体由dict、dictht、dictEntry三个结构体组成,关系如下:

// 哈希表(dictht):存储实际的键值对数组
typedef struct dictht {
    dictEntry **table;    // 数组,每个元素是指向dictEntry的指针(链表头)
    unsigned long size;   // 数组的容量(必须是2的幂,用于高效计算哈希索引)
    unsigned long sizemask; // 掩码,等于size-1,用于将哈希值映射为数组索引(hash & sizemask)
    unsigned long used;   // 已存储的dictEntry数量(用于判断是否需要扩容)
} dictht;
// 字典(dict):管理两个哈希表(用于渐进式rehash)
typedef struct dict {
    dictType *type;       // 哈希表的类型函数(如键值的比较、销毁函数)
    void *privdata;       // 类型函数的私有数据
    dictht ht[2];         // 两个哈希表:ht[0]正常使用,ht[1]用于rehash
    long rehashidx;       // rehash的进度(-1表示未进行rehash)
    unsigned long iterators; // 当前正在使用的迭代器数量
} dict;
// 哈希表节点(dictEntry):存储单个field-value对
typedef struct dictEntry {
    void *key;            // 存储field(哈希表的键)
    union {               // 存储value(哈希表的值,支持多种类型)
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next; // 指向下一个节点,用于解决哈希冲突(链表法)
} dictEntry;

核心逻辑总结:

  • dict 是顶层结构,通过ht[0]和ht[1]实现渐进式 rehash(避免一次性扩容导致的性能阻塞);

  • dictht 是实际的哈希表,table数组存储链表头指针,sizemask确保哈希索引在数组范围内;

  • dictEntry 是单个键值对节点,next指针将哈希冲突的节点串联成链表。

  • 核心机制:哈希计算与冲突解决

(1)哈希计算

当执行HSET或HGET时,Redis 会按以下步骤定位 field 对应的节点:

  • 对 field(key)执行哈希函数(Redis 6.0 + 使用 SipHash,抗哈希碰撞性能更优),得到一个 64 位的哈希值;

  • 通过哈希值 & dictht.sizemask计算出数组索引(因size是 2 的幂,sizemask=size-1,等价于 “哈希值 % size”,但运算更快);

  • 定位到table[索引]对应的链表,遍历链表查找 field 相等的节点。

(2)哈希冲突解决

当两个不同的 field 计算出相同的数组索引时,会产生哈希冲突。Redis 采用链表法解决:将冲突的节点通过next指针串联成单向链表,遍历链表即可找到目标节点。

需要注意:Redis 在 Redis 4.0 后优化了冲突链表 —— 当链表长度超过dict_force_resize_ratio(默认 5)且哈希表容量size小于dict_max_chain_depth(默认 8)时,会将链表转换为红黑树,将查找时间复杂度从 O (n) 降至 O (log n)(不过在 Hash 场景中,因 rehash 机制,链表长度通常不会过长)。

  1. 关键优化:渐进式 rehash

当 hashtable 的负载因子(used/size) 超过阈值(默认 1)时,为避免哈希冲突加剧,需要进行扩容(rehash) —— 创建一个新的ht[1](容量为当前ht[0].size的 2 倍,且是 2 的幂),并将ht[0]的所有节点迁移到ht[1]。

若直接一次性迁移所有节点,当数据量达百万级时,会导致 Redis 主线程阻塞(无法处理其他请求)。因此 Redis 设计了渐进式 rehash

  • 开启 rehash:将dict.rehashidx设为 0(表示开始 rehash),创建ht[1]并初始化容量;

  • 分步迁移:每次执行 Hash 操作(如HSET/HGET)或定时任务时,迁移ht[0]中rehashidx索引对应的一个或多个节点到ht[1],并将rehashidx加 1;

  • 双写保证:rehash 期间,所有读写操作同时在ht[0]和ht[1]执行(查询先查ht[0],再查ht[1];插入直接写ht[1]);

  • 完成迁移:当ht[0]的所有节点迁移到ht[1]后,将ht[1]赋值给ht[0],清空ht[1],并将rehashidx设为 - 1(结束 rehash)。

这种设计将 rehash 的开销分散到多次操作中,避免了主线程阻塞,保证了 Redis 的高可用性。

四、ziplist 与 hashtable 的转换机制

Redis Hash 的底层结构并非一成不变,而是根据数据量动态转换,核心触发条件由两个配置参数控制:

配置参数

默认值

作用说明

hash-max-ziplist-entries

512

当 Hash 中的 field 数量超过该值时,从 ziplist 转换为 hashtable

hash-max-ziplist-value

64

当 Hash 中任意一个 field 或 value 的字节数超过该值时,从 ziplist 转换为 hashtable

注意事项:

  1. 转换不可逆:一旦 Hash 从 ziplist 转换为 hashtable,即使后续 field 数量减少到 512 以下,或 field/value 字节数减小到 64KB 以下,也不会再转回 ziplist;

  1. 配置建议:若业务场景中 Hash 数据量始终较小(如存储用户基本信息,field 数量≤10),可适当调大hash-max-ziplist-value(如 256),延长 ziplist 的使用周期,进一步节省内存;若 Hash 数据量可能快速增长,则建议保持默认配置,避免 ziplist 插入 / 删除效率过低。

五、总结:Redis Hash 的设计哲学

Redis Hash 的底层实现,是 “内存效率” 与 “操作性能” 平衡的典型案例:

  • 小数据量用 ziplist:通过连续内存存储、无指针开销,实现极致的内存利用率,适合读多写少、数据量稳定的场景;

  • 大数据量用 hashtable:通过哈希表 + 渐进式 rehash,实现 O (1) 级别的查找 / 插入 / 删除,避免数据量增大导致的性能下降,适合写多读多、数据量动态增长的场景。

理解这两种底层结构的差异与转换机制,能帮助开发者更合理地设计 Redis 存储方案 —— 例如,避免将大量大字段数据存入同一个 Hash(导致过早转换为 hashtable,浪费内存),或避免在高频更新的场景中使用 ziplist(导致频繁内存移动,影响性能)。

阅后提问:怎么实现亿万级别hashmap安全快速的扩容?