基于这些,我习惯从存储、缓存、计算、更新四个维度来设计。👇
需求类型 | 具体要求 |
|---|---|
基础功能 | 按用户积分 / 贡献值排序,展示 Top N(通常 Top100/Top500),每日 0 点自动重置 |
性能要求 | 排行榜查询 QPS≥10 万,写入延迟 < 50ms,支持百万级用户参与 |
一致性要求 | 最终一致性即可,允许 1-2 秒的延迟,不要求强一致 |
特殊要求 | 支持查看自己的排名,支持历史排行榜归档 |


💡 核心理念:写的时候异步累加分数到 Redis,查的时候直接读 Sorted Set,客户端可以再缓存一小会儿。
我们会有一张分数字表记录用户每日得分,方便对账和历史查询。
表名:`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 + ?,保证原子累加。
# 当日排行榜 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分) 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)),完全够用。

每天零点需要生成新的榜单。做法:
rank:daily:2026-06-02 可以保留 3~7 天,供历史查看。之后异步转存到 MySQL 归档表 ranking_archive,然后删除 Redis key。 🕒 小技巧:key 上带日期,可以平滑过渡,无需清空操作。
**关键优化**:
RENAME原子操作重置,避免数据丢失 
为什么用异步?:
然后由一个 排行榜消费者 做两件事:
UPDATE ... score = score + 5) ZINCRBY rank:daily:2026-06-03 5 1001(Redis 直接累加)✅ 优点:实时性很高,用户可以立刻看到自己排名变化。
❌ 缺点:消息乱序时需要幂等,MySQL 和 Redis 可能短暂不一致。
如果担心消息积压或 Redis 数据丢失,可以采用定时任务兜底:
ZADD 更新到 Redis(或增量 ZINCRBY 差值)。 
排行榜前100名是热点中的热点,可以再加一层本地缓存或 CDN 缓存。
/api/ranking/top?n=100&date=2026-06-03 查询 Redis,返回 JSON。 📊 压测表明,一个 4 核 Redis 实例,1000 万成员的 Sorted Set,ZREVRANGE top100 可以轻松抗住 10万+ QPS。
ZREVRANGE 0 -1全量扫描 异常场景 | 处理方案 |
|---|---|
Redis 宕机 | 降级为查询 MySQL 中当日积分前 100 的用户,同时告警,Redis 恢复后自动同步数据 |
定时任务执行失败 | 增加兜底检查任务,每小时检查当日 key 是否存在,不存在则重新创建 |
积分重复更新 | 消息消费时做幂等处理,用唯一消息 ID 去重 |
恶意刷分 | 在业务层做频率限制,单用户每分钟积分更新不超过 N 次 |
rank:daily:2026-06-03:shard0 到 shard9,查询时并行查多个分片再合并排序。 ConcurrentSkipListMap 做内存排行榜,定时从 DB 重建。 SETNX 或限流器控制单用户单行为频次,防止恶意刷榜。rank:daily:20260603:beijing“每日排行榜,我会用 Redis Sorted Set 存储当天排名,key 按日期隔离。写操作通过消息队列异步更新 Redis 和 MySQL,读操作直接从 Redis 取 Top N。用定时任务定期对账,保证数据一致性。每天零点自动切新 key,旧 key 留几天做历史查询。前端加一层短缓存,整个系统可以轻松支撑千万用户高并发。”
面试官,以上就是我对每日排行榜功能的整体设计方案,核心是利用 Redis ZSet 的特性解决排序问题,通过异步和缓存提升性能,同时做好异常兜底和扩展考虑。
面试官您好,我接下来补充这个方案的生产级核心代码和大厂高频追问的技术难点及解决方案,代码基于 Spring Boot 2.7+RedisTemplate+Redisson 实现,完全贴合互联网大厂技术栈。
@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;
});
}
}@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();
}
}
}
}@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());
}
}
}@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"))));
}
}
}@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,几百上千万数据可以在几秒内完成,每日对账丝般顺滑。
@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 秒,回源压力大幅下降,同时允许短时不一致,对排行榜体验几乎无感。
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,导致数据丢失;多实例定时任务重复执行重置 | 数据不一致、重复归档、排行榜清空 |
| 用RENAME而非DEL+CREATE;锁超时时间大于归档时间;增加兜底检查任务每小时校验 key |
千万级用户 ZSet 性能瓶颈 | 单 ZSet 成员超过 1000 万时,插入 / 查询性能下降 30%+,内存占用激增 | Redis CPU 飙升、响应超时、OOM 风险 | 按积分段分桶存储 |
|
高并发 "我的排名" 查询 | ZREVRANK是 O (logN) 复杂度,百万用户下 10 万 QPS 会打垮 Redis | Redis CPU 打满、接口超时 |
|
|
积分更新的顺序性与幂等性 | 消息重复消费导致积分多算;消息乱序导致积分计算错误 | 用户积分不准确、排行榜数据错误 |
|
|
历史排行榜高效查询 | 归档到 MySQL 后,查询历史 TopN 需要全表扫描,性能差 | 历史排行榜查询慢、数据库压力大 |
|
|
热点排行榜缓存击穿 | Top10/Top100 是绝对热点,缓存过期瞬间大量请求穿透到 Redis | Redis QPS 突增、响应变慢 |
|
|
Redis 热点大 Key 🔥 | 当日排行榜 ZSet 成为全局唯一热点 Key,所有读写请求都打在同一个 Key 上 | Redis 单节点 CPU 100%,整个集群雪崩,影响所有业务 |
|
|
凌晨零点瞬时大流量 🌊 | 每日 0 点排行榜重置瞬间,千万用户同时刷新,产生 10 倍以上瞬时流量洪峰 | 缓存雪崩,所有请求穿透到 Redis,导致服务和数据库雪崩 |
|
|
消息乱序 / 重复消费 🔄 | MQ 网络抖动导致消息重发或乱序,同一用户的积分更新先后顺序颠倒 | 用户最终积分计算错误,排行榜数据失真 |
|
|
大批量用户排名查询 📊 | 运营后台需要批量导出 10 万 + 用户排名,直接调用ZREVRANK会导致 Redis 阻塞 | Redis 单线程阻塞,影响所有线上用户的正常访问 |
|
|
Redis 宕机数据丢失 💣 | Redis 主节点宕机,从节点切换后丢失部分未同步的积分更新数据 | 当日排行榜数据部分丢失,用户积分清零,引发大规模客诉 |
|
|
这样一套组合拳下来,每日排行榜既能满足千万用户、毫秒级响应,又不会因为极端情况数据错乱。面试时把代码和难点表格亮出来,绝对是加分项。💪
【别走,交个朋友】
我是Rain ,一个喜欢把复杂技术讲透、让代码落地的实践者。
如果你厌倦了四处收集碎片化的八股文,想看看一线项目里真实的架构决策和踩坑记录,欢迎来我的公众号「Rain的Java大神之路」坐坐。
在这里,我会把每一次技术复盘、每一个项目的设计源码,毫无保留地分享给你。
🔥技术这条路很酷,我们结伴同行。
🚀Keep Coding, Keep Loving —— 与所有 Java 同路人共勉。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。