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不会一次性迁移所有数据,而是分批次进行:
- 分配新表:新哈希表大小为第一个≥当前已用节点数×2的2^n(如原大小4,新大小为8)。
- 逐步迁移:每次执行增删改查命令时,顺带迁移一个bucket(哈希桶)的数据到新表。
- 切换表:迁移完成后,用新表替换旧表,回收旧表内存。
优势:避免一次性迁移导致服务阻塞,保证高可用性。
3. 编码切换的幕后故事
ziplist → hashtable
触发条件:
- 新增Field导致总数超过
hash-max-ziplist-entries - 插入的某个Value长度超过
hash-max-ziplist-value
切换过程:
- 新建一个hashtable。
- 遍历ziplist,依次将每个Field-Value插入hashtable。
- 释放ziplist内存,将Hash的编码类型改为hashtable。
注意:切换是单向的!即使后续删除数据使得条件重新满足,也不会变回ziplist。
hashtable → ziplist
Redis不提供自动逆向切换,需手动删除Key后重新写入小数据。
4. 操作复杂度与性能对比
| 操作 | ziplist | hashtable |
|---|---|---|
| 插入元素 | O(N)(可能触发内存重分配) | O(1)(平均) |
| 查找元素 | O(N)(遍历) | O(1)(平均) |
| 删除元素 | O(N)(可能触发内存重分配) | O(1)(平均) |
| 内存占用 | 极低(无指针,紧凑存储) | 较高(指针开销) |
结论:
- 小数据用ziplist:省内存,但频繁修改代价高。
- 大数据用hashtable:性能稳定,但内存开销大。
5. 调优技巧:让Hash飞起来
- 监控编码类型:用
OBJECT ENCODING key查看当前是ziplist还是hashtable。 - 调整配置参数:
# 适当调大阈值,让更多Hash使用ziplist config set hash-max-ziplist-entries 1024 config set hash-max-ziplist-value 128 - 预分配大小:若已知Hash会增长到较大规模,主动用
HSET初始化,避免多次Rehash。
6. 面试加餐:为什么Redis用渐进式Rehash?
标准答案:
传统Rehash一次性迁移数据会阻塞主线程,而Redis是单线程模型,必须避免长时间阻塞。渐进式Rehash将迁移成本分摊到每次请求,保证服务高可用。在此期间,查询会同时访问新旧两个表,新增数据直接写入新表。
加分回答:
类似Java ConcurrentHashMap的分段锁思想,通过“化整为零”实现平滑过渡。
总结:Hash的“变形哲学”
Redis Hash的底层设计完美体现了空间与时间的权衡艺术:
- 省内存时:化身ziplist,像压缩饼干一样极致紧凑。
- 要性能时:切换hashtable,像高速公路一样快速直达。
- 扩容缩容:渐进式Rehash,像“蚂蚁搬家”一样悄无声息。
下次看到Hash性能波动,不妨用OBJECT ENCODING看看它是不是偷偷“变形”了! 🔄