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 编码:
- 元素数量 ≤
zset-max-listpack-entries - 所有元素值的长度 ≤
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 编码,这实际上是两种数据结构的组合:
- 跳跃表 (skiplist):按 score 排序存储所有元素,支持高效的范围查询
- 哈希表 (hashtable):存储 member 到 score 的映射,支持 O(1) 复杂度的单点查询
这种组合设计充分发挥了两种数据结构的优势:
- 跳跃表:优秀的有序性和范围查询性能
- 哈希表:高效的点查询性能
3. 跳跃表的深度解析
3.1 跳跃表的基本原理
跳跃表是一种概率性的有序数据结构,通过多级索引提高查找效率。它的设计思想类似于二叉搜索树,但实现更简单且性能稳定。
跳跃表的特点:
- 多层结构:每个节点有多个向前指针
- 概率平衡:通过随机算法确定节点层数,避免重新平衡的开销
- 有序性:所有元素按 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 | ~8KB | 69% |
| 500个元素 | ~12KB | ~40KB | 70% |
| 1000个元素 | ~25KB | ~80KB | 69% |
| 1000个大元素 | 不适用 | ~160KB | - |
5.2 操作性能对比
| 操作类型 | listpack 编码 | skiplist 编码 |
|---|---|---|
| ZADD | O(n) | O(log n) |
| ZREM | O(n) | O(log n) |
| ZSCORE | O(n) | O(1) |
| ZRANK | O(n) | O(log n) |
| ZRANGE | O(n) | O(log n + m) |
| ZRANGEBYSCORE | O(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 的组合则确保了大规
本文来自博客园,作者:NeoLshu,转载请注明原文链接:https://www.cnblogs.com/neolshu/p/19120367

浙公网安备 33010602011771号