如何实现榜单 top N 统计
以下是一个基于本地缓存 + Redis ZSet + 定时任务的榜单方案,适用于高并发场景:
方案概述
- 本地缓存 :在应用服务器本地缓存榜单数据,减少对 Redis 的访问频率,提高读取速度。
- Redis ZSet :使用 Redis 的有序集合存储榜单数据,利用其高效的排序和范围查询功能。
- 定时任务 :定期更新本地缓存和 Redis ZSet 中的榜单数据,确保数据的实时性和准确性。
数据存储架构
全局前 1000 名榜单存储在 Redis 中
- Redis ZSet:
- 键名:
global_ranking_top_1000
- ZSet 中的成员是用户 ID,分数是排名依据(如积分、销售额等)。
- 定时任务每小时更新一次,覆盖之前的 ZSet 数据。
- 键名:
前 100 名榜单同步到本地缓存
- 本地缓存:
- 每个应用服务器使用内存数据结构(如
ConcurrentHashMap
)存储前 100 名榜单。 - 键名:
local_ranking_top_100
- 数据来源于 Redis 的
global_ranking_top_1000
,每分钟同步一次。
- 每个应用服务器使用内存数据结构(如
实现步骤
- 定时任务计算全局前 1000 名
任务频率:每小时执行一次
任务逻辑:
- 检索数据库中所有用户的实时数据(如积分、销售额)。
- 排序并选出前 1000 名用户。
- 将前 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());
}
}
- 定时同步前 100 名到本地缓存
任务频率:每分钟执行一次
任务逻辑:
- 从 Redis 的
global_ranking_top_1000
ZSet 中获取前 100 名用户。 - 将前 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;
}
- 读取榜单数据
访问逻辑:
- 优先从本地缓存读取前 100 名榜单。
- 如果本地缓存中没有数据(如缓存过期或未初始化),则从 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();
}
}
}
优化效果
- 性能:通过使用临时键和
rename
命令,确保 Redis 数据更新的原子性,减少数据更新时的锁竞争。 - 可靠性:增加异常处理逻辑,确保系统在异常情况下仍能正常运行。
- 可维护性:将功能模块分离,提高代码的可读性和可维护性。
- 数据一致性:确保本地缓存和 Redis ZSet 的数据一致性,避免因网络问题或系统故障导致数据不一致。
适用场景
- 游戏排行榜
- 实时战力排行榜:玩家的战力值实时更新,排行榜需要立即反映变化。通过 Redis ZSet 实时维护战力值,定时任务每小时计算全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保玩家可以快速查询自己的排名。
- 每日任务排行榜:统计玩家每日完成任务的数量或得分,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
- 电商平台
- 热销产品榜单:实时统计产品的销量或浏览量,通过 Redis ZSet 维护热销产品榜单。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保用户可以快速查看热销产品。
- 商家销售额排行榜:统计商家的销售额,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
- 社交平台
- 用户活跃度排行榜:统计用户的活跃度(如点赞数、评论数、分享数),通过 Redis ZSet 实时维护活跃度排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保用户可以快速查看自己的排名。
- 话题热度排行榜:统计话题的热度(如阅读量、参与人数),定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
- 内容平台
- 文章阅读量排行榜:统计文章的阅读量,通过 Redis ZSet 实时维护阅读量排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保用户可以快速查看热门文章。
- 视频播放量排行榜:统计视频的播放量,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
- 金融平台
- 交易额排行榜:统计用户的交易额,通过 Redis ZSet 实时维护交易额排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保用户可以快速查看自己的排名。
- 投资收益排行榜:统计用户的 investment 收益,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
- 教育平台
- 学习时长排行榜:统计学生的学习时长,通过 Redis ZSet 实时维护学习时长排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保学生可以快速查看自己的排名。
- 课程评分排行榜:统计课程的评分,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
- 物流平台
- 配送效率排行榜:统计配送员的配送效率(如配送单量、配送时长),通过 Redis ZSet 实时维护配送效率排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保配送员可以快速查看自己的排名。
- 客户满意度排行榜:统计客户的满意度评分,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
- 医疗平台
- 医生好评排行榜:统计医生的好评数,通过 Redis ZSet 实时维护好评排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保用户可以快速查看医生的排名。
- 医院服务排行榜:统计医院的服务评分,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
- 交通出行平台
- 司机服务排行榜:统计司机的服务评分,通过 Redis ZSet 实时维护服务排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保用户可以快速查看司机的排名。
- 车辆使用排行榜:统计车辆的使用频率,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
- 企业内部系统
- 员工绩效排行榜:统计员工的绩效得分,通过 Redis ZSet 实时维护绩效排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保员工可以快速查看自己的排名。
- 部门贡献排行榜:统计部门的贡献值,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
系统优化
数据一致性
- 本地缓存与 Redis 数据的一致性:
- 定时任务每分钟同步前 100 名数据,确保本地缓存数据不超过 1 分钟的延迟。
- 如果本地缓存失效或未同步,系统自动回退到 Redis 数据。
Redis 性能优化
- 使用 Redis 集群:如果数据量巨大,可以使用 Redis Cluster 分散读写压力。
- 设置过期时间:为 Redis 中的榜单数据设置过期时间(如 24 小时),避免数据永久占用内存。
高并发优化
- 本地缓存优先:99% 的请求从本地缓存获取数据,减少对 Redis 的访问。
- 异步更新:定时任务异步执行,不影响系统响应速度。
注意事项
- 热点数据:如果榜单数据是系统热点,需要确保本地缓存的容量足够大,避免频繁访问 Redis。
- 数据持久化:Redis 的数据是内存存储,建议定期将榜单数据持久化到数据库或文件中,防止数据丢失。
- 监控:对定时任务和缓存更新逻辑进行监控,确保任务按时执行,数据及时更新。