如何实现榜单 top N 统计

以下是一个基于本地缓存 + Redis ZSet + 定时任务的榜单方案,适用于高并发场景:

方案概述

  1. 本地缓存 :在应用服务器本地缓存榜单数据,减少对 Redis 的访问频率,提高读取速度。
  2. Redis ZSet :使用 Redis 的有序集合存储榜单数据,利用其高效的排序和范围查询功能。
  3. 定时任务 :定期更新本地缓存和 Redis ZSet 中的榜单数据,确保数据的实时性和准确性。

数据存储架构

全局前 1000 名榜单存储在 Redis 中

前 100 名榜单同步到本地缓存

实现步骤

  1. 定时任务计算全局前 1000 名

任务频率:每小时执行一次

任务逻辑

  1. 检索数据库中所有用户的实时数据(如积分、销售额)。
  2. 排序并选出前 1000 名用户。
  3. 将前 1000 名用户写入 Redis 的 global_ranking_top_1000 ZSet,覆盖原有数据。

代码示例

/ 使用 Spring 定时任务
@Scheduled(cron = "0 0 * * * ?")
public void updateGlobalRanking() {
/ 1. 从数据库获取所有用户数据
List<User> users = userRepository.findAll();
// 2. 排序并选出前 1000 名
List<User> top1000 = users.stream()
.sorted((u1, u2) -> Double.compare(u2.getScore(), u1.getScore()))
.limit(1000)
.collect(Collectors.toList());
// 3. 写入 Redis ZSet,覆盖原有数据
redisTemplate.delete("global_ranking_top_1000"); / 清空旧数据
for (User user : top1000) {
redisTemplate.opsForZSet().add("global_ranking_top_1000", user.getId(), user.getScore());
}
}
  1. 定时同步前 100 名到本地缓存

任务频率:每分钟执行一次

任务逻辑

  1. 从 Redis 的 global_ranking_top_1000 ZSet 中获取前 100 名用户。
  2. 将前 100 名用户数据写入本地缓存。

代码示例

// 使用 Spring 定时任务
@Scheduled(cron = "0 * * * * ?")
public void updateLocalRankingCache() {
// 1. 从 Redis 获取前 100 名
Set<ZSetOperations.TypedTuple<String>> top100 = redisTemplate.opsForZSet()
.reverseRangeWithScores("global_ranking_top_1000", 0, 99);
// 2. 将数据写入本地缓存
Map<String, Double> localCache = new ConcurrentHashMap<>();
for (ZSetOperations.TypedTuple<String> tuple : top100) {
String userId = tuple.getValue();
double score = tuple.getScore();
localCache.put(userId, score);
}
// 更新本地缓存
localRankingCache = localCache;
}
  1. 读取榜单数据

访问逻辑

  1. 优先从本地缓存读取前 100 名榜单。
  2. 如果本地缓存中没有数据(如缓存过期或未初始化),则从 Redis 的 global_ranking_top_1000 ZSet 中读取前 100 名。

代码示例

public Map<String, Double> getTop100Ranking() {
// 1. 优先从本地缓存读取
if (localRankingCache != null && !localRankingCache.isEmpty()) {
return localRankingCache;
}
// 2. 从 Redis 读取
Set<ZSetOperations.TypedTuple<String>> top100 = redisTemplate.opsForZSet()
.reverseRangeWithScores("global_ranking_top_1000", 0, 99);
Map<String, Double> result = new LinkedHashMap<>();
for (ZSetOperations.TypedTuple<String> tuple : top100) {
result.put(tuple.getValue(), tuple.getScore());
}
return result;
}

优化后的代码:

