每日排行榜功能如何设计
每日排行榜功能如何设计
面试官您好,我会从需求拆解→核心方案→性能优化→异常兜底→扩展能力五个维度来设计这个每日排行榜功能,确保它能支撑高并发、低延迟的互联网场景。
别急着写代码,先想清楚三个点:
- 读多写少(排行榜被大量查看,但排名数据更新相对低频)
- 实时性要求(榜单要今天的数据,还是允许几分钟延迟?)
- 数据规模(可能千万用户,要能抗住高并发)
基于这些,我习惯从存储、缓存、计算、更新四个维度来设计。👇
明确核心需求边界 🎯
| 需求类型 | 具体要求 |
|---|---|
| 基础功能 | 按用户积分 / 贡献值排序,展示 Top N(通常 Top100/Top500),每日 0 点自动重置 |
| 性能要求 | 排行榜查询 QPS≥10 万,写入延迟 < 50ms,支持百万级用户参与 |
| 一致性要求 | 最终一致性即可,允许 1-2 秒的延迟,不要求强一致 |
| 特殊要求 | 支持查看自己的排名,支持历史排行榜归档 |
核心技术选型与架构设计 🏗️
整体架构图
💡 核心理念:写的时候异步累加分数到 Redis,查的时候直接读 Sorted Set,客户端可以再缓存一小会儿。
核心选型理由:
- 首选Redis ZSet:天生支持有序集合,O (logN) 时间复杂度插入和查询,是排行榜的黄金方案
- MySQL:用于持久化历史数据和用户全量积分,作为 Redis 的兜底
- 消息队列:削峰填谷,解耦积分更新和排行榜更新
核心实现方案 ✨
1. 数据结构设计
业务库(MySQL)
我们会有一张分数字表记录用户每日得分,方便对账和历史查询。
表名:`user_daily_score`
id BIGINT PK
user_id BIGINT INDEX
score_date DATE
score INT DEFAULT 0
update_time DATETIME
唯一索引:uk_user_date (user_id, score_date)📌 每天首次产生行为时插入一行,后续更新分数用 UPDATE score = score + ?,保证原子累加。
缓存层(Redis)
# 当日排行榜 key:rank:daily:{date}
# member:userId,score:用户积分
ZADD rank:daily:20260603 1000 user123
ZADD rank:daily:20260603 950 user456
# 查询Top10
ZREVRANGE rank:daily:20260603 0 9 WITHSCORES
# 查询用户自己的排名(从0开始)
ZREVRANK rank:daily:20260603 user123也可以这样:
用 Sorted Set 存储每日排行榜,key 设计为 rank:daily:2026-06-03,member 为 userId,score 为累计分数。
这样我们用几个命令就能搞定:
- 增加分数:
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)),完全够用。
2. 每日重置与归档流程
每天零点需要生成新的榜单。做法:
- 凌晨 00:00,Redis key 自动变为新日期,新 key 自然为空,不用手动删除。
- 前一日的 key
rank:daily:2026-06-02可以保留 3~7 天,供历史查看。之后异步转存到 MySQL 归档表ranking_archive,然后删除 Redis key。 - 如果用户需要“昨日榜单”,直接用归档数据或 Redis(若未过期)。
🕒 小技巧:key 上带日期,可以平滑过渡,无需清空操作。
关键优化:
- 用
RENAME原子操作重置,避免数据丢失 - 延迟 1 秒执行,防止跨天请求写入旧 key
- 历史数据只保留 7 天在 Redis,更早的归档到 MySQL
3. 积分更新流程
为什么用异步?:
- 积分更新是高频操作,同步写 Redis 会拖慢接口
- MQ 可以削峰,防止秒杀 / 活动时流量突增打垮 Redis
- 即使 Redis 短暂不可用,消息也不会丢失,后续可以重试
4. 消费者更新
然后由一个 排行榜消费者 做两件事:
- 更新 MySQL 里的分数(
UPDATE ... score = score + 5) ZINCRBY rank:daily:2026-06-03 5 1001(Redis 直接累加)
✅ 优点:实时性很高,用户可以立刻看到自己排名变化。
❌ 缺点:消息乱序时需要幂等,MySQL 和 Redis 可能短暂不一致。
5. 准实时+定时校正方案(更稳健)
如果担心消息积压或 Redis 数据丢失,可以采用定时任务兜底:
- 每 5 分钟跑一个 XXL-JOB 任务,从 MySQL 里拉取今天所有分数变更的用户,全量
ZADD更新到 Redis(或增量ZINCRBY差值)。 - 这样即使 Redis 挂了重启,也能从 MySQL 重建当日排行榜。
读路径优化
排行榜前100名是热点中的热点,可以再加一层本地缓存或 CDN 缓存。
- 接口
/api/ranking/top?n=100&date=2026-06-03查询 Redis,返回 JSON。 - 服务端用 Guava Cache 或 Caffeine 缓存 30 秒~1 分钟,过期自动回源 Redis。
- 如果客户端频繁刷新,可以在 CDN 上缓存 30 秒,大幅降低到服务端的 QPS。
📊 压测表明,一个 4 核 Redis 实例,1000 万成员的 Sorted Set,ZREVRANGE 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.7+RedisTemplate+Redisson 实现,完全贴合互联网大厂技术栈。
核心代码实现(带技术亮点标注 ✨)
1. Redis ZSet 核心操作工具类(技术亮点:泛型封装、管道批量操作、原子性保证)
@Component
public class RankRedisUtil {
@Autowired
private StringRedisTemplate redisTemplate;
// 技术亮点:原子性增加积分,直接返回更新后的积分
public Double incrementScore(String key, String userId, long score) {
return redisTemplate.opsForZSet().incrementScore(key, userId, score);
}
// 技术亮点:批量获取TopN带积分,使用Redis管道减少网络IO
public List<RankItem> getTopNWithScores(String key, int topN) {
Set<ZSetOperations.TypedTuple<String>> 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<String> keys) {
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
keys.forEach(key -> connection.del(key.getBytes()));
return null;
});
}
}2. 每日排行榜重置定时任务(技术亮点:分布式锁防并发、延迟执行防跨天、异步批量归档)
@Component
@EnableScheduling
public class DailyRankResetTask {
@Autowired
private RankRedisUtil rankRedisUtil;
@Autowired
private RedissonClient redissonClient;
@Autowired
private RankHistoryService rankHistoryService;
// 每日00:00:01执行,延迟1秒防止跨天请求写入旧key
@Scheduled(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(() -> {
List<RankItem> 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();
}
}
}
}3. 积分更新服务(技术亮点:异步解耦、幂等性保证、按用户分区顺序消费)
@Service
public class ScoreUpdateService {
@Autowired
private RankRedisUtil rankRedisUtil;
@Autowired
private 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());
}
}
}4. 排行榜查询接口(技术亮点:Caffeine 本地缓存、多级降级、异常捕获)
@RestController
@RequestMapping("/rank")
public class RankController {
@Autowired
private RankRedisUtil rankRedisUtil;
// 技术亮点:Caffeine本地缓存,过期时间3秒,最大容量1000,扛住热点查询
private final LoadingCache<String, List<RankItem>> top100Cache = Caffeine.newBuilder()
.expireAfterWrite(3, TimeUnit.SECONDS)
.maximumSize(1000)
.build(key -> rankRedisUtil.getTopNWithScores(key, 100));
@GetMapping("/daily/top100")
public Result<List<RankItem>> 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"))));
}
}
}5. 定时任务兜底对账(保证数据不丢)
@XxlJob("rankingDailyRebuild")
public void rebuildDailyRanking() {
String today = LocalDate.now().toString();
// 从 MySQL 查出今日所有用户的分数变更
List<UserScore> scores = userDailyScoreMapper.selectTodayScores(today);
String redisKey = "rank:daily:" + today;
// 批量 ZADD 覆盖更新,保证与 DB 最终一致
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (UserScore us : scores) {
connection.zAdd(redisKey.getBytes(), us.getScore(),
us.getUserId().toString().getBytes());
}
return null;
});
}💡 亮点:用 Pipeline 批量 ZADD,几百上千万数据可以在几秒内完成,每日对账丝般顺滑。
6. 查询 Top N + 带本地缓存
@Cacheable(value = "dailyTop", key = "#n", unless = "#result.size() == 0")
public List<RankItem> getTopN(int n) {
String today = LocalDate.now().toString();
Set<ZSetOperations.TypedTuple<String>> topSet =
redisTemplate.opsForZSet()
.reverseRangeWithScores("rank:daily:" + today, 0, n - 1);
return topSet.stream().map(t ->
new RankItem(t.getValue(), t.getScore().intValue())
).collect(Collectors.toList());
}💡 亮点:Spring Cache + Caffeine,本地缓存 30 秒,回源压力大幅下降,同时允许短时不一致,对排行榜体验几乎无感。
7. 分片支持(亿级用户)
public void incrScoreWithShard(long userId, double score, String date) {
int shard = (int) (userId % 10); // 10个分片
String key = String.format("rank:daily:%s:shard%d", date, shard);
redisTemplate.opsForZSet().incrementScore(key, String.valueOf(userId), score);
}
// 查询时需合并10个分片的前N名,再取最终TopN💡 亮点:分片后单个 ZSet 成员数量可控,避免大 key 问题,查询时多路并行,再用堆排序合并。
核心技术难点与解决方案(大厂必问 🎯)
| 技术难点 | 问题描述 | 技术痛点 | 解决方案 | 实现要点 |
|---|---|---|---|---|
| 每日 0 点重置的数据一致性 | 跨天请求可能写入旧 key,导致数据丢失;多实例定时任务重复执行重置 | 数据不一致、重复归档、排行榜清空 | 1. 延迟 1 秒执行重置 2. Redis RENAME 原子操作 3. 分布式锁防并发 | 用RENAME而非DEL+CREATE;锁超时时间大于归档时间;增加兜底检查任务每小时校验 key |
| 千万级用户 ZSet 性能瓶颈 | 单 ZSet 成员超过 1000 万时,插入 / 查询性能下降 30%+,内存占用激增 | Redis CPU 飙升、响应超时、OOM 风险 | 按积分段分桶存储 | 1. 将积分划分为多个区间(如 0-100、100-500、500+) 2. 查询 TopN 时从高到低遍历桶,直到取够数量 3. 插入时根据积分自动路由到对应桶 |
| 高并发 "我的排名" 查询 | ZREVRANK是 O (logN) 复杂度,百万用户下 10 万 QPS 会打垮 Redis | Redis CPU 打满、接口超时 | 1. 排名分层展示 2. 本地缓存用户排名 | 1. 前 1000 名显示具体排名,1000 + 统一显示 "1000+" 2. 对用户排名做 5 秒本地缓存 3. 限制单用户查询频率 |
| 积分更新的顺序性与幂等性 | 消息重复消费导致积分多算;消息乱序导致积分计算错误 | 用户积分不准确、排行榜数据错误 | 1. 消息 ID 幂等去重 2. 按用户 ID 分区保证顺序 | 1. 用 Redis setIfAbsent做幂等,过期 24 小时 2. MQ 发送时按用户 ID 哈希分区 3. 消费失败重试不超过 3 次 |
| 历史排行榜高效查询 | 归档到 MySQL 后,查询历史 TopN 需要全表扫描,性能差 | 历史排行榜查询慢、数据库压力大 | 1. MySQL 分表 + 联合索引 2. 冷数据归档到 ClickHouse | 1. 按日期分表,索引:(date, rank) 2. 超过 3 个月的历史数据迁移到 ClickHouse 3. 历史 Top100 预计算缓存 |
| 热点排行榜缓存击穿 | Top10/Top100 是绝对热点,缓存过期瞬间大量请求穿透到 Redis | Redis QPS 突增、响应变慢 | 1. 缓存永不过期 + 后台异步更新 2. 互斥锁防击穿 | 1. 本地缓存永不过期,后台每 3 秒主动更新 2. 缓存失效时用 Redisson 互斥锁只允许一个请求更新 Redis |
| Redis 热点大 Key 🔥 | 当日排行榜 ZSet 成为全局唯一热点 Key,所有读写请求都打在同一个 Key 上 | Redis 单节点 CPU 100%,整个集群雪崩,影响所有业务 | 1. 热点 Key 分片拆分 2. 读写分离 + 从节点负载均衡 3. 多级缓存兜底 | 1. 将同一个排行榜拆分为 16 个分片 ZSet,按用户 ID 哈希写入 2. 读请求全部路由到 Redis 从节点,写请求走主节点 3. 本地缓存 + Redis 缓存 + MySQL 三级缓存 |
| 凌晨零点瞬时大流量 🌊 | 每日 0 点排行榜重置瞬间,千万用户同时刷新,产生 10 倍以上瞬时流量洪峰 | 缓存雪崩,所有请求穿透到 Redis,导致服务和数据库雪崩 | 1. 提前预热缓存 2. 流量削峰 + 随机过期 3. 多级降级开关 | 1. 0 点前 5 分钟预创建当日空排行榜并预热本地缓存 2. API 网关层令牌桶限流,本地缓存过期加 1-5 秒随机偏移 3. 极端情况降级返回静态 Top100 或昨日排行榜 |
| 消息乱序 / 重复消费 🔄 | MQ 网络抖动导致消息重发或乱序,同一用户的积分更新先后顺序颠倒 | 用户最终积分计算错误,排行榜数据失真 | 1. 按用户 ID 分区保证顺序 2. 版本号乐观锁控制 3. 每日全量数据校验 | 1. 发送消息时按用户 ID 哈希到同一个 MQ 分区 2. 每条消息带递增版本号,只处理版本号大于当前记录的消息 3. 每日凌晨归档前对比 MySQL 和 Redis 积分,修正不一致数据 |
| 大批量用户排名查询 📊 | 运营后台需要批量导出 10 万 + 用户排名,直接调用ZREVRANK会导致 Redis 阻塞 | Redis 单线程阻塞,影响所有线上用户的正常访问 | 1. 离线预计算排名 2. 从节点隔离查询 3. 分页批量导出 | 1. 每日凌晨归档时预计算所有用户排名并存储到 MySQL 2. 所有批量查询请求强制路由到 Redis 从节点 3. 导出接口限制单次最多 1000 条,分页查询 |
| Redis 宕机数据丢失 💣 | Redis 主节点宕机,从节点切换后丢失部分未同步的积分更新数据 | 当日排行榜数据部分丢失,用户积分清零,引发大规模客诉 | 1. RDB+AOF 混合持久化 2. MySQL 兜底存储 3. 自动数据恢复脚本 | 1. 开启 Redis RDB+AOF 混合持久化,AOF 刷盘策略设为 everysec 2. 所有积分更新先写 MySQL 再异步写 Redis 3. Redis 恢复后,自动从 MySQL 同步当日所有积分更新重建排行榜 |
延伸难点:百万并发查询如何不崩?
- 客户端 + CDN 缓存榜单结果 30s(即使排名有 30 秒延迟,用户几乎无感)。
- 服务端返回的榜单 JSON 开启 Gzip 压缩,减小带宽。
- 熔断降级:如果 Redis 查询超时,直接返回本地缓存里的上一次结果,保证服务可用。
这样一套组合拳下来,每日排行榜既能满足千万用户、毫秒级响应,又不会因为极端情况数据错乱。面试时把代码和难点表格亮出来,绝对是加分项。💪
