在 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配置)。
ziplist 的结构设计
ziplist 并非传统意义上的 “列表”,而是通过连续内存块存储数据,避免了指针开销(传统链表每个节点需额外存储前后指针)。其整体结构如下(从左到右):
其中,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 机制,链表长度通常不会过长)。
关键优化:渐进式 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 从 ziplist 转换为 hashtable,即使后续 field 数量减少到 512 以下,或 field/value 字节数减小到 64KB 以下,也不会再转回 ziplist;
配置建议:若业务场景中 Hash 数据量始终较小(如存储用户基本信息,field 数量≤10),可适当调大hash-max-ziplist-value(如 256),延长 ziplist 的使用周期,进一步节省内存;若 Hash 数据量可能快速增长,则建议保持默认配置,避免 ziplist 插入 / 删除效率过低。
五、总结:Redis Hash 的设计哲学
Redis Hash 的底层实现,是 “内存效率” 与 “操作性能” 平衡的典型案例:
小数据量用 ziplist:通过连续内存存储、无指针开销,实现极致的内存利用率,适合读多写少、数据量稳定的场景;
大数据量用 hashtable:通过哈希表 + 渐进式 rehash,实现 O (1) 级别的查找 / 插入 / 删除,避免数据量增大导致的性能下降,适合写多读多、数据量动态增长的场景。
理解这两种底层结构的差异与转换机制,能帮助开发者更合理地设计 Redis 存储方案 —— 例如,避免将大量大字段数据存入同一个 Hash(导致过早转换为 hashtable,浪费内存),或避免在高频更新的场景中使用 ziplist(导致频繁内存移动,影响性能)。