@Component
public class RankingManager {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserService userService;
// 本地缓存
private final Map<String, Map<String, Double>> localCache = new ConcurrentHashMap<>();
// Redis ZSet 键名
private static final String GLOBAL_RANKING_KEY = "global_ranking_top_1000";
private static final String TEMP_GLOBAL_RANKING_KEY = "temp_global_ranking_top_1000";
//定时任务:每小时计算全局前1000名
@Scheduled(cron = "0 0 * * * ?")
public void updateGlobalRanking() {
try {
// 1. 从数据库获取所有用户数据
List<User> users = userService.findAll();
// 2. 排序并选出前1000名
List<User> top1000 = users.stream()
.sorted((u1, u2) -> Double.compare(u2.getScore(), u1.getScore()))
.limit(1000)
.collect(Collectors.toList());
// 3. 使用临时键存储数据
redisTemplate.delete(TEMP_GLOBAL_RANKING_KEY);
for (User user : top1000) {
redisTemplate.opsForZSet().add(TEMP_GLOBAL_RANKING_KEY, user.getId(), user.getScore());
}
// 4. 原子性地将临时键重命名为目标键
redisTemplate.rename(TEMP_GLOBAL_RANKING_KEY, GLOBAL_RANKING_KEY);
// 5. 更新本地缓存
updateLocalCache(top1000);
System.out.println("Global ranking updated successfully.");
} catch (Exception e) {
System.err.println("Error updating global ranking: " + e.getMessage());
}
}
private void updateLocalCache(List<User> top1000) {
Map<String, Double> cache = new ConcurrentHashMap<>();
for (User user : top1000) {
cache.put(user.getId(), user.getScore());
}
localCache.put("global_top_1000", cache);
}
// 定时任务:每分钟同步前100名到本地缓存
@Scheduled(cron = "0 * * * * ?")
public void updateLocalCacheFromRedis() {
try {
// 1. 从Redis获取前100名
Set<ZSetOperations.TypedTuple<String>> top100 = redisTemplate.opsForZSet()
.reverseRangeWithScores(GLOBAL_RANKING_KEY, 0, 99);
// 2. 更新本地缓存
Map<String, Double> localTop100 = new LinkedHashMap<>();
for (ZSetOperations.TypedTuple<String> tuple : top100) {
localTop100.put(tuple.getValue(), tuple.getScore());
}
localCache.put("local_top_100", localTop100);
System.out.println("Local cache updated successfully.");
} catch (Exception e) {
System.err.println("Error updating local cache: " + e.getMessage());
}
}
// 获取榜单数据
public Map<String, Double> getTop100Ranking() {
// 优先从本地缓存读取
Map<String, Double> localTop100 = localCache.get("local_top_100");
if (localTop100 != null && !localTop100.isEmpty()) {
return new LinkedHashMap<>(localTop100);
}
// 从Redis读取
try {
Set<ZSetOperations.TypedTuple<String>> top100 = redisTemplate.opsForZSet()
.reverseRangeWithScores(GLOBAL_RANKING_KEY, 0, 99);
Map<String, Double> result = new LinkedHashMap<>();
for (ZSetOperations.TypedTuple<String> tuple : top100) {
result.put(tuple.getValue(), tuple.getScore());
}
return result;
} catch (Exception e) {
System.err.println("Error fetching data from Redis: " + e.getMessage());
return Collections.emptyMap();
}
}
}

优化效果

  1. 性能:通过使用临时键和 rename 命令,确保 Redis 数据更新的原子性,减少数据更新时的锁竞争。
  2. 可靠性:增加异常处理逻辑,确保系统在异常情况下仍能正常运行。
  3. 可维护性:将功能模块分离,提高代码的可读性和可维护性。
  4. 数据一致性:确保本地缓存和 Redis ZSet 的数据一致性,避免因网络问题或系统故障导致数据不一致。

适用场景

  1. 游戏排行榜
  1. 电商平台
  1. 社交平台
  1. 内容平台
  1. 金融平台
  1. 教育平台
  1. 物流平台
  1. 医疗平台
  1. 交通出行平台
  1. 企业内部系统

系统优化

数据一致性

Redis 性能优化

高并发优化

注意事项


常见分布式 ID 解决方案
区分偶发性超时和频繁超时的重试策略