Redis zset 渐进式rehash 实现原理、触发条件、执行流程以及数据一致性保障机制【分步源码解析】

前言

Redis的zset数据结构是一种实现有序集合的数据类型,其底层采用哈希表和跳跃表的结合方式,通过哈希表实现快速查询,通过跳跃表实现有序性。在Redis中,zset的哈希表部分使用了渐进式rehash机制,以避免在哈希表扩容或缩容时出现长时间阻塞,从而保障Redis单线程模型下的高性能。本文将深入分析zset渐进式rehash的实现原理、触发条件、执行流程以及数据一致性保障机制,从源码层面揭示这一优化技术的细节。

一、zset数据结构概述

Redis的zset(有序集合)由两个主要部分组成:哈希表(dict)和跳跃表(zskiplist)。这种组合结构的设计是为了同时满足两种需求:

  1. 哈希表部分:存储成员 member 到分数 score 的映射关系,使我们能够快速查找某个成员的分数,时间复杂度为O(1) 。
c 复制代码
typedef struct zset {
    dict *dict;      // 存储 member->score 的映射
    zskiplist *zsl; // 跳跃表,维护有序性
} zset;
  1. 跳跃表部分:按分数 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的哈希表部分,触发条件与通用哈希表完全一致:

  1. 扩容触发条件:
    • 当服务器没有执行BGSAVE/BGREWRITEAOF命令,且哈希表的负载因子 ≥ 1时
    • 当服务器正在执行BGSAVE/BGREWRITEAOF命令,且哈希表的负载因子 ≥ 5时
  2. 缩容触发条件:
    • 当哈希表的负载因子 < 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会执行以下初始化步骤:

  1. 分配新哈希表:为ht[1]分配空间,大小根据扩容或缩容策略计算:
    • 扩容:第一个大于等于ht[0].used * 2的2的幂
    • 缩容:第一个大于等于ht[0].used的2的幂
  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操作分解为多个小步骤,每次只迁移一个桶的数据:

  1. 每次操作执行一步rehash:在zset的增、删、改、查等操作执行过程中,Redis会调用dictRehashStep函数执行一个rehash步骤 。
c 复制代码
// dictRehashStep函数
static void _dictRehashStep(dict *d) {
    if (d->pauserehash == 0) dictRehash(d, 1);
}
  1. 迁移桶中的所有键值对:每次rehash步骤会将ht[0]哈希表在rehashidx索引上的所有键值对(即一个桶中的所有链表节点)重新计算哈希值并插入到ht[1]对应的桶中 。
  2. 更新rehashidx:当完成一个桶的迁移后,rehashidx属性的值会增加1,表示下一个要迁移的桶 。
  3. 循环迁移:随着zset操作的不断执行,rehashidx会逐渐增加,直到ht[0]中的所有键值对都被迁移到ht[1]

rehash完成阶段

rehashidx达到ht[0].size时,表示rehash过程已完成。此时:

  1. 重置rehashidx:将rehashidx的值设为-1,表示rehash操作结束 。
  2. 释放旧哈希表:释放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机制,但在某些方面存在差异:

  1. 数据迁移方式:
    • 通用哈希表:迁移的是键值对(dictEntry节点)
    • zset哈希表:迁移的也是键值对,但键是成员 member,值是分数 score
  2. 跳跃表的协同处理:
    • 在rehash期间,跳跃表的节点通过robj *obj指针引用成员对象,而成员对象的生命周期由哈希表管理
    • 因此,跳跃表无需特别处理rehash,只需确保成员对象在rehash过程中不被释放即可
  3. 操作触发点:
    • 通用哈希表:在dictAdddictDeletedictFind等操作中触发rehash
    • zset哈希表:在zaddzremzrange等zset特定操作中触发rehash

五、rehash期间的读写操作处理

读操作处理

在rehash期间,zset的读操作需要同时考虑两个哈希表:

  1. 同时查询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);
  1. 跳跃表的查询:在需要根据分数范围或排名查询时,Redis会直接查询跳跃表,因为跳跃表始终维护完整的成员顺序 。
  2. 数据一致性保证:
    • 如果成员已迁移至ht[1],则在ht[0]中不再存在
    • 如果成员未迁移,则在ht[0]中存在
    • 因此,读操作需要同时检查两个哈希表以确保数据完整性

写操作处理

