文章中如果有图看不到,可以点这里去 csdn 看看。从那边导过来的,文章太多,没法一篇篇修改好。

Redis 底层数据结构解析(五):ZSet 类型与跳跃表的有序世界

1. 引言:Redis ZSet 的独特价值

Redis ZSet(Sorted Set)是 Redis 中最复杂且功能最丰富的数据结构之一,它结合了 Set 的唯⼀性和 Hash 的评分机制,提供了按分数排序和范围查询的能力。ZSet 的底层实现体现了 Redis 在有序数据存储方面的精妙设计,通过两种编码方式(ziplist/listpack 和 skiplist)来适应不同规模的数据集。

ZSet 的应用场景非常广泛,包括排行榜、延迟队列、优先级系统、范围查询等。理解其底层实现机制对于正确使用和优化 ZSet 类型至关重要。本文将深入剖析 Redis ZSet 的实现原理,重点关注跳跃表的结构设计和双编码策略的智能切换。

2. ZSet 类型的双编码策略

2.1 编码转换的配置阈值

Redis ZSet 根据以下配置参数决定使用哪种编码:

zset-max-listpack-entries 128    # 最大元素数量阈值
zset-max-listpack-value 64       # 元素值的最大长度阈值

当同时满足以下两个条件时,使用 listpack 编码:

  1. 元素数量 ≤ zset-max-listpack-entries
  2. 所有元素值的长度 ≤ zset-max-listpack-value

否则,使用 skiplist 编码(实际是 skiplist + hashtable 的组合)。

2.2 listpack 编码的紧凑存储

当使用 listpack 编码时,ZSet 的所有 member-score 对按照以下格式存储:

[member1, score1, member2, score2, ..., memberN, scoreN]

所有元素按照 score 从小到大排序,这种结构对于小规模数据非常高效。

listpack 中的 ZSet 元素布局:

+-----------+----------+-----------+----------+-----+-----------+----------+
|  member1  |  score1  |  member2  |  score2  | ... |  memberN  |  scoreN  |
+-----------+----------+-----------+----------+-----+-----------+----------+

查找操作的实现:

unsigned char *zzlFind(unsigned char *zl, sds ele, double *score) {
    // 遍历listpack查找元素
    unsigned char *eptr = lpSeek(zl, 0);
    while (eptr != NULL) {
        unsigned char *sptr = lpNext(zl, eptr);  // 获取score指针
        double sval = zzlGetScore(sptr);         // 获取score值
        
        // 比较member
        if (lpCompare(eptr, (unsigned char*)ele, sdslen(ele))) {
            if (score) *score = sval;
            return eptr;
        }
        
        // 移动到下一个member-score对
        eptr = lpNext(zl, sptr);
    }
    return NULL;
}

2.3 skiplist + hashtable 的组合实现

当数据规模超过阈值时,ZSet 转换为 skiplist 编码,这实际上是两种数据结构的组合:

  1. 跳跃表 (skiplist):按 score 排序存储所有元素,支持高效的范围查询
  2. 哈希表 (hashtable):存储 member 到 score 的映射,支持 O(1) 复杂度的单点查询

这种组合设计充分发挥了两种数据结构的优势:

  • 跳跃表:优秀的有序性和范围查询性能
  • 哈希表:高效的点查询性能

3. 跳跃表的深度解析

3.1 跳跃表的基本原理

跳跃表是一种概率性的有序数据结构,通过多级索引提高查找效率。它的设计思想类似于二叉搜索树,但实现更简单且性能稳定。

跳跃表的特点:

  1. 多层结构:每个节点有多个向前指针
  2. 概率平衡:通过随机算法确定节点层数,避免重新平衡的开销
  3. 有序性:所有元素按 score 排序,相同 score 按 member 字典序排序

3.2 跳跃表的结构定义

Redis 跳跃表结构(server.h):

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;  // 头尾指针
    unsigned long length;                  // 节点数量
    int level;                             // 最大层数
} zskiplist;

typedef struct zskiplistNode {
    sds ele;                               // 成员元素
    double score;                          // 分数
    struct zskiplistNode *backward;        // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;     // 前进指针
        unsigned long span;                 // 跨度
    } level[];                             // 柔性数组,表示层级
} zskiplistNode;

3.3 跳跃表的查找算法

跳跃表的查找从最高层开始,逐步向下缩小范围:

