前言
Redis的zset数据结构是一种实现有序集合的数据类型,其底层采用哈希表和跳跃表的结合方式,通过哈希表实现快速查询,通过跳跃表实现有序性。在Redis中,zset的哈希表部分使用了渐进式rehash机制,以避免在哈希表扩容或缩容时出现长时间阻塞,从而保障Redis单线程模型下的高性能。本文将深入分析zset渐进式rehash的实现原理、触发条件、执行流程以及数据一致性保障机制,从源码层面揭示这一优化技术的细节。
一、zset数据结构概述
Redis的zset(有序集合)由两个主要部分组成:哈希表(dict)和跳跃表(zskiplist)。这种组合结构的设计是为了同时满足两种需求:
- 哈希表部分:存储成员 member 到分数 score 的映射关系,使我们能够快速查找某个成员的分数,时间复杂度为O(1) 。
c
typedef struct zset {
dict *dict; // 存储 member->score 的映射
zskiplist *zsl; // 跳跃表,维护有序性
} zset;
- 跳跃表部分:按分数 score 维护成员的有序性,支持范围查询、排名查询等操作,时间复杂度为O(logN) 。
c
typedef struct zskiplistNode {
sds ele; // 成员
double score; // 分数
struct zskiplistNode *backward; // 后退指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 前进指针
unsigned long span; // 跨度
} level[]; // 多层指针数组
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 头尾节点
unsigned long length; // 节点数量
int level; // 最大层数
} zskiplist;
zset的哈希表与Redis中其他数据结构的哈希表实现完全一致,都采用双哈希表结构并支持渐进式rehash。这种设计使得zset能够复用Redis通用的哈希表实现,而无需额外开发专门的rehash机制。
二、渐进式rehash的触发条件
Redis中的哈希表在特定条件下会触发渐进式rehash,对于zset的哈希表部分,触发条件与通用哈希表完全一致:
- 扩容触发条件:
- 当服务器没有执行BGSAVE/BGREWRITEAOF命令,且哈希表的负载因子 ≥ 1时
- 当服务器正在执行BGSAVE/BGREWRITEAOF命令,且哈希表的负载因子 ≥ 5时
- 缩容触发条件:
- 当哈希表的负载因子 < 0.1时
- 且哈希表的大小 > 4(最小哈希表大小)
负载因子的计算方式为:ht[0].used / ht[0].size
,其中:
ht[0].used
:哈希表当前已存储的键值对数量ht[0].size
:哈希表当前的桶数量(总是2的幂)
zset的哈希表触发rehash的条件完全由通用哈希表模块控制,无需zset层额外的逻辑。当zset的成员数量增加或减少到满足上述条件时,Redis会自动开始渐进式rehash过程。
三、渐进式rehash的执行流程
rehash初始化阶段
当触发rehash条件时,Redis会执行以下初始化步骤:
- 分配新哈希表:为
ht[1]
分配空间,大小根据扩容或缩容策略计算:- 扩容:第一个大于等于
ht[0].used * 2
的2的幂 - 缩容:第一个大于等于
ht[0].used
的2的幂
- 扩容:第一个大于等于
- 设置rehashidx标志:将字典的
rehashidx
属性设置为0,表示rehash工作正式开始 。
c
// dict结构定义
typedef struct dict {
dictType *type; // 指向类型特定函数的指针
void *privdata; // 私有数据
dictht ht[2]; // 两个哈希表
long rehashidx; // rehash进度,-1表示未进行
int16_t pauserehash; // rehash暂停标志
} dict;
rehash执行阶段
在rehash进行期间,Redis采用分而治之的策略,将大规模的rehash操作分解为多个小步骤,每次只迁移一个桶的数据:
- 每次操作执行一步rehash:在zset的增、删、改、查等操作执行过程中,Redis会调用
dictRehashStep
函数执行一个rehash步骤 。
c
// dictRehashStep函数
static void _dictRehashStep(dict *d) {
if (d->pauserehash == 0) dictRehash(d, 1);
}
- 迁移桶中的所有键值对:每次rehash步骤会将
ht[0]
哈希表在rehashidx
索引上的所有键值对(即一个桶中的所有链表节点)重新计算哈希值并插入到ht[1]
对应的桶中 。 - 更新rehashidx:当完成一个桶的迁移后,
rehashidx
属性的值会增加1,表示下一个要迁移的桶 。 - 循环迁移:随着zset操作的不断执行,
rehashidx
会逐渐增加,直到ht[0]
中的所有键值对都被迁移到ht[1]
。
rehash完成阶段
当rehashidx
达到ht[0].size
时,表示rehash过程已完成。此时:
- 重置rehashidx:将
rehashidx
的值设为-1,表示rehash操作结束 。 - 释放旧哈希表:释放
ht[0]
的内存,将ht[1]
设置为ht[0]
,并在ht[1]
中创建一个空白哈希表,为下一次rehash做准备 。
c
// rehash完成后的处理
if (d->rehashidx == -1) {
// 释放旧哈希表
zfree(d->ht[0].table);
// 将ht[1]设为ht[0]
d->ht[0] = d->ht[1];
// 初始化新的ht[1]
d->ht[1].size = 0;
d->ht[1].sizemask = 0;
d->ht[1].table = NULL;
d->ht[1].used = 0;
}
四、zset与通用哈希表rehash的区别
虽然zset的哈希表部分复用Redis通用的渐进式rehash机制,但在某些方面存在差异:
- 数据迁移方式:
- 通用哈希表:迁移的是键值对(
dictEntry
节点) - zset哈希表:迁移的也是键值对,但键是成员 member,值是分数 score
- 通用哈希表:迁移的是键值对(
- 跳跃表的协同处理:
- 在rehash期间,跳跃表的节点通过
robj *obj
指针引用成员对象,而成员对象的生命周期由哈希表管理 - 因此,跳跃表无需特别处理rehash,只需确保成员对象在rehash过程中不被释放即可
- 在rehash期间,跳跃表的节点通过
- 操作触发点:
- 通用哈希表:在
dictAdd
、dictDelete
、dictFind
等操作中触发rehash - zset哈希表:在
zadd
、zrem
、zrange
等zset特定操作中触发rehash
- 通用哈希表:在
五、rehash期间的读写操作处理
读操作处理
在rehash期间,zset的读操作需要同时考虑两个哈希表:
- 同时查询ht[0]和ht[1]:在查找成员或分数时,Redis会先查询
ht[0]
,如果未找到,再查询ht[1]
。
c
// zset查找成员的示例代码
dictEntry *de = dictFind(d->dict, member);
if (!de) return NULL;
double score = dictGetDoubleVal(de);
- 跳跃表的查询:在需要根据分数范围或排名查询时,Redis会直接查询跳跃表,因为跳跃表始终维护完整的成员顺序 。
- 数据一致性保证:
- 如果成员已迁移至
ht[1]
,则在ht[0]
中不再存在 - 如果成员未迁移,则在
ht[0]
中存在 - 因此,读操作需要同时检查两个哈希表以确保数据完整性
- 如果成员已迁移至
写操作处理
在rehash期间,zset的写操作需要同时更新两个哈希表:
- 同时更新ht[0]和ht[1]:在执行
zadd
、zrem
等操作时,Redis会同时更新ht[0]
和ht[1]
中的成员信息 。 - 跳跃表的原子性更新:在更新成员分数时,Redis会先更新哈希表中的分数,然后删除并重新插入跳跃表中的节点,确保跳跃表的顺序与分数一致 。
c
// ZINCRBY命令的示例逻辑
void zsetIncrDecrCommand(client *c, int increment) {
// 获取zset对象
robj *zobj = lookupKeyWrite(c->db, c->argv[1]);
// 如果zset不存在,创建一个新的
if (!zobj) {
// 创建zset对象
}
// 获取成员和分数
char *member = (char*)c->argv[2]->ptr;
double delta =(atof(c->argv[3]->ptr));
// 执行分数增减
zsetIncrDecr(zobj, member, delta, &score);
// 如果是更新操作,需要更新跳跃表
if (node) {
// 删除旧节点
zslDeleteNode(zsl, node, update);
// 插入新节点
zslInsert(zsl, score, member, update);
}
}
- 数据一致性保证:
- 如果成员已迁移至
ht[1]
,则写操作仅更新ht[1]
中的信息 - 如果成员未迁移,则写操作仅更新
ht[0]
中的信息 - 写操作需要根据rehashidx的值决定是否同时更新两个哈希表
- 如果成员已迁移至
六、源码分析:渐进式rehash的核心函数
Redis的渐进式rehash主要由以下几个核心函数实现:
- dictRehash函数
c
/* 执行N步的增量rehash */
int dictRehash(dict *d, int n) {
int empty_visits = n * 10; /* 最多访问的空桶数量 */
if (!dictIsRehashing(d)) return 0;
/* 循环执行rehash步骤,直到完成或达到空桶访问限制 */
while (n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
/* 获取下一个需要rehash的桶 */
int index = d->rehashidx % d->ht[0].size;
de = d->ht[0].table[index];
d->ht[0].table[index] = NULL;
d->ht[0].used--;
/* 遍历该桶中的所有键值对 */
while (de) {
nextde = de->next;
/* 重新计算哈希值并插入到ht[1] */
_dictRehashOne(d, de);
de = nextde;
}
/* 更新rehashidx */
d->rehashidx++;
/* 如果rehashidx超过ht[0].size,则表示rehash完成 */
if (d->rehashidx == d->ht[0].size) {
d->rehashidx = -1; /* 标记rehash完成 */
/* 释放ht[0]并重置ht数组 */
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
d->ht[1].size = 0;
d->ht[1].sizemask = 0;
d->ht[1].table = NULL;
d->ht[1].used = 0;
return 0;
}
}
return 1; /* 还有键值对需要rehash */
}
- _dictRehashOne函数
c
/* 将一个键值对rehash到ht[1] */
static void _dictRehashOne(dict *d, dictEntry *de) {
unsigned int h;
/* 计算在新哈希表中的索引 */
if (d->type->hashFunction) {
h = d->type->hashFunction(de->key,
(unsigned int)d->type->keyLength(de->key));
} else {
h = dictHashKey(de->key, (unsigned int)d->type->keyLength(de->key));
}
h &= d->ht[1].sizemask;
/* 将键值对插入到新哈希表 */
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[1].used++;
}
- zset操作中的rehash触发
在zset的增删操作中,会调用通用哈希表的函数,从而触发rehash:
c
// zaddCommand函数中的部分逻辑
void zaddCommand(client *c) {
robj *zobj = lookupKeyWrite(c->db, c->argv[1]);
// 如果zset不存在,创建一个新的
if (!zobj) {
// 创建zset对象
}
// 添加成员到哈希表
dictAdd(zobj->dict, member, &score);
// 更新跳跃表
zslInsert(zobj->zsl, score, member, update);
// 触发rehash
_dictRehashStep(zobj->dict);
}
七、性能优化与数据一致性保障
渐进式rehash的优势
与传统一次性rehash相比,渐进式rehash具有以下优势:
- 减少阻塞时间:将大规模的rehash操作分解为多个小步骤,每个步骤只迁移一个桶的数据,避免长时间阻塞 。
- 负载均衡:rehash操作均匀分布在后续的字典操作中,不会在某一时刻突然占用大量CPU资源 。
- 灵活控制:Redis可以通过设置
pauserehash
标志来暂停或恢复rehash过程,例如在服务器负载较高时暂停rehash,以保障服务性能 。
数据一致性保障机制
在rehash过程中,Redis通过以下机制保障数据一致性:
- 双表并行机制:
- 读操作:同时检查
ht[0]
和ht[1]
,确保不遗漏任何数据 - 写操作:根据rehash进度,决定是否同时更新两个哈希表
- 读操作:同时检查
- 跳跃表的引用管理:
- 跳跃表节点通过指针引用哈希表中的成员对象
- 在rehash过程中,成员对象不会被释放,因此跳跃表的引用始终有效
- 原子性操作:
- 在zset的更新操作中,哈希表和跳跃表的更新是原子性的
- 例如,ZINCRBY命令会先更新哈希表中的分数,然后删除并重新插入跳跃表中的节点
八、总结与实践建议
zset的渐进式rehash机制是Redis高性能设计的关键组成部分,它通过以下方式优化了zset的操作性能:
- 分步迁移数据:避免一次性rehash导致的长时间阻塞,使Redis能够持续响应客户端请求 。
- 双表并行查询:在rehash过程中,读操作同时查询两个哈希表,确保数据完整性 。
- 跳跃表与哈希表协同:跳跃表通过指针引用哈希表中的成员对象,无需在rehash时特别处理跳跃表的结构
对于使用zset的实践建议:
- 监控哈希表负载:定期检查zset的哈希表负载因子,避免频繁触发rehash。
- 合理设置参数:根据业务需求调整
zset_max_ziplist_entries
和zset_max_ziplist_value
参数,优化zset的存储结构。 - 避免大批量操作:在服务器负载较高时,尽量避免执行大批量的zset操作,以减少rehash对性能的影响。
Redis的渐进式rehash机制是一种非常高效的哈希表扩容/缩容策略,它通过将大规模操作分解为多个小步骤,使Redis能够在单线程模型下保持高性能,同时处理大量数据。对于zset这样的复杂数据结构,这种机制确保了哈希表和跳跃表的协同工作,维持了数据的一致性和有序性。