基于Redis ZSet实现多维度题目贡献度排行榜

一、概述

在在线教育平台中,题目贡献度排行榜是激励用户积极参与的重要功能。Redis的ZSet(有序集合)数据结构非常适合实现这类排行榜功能,因为它天然支持按分数排序和范围查询,能够提供O(log N)的复杂度的排序操作,极大地提高了系统性能。

核心优势

  • 高性能:Redis基于内存操作,读写速度极快

  • 原子性操作:保证数据一致性

  • 丰富的数据结构操作:支持排名、分数范围查询等多种操作

  • 过期策略:可自动清理历史数据

二、设计思路

2.1 数据结构设计

我们将使用不同的Redis Key来存储不同时间维度的排行榜数据:

  • rank:day:{YYYY-MM-DD} - 日榜

  • rank:week:{YYYY-WW} - 周榜

  • rank:month:{YYYY-MM} - 月榜

  • rank:year:{YYYY} - 年榜

  • rank:total - 总榜

维度 Key格式 示例 过期时间 说明
日榜 rank:day:yyyy-MM-dd rank:day:2024-01-15 1天 每日排行榜
周榜 rank:week:yyyy-ww rank:week:2024-03 1周 每周排行榜
月榜 rank:month:yyyy-MM rank:month:2024-01 1个月 每月排行榜
年榜 rank:year:yyyy rank:year:2024 1年 年度排行榜
总榜 rank:total rank:total 永不过期 历史累计排行榜

我们不想依赖Redis的过期时间,那么我们需要自己管理排行榜数据的生命周期。我们可以考虑以下方案:

  1. 定期清理:使用定时任务(如Spring的@Scheduled)来删除过期的排行榜数据。

  2. 在更新数据时,同时更新一个记录数据创建时间的键,然后在获取数据时检查是否过期,如果过期则从排行榜中删除该数据(但这会影响获取数据的性能,且实现复杂)。

  3. 使用两个有序集合:一个存储分数,另一个存储时间戳。然后定期使用ZRANGEBYSCORE命令删除过期的数据。

数据结构细节

  • Key: 如上表所示的格式

  • Member : 用户ID(如:user_12345

  • Score: 贡献度分数(Double类型)

  • 排序: 默认按Score降序排列

2.2 贡献度计算策略

每次用户贡献题目时,我们将同时更新所有相关时间维度的排行榜。这种设计确保了:

  1. 数据实时性:用户贡献后立即反映在排行榜上

  2. 数据一致性:所有时间维度的数据保持同步

  3. 性能优化:批量更新减少Redis操作次数

三、核心实现

3.1 排行榜Key生成工具类

复制代码
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.time.temporal.WeekFields;
import java.util.Locale;

/**
 * 排行榜Key生成工具类
 */
public class RankKeyGenerator {
    
    // 日期格式化器
    private static final DateTimeFormatter DAY_FORMATTER = 
        DateTimeFormatter.ofPattern("yyyy-MM-dd");
    private static final DateTimeFormatter WEEK_FORMATTER = 
        DateTimeFormatter.ofPattern("yyyy-ww");
    private static final DateTimeFormatter MONTH_FORMATTER = 
        DateTimeFormatter.ofPattern("yyyy-MM");
    private static final DateTimeFormatter YEAR_FORMATTER = 
        DateTimeFormatter.ofPattern("yyyy");
    
    /**
     * 生成日榜Key
     */
    public static String generateDayKey(LocalDateTime dateTime) {
        return "rank:day:" + dateTime.format(DAY_FORMATTER);
    }
    
    /**
     * 生成今日排行榜Key
     */
    public static String generateTodayKey() {
        return generateDayKey(LocalDateTime.now());
    }
    
    /**
     * 生成周榜Key
     */
    public static String generateWeekKey(LocalDateTime dateTime) {
        LocalDate date = dateTime.toLocalDate();
        int weekNumber = date.get(WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear());
        String yearWeek = date.getYear() + "-" + String.format("%02d", weekNumber);
        return "rank:week:" + yearWeek;
    }
    
    /**
     * 生成本周排行榜Key
     */
    public static String generateThisWeekKey() {
        return generateWeekKey(LocalDateTime.now());
    }
    
    /**
     * 生成月榜Key
     */
    public static String generateMonthKey(LocalDateTime dateTime) {
        return "rank:month:" + dateTime.format(MONTH_FORMATTER);
    }
    
    /**
     * 生成本月排行榜Key
     */
    public static String generateThisMonthKey() {
        return generateMonthKey(LocalDateTime.now());
    }
    
    /**
     * 生成年榜Key
     */
    public static String generateYearKey(LocalDateTime dateTime) {
        return "rank:year:" + dateTime.format(YEAR_FORMATTER);
    }
    
    /**
     * 生成本年排行榜Key
     */
    public static String generateThisYearKey() {
        return generateYearKey(LocalDateTime.now());
    }
    
    /**
     * 获取总榜Key
     */
    public static String getTotalKey() {
        return "rank:total";
    }
    
    /**
     * 获取指定日期的开始和结束时间戳
     */
    public static TimeRange getDayRange(LocalDate date) {
        LocalDateTime start = date.atStartOfDay();
        LocalDateTime end = date.plusDays(1).atStartOfDay();
        return new TimeRange(start, end);
    }
    
    /**
     * 获取本周的开始和结束时间
     */
    public static TimeRange getWeekRange() {
        LocalDate now = LocalDate.now();
        LocalDateTime start = now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
                                 .atStartOfDay();
        LocalDateTime end = now.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY))
                               .plusDays(1).atStartOfDay();
        return new TimeRange(start, end);
    }
    
    /**
     * 获取本月的开始和结束时间
     */
    public static TimeRange getMonthRange() {
        LocalDate now = LocalDate.now();
        LocalDateTime start = now.with(TemporalAdjusters.firstDayOfMonth())
                                 .atStartOfDay();
        LocalDateTime end = now.with(TemporalAdjusters.firstDayOfNextMonth())
                               .atStartOfDay();
        return new TimeRange(start, end);
    }
    
    /**
     * 时间范围类
     */
    public static class TimeRange {
        private final LocalDateTime start;
        private final LocalDateTime end;
        
        public TimeRange(LocalDateTime start, LocalDateTime end) {
            this.start = start;
            this.end = end;
        }
        
        public LocalDateTime getStart() {
            return start;
        }
        
        public LocalDateTime getEnd() {
            return end;
        }
    }
}