zskiplistNode *zslGetElementByRank(zskiplist *zsl, unsigned long rank) {
    zskiplistNode *x;
    unsigned long traversed = 0;
    int i;
    
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {
        while (x->level[i].forward && (traversed + x->level[i].span) <= rank) {
            traversed += x->level[i].span;
            x = x->level[i].forward;
        }
        if (traversed == rank) {
            return x;
        }
    }
    return NULL;
}

3.4 跳跃表的插入操作

插入操作需要先查找合适位置,然后随机确定新节点的层数:

zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;
    
    // 查找插入位置并记录搜索路径
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        while (x->level[i].forward &&
               (x->level[i].forward->score < score ||
                (x->level[i].forward->score == score &&
                 sdscmp(x->level[i].forward->ele, ele) < 0))) {
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;
    }
    
    // 随机生成节点层数
    level = zslRandomLevel();
    if (level > zsl->level) {
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }
        zsl->level = level;
    }
    
    // 创建新节点并插入
    x = zslCreateNode(level, score, ele);
    for (i = 0; i < level; i++) {
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;
        
        // 更新跨度
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }
    
    // 增加高层节点的跨度
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }
    
    // 设置后退指针
    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    
    zsl->length++;
    return x;
}

3.5 随机层数生成算法

Redis 使用一个简单的概率算法来确定新节点的层数:

int zslRandomLevel(void) {
    int level = 1;
    while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

其中 ZSKIPLIST_P 的值为 0.25,这意味着:

  • 第 1 层的概率:75%
  • 第 2 层的概率:25% × 75% = 18.75%
  • 第 3 层的概率:25%² × 75% = 4.6875%
  • 以此类推…

4. ZSet 操作的高级实现

4.1 范围查询的实现

ZSet 的范围查询(ZRANGE、ZRANGEBYSCORE 等)是其主要优势之一:

void zrangeGenericCommand(client *c, int reverse) {
    robj *key = c->argv[1];
    robj *zobj;
    long start;
    long end;
    int withscores = 0;
    
    // 解析参数
    if (getLongFromObjectOrReply(c, c->argv[2], &start, NULL) != C_OK ||
        getLongFromObjectOrReply(c, c->argv[3], &end, NULL) != C_OK)
        return;
    
    // 检查是否包含scores
    if (c->argc == 5 && !strcasecmp(c->argv[4]->ptr, "withscores")) {
        withscores = 1;
    }
    
    // 获取ZSet对象
    if ((zobj = lookupKeyReadOrReply(c, key, shared.emptyarray)) == NULL ||
        checkType(c, zobj, OBJ_ZSET)) return;
    
    // 根据编码类型处理
    if (zobj->encoding == OBJ_ENCODING_LISTPACK) {
        // listpack编码的范围查询
        unsigned char *zl = zobj->ptr;
        unsigned char *eptr, *sptr;
        unsigned long rangelen = end - start + 1;
        
        // 定位起始位置
        eptr = lpSeek(zl, start * 2);
        for (int i = 0; i < rangelen && eptr != NULL; i++) {
            sptr = lpNext(zl, eptr);
            
            // 添加结果到响应
            addReplyBulkCBuffer(c, lpGetValue(eptr, NULL), lpGetValueSize(eptr));
            if (withscores) {
                double score = zzlGetScore(sptr);
                addReplyDouble(c, score);
            }
            
            eptr = lpNext(zl, sptr);
        }
    } else if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
        // skiplist编码的范围查询
        zset *zs = zobj->ptr;
        zskiplist *zsl = zs->zsl;
        zskiplistNode *node;
        
        // 检查范围是否有效
        if (start < 0) start = zsl->length + start;
        if (end < 0) end = zsl->length + end;
        if (start < 0) start = 0;
        if (end >= zsl->length) end = zsl->length - 1;
        
        rangelen = end - start + 1;
        if (rangelen <= 0) {
            addReply(c, shared.emptyarray);
            return;
        }
        
        // 定位起始节点
        node = zslGetElementByRank(zsl, start + 1);
        for (int i = 0; i < rangelen && node != NULL; i++) {
            addReplyBulkSds(c, sdsdup(node->ele));
            if (withscores) {
                addReplyDouble(c, node->score);
            }
            node = reverse ? node->backward : node->level[0].forward;
        }
    }
}

4.2 排名查询的实现

ZSet 支持获取元素的排名(ZRANK、ZREVRANK):

