深入解析Redis Hash的底层实现原理

120 阅读5分钟

1. 小数据形态:ziplist(压缩列表)

结构揭秘:像“火车车厢”一样紧凑

ziplist是一块连续的内存空间,所有数据紧密排列,没有空隙。结构示意图如下:

<zlbytes> | <zltail> | <zllen> | <entry1> | <entry2> | ... | <entryN> | <zlend>
  • zlbytes:总字节数(方便重新分配内存)
  • zltail:最后一个entry的偏移量(方便快速追加)
  • zllen:entry数量(最多存65535个,超过需要遍历计数)
  • entry:实际存储的键值对
  • zlend:结束标记(固定值0xFF)

每个entry的结构

<prevlen> | <encoding> | <content>
  • prevlen:前一个entry的长度(支持反向遍历)
  • encoding:编码类型(决定content是整数还是字符串)
  • content:实际数据(比如字符串"张三"或整数28)

为什么省内存?

  • 无指针开销:传统链表每个节点需要前后指针(各占8字节),而ziplist通过prevlen字段隐式记录位置。
  • 整数优化:若Value是整数,直接用变长编码存储(比如数字28用1字节存,而非字符串"28"占2字节)。

适用场景

默认当Hash满足以下条件时使用ziplist:

  • Field数量 ≤ hash-max-ziplist-entries(默认512)
  • 每个Value长度 ≤ hash-max-ziplist-value(默认64字节)

缺点与风险

  • 修改代价高:插入/删除元素可能需要重新分配内存并拷贝数据,时间复杂度O(N)。
  • 内存碎片:频繁修改可能导致小块内存无法复用。

2. 大数据形态:hashtable(哈希表)

结构揭秘:和Java HashMap是“亲戚”

当Hash突破ziplist的阈值,Redis会将其转换为hashtable,核心结构是一个字典(dict)

struct dict {
    dictht ht[2];    // 两个哈希表(用于渐进式Rehash)
    int rehashidx;   // Rehash进度(-1表示未进行)
};

每个dictht(哈希表)包含:

  • 哈希桶数组:每个桶指向一个链表(解决哈希冲突)
  • 大小掩码:计算索引(size-1)
  • 已用节点数

哈希冲突解决

采用链地址法:相同哈希值的键值对组成链表。但为了性能,Redis 6.0后链表长度超过阈值会转换为跳表(类似Java ConcurrentHashMap的树化优化)。

渐进式Rehash:边搬砖边营业

当哈希表需要扩容(负载因子>0.75)或缩容时,Redis不会一次性迁移所有数据,而是分批次进行:

  1. 分配新表:新哈希表大小为第一个≥当前已用节点数×2的2^n(如原大小4,新大小为8)。
  2. 逐步迁移:每次执行增删改查命令时,顺带迁移一个bucket(哈希桶)的数据到新表。
  3. 切换表:迁移完成后,用新表替换旧表,回收旧表内存。

优势:避免一次性迁移导致服务阻塞,保证高可用性。


3. 编码切换的幕后故事

ziplist → hashtable

触发条件:

  • 新增Field导致总数超过hash-max-ziplist-entries
  • 插入的某个Value长度超过hash-max-ziplist-value

切换过程:

  1. 新建一个hashtable。
  2. 遍历ziplist,依次将每个Field-Value插入hashtable。
  3. 释放ziplist内存,将Hash的编码类型改为hashtable。

注意:切换是单向的!即使后续删除数据使得条件重新满足,也不会变回ziplist。

hashtable → ziplist

Redis不提供自动逆向切换,需手动删除Key后重新写入小数据。


4. 操作复杂度与性能对比

操作ziplisthashtable
插入元素O(N)(可能触发内存重分配)O(1)(平均)
查找元素O(N)(遍历)O(1)(平均)
删除元素O(N)(可能触发内存重分配)O(1)(平均)
内存占用极低(无指针,紧凑存储)较高(指针开销)

结论

  • 小数据用ziplist:省内存,但频繁修改代价高。
  • 大数据用hashtable:性能稳定,但内存开销大。

5. 调优技巧:让Hash飞起来

  1. 监控编码类型:用OBJECT ENCODING key查看当前是ziplist还是hashtable。
  2. 调整配置参数
    # 适当调大阈值,让更多Hash使用ziplist
    config set hash-max-ziplist-entries 1024
    config set hash-max-ziplist-value 128
    
  3. 预分配大小:若已知Hash会增长到较大规模,主动用HSET初始化,避免多次Rehash。

6. 面试加餐:为什么Redis用渐进式Rehash?

标准答案

传统Rehash一次性迁移数据会阻塞主线程,而Redis是单线程模型,必须避免长时间阻塞。渐进式Rehash将迁移成本分摊到每次请求,保证服务高可用。在此期间,查询会同时访问新旧两个表,新增数据直接写入新表。

加分回答

类似Java ConcurrentHashMap的分段锁思想,通过“化整为零”实现平滑过渡。


总结:Hash的“变形哲学”

Redis Hash的底层设计完美体现了空间与时间的权衡艺术

  • 省内存时:化身ziplist,像压缩饼干一样极致紧凑。
  • 要性能时:切换hashtable,像高速公路一样快速直达。
  • 扩容缩容:渐进式Rehash,像“蚂蚁搬家”一样悄无声息。

下次看到Hash性能波动,不妨用OBJECT ENCODING看看它是不是偷偷“变形”了! 🔄