1. 完整回答
ZSet 是 Redis 提供的有序集合(Sorted Set),它的特点是元素唯一,并且每个元素都会关联一个分数,也就是 score。Redis 会根据 score 自动维护排序,因此特别适合排行榜、范围查询和按时间调度任务等场景。
ZSet 默认按照 score 从小到大排序,如果多个元素的分数相同,就按照 member 的字典序排序。
在底层实现上,Redis 会根据数据规模选择不同的编码方式。数据量较小时使用 listpack 紧凑列表,主要是为了节省内存;常规情况下使用哈希表加跳表的组合结构。
哈希表负责根据 member 快速定位元素,跳表负责维护元素的有序关系。跳表通过多级索引加速查找,同时利用跨度信息支持排名查询,所以插入、删除和查询排名的平均时间复杂度通常是 O(log N)。
在实际使用中,可以通过 ZADD 添加元素或更新分数,通过 ZINCRBY 累加分数,通过 ZRANGE 获取指定区间的数据,通过 ZREVRANK 查询倒序排名。
例如实现游戏排行榜时,可以把用户 ID 作为 member,把积分作为 score。每次积分变化后,Redis 会自动调整用户的位置,能够直接查询前十名或者某个用户的排名。
ZSet 的优势是去重、有序、动态更新和高效范围查询,但对于复杂业务排名、可靠消息消费等需求,还需要额外的业务逻辑配合。
2. 核心原理与关键概念
2.1 核心原理
ZSet 的核心是同时支持按成员查找和按分数排序。
在常规编码下,主要依赖哈希表和跳表配合完成。
- 哈希表负责快速定位
哈希表以 member 为查找依据,可以快速判断成员是否存在,并定位对应数据。
例如查询用户当前积分,平均时间复杂度为 O(1)。
- 跳表负责维护有序关系
跳表可以理解为在有序链表上增加多级索引。
查找过程:
最高层索引定位大致范围 → 逐层向下缩小范围 → 在底层找到目标节点。
这种结构使查找、插入和删除的平均时间复杂度达到 O(log N)。
Redis 的跳表还维护了跨度(span),用于记录索引跨越的节点数量,因此可以高效计算元素排名。
- 添加或更新元素的流程
以 ZADD 为例:
检查 member 是否存在 → 确定新增或更新 → 调整跳表中的有序位置 → 同步维护哈希表中的对应关系 → 返回结果。
如果成员已经存在,ZADD 会更新其 score,并确保排序位置正确。
- 小集合的内存优化
Redis 7.0 及以后版本,小规模 ZSet 可以使用 listpack 编码。
-
listpack:紧凑存储,减少内存开销,但定位元素通常需要遍历。
-
跳表 + 哈希表:占用更多内存,但适合大集合的快速查找、更新和排序。
当元素数量或大小超过配置阈值时,Redis 会自动转换为常规编码。
2.2 关键概念
| 概念 | 作用 |
|---|---|
| member | 集合中的唯一元素,通常存储用户 ID、任务 ID |
| score | 与 member 关联的双精度浮点数,决定排序 |
| 跳表(Skip List) | 维护有序关系,支持快速范围查询和排名 |
| 哈希表(Hash Table) | 根据 member 快速定位元素 |
| listpack | 小集合使用的紧凑编码,节省内存 |
| span | 跳表索引中的跨度信息,用于计算排名 |
2.3 常用命令及时间复杂度
以下复杂度主要对应常规的跳表与哈希表编码。
| 命令 | 作用 | 时间复杂度 |
|---|---|---|
ZADD |
添加元素或更新分数 | O(log N) / 元素 |
ZINCRBY |
增加指定元素的分数 | O(log N) |
ZSCORE |
查询元素分数 | O(1) |
ZRANK / ZREVRANK |
查询升序或降序排名 | O(log N) |
ZRANGE |
按排名、分数等获取范围 | O(log N + M) |
ZREM |
删除指定元素 | O(log N) / 元素 |
ZREMRANGEBYSCORE |
删除指定分数范围的元素 | O(log N + M) |
ZCARD |
查询集合元素数量 | O(1) |
其中,N 表示集合中的元素数量,M 表示返回或删除的元素数量。
3. 高频面试追问
3.1 ZSet 为什么同时使用哈希表和跳表?
主要是为了同时满足快速查找元素和高效维护排序这两个需求。
哈希表可以根据 member 快速定位元素,平均时间复杂度为 O(1),但它无法高效完成按 score 排序和范围查询。
跳表能够按照 score 维护有序关系,支持插入、删除、排名和范围查询,但通过 member 直接查找元素没有哈希表方便。
所以 Redis 将两者结合:哈希表负责成员定位,跳表负责排序和排名。
代价是需要维护两套结构,增加一定内存开销,但能够兼顾不同类型的操作。
3.2 Redis 为什么选择跳表,而没有选择红黑树?
主要是因为跳表实现相对简单,而且非常适合有序范围查询。
跳表通过随机层级构建多级索引,插入和删除主要通过调整指针完成,平均时间复杂度是 O(log N)。
红黑树同样可以实现 O(log N) 的查找、插入和删除,但需要通过旋转和颜色调整维护平衡,实现和维护相对复杂。
另外,ZSet 经常需要连续获取某个排名区间或者分数区间的数据。跳表找到起点后,可以沿底层有序链表继续遍历,非常适合这类操作。
Redis 还为跳表维护了跨度信息,可以高效计算排名。
需要注意,跳表的 O(log N) 是期望复杂度,理论最坏情况可能达到 O(N);红黑树具有严格的 O(log N) 最坏时间复杂度保证。
3.3 ZSet 底层一定使用跳表吗?
不一定,ZSet 的底层编码与 Redis 版本和集合规模有关。
Redis 7.0 以后,小集合通常采用 listpack 编码,通过紧凑的连续内存结构存储 member 和 score,降低内存开销。
默认配置下,元素数量不超过 128,且成员大小不超过 64 字节时,可以使用 listpack。
超过对应阈值后,会转换为跳表加哈希表的结构。
listpack 更节省内存,但成员查找需要遍历;跳表加哈希表的内存开销更高,适合大规模数据的快速更新和查询。
可以通过 OBJECT ENCODING key 查看实际使用的编码方式。
3.4 ZADD 和 ZINCRBY 有什么区别?重复添加元素会怎样?
ZADD 主要用于设置分数,ZINCRBY 主要用于累加分数。
如果 member 不存在,ZADD 会新增元素;如果已经存在,就更新原有分数,并维护新的排序位置。
例如某个用户原来的积分是 100:
-
执行
ZADD rank 150 user1,积分变为 150。 -
执行
ZINCRBY rank 50 user1,积分在原有基础上增加 50。
因此,已存在的 member 不会产生重复元素。
ZADD 还支持 NX 和 XX 参数,分别用于只添加新成员、只更新已有成员。
在并发积分累加场景中,ZINCRBY 本身是原子命令,可以避免客户端先读取分数、再计算、再写回导致的丢失更新问题。
3.5 ZSet 的排名是怎么计算的?分数相同怎么办?
ZSet 的排名依赖跳表维护的有序关系和跨度信息,因此查询排名的时间复杂度通常是 O(log N)。
ZRANK 查询升序排名,ZREVRANK 查询降序排名,返回的下标都从 0 开始。
对于相同分数,Redis 会按照 member 字典序确定顺序。升序查询时,字典序较小的元素排在前面;反向查询时顺序相反。
需要注意,Redis 返回的是元素在有序集合中的实际位置。
如果业务要求同分并列,例如两个用户都排第一名,下一名排第三名,就需要按照业务规则额外计算名次。可以通过 ZCOUNT 统计分数高于当前用户的元素数量,再加 1 得到竞赛排名。
3.6 如何使用 ZSet 实现实时排行榜?
可以使用用户 ID 作为 member,用户积分作为 score,由 Redis 自动维护排序。
整体流程是:
用户积分发生变化 → ZINCRBY 更新积分 → ZRANGE 获取排行榜 → ZREVRANK 查询用户排名。
例如:
-
ZINCRBY rank 10 user1:用户增加 10 分。 -
ZRANGE rank 0 9 REV WITHSCORES:获取积分最高的十个用户。 -
ZREVRANK rank user1:查询用户排名,结果加 1 后作为展示名次。
实际项目中,通常会将用户昵称、头像等资料存储在 Hash 或数据库中,ZSet 只保存排序需要的数据,减少内存占用。
如果需要日榜、周榜、月榜,可以按照时间维度使用不同的 key,再为历史榜单设置合理的过期时间。
继续追问:排行榜数据量很大时怎么优化?
首先控制单次返回的数据量,避免一次性查询整个排行榜。
其次,可以按照游戏、地区或者业务维度拆分排行榜,降低单个 key 的数据规模和访问压力。
对于热门榜单,可以缓存前几十名的结果,减少重复查询。
但也要注意,Redis Cluster 会将同一个 ZSet key 存放在同一个分片上,因此单个超大热门榜单无法仅通过增加分片自动分散负载。
3.7 如何使用 ZSet 实现滑动窗口限流?
ZSet 适合实现精确记录每次请求的滑动窗口限流。
可以把请求时间戳作为 score,把每次请求的唯一 ID 作为 member。
例如限制某个用户一分钟最多访问 100 次,执行流程是:
-
使用
ZREMRANGEBYSCORE删除一分钟之前的请求记录。 -
使用
ZCARD统计当前窗口内的请求数量。 -
如果数量小于 100,就通过
ZADD记录本次请求并放行;否则拒绝。 -
为对应 key 设置合理的过期时间,清理长期不活跃的用户数据。
这里有两个关键问题。
第一,member 必须唯一,因为多个请求可能在同一毫秒发生。如果直接使用时间戳作为 member,会出现覆盖,导致统计偏少。
第二,删除、统计、判断和添加需要保证原子性。可以使用 Lua 脚本在 Redis 服务端执行整个流程,避免多个并发请求同时通过数量检查。
这种方式限流比较精确,但每次请求都要存储一条记录,内存开销相对较高。对于请求量很大的场景,可以考虑滑动窗口计数器或令牌桶算法。
3.8 如何使用 ZSet 实现延迟队列?
可以使用任务 ID 作为 member,任务预计执行的时间戳作为 score。
例如某个订单需要 30 分钟后检查是否支付,就可以把任务 ID 放入 ZSet,score 设置成预计执行时间。
消费者定期查询 score 小于或等于当前时间的任务,获取已经到期的数据,再执行对应业务。
完整流程是:
任务入队 → 根据时间戳排序 → 查询到期任务 → 原子领取任务 → 执行业务 → 完成确认或失败重试。
需要特别注意,多消费者并发时,查询和领取可能发生竞争。
可以使用 Lua 脚本实现原子的到期检查与领取,也可以在查询后通过 ZREM 的返回值判断是否成功抢到任务。
另外,直接使用 ZPOPMIN 会弹出分数最小的任务,即使任务尚未到期,因此不能直接作为延迟时间判断。
继续追问:使用 ZSet 实现延迟队列有什么问题?
主要是缺少完整的消息可靠性机制。
如果任务领取后立即从 ZSet 删除,消费者执行过程中发生宕机,就可能丢失任务。
因此,可靠性要求较高时,需要增加处理中队列、确认机制、超时重试和业务幂等处理。
对于复杂的消息消费场景,可以考虑 Redis Stream 或专业消息队列,再结合延迟调度机制实现。
3.9 ZSet 在实际使用中有哪些限制和注意事项?
主要需要关注内存、时间复杂度、分数精度和业务一致性。
第一,ZSet 中的元素需要占用内存,大规模集合使用跳表和哈希表时,内存成本会进一步增加。
第二,虽然范围查询的起始定位很快,但返回大量数据时,时间复杂度包含结果数量 M。大范围查询和深度偏移分页都会增加执行开销。
第三,score 使用双精度浮点数。如果存储整数,超过 (2^{53}) 后就无法保证所有整数都被精确表示,因此需要注意大整数和高精度时间戳的问题。
第四,普通 ZSet 的过期时间作用于整个 key,不能直接通过 EXPIRE 为其中某个 member 设置独立过期时间。如果需要按成员时间清理,可以将时间戳作为 score,再定期删除过期成员。
最后,单条 Redis 命令可以保证原子执行,但涉及多条命令的业务流程,需要根据一致性要求选择 Lua 脚本或事务机制。
4. 易混淆概念对比
4.1 ZSet 与其他 Redis 数据类型
| 对比 | 核心区别 | 适用场景 |
|---|---|---|
| ZSet vs Set | 两者都保证元素唯一;ZSet 额外维护 score 和有序关系 | 排行榜选 ZSet,普通去重选 Set |
| ZSet vs List | List 按列表位置维护顺序;ZSet 根据 score 自动排序 | FIFO 队列选 List,按优先级排序选 ZSet |
| ZSet vs Hash | Hash 存储 field-value 映射;ZSet 维护 member-score 关系并支持排名 | 用户属性选 Hash,积分排名选 ZSet |
| ZSet vs Stream | ZSet 擅长分数排序和范围查询;Stream 提供消息流、消费组和待确认消息机制 | 延迟任务调度可用 ZSet,可靠消息消费更适合 Stream |
4.2 listpack 与跳表 + 哈希表
| 对比维度 | listpack | 跳表 + 哈希表 |
|---|---|---|
| 适用规模 | 小规模集合 | 较大规模集合 |
| 内存占用 | 较低 | 较高 |
| 成员定位 | 通常需要线性遍历 | 哈希表平均 O(1) |
| 排序维护 | 紧凑顺序存储 | 跳表维护有序关系 |
| 主要优势 | 节省内存 | 高效查找、更新和范围查询 |
4.3 跳表与红黑树
| 对比维度 | 跳表 | 红黑树 |
|---|---|---|
| 组织方式 | 多级有序链表索引 | 自平衡二叉搜索树 |
| 查找复杂度 | 期望 O(log N) | 最坏 O(log N) |
| 插入删除 | 调整多层索引指针 | 可能需要旋转和颜色调整 |
| 范围遍历 | 底层链表连续遍历 | 通过有序遍历获取 |
| 排名支持 | 可以利用跨度信息 | 通常需要维护子树大小等信息 |
| 实现特点 | 结构相对简单 | 平衡性保证更严格 |
5. 总结
ZSet 是 Redis 中支持元素唯一和按分数自动排序的有序集合,主要用于实时排行榜、滑动窗口限流和延迟任务调度。
它在数据量较小时可以使用 listpack 节省内存,在常规编码下通过哈希表加跳表同时支持快速成员查找和有序范围查询。哈希表负责定位元素,跳表负责维护顺序,并通过跨度信息高效计算排名。
ZSet 的主要特点是动态更新方便、排名查询高效、支持按分数范围检索。大部分排序操作的平均时间复杂度是 O(log N)。
与普通 Set 相比,ZSet 增加了 score 和排序能力,但也需要额外的内存维护有序结构。在实际使用中,还需要注意同分排名规则、大集合的查询成本,以及延迟队列场景下的消息可靠性问题。