long zsetRank(robj *zobj, sds ele, int reverse) {
    zset *zs;
    dictEntry *de;
    double score;
    
    if (zobj->encoding == OBJ_ENCODING_LISTPACK) {
        // listpack编码的排名查询
        unsigned char *zl = zobj->ptr;
        unsigned char *eptr = lpSeek(zl, 0);
        long rank = 0;
        
        while (eptr != NULL) {
            if (lpCompare(eptr, (unsigned char*)ele, sdslen(ele))) {
                return reverse ? (lpLength(zl)/2 - rank - 1) : rank;
            }
            rank++;
            eptr = lpNext(zl, lpNext(zl, eptr));  // 跳过score,移动到下一个member
        }
        return -1;
    } else if (zobj->encoding == OBJ_ENCODING_SKIPLIST) {
        // skiplist编码的排名查询
        zs = zobj->ptr;
        de = dictFind(zs->dict, ele);
        if (de == NULL) return -1;
        
        score = *(double*)dictGetVal(de);
        zskiplist *zsl = zs->zsl;
        zskiplistNode *x;
        unsigned long rank = 0;
        
        x = zsl->header;
        for (int i = zsl->level-1; i >= 0; i--) {
            while (x->level[i].forward &&
                   (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                     sdscmp(x->level[i].forward->ele, ele) <= 0))) {
                rank += x->level[i].span;
                x = x->level[i].forward;
            }
            
            if (x->ele && sdscmp(x->ele, ele) == 0) {
                return reverse ? (zsl->length - rank) : (rank - 1);
            }
        }
    }
    return -1;
}

5. 性能分析与优化策略

5.1 内存使用对比

场景listpack 编码skiplist 编码内存节省
100个小元素~2.5KB~8KB69%
500个元素~12KB~40KB70%
1000个元素~25KB~80KB69%
1000个大元素不适用~160KB-

5.2 操作性能对比

操作类型listpack 编码skiplist 编码
ZADDO(n)O(log n)
ZREMO(n)O(log n)
ZSCOREO(n)O(1)
ZRANKO(n)O(log n)
ZRANGEO(n)O(log n + m)
ZRANGEBYSCOREO(n)O(log n + m)

5.3 配置优化建议

针对有序集合的优化:

# 根据实际数据特征调整
zset-max-listpack-entries 256    # 适当提高阈值
zset-max-listpack-value 128      # 根据元素值大小调整

监控与诊断命令:

# 查看ZSet对象的编码信息
redis-cli object encoding myzset

# 监控内存使用情况
redis-cli memory usage myzset

# 获取ZSet基本信息
redis-cli zcard myzset

6. 实战应用场景

6.1 排行榜系统实现

class LeaderboardSystem:
    def __init__(self, leaderboard_name):
        self.r = redis.Redis()
        self.name = leaderboard_name
    
    def add_score(self, user_id, score):
        # 添加或更新用户分数
        return self.r.zadd(self.name, {user_id: score})
    
    def get_rank(self, user_id):
        # 获取用户排名(升序,0表示第一名)
        return self.r.zrank(self.name, user_id)
    
    def get_reverse_rank(self, user_id):
        # 获取用户排名(降序,0表示最后一名)
        return self.r.zrevrank(self.name, user_id)
    
    def get_top_n(self, n=10, with_scores=True):
        # 获取前N名用户
        if with_scores:
            return self.r.zrevrange(self.name, 0, n-1, withscores=True)
        else:
            return self.r.zrevrange(self.name, 0, n-1)
    
    def get_users_by_score_range(self, min_score, max_score):
        # 获取分数范围内的用户
        return self.r.zrangebyscore(self.name, min_score, max_score, withscores=True)
    
    def increment_score(self, user_id, increment=1):
        # 增加用户分数
        return self.r.zincrby(self.name, increment, user_id)

6.2 延迟队列实现

class DelayedQueue:
    def __init__(self, queue_name):
        self.r = redis.Redis()
        self.queue_name = queue_name
    
    def add_task(self, task_id, delay_seconds):
        # 添加延迟任务,分数为当前时间 + 延迟时间
        execute_time = time.time() + delay_seconds
        return self.r.zadd(self.queue_name, {task_id: execute_time})
    
    def get_ready_tasks(self):
        # 获取已到期的任务
        current_time = time.time()
        tasks = self.r.zrangebyscore(self.queue_name, 0, current_time)
        
        # 移除已获取的任务(原子操作)
        pipe = self.r.pipeline()
        pipe.zremrangebyscore(self.queue_name, 0, current_time)
        pipe.zrangebyscore(self.queue_name, 0, current_time)
        results = pipe.execute()
        
        return results[1] if len(results) > 1 else []
    
    def check_pending_tasks(self):
        # 检查待处理任务数量
        return self.r.zcard(self.queue_name)
    
    def reschedule_task(self, task_id, new_delay):
        # 重新调度任务
        current_score = self.r.zscore(self.queue_name, task_id)
        if current_score is not None:
            new_score = time.time() + new_delay
            return self.r.zadd(self.queue_name, {task_id: new_score}, xx=True)
        return False