3.2 排行榜服务实现

复制代码
import org.springframework.data.redis.connection.RedisZSetCommands;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.*;

/**
 * 题目贡献度排行榜服务
 */
@Service
public class ContributionRankService {
    
    @Resource
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 增加用户贡献度
     * @param userId 用户ID
     * @param score 增加的贡献度
     * @param dateTime 贡献时间
     */
    public void addContribution(String userId, double score, LocalDateTime dateTime) {
        // 更新各个时间维度的排行榜
        updateRank(userId, score, dateTime);
    }
    
    /**
     * 更新用户贡献度
     * @param userId 用户ID
     * @param score 贡献度
     * @param dateTime 贡献时间
     */
    private void updateRank(String userId, double score, LocalDateTime dateTime) {
        // 更新总榜
        redisTemplate.opsForZSet().incrementScore(
            RankKeyGenerator.getTotalKey(), 
            userId, 
            score
        );
        
        // 更新日榜
        redisTemplate.opsForZSet().incrementScore(
            RankKeyGenerator.generateDayKey(dateTime), 
            userId, 
            score
        );
        
        // 设置日榜过期时间(7天)
        redisTemplate.expire(
            RankKeyGenerator.generateDayKey(dateTime),
            7 * 24 * 60 * 60L
        );
        
        // 更新周榜
        redisTemplate.opsForZSet().incrementScore(
            RankKeyGenerator.generateWeekKey(dateTime), 
            userId, 
            score
        );
        
        // 设置周榜过期时间(4周)
        redisTemplate.expire(
            RankKeyGenerator.generateWeekKey(dateTime),
            4 * 7 * 24 * 60 * 60L
        );
        
        // 更新月榜
        redisTemplate.opsForZSet().incrementScore(
            RankKeyGenerator.generateMonthKey(dateTime), 
            userId, 
            score
        );
        
        // 设置月榜过期时间(13个月)
        redisTemplate.expire(
            RankKeyGenerator.generateMonthKey(dateTime),
            13 * 30 * 24 * 60 * 60L
        );
        
        // 更新年榜
        redisTemplate.opsForZSet().incrementScore(
            RankKeyGenerator.generateYearKey(dateTime), 
            userId, 
            score
        );
        
        // 设置年榜过期时间(2年)
        redisTemplate.expire(
            RankKeyGenerator.generateYearKey(dateTime),
            2 * 365 * 24 * 60 * 60L
        );
    }
    
