一、概述
在在线教育平台中,题目贡献度排行榜是激励用户积极参与的重要功能。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的过期时间,那么我们需要自己管理排行榜数据的生命周期。我们可以考虑以下方案:
-
定期清理:使用定时任务(如Spring的@Scheduled)来删除过期的排行榜数据。
-
在更新数据时,同时更新一个记录数据创建时间的键,然后在获取数据时检查是否过期,如果过期则从排行榜中删除该数据(但这会影响获取数据的性能,且实现复杂)。
-
使用两个有序集合:一个存储分数,另一个存储时间戳。然后定期使用ZRANGEBYSCORE命令删除过期的数据。
数据结构细节:
-
Key: 如上表所示的格式
-
Member : 用户ID(如:
user_12345) -
Score: 贡献度分数(Double类型)
-
排序: 默认按Score降序排列
2.2 贡献度计算策略
每次用户贡献题目时,我们将同时更新所有相关时间维度的排行榜。这种设计确保了:
-
数据实时性:用户贡献后立即反映在排行榜上
-
数据一致性:所有时间维度的数据保持同步
-
性能优化:批量更新减少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实现的题目贡献度排行榜具有以下优势:
-
高性能:Redis内存操作,O(logN)的复杂度
-
灵活的时间维度:支持日、周、月、年等多维度统计
-
原子性操作:确保数据一致性
-
丰富的操作:支持排名查询、范围查询、分数更新等
该方案已经成功应用于本在线教育平台,能够有效激励用户贡献优质题目,提升平台内容质量。