1. 什么是 ZSet?
ZSet(Sorted Set)是 Redis 提供的一种非常特殊且功能强大的数据结构。它类似于 Redis 的 Set(集合) ,保证了元素的唯一性(即集合中不会出现重复的元素),但它与 Set 的根本区别在于:
ZSet 中的每个元素都会关联一个 double 类型的分数(Score)。
Redis 正是利用这个分数对集合中的成员进行从小到大的排序。
核心定义
- Member(成员) :唯一,字符串类型。
- Score(分数) :浮点数,可以重复。用于排序。
2. 核心特性
ZSet 结合了 Hash、List 和 Set 的部分特性,具有以下显著特点:
- 唯一性 :集合中的
Member是唯一的,不能重复。如果尝试添加已存在的 Member,会更新其对应的Score。 - 有序性 :
- 元素默认按照
Score从小到大排序。 - 如果
Score相同,则按照Member的字典序(Lexicographical Order)排序。
- 元素默认按照
- 高性能 :
- 添加、删除、更新元素的时间复杂度通常为 O(log N)。
- 查找某个成员的排名或分数也是 O(log N)。
- 范围查询(如获取前 10 名)非常高效。
- 双向访问 :支持正序(从小到大)和倒序(从大到小)获取数据。
3. 底层数据结构(内部实现原理)
这是 ZSet 能够同时支持高效的点查询和范围查询的关键。Redis 会根据数据量的大小,动态选择底层实现:
3.1. 压缩列表 (ZipList) / Listpack
当 ZSet 满足以下条件时,Redis 使用紧凑的线性存储结构(旧版本为 ZipList,Redis 7.0+ 逐渐过渡为 Listpack):
- 元素数量较少(默认少于 128 个)。
- 每个元素的大小较小(默认小于 64 字节)。
特点 :内存占用极低,但插入和删除需要内存重分配,适合小数据量。
3.2. 跳跃表 (SkipList) + 哈希表 (Dict)
当数据量超出上述阈值时,ZSet 底层会转换为 SkipList + Dict 的组合结构。
- Dict(字典/哈希表) :存储
Member -> Score的映射。- 作用 :保证能以 O(1) 的时间复杂度查询某成员的分数。
- SkipList(跳跃表) :存储
Score -> Member的有序链表结构。- 作用 :一种多层的链表结构,支持 O(log N) 的查找、插入和删除,且支持范围查询(Range Query)。
为什么用跳表不用红黑树?
跳表实现比红黑树简单,内存占用稍低,且在并发场景下更容易通过锁机制进行优化,同时跳表在做范围查询(Range)时性能极佳(相当于链表遍历)。
4. 常用命令
| 类别 | 命令 | 描述 | 时间复杂度 |
|---|---|---|---|
| 增/改 | ZADD key score member ... |
添加元素或更新分数 | O(log N) |
| 删 | ZREM key member ... |
删除指定元素 | O(log N) |
| 查(排名) | ZRANK key member |
获取正序排名(从0开始) | O(log N) |
| 查(排名) | ZREVRANK key member |
获取倒序排名(从0开始) | O(log N) |
| 查(范围) | ZRANGE key start stop |
获取指定索引范围的元素 | O(log N + M) |
| 查(范围) | ZREVRANGE key start stop |
获取指定索引范围的元素(倒序) | O(log N + M) |
| 查(分数) | ZRANGEBYSCORE key min max |
获取指定分数范围的元素 | O(log N + M) |
| 统计 | ZCOUNT key min max |
统计指定分数范围内的元素个数 | O(log N) |
| 计算 | ZINCRBY key increment member |
对指定成员的分数进行增量计算 | O(log N) |
| 集合运算 | ZINTERSTORE / ZUNIONSTORE |
交集/并集运算,并可聚合分数 | O(NK) + O(MlogM) |
5. 特定的场景用途 (Use Cases)
ZSet 是 Redis 中应用模式最丰富的数据结构之一,以下是其经典应用场景:
5.1. 排行榜系统 (Leaderboards)
这是 ZSet 最经典的使用场景。
- 场景描述 :游戏积分榜、视频热度榜、直播间礼物榜、电商销量榜。
- 实现逻辑 :
Member= 用户ID / 商品IDScore= 积分 / 销量 / 热度值ZADD leaderboard 100 userA:更新分数。ZREVRANGE leaderboard 0 9:获取前 10 名(Top 10)。ZRANK leaderboard userA:查看自己的排名。
5.2. 延时队列 (Delay Queue)
- 场景描述 :订单超时自动取消、消息延迟发送。
- 实现逻辑 :
Member= 任务ID / 订单号Score= 执行时间的 Unix 时间戳(未来时间)ZADD delay_queue <exec_timestamp> <order_id>:加入队列。- 消费者轮询:
ZRANGEBYSCORE delay_queue 0 <current_timestamp> LIMIT 0 1。 - 如果查到了数据,执行任务并使用
ZREM删除;否则等待。
5.3. 滑动窗口限流 (Sliding Window Rate Limiter)
比固定窗口算法更平滑的限流策略。
- 场景描述 :限制某用户 1 分钟内只能访问 10 次接口。
- 实现逻辑 :
Key= 用户行为标识(如limit:user:123)。Member= 请求的唯一ID(防止去重影响,或者直接用毫秒级时间戳)。Score= 当前请求的时间戳。- 步骤 :
ZADD key <now> <now>:添加当前请求记录。ZREMRANGEBYSCORE key 0 <now - window_size>:移除窗口之外的旧记录。ZCARD key:统计窗口内的记录数量。- 如果数量 > 阈值,则拒绝请求。
5.4. 带权重的队列 / 任务调度
- 场景描述 :任务优先级管理,VIP 用户的任务优先处理。
- 实现逻辑 :
Score= 优先级(数字越小优先级越高,或反之)。- 消费者总是通过
ZRANGE获取分数最高/最低的任务进行处理。
5.5. 社交网络的时间线 (Timeline / Feeds)
- 场景描述 :朋友圈、关注人的动态流。
- 实现逻辑 :
Member= 动态内容ID。Score= 发布时间戳。- 用户刷新时,通过
ZREVRANGE按时间倒序拉取最新动态。
5.6. 自动补全 (Autocomplete) - 字典序用法
- 场景描述 :搜索框输入前缀,提示补全词。
- 实现逻辑 :
- 所有元素的
Score设置为 0(迫使 Redis 按照 Member 的字典序排序)。 - 使用
ZRANGEBYLEX命令根据字典序范围查找字符串。
- 所有元素的
6. 性能与注意事项
- 大数据量慎用
ZRANGE获取全量数据 :ZRANGE key 0 -1的时间复杂度是 O(N),如果 ZSet 包含数百万元素,会导致 Redis 阻塞。- 建议分页查询或只取 Top N。
- ZINTERSTORE / ZUNIONSTORE 的计算成本 :
- 集合的交集和并集运算涉及大量的数据读取和计算,如果在超大 ZSet 上执行,CPU 消耗很高。建议在从节点执行或减少频率。
- 内存开销 :
- ZSet 因为同时维护了跳表和哈希表(在数据量大时),相比 List 和 Set,其单位元素占用的内存更多。
- 浮点数精度 :
- ZSet 的 Score 是 double 类型,存在浮点数精度问题(IEEE 754)。如果需要绝对精确的整数排序(如金融金额),建议存为“分”或“毫厘”的整数形式表示。
7. 实战案例
这是一个使用 C# 和官方推荐的客户端库 StackExchange.Redis 实现的排行榜完整示例。
7.1 准备工作
首先,你需要在你的 C# 项目中安装 NuGet 包:
dotnet add package StackExchange.Redis
7.2 场景设定
我们将模拟一个 游戏高分排行榜 :
- Key :
game:leaderboard - Member : 玩家 ID 或 用户名
- Score : 游戏分数(分数越高排名越靠前)
7.3 C# 代码实现
这个示例包含了一个封装好的 LeaderboardService 类,以及如何在 Program.Main 中调用它。
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace RedisLeaderboardExample
{
// 定义排行榜的返回数据模型
public class PlayerRank info
{
public string PlayerId { get; set; }
public double Score { get; set; }
public long Rank { get; set; } // 1 代表第一名
}
// 排行榜服务封装
public class LeaderboardService
{
private readonly IDatabase _db;
private readonly string _leaderboardKey = "game:leaderboard:season1";
public LeaderboardService(string connectionString)
{
// 建立连接 (生产环境中建议将 ConnectionMultiplexer 注册为单例)
var redis = ConnectionMultiplexer.Connect(connectionString);
_db = redis.GetDatabase();
}
/// <summary>
/// 1. 更新或添加玩家分数 (ZADD)
/// </summary>
public async Task AddOrUpdateScoreAsync(string playerId, double score)
{
// SortedSetAddAsync 对应命令: ZADD key score member
await _db.SortedSetAddAsync(_leaderboardKey, playerId, score);
Console.WriteLine($"[记录] 玩家 {playerId} 分数更新为: {score}");
}
/// <summary>
/// 2. 获取 Top N 排行榜 (ZREVRANGE)
/// </summary>
public async Task<List<PlayerRank>> GetTopPlayersAsync(int count = 10)
{
// 获取前 count 名。
// Order.Descending 对应 ZREVRANGE (从大到小),Order.Ascending 对应 ZRANGE (从小到大)
// stop: count - 1 因为 Redis 索引是 0-based
SortedSetEntry[] results = await _db.SortedSetRangeByRankWithScoresAsync(
_leaderboardKey,
start: 0,
stop: count - 1,
order: Order.Descending
);
// 将 Redis 结果转换为我们的模型
var list = new List<PlayerRank>();
for (int i = 0; i < results.Length; i++)
{
list.Add(new PlayerRank
{
PlayerId = results[i].Element.ToString(),
Score = results[i].Score,
Rank = i + 1 // 转换为人类习惯的 1-based 排名
});
}
return list;
}
/// <summary>
/// 3. 获取特定玩家的排名和分数 (ZREVRANK, ZSCORE)
/// </summary>
public async Task<PlayerRank> GetPlayerInfoAsync(string playerId)
{
// ZREVRANK: 获取倒序排名 (0是第一名)
long? rankIndex = await _db.SortedSetRankAsync(_leaderboardKey, playerId, Order.Descending);
if (!rankIndex.HasValue)
{
Console.WriteLine($"[查询] 玩家 {playerId} 不在榜单上。");
return null;
}
// ZSCORE: 获取分数
double? score = await _db.SortedSetScoreAsync(_leaderboardKey, playerId);
return new PlayerRank
{
PlayerId = playerId,
Score = score ?? 0,
Rank = rankIndex.Value + 1 // 0-based 转 1-based
};
}
/// <summary>
/// 4. 增加分数 (ZINCRBY) - 适用于累积积分场景
/// </summary>
public async Task IncrementScoreAsync(string playerId, double increment)
{
// ZINCRBY key increment member
double newScore = await _db.SortedSetIncrementAsync(_leaderboardKey, playerId, increment);
Console.WriteLine($"[增量] 玩家 {playerId} 增加了 {increment} 分,当前总分: {newScore}");
}
}
// 主程序
class Program
{
static async Task Main(string[] args)
{
// 确保你本地 Redis 正在运行,默认端口 6379
var service = new LeaderboardService("localhost:6379");
Console.WriteLine("--- 游戏开始,玩家上分 ---");
// 1. 模拟数据录入
await service.AddOrUpdateScoreAsync("User_A", 1000);
await service.AddOrUpdateScoreAsync("User_B", 500);
await service.AddOrUpdateScoreAsync("User_C", 1200);
await service.AddOrUpdateScoreAsync("User_D", 800);
await service.AddOrUpdateScoreAsync("User_E", 3000); // 大神
Console.WriteLine("\n--- 某些玩家通过关卡,分数增加 ---");
await service.IncrementScoreAsync("User_A", 500); // User_A 变成了 1500
// 2. 获取 Top 3 排行榜
Console.WriteLine("\n--- 当前 Top 3 排行榜 ---");
var top3 = await service.GetTopPlayersAsync(3);
foreach (var player in top3)
{
Console.WriteLine($"第 {player.Rank} 名: {player.PlayerId} - 分数: {player.Score}");
}
// 3. 查询特定玩家信息
Console.WriteLine("\n--- 查询 User_C 的个人排名 ---");
var myInfo = await service.GetPlayerInfoAsync("User_C");
if (myInfo != null)
{
Console.WriteLine($"玩家: {myInfo.PlayerId}, 当前排名: {myInfo.Rank}, 分数: {myInfo.Score}");
}
// 4. 查询一个不存在的玩家
await service.GetPlayerInfoAsync("User_Ghost");
Console.ReadKey();
}
}
}
7.4 代码核心点解析
A. SortedSetAddAsync (ZADD)
- 用途 :如果玩家不存在,则添加;如果存在,则更新其分数。
- 时间复杂度 :O(log N)。即使排行榜有几百万数据,更新也极快。
B. SortedSetRangeByRankWithScoresAsync (ZREVRANGE)
- 关键参数 :
Order.Descending。 - 原理 :默认 ZSet 是从小到大排序的(低分在前)。排行榜通常是高分在前,所以必须使用 倒序 (Descending)。
- 性能 :只取前几名(Top N)非常高效,不需要遍历全表。
C. SortedSetRankAsync (ZREVRANK)
- 返回值 :Redis 返回的是 索引 (Index),即第一名返回
0,第二名返回1。 - 处理 :在展示给用户时,我们通常需要执行
Rank + 1操作。 - 空值处理 :如果玩家不在榜单中,该方法返回
null,代码中需要处理这种情况。
D. SortedSetIncrementAsync (ZINCRBY)
- 场景 :比如打怪游戏,每杀一个怪加 10 分。不需要知道当前多少分,直接让 Redis 做加法,这也是原子操作,线程安全。
7.5 扩展功能
在实际生产环境中,你可能还需要:
- 周期性榜单 :
- Key 的设计改为
game:leaderboard:202310(按月)或game:leaderboard:week42(按周)。
- Key 的设计改为
- 获取“我”周围的排名 :
- 有些游戏显示“你的排名是 100,你的前一名是 A,后一名是 B”。
- 可以结合
SortedSetRank拿到索引i,然后用SortedSetRangeByRank获取i-1到i+1的数据。
- 分数相同时的处理 :
- Redis ZSet 在分数相同时,按 Member 的字典序排序(UserA 排在 UserB 前面)。
- 如果希望“先达到该分数的排名靠前”,通常的做法是将 时间戳 组合进 Score 中(例如:使用带小数的 Score,整数部分是分数,小数部分是
MAX_TIME - 当前时间戳)。