    /**
     * 获取今日排行榜
     * @param topN 前N名
     * @return 排行榜列表
     */
    public List<RankItem> getTodayRank(int topN) {
        return getRank(RankKeyGenerator.generateTodayKey(), topN);
    }
    
    /**
     * 获取本周排行榜
     * @param topN 前N名
     * @return 排行榜列表
     */
    public List<RankItem> getWeekRank(int topN) {
        return getRank(RankKeyGenerator.generateThisWeekKey(), topN);
    }
    
    /**
     * 获取本月排行榜
     * @param topN 前N名
     * @return 排行榜列表
     */
    public List<RankItem> getMonthRank(int topN) {
        return getRank(RankKeyGenerator.generateThisMonthKey(), topN);
    }
    
    /**
     * 获取本年排行榜
     * @param topN 前N名
     * @return 排行榜列表
     */
    public List<RankItem> getYearRank(int topN) {
        return getRank(RankKeyGenerator.generateThisYearKey(), topN);
    }
    
    /**
     * 获取总排行榜
     * @param topN 前N名
     * @return 排行榜列表
     */
    public List<RankItem> getTotalRank(int topN) {
        return getRank(RankKeyGenerator.getTotalKey(), topN);
    }
    
    /**
     * 获取指定排行榜数据
     * @param key Redis Key
     * @param topN 前N名
     * @return 排行榜列表
     */
    private List<RankItem> getRank(String key, int topN) {
        Set<ZSetOperations.TypedTuple<String>> typedTuples = 
            redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, topN - 1);
        
        if (CollectionUtils.isEmpty(typedTuples)) {
            return Collections.emptyList();
        }
        
        List<RankItem> rankList = new ArrayList<>();
        int rank = 1;
        for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
            RankItem item = new RankItem();
            item.setRank(rank++);
            item.setUserId(tuple.getValue());
            item.setScore(tuple.getScore());
            rankList.add(item);
        }
        
        return rankList;
    }
    
    /**
     * 获取用户排名
     * @param key Redis Key
     * @param userId 用户ID
     * @return 排名(从0开始,-1表示不在榜中)
     */
    public Long getUserRank(String key, String userId) {
        Long rank = redisTemplate.opsForZSet().reverseRank(key, userId);
        return rank == null ? -1L : rank + 1; // 转换为从1开始
    }
    
    /**
     * 获取用户贡献度
     * @param key Redis Key
     * @param userId 用户ID
     * @return 贡献度
     */
    public Double getUserScore(String key, String userId) {
        return redisTemplate.opsForZSet().score(key, userId);
    }
    
    /**
     * 获取排行榜数据(带分页)
     * @param key Redis Key
     * @param page 页码(从1开始)
     * @param pageSize 每页大小
     * @return 排行榜列表
     */
    public List<RankItem> getRankWithPage(String key, int page, int pageSize) {
        long start = (page - 1) * pageSize;
        long end = start + pageSize - 1;
        
        Set<ZSetOperations.TypedTuple<String>> typedTuples = 
            redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);
        
        if (CollectionUtils.isEmpty(typedTuples)) {
            return Collections.emptyList();
        }
        
        List<RankItem> rankList = new ArrayList<>();
        int rank = page * pageSize - pageSize + 1;
        for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
            RankItem item = new RankItem();
            item.setRank(rank++);
            item.setUserId(tuple.getValue());
            item.setScore(tuple.getScore());
            rankList.add(item);
        }
        
        return rankList;
    }
    
    /**
     * 获取历史排行榜(指定日期)
     * @param dateTime 指定日期时间
     * @param topN 前N名
     * @return 排行榜列表
     */
    public List<RankItem> getHistoryDayRank(LocalDateTime dateTime, int topN) {
        return getRank(RankKeyGenerator.generateDayKey(dateTime), topN);
    }
    
    /**
     * 清理过期排行榜(按需调用)
     */
    public void cleanupExpiredRanks() {
        // 可以根据业务需求定期清理过期的排行榜
        // 这里可以使用Redis的Keys命令或SCAN命令找到过期的Key并删除
    }
    
    /**
     * 排行榜项
     */
    public static class RankItem {
        private Integer rank;      // 排名
        private String userId;     // 用户ID
        private Double score;      // 贡献度
        
        // getters and setters
        public Integer getRank() {
            return rank;
        }
        
        public void setRank(Integer rank) {
            this.rank = rank;
        }
        
        public String getUserId() {
            return userId;
        }
        
        public void setUserId(String userId) {
            this.userId = userId;
        }
        
        public Double getScore() {
            return score;
        }
        
        public void setScore(Double score) {
            this.score = score;
        }
    }
}

