每日排行榜功能如何设计
别急着写代码先想清楚三个点读多写少排行榜被大量查看但排名数据更新相对低频实时性要求榜单要今天的数据还是允许几分钟延迟数据规模可能千万用户要能抗住高并发基于这些我习惯从存储、缓存、计算、更新四个维度来设计。明确核心需求边界 需求类型 具体要求基础功能 按用户积分 / 贡献值排序展示 Top N通常 Top100/Top500每日 0 点自动重置性能要求 排行榜查询 QPS≥10 万写入延迟 50ms支持百万级用户参与一致性要求 最终一致性即可允许 1-2 秒的延迟不要求强一致特殊要求 支持查看自己的排名支持历史排行榜归档核心技术选型与架构设计 ️整体架构图imageimage 核心理念写的时候异步累加分数到 Redis查的时候直接读 Sorted Set客户端可以再缓存一小会儿。核心选型理由首选Redis ZSet天生支持有序集合O (logN) 时间复杂度插入和查询是排行榜的黄金方案MySQL用于持久化历史数据和用户全量积分作为 Redis 的兜底消息队列削峰填谷解耦积分更新和排行榜更新核心实现方案 ✨数据结构设计业务库MySQL我们会有一张分数字表记录用户每日得分方便对账和历史查询。表名user_daily_scoreid BIGINT PKuser_id BIGINT INDEXscore_date DATEscore INT DEFAULT 0update_time DATETIME唯一索引uk_user_date (user_id, score_date) 每天首次产生行为时插入一行后续更新分数用 UPDATE score score ?保证原子累加。缓存层Redis当日排行榜 keyrank:daily:{date}memberuserIdscore用户积分ZADD rank:daily:20260603 1000 user123ZADD rank:daily:20260603 950 user456查询Top10ZREVRANGE rank:daily:20260603 0 9 WITHSCORES查询用户自己的排名从0开始ZREVRANK rank:daily:20260603 user123也可以这样用 Sorted Set 存储每日排行榜key 设计为 rank:daily:2026-06-03member 为 userIdscore 为累计分数。这样我们用几个命令就能搞定增加分数ZINCRBY rank:daily:2026-06-03 10 1001 用户1001加10分查询前100名ZREVRANGE rank:daily:2026-06-03 0 99 WITHSCORES查询某人排名ZREVRANK rank:daily:2026-06-03 1001 返回排名从0开始⚠️ Sorted Set 在成员数非常大时比如上千万ZREVRANGE 前100名仍然很快O(log(N)100)但是 ZREVRANK 也是 O(log(N))完全够用。每日重置与归档流程image每天零点需要生成新的榜单。做法凌晨 00:00Redis key 自动变为新日期新 key 自然为空不用手动删除。前一日的 key rank:daily:2026-06-02 可以保留 3~7 天供历史查看。之后异步转存到 MySQL 归档表 ranking_archive然后删除 Redis key。如果用户需要“昨日榜单”直接用归档数据或 Redis若未过期。 小技巧key 上带日期可以平滑过渡无需清空操作。关键优化用RENAME原子操作重置避免数据丢失延迟 1 秒执行防止跨天请求写入旧 key历史数据只保留 7 天在 Redis更早的归档到 MySQL3. 积分更新流程image为什么用异步积分更新是高频操作同步写 Redis 会拖慢接口MQ 可以削峰防止秒杀 / 活动时流量突增打垮 Redis即使 Redis 短暂不可用消息也不会丢失后续可以重试4. 消费者更新然后由一个 排行榜消费者 做两件事更新 MySQL 里的分数UPDATE … score score 5ZINCRBY rank:daily:2026-06-03 5 1001Redis 直接累加✅ 优点实时性很高用户可以立刻看到自己排名变化。❌ 缺点消息乱序时需要幂等MySQL 和 Redis 可能短暂不一致。准实时定时校正方案更稳健如果担心消息积压或 Redis 数据丢失可以采用定时任务兜底每 5 分钟跑一个 XXL-JOB 任务从 MySQL 里拉取今天所有分数变更的用户全量 ZADD 更新到 Redis或增量 ZINCRBY 差值。这样即使 Redis 挂了重启也能从 MySQL 重建当日排行榜。image读路径优化排行榜前100名是热点中的热点可以再加一层本地缓存或 CDN 缓存。接口 /api/ranking/top?n100date2026-06-03 查询 Redis返回 JSON。服务端用 Guava Cache 或 Caffeine 缓存 30 秒~1 分钟过期自动回源 Redis。如果客户端频繁刷新可以在 CDN 上缓存 30 秒大幅降低到服务端的 QPS。 压测表明一个 4 核 Redis 实例1000 万成员的 Sorted SetZREVRANGE top100 可以轻松抗住 10万 QPS。性能优化与高可用保障 ⚡Redis 集群化将不同日期的排行榜 key 散列到不同 Redis 节点避免单节点压力过大查询缓存对 Top10/Top100 结果做本地缓存Caffeine过期时间 1-5 秒大幅降低 Redis QPS分页优化不支持翻页查看全部排名只提供 Top N 和自己的排名避免ZREVRANGE 0 -1全量扫描大 key 拆分如果单天用户超过 1000 万按积分段拆分多个 ZSet查询时合并结果主从 哨兵Redis 主从复制哨兵自动故障转移保证高可用异常情况与兜底方案 ️异常场景 处理方案Redis 宕机 降级为查询 MySQL 中当日积分前 100 的用户同时告警Redis 恢复后自动同步数据定时任务执行失败 增加兜底检查任务每小时检查当日 key 是否存在不存在则重新创建积分重复更新 消息消费时做幂等处理用唯一消息 ID 去重恶意刷分 在业务层做频率限制单用户每分钟积分更新不超过 N 次高级进阶面试加分项 ⭐分段榜单如果用户量过亿单个 Sorted Set 太大可以按用户ID哈希分片如 rank:daily:2026-06-03:shard0 到 shard9查询时并行查多个分片再合并排序。红黑树兜底极端情况下如果 Redis 崩了可以用 Java 的 ConcurrentSkipListMap 做内存排行榜定时从 DB 重建。防刷机制在累加分数前用 Redis SETNX 或限流器控制单用户单行为频次防止恶意刷榜。扩展能力考虑 多维度排行榜增加周榜、月榜、总榜复用同一套架构只是 key 不同实时排行榜如果需要秒级实时去掉 MQ直接写 Redis但要做好限流排行榜奖励发放每日重置后遍历 Top N 用户异步发放奖励地域 / 分组排行榜在 key 中增加地域 / 分组标识如rank:daily:20260603:beijing回答总结一分钟版本“每日排行榜我会用 Redis Sorted Set 存储当天排名key 按日期隔离。写操作通过消息队列异步更新 Redis 和 MySQL读操作直接从 Redis 取 Top N。用定时任务定期对账保证数据一致性。每天零点自动切新 key旧 key 留几天做历史查询。前端加一层短缓存整个系统可以轻松支撑千万用户高并发。”面试官以上就是我对每日排行榜功能的整体设计方案核心是利用 Redis ZSet 的特性解决排序问题通过异步和缓存提升性能同时做好异常兜底和扩展考虑。每日排行榜设计补充核心代码 技术难点全解 ️面试官您好我接下来补充这个方案的生产级核心代码和大厂高频追问的技术难点及解决方案代码基于 Spring Boot 2.7RedisTemplateRedisson 实现完全贴合互联网大厂技术栈。核心代码实现带技术亮点标注 ✨Redis ZSet 核心操作工具类技术亮点泛型封装、管道批量操作、原子性保证Componentpublic class RankRedisUtil {Autowiredprivate StringRedisTemplate redisTemplate;// 技术亮点原子性增加积分直接返回更新后的积分public Double incrementScore(String key, String userId, long score) {return redisTemplate.opsForZSet().incrementScore(key, userId, score);}// 技术亮点批量获取TopN带积分使用Redis管道减少网络IOpublic List getTopNWithScores(String key, int topN) {SetZSetOperations.TypedTuple tuples redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, topN - 1);return tuples.stream() .map(tuple - new RankItem(tuple.getValue(), tuple.getScore().longValue())) .collect(Collectors.toList());}// 技术亮点获取用户排名从1开始符合用户习惯public Long getUserRank(String key, String userId) {Long rank redisTemplate.opsForZSet().reverseRank(key, userId);return rank null ? null : rank 1;}// 技术亮点原子性重命名key用于每日重置public Boolean renameKey(String oldKey, String newKey) {return redisTemplate.rename(oldKey, newKey);}// 技术亮点管道批量删除归档后清理历史数据public void batchDelete(List keys) {redisTemplate.executePipelined((RedisCallback) connection - {keys.forEach(key - connection.del(key.getBytes()));return null;});}}每日排行榜重置定时任务技术亮点分布式锁防并发、延迟执行防跨天、异步批量归档ComponentEnableSchedulingpublic class DailyRankResetTask {Autowiredprivate RankRedisUtil rankRedisUtil;Autowiredprivate RedissonClient redissonClient;Autowiredprivate RankHistoryService rankHistoryService;// 每日00:00:01执行延迟1秒防止跨天请求写入旧keyScheduled(cron “1 0 0 * * ?”)public void resetDailyRank() {String lockKey “lock:rank:daily:reset”;RLock lock redissonClient.getLock(lockKey);// 技术亮点尝试锁最多等待3秒持有锁10分钟自动释放 try { if (lock.tryLock(3, 600, TimeUnit.SECONDS)) { String today LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); String yesterday LocalDate.now().minusDays(1).format(DateTimeFormatter.ofPattern(yyyyMMdd)); String currentKey rank:daily: today; String historyKey rank:daily:history: yesterday; // 技术亮点原子性重命名瞬间完成无数据丢失 if (rankRedisUtil.exists(currentKey)) { rankRedisUtil.renameKey(currentKey, historyKey); } // 技术亮点异步批量归档到MySQL不阻塞重置流程 CompletableFuture.runAsync(() - { ListRankItem historyRank rankRedisUtil.getTopNWithScores(historyKey, 1000); rankHistoryService.batchSave(yesterday, historyRank); // 保留7天历史数据在Redis更早的删除 String expireKey rank:daily:history: LocalDate.now().minusDays(7).format(DateTimeFormatter.ofPattern(yyyyMMdd)); rankRedisUtil.delete(expireKey); }); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }}}积分更新服务技术亮点异步解耦、幂等性保证、按用户分区顺序消费Servicepublic class ScoreUpdateService {Autowiredprivate RankRedisUtil rankRedisUtil;Autowiredprivate StringRedisTemplate redisTemplate;// 技术亮点消息消费幂等用消息ID去重过期时间24小时RabbitListener(queues “queue.score.update”)public void onScoreUpdate(ScoreUpdateMessage message) {String idempotentKey “idempotent:score:” message.getMessageId();Boolean isFirst redisTemplate.opsForValue().setIfAbsent(idempotentKey, “1”, 24, TimeUnit.HOURS);if (Boolean.TRUE.equals(isFirst)) { String today LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); String rankKey rank:daily: today; // 技术亮点原子性更新积分避免并发问题 rankRedisUtil.incrementScore(rankKey, message.getUserId(), message.getScore()); }}}排行榜查询接口技术亮点Caffeine 本地缓存、多级降级、异常捕获RestControllerRequestMapping(“/rank”)public class RankController {Autowiredprivate RankRedisUtil rankRedisUtil;// 技术亮点Caffeine本地缓存过期时间3秒最大容量1000扛住热点查询private final LoadingCacheString, List top100Cache Caffeine.newBuilder().expireAfterWrite(3, TimeUnit.SECONDS).maximumSize(1000).build(key - rankRedisUtil.getTopNWithScores(key, 100));GetMapping(“/daily/top100”)public ResultList getDailyTop100() {try {String today LocalDate.now().format(DateTimeFormatter.ofPattern(“yyyyMMdd”));String key “rank:daily:” today;return Result.success(top100Cache.get(key));} catch (Exception e) {// 技术亮点降级返回缓存的旧数据同时告警log.error(“查询每日排行榜失败”, e);return Result.success(top100Cache.getIfPresent(“rank:daily:” LocalDate.now().format(DateTimeFormatter.ofPattern(“yyyyMMdd”))));}}}