如何实现榜单 top N 统计

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

方案概述

  1. 本地缓存 :在应用服务器本地缓存榜单数据,减少对 Redis 的访问频率,提高读取速度。
  2. Redis ZSet :使用 Redis 的有序集合存储榜单数据,利用其高效的排序和范围查询功能。
  3. 定时任务 :定期更新本地缓存和 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,每分钟同步一次。

实现步骤

  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. 游戏排行榜
  • 实时战力排行榜:玩家的战力值实时更新,排行榜需要立即反映变化。通过 Redis ZSet 实时维护战力值,定时任务每小时计算全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保玩家可以快速查询自己的排名。
  • 每日任务排行榜:统计玩家每日完成任务的数量或得分,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
  1. 电商平台
  • 热销产品榜单:实时统计产品的销量或浏览量,通过 Redis ZSet 维护热销产品榜单。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保用户可以快速查看热销产品。
  • 商家销售额排行榜:统计商家的销售额,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
  1. 社交平台
  • 用户活跃度排行榜:统计用户的活跃度(如点赞数、评论数、分享数),通过 Redis ZSet 实时维护活跃度排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保用户可以快速查看自己的排名。
  • 话题热度排行榜:统计话题的热度(如阅读量、参与人数),定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
  1. 内容平台
  • 文章阅读量排行榜:统计文章的阅读量,通过 Redis ZSet 实时维护阅读量排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保用户可以快速查看热门文章。
  • 视频播放量排行榜:统计视频的播放量,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
  1. 金融平台
  • 交易额排行榜:统计用户的交易额,通过 Redis ZSet 实时维护交易额排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保用户可以快速查看自己的排名。
  • 投资收益排行榜:统计用户的 investment 收益,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
  1. 教育平台
  • 学习时长排行榜:统计学生的学习时长,通过 Redis ZSet 实时维护学习时长排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保学生可以快速查看自己的排名。
  • 课程评分排行榜:统计课程的评分,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
  1. 物流平台
  • 配送效率排行榜:统计配送员的配送效率(如配送单量、配送时长),通过 Redis ZSet 实时维护配送效率排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保配送员可以快速查看自己的排名。
  • 客户满意度排行榜:统计客户的满意度评分,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
  1. 医疗平台
  • 医生好评排行榜:统计医生的好评数,通过 Redis ZSet 实时维护好评排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保用户可以快速查看医生的排名。
  • 医院服务排行榜:统计医院的服务评分,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
  1. 交通出行平台
  • 司机服务排行榜:统计司机的服务评分,通过 Redis ZSet 实时维护服务排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保用户可以快速查看司机的排名。
  • 车辆使用排行榜:统计车辆的使用频率,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。
  1. 企业内部系统
  • 员工绩效排行榜:统计员工的绩效得分,通过 Redis ZSet 实时维护绩效排行榜。定时任务每小时更新全局前 1000 名,写入 Redis。每分钟同步前 100 名到本地缓存,确保员工可以快速查看自己的排名。
  • 部门贡献排行榜:统计部门的贡献值,定时任务每小时更新一次,确保排行榜数据的实时性和准确性。

系统优化

数据一致性

  • 本地缓存与 Redis 数据的一致性
    • 定时任务每分钟同步前 100 名数据,确保本地缓存数据不超过 1 分钟的延迟。
    • 如果本地缓存失效或未同步,系统自动回退到 Redis 数据。

Redis 性能优化

  • 使用 Redis 集群:如果数据量巨大,可以使用 Redis Cluster 分散读写压力。
  • 设置过期时间:为 Redis 中的榜单数据设置过期时间(如 24 小时),避免数据永久占用内存。

高并发优化

  • 本地缓存优先:99% 的请求从本地缓存获取数据,减少对 Redis 的访问。
  • 异步更新:定时任务异步执行,不影响系统响应速度。

注意事项

  • 热点数据:如果榜单数据是系统热点,需要确保本地缓存的容量足够大,避免频繁访问 Redis。
  • 数据持久化:Redis 的数据是内存存储,建议定期将榜单数据持久化到数据库或文件中,防止数据丢失。
  • 监控:对定时任务和缓存更新逻辑进行监控,确保任务按时执行,数据及时更新。
Share this post:

Related content