Redis有序集合(ZSet):排行榜功能的最优解,原理与实战

Redis有序集合(ZSet):排行榜功能的最优解,原理与实战

1. Redis有序集合概述

1.1 什么是Redis有序集合

Redis有序集合(Sorted Set,简称ZSet)是Redis中最复杂也是最强大的数据结构之一。它结合了Set的唯一性 和按分数排序的特性,每个成员都关联一个分数(score),Redis根据分数对成员进行排序。

1.2 有序集合的特点

特性 描述 优势
有序性 按分数自动排序 天然的排序功能
唯一性 成员不重复 自动去重
双索引 支持按成员和分数查询 查询灵活高效
范围操作 支持分数和排名范围查询 适合排行榜场景

2. 底层实现原理

2.1 编码方式

编码方式 使用条件 特点
ziplist 元素数≤128且成员长度≤64字节 内存紧凑
skiplist+hashtable 其他情况 查询高效

2.2 跳表(Skip List)原理

跳表是ZSet的核心数据结构:

  • 多层链表结构,支持O(log N)查询
  • 每层都是有序链表
  • 通过概率性算法维护层次结构

3. 基本操作

3.1 添加和更新

bash 复制代码
# 添加成员
127.0.0.1:6379> ZADD leaderboard 100 "player1" 200 "player2" 150 "player3"
(integer) 3

# 更新分数
127.0.0.1:6379> ZADD leaderboard 250 "player1"
(integer) 0   # 成员已存在,返回0

3.2 查询操作

bash 复制代码
# 按排名查询(分数从小到大)
127.0.0.1:6379> ZRANGE leaderboard 0 -1 WITHSCORES
1) "player3"
2) "150"
3) "player2"
4) "200"
5) "player1"
6) "250"

# 按排名查询(分数从大到小)
127.0.0.1:6379> ZREVRANGE leaderboard 0 2 WITHSCORES
1) "player1"
2) "250"
3) "player2"
4) "200"
5) "player3"
6) "150"

# 按分数范围查询
127.0.0.1:6379> ZRANGEBYSCORE leaderboard 150 200
1) "player3"
2) "player2"

# 获取成员分数
127.0.0.1:6379> ZSCORE leaderboard "player1"
"250"

# 获取成员排名
127.0.0.1:6379> ZRANK leaderboard "player1"
(integer) 2

127.0.0.1:6379> ZREVRANK leaderboard "player1"
(integer) 0   # 从大到小排名

# 获取集合大小
127.0.0.1:6379> ZCARD leaderboard
(integer) 3

4. 高级操作

4.1 分数操作

bash 复制代码
# 增加分数
127.0.0.1:6379> ZINCRBY leaderboard 50 "player2"
"250"

# 按分数范围计数
127.0.0.1:6379> ZCOUNT leaderboard 200 300
(integer) 2

4.2 删除操作

bash 复制代码
# 删除成员
127.0.0.1:6379> ZREM leaderboard "player3"
(integer) 1

# 按排名删除
127.0.0.1:6379> ZREMRANGEBYRANK leaderboard 0 0
(integer) 1

# 按分数删除
127.0.0.1:6379> ZREMRANGEBYSCORE leaderboard 200 250
(integer) 1

5. 实战应用场景

5.1 游戏排行榜系统