在rehash期间,zset的写操作需要同时更新两个哈希表:

  1. 同时更新ht[0]和ht[1]:在执行zaddzrem等操作时,Redis会同时更新ht[0]ht[1]中的成员信息 。
  2. 跳跃表的原子性更新:在更新成员分数时,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);
    }
}
  1. 数据一致性保证:
    • 如果成员已迁移至ht[1],则写操作仅更新ht[1]中的信息
    • 如果成员未迁移,则写操作仅更新ht[0]中的信息
    • 写操作需要根据rehashidx的值决定是否同时更新两个哈希表

六、源码分析:渐进式rehash的核心函数

Redis的渐进式rehash主要由以下几个核心函数实现:

  1. 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 */
}
  1. _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++;
}
  1. 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具有以下优势:

  1. 减少阻塞时间:将大规模的rehash操作分解为多个小步骤,每个步骤只迁移一个桶的数据,避免长时间阻塞 。
  2. 负载均衡:rehash操作均匀分布在后续的字典操作中,不会在某一时刻突然占用大量CPU资源 。
  3. 灵活控制:Redis可以通过设置pauserehash标志来暂停或恢复rehash过程,例如在服务器负载较高时暂停rehash,以保障服务性能 。

数据一致性保障机制

在rehash过程中,Redis通过以下机制保障数据一致性:

  1. 双表并行机制:
    • 读操作:同时检查ht[0]ht[1],确保不遗漏任何数据
    • 写操作:根据rehash进度,决定是否同时更新两个哈希表
  2. 跳跃表的引用管理:
    • 跳跃表节点通过指针引用哈希表中的成员对象
    • 在rehash过程中,成员对象不会被释放,因此跳跃表的引用始终有效
  3. 原子性操作:
    • 在zset的更新操作中,哈希表和跳跃表的更新是原子性的
    • 例如,ZINCRBY命令会先更新哈希表中的分数,然后删除并重新插入跳跃表中的节点

八、总结与实践建议

zset的渐进式rehash机制是Redis高性能设计的关键组成部分,它通过以下方式优化了zset的操作性能:

  1. 分步迁移数据:避免一次性rehash导致的长时间阻塞,使Redis能够持续响应客户端请求 。
  2. 双表并行查询:在rehash过程中,读操作同时查询两个哈希表,确保数据完整性 。
  3. 跳跃表与哈希表协同:跳跃表通过指针引用哈希表中的成员对象,无需在rehash时特别处理跳跃表的结构

对于使用zset的实践建议:

  1. 监控哈希表负载:定期检查zset的哈希表负载因子,避免频繁触发rehash。
  2. 合理设置参数:根据业务需求调整zset_max_ziplist_entrieszset_max_ziplist_value参数,优化zset的存储结构。
  3. 避免大批量操作:在服务器负载较高时,尽量避免执行大批量的zset操作,以减少rehash对性能的影响。
    Redis的渐进式rehash机制是一种非常高效的哈希表扩容/缩容策略,它通过将大规模操作分解为多个小步骤,使Redis能够在单线程模型下保持高性能,同时处理大量数据。对于zset这样的复杂数据结构,这种机制确保了哈希表和跳跃表的协同工作,维持了数据的一致性和有序性。
相关推荐
jakeswang14 分钟前
应用缓存不止是Redis!——亿级流量系统架构设计系列
redis·分布式·后端·缓存
秋难降1 小时前
零基础学SQL(八)——事务
数据库·sql·mysql
Starry_hello world1 小时前
MySql 表的约束
数据库·笔记·mysql·有问必答
RestCloud1 小时前
ETLCloud中的数据转化规则是什么意思?怎么执行
数据库·数据仓库·etl
一个天蝎座 白勺 程序猿2 小时前
Apache IoTDB(4):深度解析时序数据库 IoTDB 在Kubernetes 集群中的部署与实践指南
数据库·深度学习·kubernetes·apache·时序数据库·iotdb
君不见,青丝成雪2 小时前
大数据技术栈 —— Redis与Kafka
数据库·redis·kafka
悟能不能悟2 小时前
排查Redis数据倾斜引发的性能瓶颈
java·数据库·redis
切糕师学AI2 小时前
.net core web程序如何设置redis预热?
redis·.netcore
DemonAvenger2 小时前
事务管理:ACID特性与隔离级别详解
数据库·mysql·性能优化