3.3 控制器层实现

复制代码
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 排行榜控制器
 */
@RestController
@RequestMapping("/api/rank")
public class RankController {
    
    private final ContributionRankService rankService;
    
    public RankController(ContributionRankService rankService) {
        this.rankService = rankService;
    }
    
    /**
     * 新增贡献度
     */
    @PostMapping("/contribution")
    public Map<String, Object> addContribution(
            @RequestParam String userId,
            @RequestParam double score) {
        
        rankService.addContribution(userId, score, LocalDateTime.now());
        
        Map<String, Object> result = new HashMap<>();
        result.put("success", true);
        result.put("message", "贡献度增加成功");
        return result;
    }
    
    /**
     * 获取今日排行榜
     */
    @GetMapping("/today")
    public Map<String, Object> getTodayRank(
            @RequestParam(defaultValue = "10") int topN) {
        
        List<ContributionRankService.RankItem> rankList = 
            rankService.getTodayRank(topN);
        
        Map<String, Object> result = new HashMap<>();
        result.put("success", true);
        result.put("data", rankList);
        result.put("type", "今日榜");
        return result;
    }
    
    /**
     * 获取本周排行榜
     */
    @GetMapping("/week")
    public Map<String, Object> getWeekRank(
            @RequestParam(defaultValue = "10") int topN) {
        
        List<ContributionRankService.RankItem> rankList = 
            rankService.getWeekRank(topN);
        
        Map<String, Object> result = new HashMap<>();
        result.put("success", true);
        result.put("data", rankList);
        result.put("type", "本周榜");
        return result;
    }
    
    /**
     * 获取本月排行榜
     */
    @GetMapping("/month")
    public Map<String, Object> getMonthRank(
            @RequestParam(defaultValue = "10") int topN) {
        
        List<ContributionRankService.RankItem> rankList = 
            rankService.getMonthRank(topN);
        
        Map<String, Object> result = new HashMap<>();
        result.put("success", true);
        result.put("data", rankList);
        result.put("type", "本月榜");
        return result;
    }
    
    /**
     * 获取本年排行榜
     */
    @GetMapping("/year")
    public Map<String, Object> getYearRank(
            @RequestParam(defaultValue = "10") int topN) {
        
        List<ContributionRankService.RankItem> rankList = 
            rankService.getYearRank(topN);
        
        Map<String, Object> result = new HashMap<>();
        result.put("success", true);
        result.put("data", rankList);
        result.put("type", "本年榜");
        return result;
    }
    
    /**
     * 获取总排行榜
     */
    @GetMapping("/total")
    public Map<String, Object> getTotalRank(
            @RequestParam(defaultValue = "10") int topN) {
        
        List<ContributionRankService.RankItem> rankList = 
            rankService.getTotalRank(topN);
        
        Map<String, Object> result = new HashMap<>();
        result.put("success", true);
        result.put("data", rankList);
        result.put("type", "总榜");
        return result;
    }
    
    /**
     * 获取用户在所有榜单中的排名
     */
    @GetMapping("/user/{userId}")
    public Map<String, Object> getUserRankInfo(@PathVariable String userId) {
        Map<String, Object> rankInfo = new HashMap<>();
        
        // 获取今日排名
        Long todayRank = rankService.getUserRank(
            RankKeyGenerator.generateTodayKey(), userId);
        rankInfo.put("todayRank", todayRank);
        
        // 获取本周排名
        Long weekRank = rankService.getUserRank(
            RankKeyGenerator.generateThisWeekKey(), userId);
        rankInfo.put("weekRank", weekRank);
        
        // 获取本月排名
        Long monthRank = rankService.getUserRank(
            RankKeyGenerator.generateThisMonthKey(), userId);
        rankInfo.put("monthRank", monthRank);
        
        // 获取本年排名
        Long yearRank = rankService.getUserRank(
            RankKeyGenerator.generateThisYearKey(), userId);
        rankInfo.put("yearRank", yearRank);
        
        // 获取总排名
        Long totalRank = rankService.getUserRank(
            RankKeyGenerator.getTotalKey(), userId);
        rankInfo.put("totalRank", totalRank);
        
        Map<String, Object> result = new HashMap<>();
        result.put("success", true);
        result.put("userId", userId);
        result.put("rankInfo", rankInfo);
        return result;
    }
}