java 复制代码
@Service
public class GameLeaderboardService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String LEADERBOARD_KEY = "game:leaderboard:global";
    
    /**
     * 更新玩家分数
     */
    public void updatePlayerScore(String playerId, long score) {
        redisTemplate.opsForZSet().add(LEADERBOARD_KEY, playerId, score);
    }
    
    /**
     * 增加玩家分数
     */
    public Double addPlayerScore(String playerId, long deltaScore) {
        return redisTemplate.opsForZSet().incrementScore(LEADERBOARD_KEY, playerId, deltaScore);
    }
    
    /**
     * 获取排行榜TOP N
     */
    public List<PlayerRank> getTopPlayers(int topN) {
        Set<ZSetOperations.TypedTuple<String>> tuples = 
            redisTemplate.opsForZSet().reverseRangeWithScores(LEADERBOARD_KEY, 0, topN - 1);
        
        List<PlayerRank> rankings = new ArrayList<>();
        int rank = 1;
        for (ZSetOperations.TypedTuple<String> tuple : tuples) {
            PlayerRank playerRank = new PlayerRank();
            playerRank.setRank(rank++);
            playerRank.setPlayerId(tuple.getValue());
            playerRank.setScore(tuple.getScore().longValue());
            rankings.add(playerRank);
        }
        return rankings;
    }
    
    /**
     * 获取玩家排名和分数
     */
    public PlayerRank getPlayerRank(String playerId) {
        Double score = redisTemplate.opsForZSet().score(LEADERBOARD_KEY, playerId);
        if (score == null) {
            return null;
        }
        
        Long rank = redisTemplate.opsForZSet().reverseRank(LEADERBOARD_KEY, playerId);
        
        PlayerRank playerRank = new PlayerRank();
        playerRank.setPlayerId(playerId);
        playerRank.setScore(score.longValue());
        playerRank.setRank(rank != null ? rank.intValue() + 1 : -1);
        
        return playerRank;
    }
    
    /**
     * 获取玩家周围排名
     */
    public List<PlayerRank> getPlayersAround(String playerId, int range) {
        Long playerRank = redisTemplate.opsForZSet().reverseRank(LEADERBOARD_KEY, playerId);
        if (playerRank == null) {
            return new ArrayList<>();
        }
        
        long start = Math.max(0, playerRank - range);
        long end = playerRank + range;
        
        Set<ZSetOperations.TypedTuple<String>> tuples = 
            redisTemplate.opsForZSet().reverseRangeWithScores(LEADERBOARD_KEY, start, end);
        
        List<PlayerRank> rankings = new ArrayList<>();
        int rank = (int) start + 1;
        for (ZSetOperations.TypedTuple<String> tuple : tuples) {
            PlayerRank pr = new PlayerRank();
            pr.setRank(rank++);
            pr.setPlayerId(tuple.getValue());
            pr.setScore(tuple.getScore().longValue());
            rankings.add(pr);
        }
        return rankings;
    }
}

@Data
class PlayerRank {
    private String playerId;
    private Integer rank;
    private Long score;
}

5.2 热门内容排行

java 复制代码
@Service
public class HotContentService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 更新内容热度分数
     */
    public void updateContentScore(String contentId, String category) {
        String key = "hot:content:" + category;
        
        // 计算热度分数(浏览量 + 点赞数 + 评论数 + 时间衰减)
        double score = calculateHotScore(contentId);
        
        redisTemplate.opsForZSet().add(key, contentId, score);
        redisTemplate.expire(key, 1, TimeUnit.DAYS);
    }
    
    /**
     * 获取热门内容
     */
    public List<String> getHotContents(String category, int count) {
        String key = "hot:content:" + category;
        Set<String> contentIds = redisTemplate.opsForZSet()
            .reverseRange(key, 0, count - 1);
        return new ArrayList<>(contentIds);
    }
    
    /**
     * 计算热度分数
     */
    private double calculateHotScore(String contentId) {
        // 简化的热度计算公式
        long views = getViews(contentId);
        long likes = getLikes(contentId);
        long comments = getComments(contentId);
        long timeDecay = getTimeDecay(contentId);
        
        return (views * 1.0 + likes * 2.0 + comments * 3.0) * timeDecay;
    }
    
    private long getViews(String contentId) { return 100; }
    private long getLikes(String contentId) { return 50; }
    private long getComments(String contentId) { return 20; }
    private long getTimeDecay(String contentId) { return 1; }
}

5.3 延时队列实现

