Redis 有序集合 (Sorted Set / ZSet) 详解

1. 什么是 ZSet?

ZSet(Sorted Set)是 Redis 提供的一种非常特殊且功能强大的数据结构。它类似于 Redis 的 Set(集合) ,保证了元素的唯一性(即集合中不会出现重复的元素),但它与 Set 的根本区别在于:

ZSet 中的每个元素都会关联一个 double 类型的分数(Score)。

Redis 正是利用这个分数对集合中的成员进行从小到大的排序。

核心定义

  • Member(成员) :唯一,字符串类型。
  • Score(分数) :浮点数,可以重复。用于排序。

2. 核心特性

ZSet 结合了 Hash、List 和 Set 的部分特性,具有以下显著特点:

  1. 唯一性 :集合中的 Member 是唯一的,不能重复。如果尝试添加已存在的 Member,会更新其对应的 Score
  2. 有序性
    • 元素默认按照 Score 从小到大排序。
    • 如果 Score 相同,则按照 Member 的字典序(Lexicographical Order)排序。
  3. 高性能
    • 添加、删除、更新元素的时间复杂度通常为 O(log N)
    • 查找某个成员的排名或分数也是 O(log N)
    • 范围查询(如获取前 10 名)非常高效。
  4. 双向访问 :支持正序(从小到大)和倒序(从大到小)获取数据。

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 / 商品ID
    • Score = 积分 / 销量 / 热度值
    • 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 = 当前请求的时间戳。
    • 步骤
      1. ZADD key <now> <now>:添加当前请求记录。
      2. ZREMRANGEBYSCORE key 0 <now - window_size>:移除窗口之外的旧记录。
      3. ZCARD key:统计窗口内的记录数量。
      4. 如果数量 > 阈值,则拒绝请求。

5.4. 带权重的队列 / 任务调度

  • 场景描述 :任务优先级管理,VIP 用户的任务优先处理。
  • 实现逻辑
    • Score = 优先级(数字越小优先级越高,或反之)。
    • 消费者总是通过 ZRANGE 获取分数最高/最低的任务进行处理。

5.5. 社交网络的时间线 (Timeline / Feeds)

  • 场景描述 :朋友圈、关注人的动态流。
  • 实现逻辑
    • Member = 动态内容ID。
    • Score = 发布时间戳。
    • 用户刷新时,通过 ZREVRANGE 按时间倒序拉取最新动态。

5.6. 自动补全 (Autocomplete) - 字典序用法

  • 场景描述 :搜索框输入前缀,提示补全词。
  • 实现逻辑
    • 所有元素的 Score 设置为 0(迫使 Redis 按照 Member 的字典序排序)。
    • 使用 ZRANGEBYLEX 命令根据字典序范围查找字符串。

6. 性能与注意事项

  1. 大数据量慎用 ZRANGE 获取全量数据
    • ZRANGE key 0 -1 的时间复杂度是 O(N),如果 ZSet 包含数百万元素,会导致 Redis 阻塞。
    • 建议分页查询或只取 Top N。
  2. ZINTERSTORE / ZUNIONSTORE 的计算成本
    • 集合的交集和并集运算涉及大量的数据读取和计算,如果在超大 ZSet 上执行,CPU 消耗很高。建议在从节点执行或减少频率。
  3. 内存开销
    • ZSet 因为同时维护了跳表和哈希表(在数据量大时),相比 List 和 Set,其单位元素占用的内存更多。
  4. 浮点数精度
    • 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 扩展功能

在实际生产环境中,你可能还需要:

  1. 周期性榜单
    • Key 的设计改为 game:leaderboard:202310(按月)或 game:leaderboard:week42(按周)。
  2. 获取“我”周围的排名
    • 有些游戏显示“你的排名是 100,你的前一名是 A,后一名是 B”。
    • 可以结合 SortedSetRank 拿到索引 i,然后用 SortedSetRangeByRank 获取 i-1i+1 的数据。
  3. 分数相同时的处理
    • Redis ZSet 在分数相同时,按 Member 的字典序排序(UserA 排在 UserB 前面)。
    • 如果希望“先达到该分数的排名靠前”,通常的做法是将 时间戳 组合进 Score 中(例如:使用带小数的 Score,整数部分是分数,小数部分是 MAX_TIME - 当前时间戳)。