6.3 时间序列数据存储

class TimeSeriesStorage:
    def __init__(self, series_name):
        self.r = redis.Redis()
        self.series_name = series_name
    
    def add_data_point(self, timestamp, value):
        # 添加数据点,使用时间戳作为分数
        return self.r.zadd(self.series_name, {value: timestamp})
    
    def get_range(self, start_time, end_time, with_timestamps=True):
        # 获取时间范围内的数据
        if with_timestamps:
            return self.r.zrangebyscore(self.series_name, start_time, end_time, withscores=True)
        else:
            return self.r.zrangebyscore(self.series_name, start_time, end_time)
    
    def get_latest(self, n=1):
        # 获取最新的N个数据点
        return self.r.zrevrange(self.series_name, 0, n-1, withscores=True)
    
    def remove_old_data(self, max_age_seconds):
        # 删除过期数据
        cutoff_time = time.time() - max_age_seconds
        return self.r.zremrangebyscore(self.series_name, 0, cutoff_time)
    
    def aggregate_data(self, start_time, end_time, aggregation='avg'):
        # 聚合数据(平均值、最大值、最小值等)
        data = self.r.zrangebyscore(self.series_name, start_time, end_time, withscores=True)
        if not data:
            return None
        
        values = [float(value) for _, value in data]
        
        if aggregation == 'avg':
            return sum(values) / len(values)
        elif aggregation == 'max':
            return max(values)
        elif aggregation == 'min':
            return min(values)
        elif aggregation == 'sum':
            return sum(values)
        else:
            raise ValueError("Unsupported aggregation type")

7. 高级特性与最佳实践

7.1 大规模 ZSet 的优化策略

对于包含大量元素的 ZSet,可以考虑以下优化策略:

分片存储:

def get_sharded_zset_key(base_key, member, num_shards=100):
    # 根据member计算分片键
    shard_id = hash(member) % num_shards
    return f"{base_key}:shard:{shard_id}"

def zadd_sharded(key, mapping, num_shards=100):
    # 分片存储ZSet元素
    results = {}
    for member, score in mapping.items():
        sharded_key = get_sharded_zset_key(key, member, num_shards)
        results[sharded_key] = r.zadd(sharded_key, {member: score})
    return results

def zrange_sharded(key, start, end, num_shards=100, withscores=False):
    # 从所有分片收集数据并合并排序
    all_data = []
    for i in range(num_shards):
        sharded_key = f"{key}:shard:{i}"
        data = r.zrange(sharded_key, 0, -1, withscores=withscores)
        all_data.extend(data)
    
    # 排序并返回请求的范围
    if withscores:
        all_data.sort(key=lambda x: x[1])
    else:
        all_data.sort()
    
    return all_data[start:end+1]

7.2 内存优化技巧

定期清理过期数据:

def cleanup_old_zsets(pattern, max_age_days=30):
    # 清理旧的ZSet数据
    cursor = 0
    while True:
        cursor, keys = r.scan(cursor, match=pattern, count=100)
        if not keys:
            break
        
        for key in keys:
            # 检查最后访问时间
            last_accessed = r.object('idletime', key)
            if last_accessed > max_age_days * 24 * 3600:
                r.delete(key)

7.3 监控与告警

ZSet 大小监控:

def monitor_zset_sizes(threshold=100000):
    # 监控大型ZSet
    cursor = 0
    large_zsets = []
    
    while True:
        cursor, keys = r.scan(cursor, match="*", count=100)
        if not keys:
            break
        
        for key in keys:
            key_type = r.type(key)
            if key_type == "zset":
                size = r.zcard(key)
                if size > threshold:
                    large_zsets.append((key, size))
    
    return large_zsets

8. 总结

Redis ZSet 类型的底层实现通过 listpack 和 skiplist+hashtable 的双编码策略,在内存效率和操作性能之间取得了出色的平衡。listpack 为小规模有序集合提供了紧凑的存储,而 skiplist+hashtable 的组合则确保了大规

posted @ 2025-09-07 15:25  NeoLshu  阅读(13)  评论(0)    收藏  举报  来源