java 复制代码
@Service
public class DelayedQueueService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String DELAY_QUEUE_KEY = "delay:queue";
    
    /**
     * 添加延时任务
     */
    public void addDelayedTask(String taskId, String taskData, long delaySeconds) {
        long executeTime = System.currentTimeMillis() + delaySeconds * 1000;
        String taskInfo = taskId + ":" + taskData;
        
        redisTemplate.opsForZSet().add(DELAY_QUEUE_KEY, taskInfo, executeTime);
    }
    
    /**
     * 获取到期任务
     */
    public List<String> getExpiredTasks(int batchSize) {
        long currentTime = System.currentTimeMillis();
        
        Set<String> expiredTasks = redisTemplate.opsForZSet()
            .rangeByScore(DELAY_QUEUE_KEY, 0, currentTime, 0, batchSize);
        
        if (!expiredTasks.isEmpty()) {
            // 删除已获取的任务
            redisTemplate.opsForZSet().removeRangeByScore(DELAY_QUEUE_KEY, 0, currentTime);
        }
        
        return new ArrayList<>(expiredTasks);
    }
    
    /**
     * 取消延时任务
     */
    public boolean cancelDelayedTask(String taskId) {
        // 需要根据taskId模糊匹配删除
        Set<String> allTasks = redisTemplate.opsForZSet().range(DELAY_QUEUE_KEY, 0, -1);
        
        for (String task : allTasks) {
            if (task.startsWith(taskId + ":")) {
                return redisTemplate.opsForZSet().remove(DELAY_QUEUE_KEY, task) > 0;
            }
        }
        return false;
    }
    
    /**
     * 获取队列统计信息
     */
    public Map<String, Object> getQueueStats() {
        Map<String, Object> stats = new HashMap<>();
        
        Long totalTasks = redisTemplate.opsForZSet().zCard(DELAY_QUEUE_KEY);
        Long expiredTasks = redisTemplate.opsForZSet()
            .count(DELAY_QUEUE_KEY, 0, System.currentTimeMillis());
        
        stats.put("total_tasks", totalTasks);
        stats.put("expired_tasks", expiredTasks);
        stats.put("pending_tasks", totalTasks - expiredTasks);
        
        return stats;
    }
}

5.4 用户积分系统

java 复制代码
@Service
public class UserPointsService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String POINTS_LEADERBOARD = "user:points:leaderboard";
    private static final String MONTHLY_POINTS = "user:points:monthly:";
    
    /**
     * 增加用户积分
     */
    public void addUserPoints(String userId, long points, String reason) {
        // 更新总积分排行榜
        redisTemplate.opsForZSet().incrementScore(POINTS_LEADERBOARD, userId, points);
        
        // 更新月度积分排行榜
        String monthlyKey = MONTHLY_POINTS + getCurrentMonth();
        redisTemplate.opsForZSet().incrementScore(monthlyKey, userId, points);
        redisTemplate.expire(monthlyKey, 35, TimeUnit.DAYS);
        
        // 记录积分变动历史
        recordPointsHistory(userId, points, reason);
    }
    
    /**
     * 获取用户总积分和排名
     */
    public UserPointsInfo getUserPointsInfo(String userId) {
        Double totalPoints = redisTemplate.opsForZSet().score(POINTS_LEADERBOARD, userId);
        Long totalRank = redisTemplate.opsForZSet().reverseRank(POINTS_LEADERBOARD, userId);
        
        String monthlyKey = MONTHLY_POINTS + getCurrentMonth();
        Double monthlyPoints = redisTemplate.opsForZSet().score(monthlyKey, userId);
        Long monthlyRank = redisTemplate.opsForZSet().reverseRank(monthlyKey, userId);
        
        UserPointsInfo info = new UserPointsInfo();
        info.setUserId(userId);
        info.setTotalPoints(totalPoints != null ? totalPoints.longValue() : 0);
        info.setTotalRank(totalRank != null ? totalRank.intValue() + 1 : -1);
        info.setMonthlyPoints(monthlyPoints != null ? monthlyPoints.longValue() : 0);
        info.setMonthlyRank(monthlyRank != null ? monthlyRank.intValue() + 1 : -1);
        
        return info;
    }
    
    /**
     * 获取积分排行榜
     */
    public List<UserPointsRank> getPointsLeaderboard(String type, int topN) {
        String key = "total".equals(type) ? POINTS_LEADERBOARD : MONTHLY_POINTS + getCurrentMonth();
        
        Set<ZSetOperations.TypedTuple<String>> tuples = 
            redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, topN - 1);
        
        List<UserPointsRank> rankings = new ArrayList<>();
        int rank = 1;
        for (ZSetOperations.TypedTuple<String> tuple : tuples) {
            UserPointsRank userRank = new UserPointsRank();
            userRank.setRank(rank++);
            userRank.setUserId(tuple.getValue());
            userRank.setPoints(tuple.getScore().longValue());
            rankings.add(userRank);
        }
        return rankings;
    }
    
    private String getCurrentMonth() {
        return LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"));
    }
    
    private void recordPointsHistory(String userId, long points, String reason) {
        // 实现积分历史记录逻辑
    }
}