四、测试实战案例

4.1向有序集合中添加大量用户分数数据

4.2获取分数最高和最低的几名用户信息

4.3测试代码

复制代码
package com.zm.demo.modular;

import com.zm.demo.framework.result.response.IResult;
import com.zm.demo.framework.result.response.ResultBean;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Random;
import java.util.Set;

@RequestMapping("/redisTest")
@RestController
@Slf4j
public class RedisTestController {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 测试Redis的ZSet数据结构
     * 向有序集合中添加大量用户分数数据
     */
    @RequestMapping("/zSet")
    public IResult testZSet() {
        // 循环10万次,模拟大量用户数据
        for (int i = 0; i < 100000; i++) {
            // 生成1到1000之间的随机分数
            double score = 1 + (1000 - 1) * new Random().nextDouble();
            // 记录分数日志
            log.info("score: {}", score);
            // 将用户分数添加到名为"subject_rank"的有序集合中,用户标识为"user_"+i
            redisTemplate.opsForZSet().incrementScore("subject_rank", "user_" + i, score);
        }
        // 返回空结果
        return new ResultBean<>();
    }


    /**
     * 获取Redis中ZSet的数据
     * 获取分数最高和最低的几名用户信息
     */
    @RequestMapping("/zSetGet")
    public IResult testZSetGet() {
        // 获取分数最高的前3名用户(按分数倒序排列)
        Set<ZSetOperations.TypedTuple<Object>> subjectRank = redisTemplate.opsForZSet().reverseRangeWithScores("subject_rank", 0, 2);
        // 遍历并打印高分用户信息
        for (ZSetOperations.TypedTuple<Object> objectTypedTuple : subjectRank) {
            log.info("user: {}, score: {}", objectTypedTuple.getValue(), objectTypedTuple.getScore());
        }
        // 获取分数最低的前3名用户(按分数正序排列)
        Set<ZSetOperations.TypedTuple<Object>> subjectRank1 = redisTemplate.opsForZSet().rangeWithScores("subject_rank", 0, 2);
        // 遍历并打印低分用户信息
        for (ZSetOperations.TypedTuple<Object> objectTypedTuple : subjectRank1) {
            log.info("user: {}, score: {}", objectTypedTuple.getValue(), objectTypedTuple.getScore());
        }
        // 将两组结果合并(注意:这里的addAll可能会导致重复数据)
        subjectRank.addAll(subjectRank1);
        // 返回结果
        return new ResultBean<>(subjectRank);
    }


}

五、总结

基于Redis ZSet实现的题目贡献度排行榜具有以下优势:

  1. 高性能:Redis内存操作,O(logN)的复杂度

  2. 灵活的时间维度:支持日、周、月、年等多维度统计

  3. 原子性操作:确保数据一致性

  4. 丰富的操作:支持排名查询、范围查询、分数更新等

该方案已经成功应用于本在线教育平台,能够有效激励用户贡献优质题目,提升平台内容质量。

相关推荐
德彪稳坐倒骑驴5 小时前
SQL之前不懂,后来又学会的东西
数据库·sql
老华带你飞5 小时前
垃圾分类|基于springboot 垃圾分类系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·spring
CodeAmaz5 小时前
InnoDB的MVCC机制
java·数据库·mvcc
MC皮蛋侠客5 小时前
MySQL数据库迁移脚本及使用说明
数据库·mysql
聊天QQ:4877392786 小时前
新能源汽车 VCU:从原理到实践的全方位解析
redis
愚公移码6 小时前
蓝凌EKP产品:Hibernate 中 SessionFactory、Session 与事务的关系
java·数据库·hibernate·蓝凌
透明的玻璃杯6 小时前
sqlite数据库连接池
jvm·数据库·sqlite
VX:Fegn08956 小时前
计算机毕业设计|基于springboot + vue非遗传承文化管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
bleach-7 小时前
buuctf系列解题思路祥讲--[极客大挑战 2019]HardSQL1——sql报错注入
数据库·sql·安全·web安全·网络安全