@Data
class UserPointsInfo {
    private String userId;
    private Long totalPoints;
    private Integer totalRank;
    private Long monthlyPoints;
    private Integer monthlyRank;
}

@Data
class UserPointsRank {
    private Integer rank;
    private String userId;
    private Long points;
}

6. 性能优化与最佳实践

6.1 性能特点

操作类型 时间复杂度 性能说明
ZADD O(log N) 插入效率高
ZRANGE O(log N + M) M为返回元素数
ZSCORE O(1) 获取分数很快
ZRANK O(log N) 排名查询高效
ZREMRANGEBYSCORE O(log N + M) 范围删除

6.2 最佳实践

6.2.1 控制集合大小
java 复制代码
@Service
public class OptimizedZSetService {
    
    private static final int MAX_LEADERBOARD_SIZE = 10000;
    
    /**
     * 安全的排行榜更新
     */
    public void safeUpdateLeaderboard(String key, String member, double score) {
        redisTemplate.opsForZSet().add(key, member, score);
        
        // 保持排行榜大小
        Long size = redisTemplate.opsForZSet().zCard(key);
        if (size > MAX_LEADERBOARD_SIZE) {
            redisTemplate.opsForZSet().removeRange(key, 0, size - MAX_LEADERBOARD_SIZE - 1);
        }
    }
}
6.2.2 使用Pipeline优化
java 复制代码
/**
 * 批量更新排行榜
 */
public void batchUpdateLeaderboard(String key, Map<String, Double> playerScores) {
    redisTemplate.executePipelined(new RedisCallback<Object>() {
        @Override
        public Object doInRedis(RedisConnection connection) {
            for (Map.Entry<String, Double> entry : playerScores.entrySet()) {
                connection.zAdd(key.getBytes(), entry.getValue(), entry.getKey().getBytes());
            }
            return null;
        }
    });
}

总结

Redis有序集合是排行榜和排序场景的最佳选择:

核心知识点

  1. 底层原理:跳表+哈希表的双重数据结构
  2. 基本操作:添加、查询、删除、范围操作
  3. 高级功能:分数操作、排名查询、范围删除
  4. 实战应用:游戏排行榜、热门内容、延时队列、积分系统

关键要点

  • 双重索引:同时支持按成员和分数的高效查询
  • 自动排序:插入时自动按分数排序,无需手动维护
  • 范围操作:强大的分数和排名范围查询能力
  • 高性能:O(log N)的查询性能,适合大数据量

实战建议

  1. 合理设计分数:根据业务需求设计合适的分数计算逻辑
  2. 控制数据规模:定期清理或限制ZSet大小
  3. 优化查询范围:避免大范围查询,使用分页
  4. 监控性能:关注内存使用和操作耗时
  5. 善用范围操作:充分利用ZSet的范围查询能力

通过本文学习,你应该能够熟练使用Redis有序集合实现各种排序和排行榜功能。


下一篇预告 :《Redis Stream:Redis 5.0+新数据结构详解,替代消息队列的新选择》


相关推荐
SelectDB10 小时前
让变更流出 Doris:Apache Doris 5.0 基于增量计算的 CDC 技术解析
数据库
这个DBA有点耶10 小时前
MCP隔离层+行级语义审计+输出校验:AI访问数据库的安全边界拆解
数据库·人工智能·ai编程
这个DBA有点耶11 小时前
从反向扫描O(n)到正向扫描O(1):降序索引的物理机制与混合排序匹配规则
数据库
码哥字节12 小时前
缓存一致性:先删缓存还是先更新库?3 种方案对比 + 实战
redis
2501_9339232512 小时前
解读Redis:单线程模型
数据库·redis·缓存
我不会起名字32213 小时前
seq 空洞排查:Mongo 的 51 只是预留上界,Redis CURR 才是已消耗边界
redis·后端·mongodb·go·即时通讯·im·故障排查
Tapdata13 小时前
被忽略的第三类上下文:Agent 进入真实业务后,还缺什么?
数据库·人工智能·架构
小科先生15 小时前
Java springboot调用数据库的基本流程
java·数据库·spring boot
星恒随风15 小时前
MySQL 从零入门(四):表的增删改查与基础查询详解
linux·服务器·数据库·笔记·mysql