在业务开发中,排行榜是一个高频需求——游戏中的战力榜、电商的销量榜、内容平台的点赞榜、社交产品的热度榜……看似简单的“排序展示”,背后却藏着数据量与实时性的博弈。很多团队初期用“数据库排序”快速上线,却在用户量突破10万、100万后遭遇性能雪崩;也有团队盲目上分布式方案,导致架构复杂度过高,运维成本激增。
本文将基于业务场景演进,从“单机小数据”到“分布式大数据”,详细拆解6种排行榜实现方案的底层原理、实操步骤、性能瓶颈与优化技巧,并结合真实案例说明“不同阶段该选哪种方案”,帮你避开从设计到落地的所有坑。
一、先明确核心诉求:排行榜设计的3个关键维度
在选择方案前,必须先理清业务的核心诉求——不同诉求对应完全不同的技术选型,这是避免“过度设计”或“设计不足”的关键。
1. 数据规模:你的排行榜要存多少用户?
- 小数据(万级以下):如内部管理系统的部门业绩榜、小型工具的用户积分榜;
- 中数据(十万-百万级):如中型电商的单品销量榜、区域游戏的战力榜;
- 大数据(千万级以上):如国民级游戏的全服榜、头部电商的全平台销量榜。
2. 实时性要求:排名多久更新一次?
- 非实时(分钟/小时级延迟):如日销量榜、周热度榜,用户可接受“数据延迟1小时”;
- 准实时(秒-分钟级延迟):如游戏实时战力榜、直播平台的礼物榜,用户希望“几分钟内看到排名变化”;
- 实时(秒级更新):如秒杀活动的实时销量榜、社交平台的实时话题榜,需“用户操作后立即更新排名”。
3. 业务复杂度:排名规则有多复杂?
- 简单规则:仅按单一维度排序(如“按分数降序”“按销量降序”);
- 复杂规则:多维度加权(如“热度=点赞数0.6 + 评论数0.3 + 分享数*0.1”)、时间衰减(如“旧内容的分数随时间降低”)、特殊规则(如“相同分数按达成时间排序”)。
二、方案一:数据库直接排序(万级以下,非实时)
这是最直观的方案——直接用SQL对数据库表进行排序,适合数据量小、实时性要求低的场景(如内部系统的业绩榜、个人项目的积分榜)。
1. 基础实现:SQL排序+LIMIT分页
(1)表结构设计
核心是“用户ID+排序字段”,需为排序字段建立索引(否则会全表扫描,性能极差):
-- 用户分数表(适合简单排行榜)
CREATE TABLE `user_score` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
`user_id` VARCHAR(64) NOT NULL COMMENT '用户唯一标识',
`score` DOUBLE NOT NULL COMMENT '排序分数(如战力、销量)',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '分数更新时间',
UNIQUE KEY `uk_user_id` (`user_id`) COMMENT '用户ID唯一,避免重复',
KEY `idx_score_update_time` (`score` DESC, `update_time` ASC) COMMENT '按分数降序、更新时间升序(相同分数按时间排序)'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT '用户分数表(用于排行榜)';
- 索引设计:
score DESC + update_time ASC覆盖排序需求,避免“Using filesort”(文件排序); - 唯一键:
user_id唯一,确保一个用户只占一行,更新分数时用UPDATE而非INSERT。
(2)核心查询代码
@Service
public class DbRankingService {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 获取Top N排行榜(前100名)
*/
public List<UserScoreDTO> getTopRanking(int topN) {
// SQL:走索引idx_score_update_time,避免全表扫描
String sql = "SELECT user_id, score FROM user_score " +
"ORDER BY score DESC, update_time ASC " +
"LIMIT ?";
// 映射结果(避免ORM框架的额外开销,小数据场景JDBC更轻量)
return jdbcTemplate.query(sql, new Object[]{topN},
(rs, rowNum) -> new UserScoreDTO(
rs.getString("user_id"),
rs.getDouble("score")
)
);
}
/**
* 获取用户个人排名(含分数)
*/
public UserRankDTO getUserRank(String userId) {
// 1. 先查当前用户的分数
Double userScore = jdbcTemplate.queryForObject(
"SELECT score FROM user_score WHERE user_id = ?",
new Object[]{userId},
Double.class
);
if (userScore == null) {
return new UserRankDTO(userId, 0, 0.0); // 无数据,排名0
}
// 2. 查“分数比当前用户高”的用户数量 = 排名(+1是因为排名从1开始)
Long rank = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM user_score " +
"WHERE score > ? OR (score = ? AND update_time < ?)",
new Object[]{userScore, userScore,
jdbcTemplate.queryForObject("SELECT update_time FROM user_score WHERE user_id = ?",
new Object[]{userId}, String.class)},
Long.class
);
return new UserRankDTO(userId, rank + 1, userScore);
}
}
2. 底层原理:索引如何加速排序?
数据库排序的性能瓶颈在于“是否走索引”:
- 走索引:
idx_score_update_time是“有序索引”,数据库直接按索引顺序读取数据,无需额外排序,执行时间通常在10ms以内(万级数据); - 全表扫描:若未建索引,数据库需读取所有数据到内存,用“快速排序”算法排序,10万条数据执行时间会超过1秒,100万条数据可能超时。
3. 实操优化:避开3个常见坑
(1)深分页性能问题(LIMIT 10000, 100)
当需要查询“第10001-10100名”时,用LIMIT 10000, 100会导致数据库先扫描前10100条数据,再丢弃前10000条,性能极差:
-- 错误:深分页,10万条数据时执行时间可能超过500ms
SELECT user_id, score FROM user_score ORDER BY score DESC LIMIT 10000, 100;
-- 优化:用“主键+索引”定位,避免全表扫描
-- 前提:记录上次查询的最后一条数据的score和update_time
SELECT user_id, score FROM user_score
WHERE score < ? OR (score = ? AND update_time > ?)
ORDER BY score DESC, update_time ASC
LIMIT 100;
原理:通过条件过滤直接定位到“上次查询的位置”,只扫描100条数据,执行时间降至10ms以内。
(2)索引维护成本
当数据更新频繁(如每秒100次score更新),索引会频繁分裂和重组,导致数据库CPU和IO占用升高。解决方案:
- 非实时场景:将“实时更新”改为“批量更新”,如每10分钟更新一次score;
- 索引选择:若更新频率极高,可放弃索引,改用“定时任务+缓存”(方案二)。
(3)数据量上限
当数据量超过10万条时,即使走索引,排序和分页的性能也会明显下降:
- 10万条数据:
LIMIT 100执行时间约50ms; - 100万条数据:
LIMIT 100执行时间约200ms,已接近用户可感知的延迟(200ms是体验临界点)。
4. 优缺点总结
| 优点 | 缺点 |
|---|---|
| 实现简单,无需额外组件 | 数据量>10万时性能骤降 |
| 代码维护成本低,SQL易于调试 | 深分页性能差,需额外优化 |
| 天然支持持久化,数据不丢失 | 高并发下数据库压力大,易成为瓶颈 |
三、方案二:缓存+定时任务(十万-百万级,准实时)
当数据量增长到十万级,数据库排序的性能已无法满足高并发查询(如每秒1000次排行榜查询),此时需引入缓存(如Redis)存储排序结果,用定时任务异步更新缓存,降低数据库压力。
1. 核心思路:“定时全量更新+缓存查询”
- 更新链路:定时任务(如每1分钟)从数据库查询Top N数据,全量写入Redis;
- 查询链路:用户查询排行榜时,直接从Redis读取,无需访问数据库。
2. 基础实现:Spring Scheduled + Redis
(1)定时任务更新缓存
@Service
@EnableScheduling // 启用定时任务
public class CacheRankingService {
@Autowired
private UserScoreDao userScoreDao;
@Autowired
private StringRedisTemplate redisTemplate;
// 分布式锁:避免多实例定时任务并发执行(如部署3台机器,只让1台执行更新)
@Autowired
private RedissonClient redissonClient;
// 缓存Key:排行榜Top 1000(按业务需求调整Top N大小)
private static final String RANKING_KEY = "ranking:top:1000";
// 定时任务间隔:1分钟(可根据实时性需求调整,如5分钟、30秒)
private static final long UPDATE_INTERVAL = 60 * 1000;
/**
* 定时更新缓存(分布式环境下确保唯一执行)
*/
@Scheduled(fixedRate = UPDATE_INTERVAL)
public void updateRankingCache() {
// 1. 分布式锁:key=定时任务名称,过期时间=更新间隔*2(避免死锁)
RLock lock = redissonClient.getLock("lock:ranking:update");
try {
// 尝试获取锁,最多等待10秒,100秒后自动释放
if (lock.tryLock(10, 100, TimeUnit.SECONDS)) {
// 2. 从数据库查询Top 1000(用分页查询,避免一次性加载过多数据)
List<UserScoreDTO> topList = userScoreDao.queryTopN(1000);
// 3. 全量更新Redis缓存(先删除旧数据,再写入新数据,避免脏读)
redisTemplate.delete(RANKING_KEY);
if (!topList.isEmpty()) {
// 用Redis List存储,顺序即排名(index 0是第1名)
List<String> rankValues = topList.stream()
.map(dto -> dto.getUserId() + ":" + dto.getScore()) // 格式:userId:score
.collect(Collectors.toList());
redisTemplate.opsForList().rightPushAll(RANKING_KEY, rankValues);
}
log.info("排行榜缓存更新完成,共{}条数据", topList.size());
}
} catch (InterruptedException e) {
log.error("获取分布式锁失败", e);
} finally {
// 释放锁(确保锁一定被释放)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 从缓存查询Top N排行榜
*/
public List<UserScoreDTO> getTopRanking(int topN) {
// 1. 从Redis List获取前topN条数据
List<String> rankValues = redisTemplate.opsForList().range(RANKING_KEY, 0, topN - 1);
if (rankValues == null || rankValues.isEmpty()) {
// 缓存未命中,降级查询数据库(避免返回空)
return userScoreDao.queryTopN(topN);
}
// 2. 解析数据(userId:score)
return rankValues.stream()
.map(value -> {
String[] parts = value.split(":");
return new UserScoreDTO(parts[0], Double.parseDouble(parts[1]));
})
.collect(Collectors.toList());
}
}
(2)数据库DAO层优化:分批查询避免OOM
当Top N是1000时,一次性查询1000条数据没问题;但如果是10万条,一次性加载到内存会导致OOM,需分批查询:
@Repository
public class UserScoreDao {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 分批查询Top N数据(避免OOM)
*/
public List<UserScoreDTO> queryTopN(int topN) {
List<UserScoreDTO> result = new ArrayList<>(topN);
int batchSize = 1000; // 每批查询1000条
int offset = 0;
while (offset < topN) {
int limit = Math.min(batchSize, topN - offset);
String sql = "SELECT user_id, score FROM user_score " +
"ORDER BY score DESC, update_time ASC " +
"LIMIT ?, ?";
List<UserScoreDTO> batch = jdbcTemplate.query(sql,
new Object[]{offset, limit},
(rs, rowNum) -> new UserScoreDTO(
rs.getString("user_id"),
rs.getDouble("score")
)
);
if (batch.isEmpty()) {
break; // 无更多数据,退出循环
}
result.addAll(batch);
offset += batchSize;
}
return result;
}
}
3. 进阶优化:解决3个核心问题
(1)缓存更新的“增量优化”
全量更新的问题:当数据量达100万时,每次查询1000条数据需扫描大量行,数据库压力大。解决方案:增量更新——记录上次更新的“最大score”,只查询比这个score大的用户:
// 新增:记录上次更新的最大score(用Redis存储)
private static final String LAST_MAX_SCORE_KEY = "ranking:last:max:score";
public void updateRankingCache() {
// ... 省略分布式锁 ...
// 1. 获取上次更新的最大score(初始为0)
String lastMaxScoreStr = redisTemplate.opsForValue().get(LAST_MAX_SCORE_KEY);
double lastMaxScore = lastMaxScoreStr == null ? 0.0 : Double.parseDouble(lastMaxScoreStr);
// 2. 增量查询:只查score > lastMaxScore 的用户(减少数据库扫描行数)
List<UserScoreDTO> newTopList = userScoreDao.queryIncremental(lastMaxScore, 1000);
if (!newTopList.isEmpty()) {
// 3. 合并旧缓存和新数据(保持Top 1000)
List<UserScoreDTO> oldTopList = getTopRanking(1000);
oldTopList.addAll(newTopList);
// 去重+排序+截取Top 1000
List<UserScoreDTO> mergedList = oldTopList.stream()
.collect(Collectors.toMap(
UserScoreDTO::getUserId,
Function.identity(),
(a, b) -> b.getScore() > a.getScore() ? b : a // 相同用户保留高分
))
.values().stream()
.sorted((a, b) -> {
if (b.getScore() != a.getScore()) {
return Double.compare(b.getScore(), a.getScore());
}
return a.getUpdateTime().compareTo(b.getUpdateTime()); // 相同分数按时间排序
})
.limit(1000)
.collect(Collectors.toList());
// 4. 更新缓存和上次最大score
redisTemplate.delete(RANKING_KEY);
// ... 写入缓存 ...
// 更新上次最大score(取合并后列表的第一个元素的score)
double newMaxScore = mergedList.get(0).getScore();
redisTemplate.opsForValue().set(LAST_MAX_SCORE_KEY, String.valueOf(newMaxScore));
}
}
(2)缓存穿透与击穿
- 缓存穿透:查询“不存在的用户排名”,导致请求穿透到数据库。解决方案:用布隆过滤器过滤不存在的userId,或缓存“空结果”(如排名0);
- 缓存击穿:缓存过期时,大量请求同时查询同一用户的排名,导致数据库压力骤增。解决方案:用互斥锁(如Redis的SETNX),只让一个请求去数据库查询,其他请求等待。
(3)多实例缓存一致性
当部署多台应用服务器时,本地缓存(如Caffeine)会导致“不同实例缓存不一致”。解决方案:
- 优先用Redis缓存(分布式共享),避免本地缓存;
- 若用本地缓存,需在Redis缓存更新后,通过消息队列(如RabbitMQ)通知所有实例刷新本地缓存。
4. 优缺点总结
| 优点 | 缺点 |
|---|---|
| 减轻数据库压力,查询性能高(Redis响应<10ms) | 数据有延迟(取决于定时任务间隔) |
| 实现相对简单,无需复杂组件 | 增量更新实现复杂,需处理数据合并 |
| 支持多实例共享缓存 | 高并发更新时,定时任务可能成为瓶颈 |
四、方案三:Redis有序集合(百万级,准实时)
当数据量达百万级,且需要“实时更新排名”(如用户分数变化后立即更新排名),Redis的有序集合(ZSet) 是最优选择。ZSet底层基于“跳表+哈希表”,支持O(logN)的插入、删除和排序,天生适合做排行榜。
1. ZSet底层原理:为什么适合排序?
ZSet的核心是“键(member)+分数(score)”,底层用两种结构实现:
- 哈希表:存储“member→score”的映射,快速获取某个member的score(O(1));
- 跳表:按score排序存储member,支持快速插入、删除和范围查询(O(logN))。
跳表的优势:相比红黑树,跳表的插入、删除逻辑更简单,且支持“范围查询”(如获取score在100-200之间的member),这是红黑树难以高效实现的。
2. 基础实现:ZSet核心API
(1)核心操作代码
@Service
public class RedisZSetRankingService {
@Autowired
private StringRedisTemplate redisTemplate;
// ZSet的Key(排行榜名称,如“game:ranking:202405”表示2024年5月的游戏榜)
private static final String RANKING_ZSET_KEY = "game:ranking:202405";
/**
* 1. 添加/更新用户分数(实时更新)
* - 若用户已存在:更新score
* - 若用户不存在:新增用户
*/
public void updateUserScore(String userId, double score) {
// ZADD:添加member到ZSet,score为排序依据
// 第三个参数“NX”表示“仅新增”,“XX”表示“仅更新”,这里用默认(新增或更新)
redisTemplate.opsForZSet().add(RANKING_ZSET_KEY, userId, score);
}
/**
* 2. 获取Top N排行榜(按score降序,取前N名)
*/
public List<UserRankDTO> getTopRanking(int topN) {
// ZREVRANGE:按score降序获取指定范围的member(0=第1名,topN-1=第N名)
// withScores=true:同时返回score
Set<ZSetOperations.TypedTuple<String>> typedTuples =
redisTemplate.opsForZSet().reverseRangeWithScores(RANKING_ZSET_KEY, 0, topN - 1);
if (typedTuples == null || typedTuples.isEmpty()) {
return Collections.emptyList();
}
// 解析结果,生成排名(index+1=排名)
List<UserRankDTO> result = new ArrayList<>(topN);
int rank = 1;
for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
result.add(new UserRankDTO(
tuple.getValue(), // userId
rank++, // 排名(从1开始)
tuple.getScore() // score
));
}
return result;
}
/**
* 3. 获取用户个人排名(含并列排名处理)
*/
public UserRankDTO getUserRank(String userId) {
// 3.1 获取用户的score(判断是否存在)
Double score = redisTemplate.opsForZSet().score(RANKING_ZSET_KEY, userId);
if (score == null) {
return new UserRankDTO(userId, 0, 0.0); // 无数据,排名0
}
// 3.2 获取“score > 当前用户score”的用户数量 = 排名(+1是因为排名从1开始)
// ZCOUNT:统计score在指定区间的member数量
Long higherCount = redisTemplate.opsForZSet().count(
RANKING_ZSET_KEY,
ScoreRange.fromString(String.valueOf(score + 1e-9)) // 避免浮点精度问题,+1e-9
.toPositiveInfinity()
);
// 3.3 处理并列排名(获取与当前用户score相同的用户数量)
Long sameScoreCount = redisTemplate.opsForZSet().count(
RANKING_ZSET_KEY,
ScoreRange.fromString(String.valueOf(score - 1e-9))
.toString(String.valueOf(score + 1e-9))
);
return new UserRankDTO(
userId,
higherCount + 1, // 最终排名
score,
sameScoreCount.intValue() // 并列人数(可选)
);
}
/**
* 4. 按分数区间查询用户(如获取score在1000-2000之间的用户)
*/
public List<UserRankDTO> getUsersByScoreRange(double minScore, double maxScore) {
// ZREVRANGEBYSCORE:按score降序获取区间内的member
Set<ZSetOperations.TypedTuple<String>> typedTuples =
redisTemplate.opsForZSet().reverseRangeByScoreWithScores(
RANKING_ZSET_KEY,
minScore,
maxScore
);
if (typedTuples == null) {
return Collections.emptyList();
}
// 解析结果(排名需要单独计算)
return typedTuples.stream()
.map(tuple -> {
Long rank = redisTemplate.opsForZSet().reverseRank(RANKING_ZSET_KEY, tuple.getValue()) + 1;
return new UserRankDTO(
tuple.getValue(),
rank.intValue(),
tuple.getScore()
);
})
.collect(Collectors.toList());
}
}
(2)关键API解析
| API | 用途 | 时间复杂度 |
|---|---|---|
ZADD key score member |
添加/更新member的score | O(logN) |
ZREVRANGE key start stop [WITHSCORES] |
按score降序获取区间member | O(logN + K),K=返回数量 |
ZREVRANK key member |
获取member的排名(降序,从0开始) | O(logN) |
ZSCORE key member |
获取member的score | O(1) |
ZCOUNT key min max |
统计score在[min,max]的member数量 | O(logN) |
3. 实操优化:避开ZSet的5个坑
(1)score的精度问题(double类型陷阱)
double类型有精度丢失风险,比如0.1 + 0.2 = 0.30000000000000004,导致分数计算错误。解决方案:将score转为整数存储,如乘以100(保留2位小数)或1000(保留3位小数):
// 错误:直接用double累加
double score = 0.1;
score += 0.2; // 变成0.30000000000000004
// 正确:用long存储整数(乘以100,保留2位小数)
long scoreInCent = 10; // 0.10
scoreInCent += 20; // 30 → 0.30
// 使用时转为double:scoreInCent / 100.0
redisTemplate.opsForZSet().add(RANKING_ZSET_KEY, userId, scoreInCent / 100.0);
(2)ZSet的内存占用优化
百万级member的ZSet会占用大量内存,需优化存储:
- userId精简:用整数ID(如Long)代替字符串ID(如“user_123”),每个member可节省10-20字节;
- 分数压缩:如用整数存储(见上文),避免double的额外开销;
- 过期清理:历史排行榜(如“202404月榜”)可设置过期时间(
EXPIRE key 2592000,30天过期),释放内存。
内存占用估算:每个ZSet元素(Long userId + double score)约占用24字节,100万元素约需24MB,1000万元素约需240MB,单机Redis完全可承受。
(3)并列排名的正确处理
ZSet的ZREVRANK会给相同score的member分配不同排名(按插入顺序),但业务中通常需要“并列排名”(如两个用户都是90分,排名都是第1)。解决方案:
- 先查“分数高于当前用户”的数量(
ZCOUNT key score+1 +inf),这是排名的起始值; - 再查“分数等于当前用户”的数量(
ZCOUNT key score-eps score+eps,eps是精度值),即并列人数。
(4)大数量分页查询优化
当需要查询“第10000-10100名”时,ZREVRANGE 10000 10100的性能会下降(需遍历到第10000个元素)。解决方案:用ZREVRANGEBYSCORE按分数区间查询,而非按索引:
// 错误:深分页,性能差
Set<String> users = redisTemplate.opsForZSet().reverseRange(RANKING_ZSET_KEY, 10000, 10100);
// 正确:先获取第10000名的score,再查分数<=该score的用户
Double scoreOf10000 = redisTemplate.opsForZSet()
.score(RANKING_ZSET_KEY,
redisTemplate.opsForZSet().reverseRange(RANKING_ZSET_KEY, 10000, 10000).iterator().next()
);
// 查分数<=scoreOf10000的用户,取前100
Set<String> users = redisTemplate.opsForZSet()
.reverseRangeByScore(RANKING_ZSET_KEY, 0, scoreOf10000, 0, 100);
(5)ZSet的持久化与高可用
单机Redis的ZSet存在数据丢失风险,需配置持久化和高可用:
- 持久化:开启RDB+AOF混合持久化(RDB做全量备份,AOF做增量备份),确保重启后数据不丢失;
- 高可用:搭建Redis主从复制(1主2从)+哨兵(Sentinel),主节点故障时自动切换到从节点,避免排行榜不可用。
4. 优缺点总结
| 优点 | 缺点 |
|---|---|
| 实时更新排名,响应速度快(O(logN)) | 单机Redis内存有限(千万级以上需分片) |
| 支持复杂查询(分数区间、并列排名) | score精度需特殊处理,避免double陷阱 |
| 无需数据库,高并发下性能稳定 | 持久化和高可用配置复杂 |
五、方案四:分片+Redis集群(千万级,高并发)
当数据量突破千万级,单机Redis的内存和性能会达到瓶颈(如1000万元素的ZSet约需240MB内存,但若每个userId是长字符串,可能达数GB),此时需用Redis集群+分片实现水平扩展。
1. 核心思路:分片存储,分布式排序
将排行榜按“用户ID分片”,每个分片对应一个Redis节点,查询时:
- 单分片查询:若只需某个分片的Top N(如“华东区排行榜”),直接查询对应分片;
- 全局排行榜:聚合所有分片的Top N数据,再排序得到全局Top N(如“全服排行榜”)。
2. 分片策略选择:哈希分片 vs 范围分片
(1)哈希分片(推荐)
按用户ID的哈希值分配到不同分片,实现负载均衡:
@Service
public class ShardedRankingService {
@Autowired
private RedissonClient redissonClient;
// 分片数量:16(需与Redis集群节点数匹配,避免数据倾斜)
private static final int SHARD_COUNT = 16;
// 排行榜前缀(每个分片的Key=前缀+分片ID)
private static final String RANKING_SHARD_PREFIX = "game:ranking:shard:";
/**
* 1. 计算用户ID对应的分片(哈希分片)
*/
private int getShardId(String userId) {
// 一致性哈希:避免分片扩容时数据迁移过多(Redisson已内置)
// 简化版:用userId的哈希值取模,实际推荐用一致性哈希
return Math.abs(userId.hashCode()) % SHARD_COUNT;
}
/**
* 2. 添加/更新用户分数(按分片存储)
*/
public void updateUserScore(String userId, double score) {
int shardId = getShardId(userId);
String shardKey = RANKING_SHARD_PREFIX + shardId;
// Redisson的RScoredSortedSet:封装ZSet操作,支持分布式
RScoredSortedSet<String> zSet = redissonClient.getScoredSortedSet(shardKey);
zSet.add(score, userId);
}
/**
* 3. 获取单分片的Top N(如“分片0的Top 100”)
*/
public List<UserRankDTO> getShardTopRanking(int shardId, int topN) {
if (shardId < 0 || shardId >= SHARD_COUNT) {
throw new IllegalArgumentException("分片ID无效");
}
String shardKey = RANKING_SHARD_PREFIX + shardId;
RScoredSortedSet<String> zSet = redissonClient.getScoredSortedSet(shardKey);
// 按score降序取前N名
Collection<String> topUsers = zSet.valueRangeReversed(0, topN - 1);
List<UserRankDTO> result = new ArrayList<>(topN);
int rank = 1;
for (String userId : topUsers) {
Double score = zSet.getScore(userId);
result.add(new UserRankDTO(userId, rank++, score));
}
return result;
}
/**
* 4. 获取全局Top N(聚合所有分片的Top N,再排序)
*/
public List<UserRankDTO> getGlobalTopRanking(int topN) {
// 1. 并行查询所有分片的Top N(提高效率)
ExecutorService executor = Executors.newFixedThreadPool(SHARD_COUNT);
List<Future<List<UserRankDTO>>> futures = new ArrayList<>(SHARD_COUNT);
for (int i = 0; i < SHARD_COUNT; i++) {
int shardId = i;
futures.add(executor.submit(() -> getShardTopRanking(shardId, topN)));
}
// 2. 聚合所有分片的结果
List<UserRankDTO> allCandidates = new ArrayList<>(SHARD_COUNT * topN);
for (Future<List<UserRankDTO>> future : futures) {
try {
allCandidates.addAll(future.get());
} catch (InterruptedException | ExecutionException e) {
log.error("查询分片排行榜失败", e);
}
}
executor.shutdown();
// 3. 去重+排序+截取全局Top N
return allCandidates.stream()
.collect(Collectors.toMap(
UserRankDTO::getUserId,
Function.identity(),
(a, b) -> b.getScore() > a.getScore() ? b : a // 相同用户保留高分
))
.values().stream()
.sorted((a, b) -> Double.compare(b.getScore(), a.getScore()))
.limit(topN)
.collect(Collectors.toList());
}
}
(2)范围分片(适合按业务分区)
按用户ID的范围分配分片(如userId以1-10000开头的用户分到分片0,10001-20000分到分片1),适合“按区域/等级分区”的排行榜(如“新手区排行榜”“老区排行榜”)。
3. Redis集群部署:Redisson vs Redis Cluster
(1)Redisson分片(简化版集群)
Redisson支持客户端分片,无需搭建Redis Cluster,适合中小团队:
- 配置多个Redis节点,Redisson客户端自动按分片策略分配数据;
- 支持动态扩容:新增分片时,需手动迁移数据(或用一致性哈希减少迁移量)。
(2)Redis Cluster(推荐,大规模场景)
Redis官方集群方案,支持自动分片(16384个Slot)、主从复制和故障转移:
- Slot分配:每个Redis节点负责一部分Slot,用户ID的哈希值映射到Slot,再由Slot找到对应的节点;
- 全局排行榜优化:用Redis Cluster的
ASK/MOVED命令,自动路由到目标节点,聚合查询时无需客户端手动分片; - 动态扩容:支持在线添加节点,自动迁移Slot和数据,无需停机。
4. 跨分片查询的解决方案
全局排行榜需要聚合所有分片的数据,这是分片方案的核心难点,有两种优化思路:
(1)预计算全局Top N
- 定时任务(如每5分钟)查询所有分片的Top 1000,聚合后存入“全局排行榜ZSet”;
- 用户查询全局排行榜时,直接读取这个预计算的ZSet,避免实时聚合的性能开销。
(2)增量聚合
- 每个分片维护“分片Top 100”,并将这些数据同步到一个“聚合节点”;
- 聚合节点实时维护全局Top 1000,用户查询时直接从聚合节点获取。
5. 优缺点总结
| 优点 | 缺点 |
|---|---|
| 水平扩展能力强,支持千万级以上数据 | 跨分片查询复杂,全局排行榜需预计算 |
| 高并发下性能稳定,单分片负载低 | 架构复杂,需维护Redis集群和分片策略 |
| 支持业务分区(如区域榜、等级榜) | 数据迁移和扩容成本高 |
六、方案五:预计算+分层缓存(读多写少,高访问量)
当排行榜“更新频率低但访问量极高”(如日销量榜、周热度榜,每天更新一次,但每秒有1000次查询),需用“预计算+分层缓存”进一步提升性能,减少Redis压力。
1. 核心思路:“本地缓存+Redis+预计算”三层架构
- 预计算:每天凌晨用离线任务计算排行榜结果,避免实时计算;
- 分层缓存:本地缓存(如Caffeine)→ Redis → 数据库,优先从上层缓存读取,减少下层压力。
2. 基础实现:Caffeine本地缓存 + Redis + 定时预计算
(1)预计算任务(离线计算排行榜)
@Service
public class PrecomputeRankingService {
@Autowired
private UserScoreDao userScoreDao;
@Autowired
private StringRedisTemplate redisTemplate;
// 本地缓存(Caffeine:高性能本地缓存,命中率高于Guava Cache)
private final LoadingCache<String, Integer> localRankCache = Caffeine.newBuilder()
.maximumSize(100_000) // 本地缓存最大10万条(存储热点用户的排名)
.expireAfterWrite(30, TimeUnit.MINUTES) // 30分钟过期
.build(this::loadRankFromRedis); // 缓存未命中时,从Redis加载
// Redis哈希表Key:存储用户ID→排名(日销量榜)
private static final String RANKING_HASH_KEY = "ranking:daily:hash";
// Redis List Key:存储Top 1000排行榜
private static final String RANKING_TOP_LIST_KEY = "ranking:daily:top1000";
/**
* 1. 每日凌晨1点预计算排行榜(离线任务)
*/
@Scheduled(cron = "0 0 1 * * ?") // 每天1点执行
public void precomputeDailyRanking() {
log.info("开始预计算每日排行榜");
long start = System.currentTimeMillis();
// 1. 从数据库查询所有用户的分数,排序后生成排名
List<UserScoreDTO> allUsers = userScoreDao.queryAllSorted(); // 按score降序查询所有用户
Map<String, Integer> userIdToRank = new HashMap<>(allUsers.size());
List<String> top1000 = new ArrayList<>(1000);
int rank = 1;
double lastScore = -1;
int sameScoreCount = 1;
for (int i = 0; i < allUsers.size(); i++) {
UserScoreDTO user = allUsers.get(i);
// 处理并列排名
if (i > 0 && user.getScore() == lastScore) {
sameScoreCount++;
} else {
rank = i + 1;
lastScore = user.getScore();
sameScoreCount = 1;
}
// 存储用户→排名映射
userIdToRank.put(user.getUserId(), rank);
// 记录Top 1000
if (i < 1000) {
top1000.add(user.getUserId() + ":" + user.getScore());
}
}
// 2. 写入Redis(哈希表存储所有用户排名,List存储Top 1000)
redisTemplate.delete(RANKING_HASH_KEY);
redisTemplate.delete(RANKING_TOP_LIST_KEY);
// 批量写入哈希表(效率高于多次HSET)
redisTemplate.opsForHash().putAll(RANKING_HASH_KEY, userIdToRank);
// 写入Top 1000列表
redisTemplate.opsForList().rightPushAll(RANKING_TOP_LIST_KEY, top1000);
// 3. 清空本地缓存(触发重新加载)
localRankCache.invalidateAll();
log.info("预计算每日排行榜完成,耗时{}ms,共{}个用户",
System.currentTimeMillis() - start, allUsers.size());
}
/**
* 2. 从本地缓存获取用户排名(优先)
*/
public Integer getUserRank(String userId) {
try {
// 本地缓存命中:直接返回(O(1),响应<1ms)
return localRankCache.get(userId);
} catch (Exception e) {
log.error("本地缓存获取排名失败,降级到Redis", e);
// 本地缓存失效,直接从Redis获取
return loadRankFromRedis(userId);
}
}
/**
* 3. 从Redis加载用户排名(本地缓存未命中时调用)
*/
private Integer loadRankFromRedis(String userId) {
Object rankObj = redisTemplate.opsForHash().get(RANKING_HASH_KEY, userId);
if (rankObj == null) {
// Redis未命中,降级到数据库(仅在预计算失败时触发)
return userScoreDao.getUserRank(userId);
}
return (Integer) rankObj;
}
/**
* 4. 从Redis获取Top 1000排行榜(本地缓存不存Top列表,避免内存占用过高)
*/
public List<UserScoreDTO> getTop1000Ranking() {
List<String> topList = redisTemplate.opsForList().range(RANKING_TOP_LIST_KEY, 0, 999);
if (topList == null || topList.isEmpty()) {
return Collections.emptyList();
}
return topList.stream()
.map(value -> {
String[] parts = value.split(":");
return new UserScoreDTO(parts[0], Double.parseDouble(parts[1]));
})
.collect(Collectors.toList());
}
}
(2)Caffeine本地缓存的优势
- 高命中率:采用“Window TinyLfu”淘汰策略,适合热点数据(如前10万用户的排名查询);
- 低延迟:本地内存访问,响应时间<1ms,远快于Redis的网络请求(10-20ms);
- 线程安全:支持并发访问,无需额外加锁。
3. 优化技巧:热点数据与缓存更新
(1)热点用户的本地缓存优先
将查询频率高的“热点用户”(如Top 1000用户)优先存入本地缓存,减少Redis请求:
// 预计算时,将Top 1000用户的排名直接写入本地缓存
for (int i = 0; i < Math.min(1000, allUsers.size()); i++) {
UserScoreDTO user = allUsers.get(i);
localRankCache.put(user.getUserId(), userIdToRank.get(user.getUserId()));
}
(2)缓存更新的通知机制
当预计算任务更新Redis后,需通知所有应用实例刷新本地缓存,避免“本地缓存不一致”:
- 用消息队列(如RabbitMQ)发送“缓存更新通知”;
- 应用实例收到通知后,调用
localRankCache.invalidateAll()清空本地缓存。
4. 优缺点总结
| 优点 | 缺点 |
|---|---|
| 访问性能极高(本地缓存<1ms) | 数据实时性差(每日更新,无法实时反映变化) |
| 大幅减轻Redis和数据库压力 | 预计算任务资源消耗大(全量查询所有用户) |
| 适合读多写少场景,扩展性强 | 本地缓存占用内存,多实例需同步更新 |
七、方案六:实时计算+流处理(千万级,实时)
当业务需要“秒级实时更新”且数据量极大(如社交平台的实时话题榜、秒杀活动的实时销量榜),传统方案已无法满足,需用流处理框架(如Flink) 实时计算排名。
1. 核心架构:Kafka + Flink + Redis/TiDB
- 数据源:用户行为数据(如点赞、购买、得分)实时写入Kafka;
- 实时计算:Flink消费Kafka数据,实时计算用户分数和排名;
- 结果存储:将实时排名写入Redis(供查询)或TiDB(分布式数据库,供持久化)。
2. 基础实现:Flink实时计算排行榜
(1)Flink状态管理:存储用户分数
Flink用MapState存储每个用户的当前分数,支持实时更新:
public class UserScoreProcessFunction extends ProcessFunction<UserAction, Tuple2<String, Double>> {
// 存储用户ID→当前分数的状态(Flink State:故障恢复时数据不丢失)
private MapState<String, Double> userScoreState;
@Override
public void open(Configuration parameters) throws Exception {
// 初始化MapState(用RocksDB作为State后端,支持大状态)
MapStateDescriptor<String, Double> stateDesc = new MapStateDescriptor<>(
"userScoreState",
BasicTypeInfo.STRING_TYPE_INFO,
BasicTypeInfo.DOUBLE_TYPE_INFO
);
userScoreState = getRuntimeContext().getMapState(stateDesc);
}
/**
* 处理每条用户行为数据(如“用户A购买了商品,得分+10”)
*/
@Override
public void processElement(UserAction action, Context ctx, Collector<Tuple2<String, Double>> out) throws Exception {
// 1. 计算新增分数(根据行为类型:购买+10,点赞+1,评论+2)
double addScore = getScoreByActionType(action.getType());
// 2. 更新用户当前分数(从State读取旧分数,累加后写入)
String userId = action.getUserId();
double oldScore = userScoreState.getOrDefault(userId, 0.0);
double newScore = oldScore + addScore;
userScoreState.put(userId, newScore);
// 3. 输出用户ID和新分数(供下游计算排名)
out.collect(Tuple2.of(userId, newScore));
}
/**
* 根据行为类型获取分数
*/
private double getScoreByActionType(ActionType type) {
switch (type) {
case PURCHASE: return 10.0;
case LIKE: return 1.0;
case COMMENT: return 2.0;
default: return 0.0;
}
}
}
(2)Flink窗口与排名计算
用“滑动窗口”实时维护Top N排名(如每5秒更新一次Top 1000):
public class RealTimeRankingJob {
public static void main(String[] args) throws Exception {
// 1. 创建Flink执行环境
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 启用Checkpoint(每10秒一次,故障恢复时恢复State)
env.enableCheckpointing(10000);
env.getCheckpointConfig().setCheckpointStorage("hdfs:///flink/checkpoints/");
// 2. 从Kafka读取用户行为数据
Properties kafkaProps = new Properties();
kafkaProps.setProperty("bootstrap.servers", "kafka:9092");
kafkaProps.setProperty("group.id", "flink-ranking-group");
DataStream<UserAction> actionStream = env.addSource(
new FlinkKafkaConsumer<>("user-actions", new UserActionSchema(), kafkaProps)
);
// 3. 实时计算用户分数(调用上文的ProcessFunction)
DataStream<Tuple2<String, Double>> scoreStream = actionStream
.keyBy(UserAction::getUserId) // 按用户ID分组
.process(new UserScoreProcessFunction());
// 4. 全局窗口计算Top 1000(每5秒更新一次)
DataStream<List<UserRankDTO>> rankingStream = scoreStream
.keyBy(t -> "global") // 全局分组(计算全服排名)
.window(TumblingProcessingTimeWindows.of(Time.seconds(5))) // 5秒滚动窗口
.process(new TopNRankingProcessFunction(1000)); // 计算Top 1000
// 5. 将排名结果写入Redis(供查询)
rankingStream.addSink(new RedisRankingSink());
// 6. 执行任务
env.execute("Real-Time Ranking Job");
}
}
/**
* 计算窗口内的Top N排名
*/
class TopNRankingProcessFunction extends ProcessWindowFunction<
Tuple2<String, Double>, // 输入:(userId, score)
List<UserRankDTO>, // 输出:Top N排名列表
String, // Key类型:全局分组(固定为"global")
TimeWindow // 窗口类型
> {
private final int topN;
public TopNRankingProcessFunction(int topN) {
this.topN = topN;
}
@Override
public void process(String key, Context context,
Iterable<Tuple2<String, Double>> elements,
Collector<List<UserRankDTO>> out) {
// 1. 收集窗口内所有用户的分数(去重,保留最高分)
Map<String, Double> userIdToScore = new HashMap<>();
for (Tuple2<String, Double> element : elements) {
String userId = element.f0;
double score = element.f1;
// 保留用户的最高分
userIdToScore.put(userId, Math.max(userIdToScore.getOrDefault(userId, 0.0), score));
}
// 2. 排序并截取Top N
List<UserRankDTO> topList = userIdToScore.entrySet().stream()
.sorted((a, b) -> Double.compare(b.getValue(), a.getValue()))
.limit(topN)
.map(entry -> new UserRankDTO(
entry.getKey(),
// 计算排名(从1开始)
(int) userIdToScore.entrySet().stream()
.filter(e -> e.getValue() > entry.getValue())
.count() + 1,
entry.getValue()
))
.collect(Collectors.toList());
// 3. 输出Top N排名
out.collect(topList);
}
}
(3)结果写入Redis:供业务查询
class RedisRankingSink extends RichSinkFunction<List<UserRankDTO>> {
private StringRedisTemplate redisTemplate;
@Override
public void open(Configuration parameters) {
// 初始化Redis连接(实际项目中用连接池)
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration("redis", 6379);
redisTemplate = new StringRedisTemplate(new JedisConnectionFactory(config));
redisTemplate.afterPropertiesSet();
}
@Override
public void invoke(List<UserRankDTO> value, Context context) {
// 写入Redis ZSet(实时排行榜)
String zSetKey = "ranking:real-time:top1000";
redisTemplate.delete(zSetKey);
for (UserRankDTO rankDTO : value) {
redisTemplate.opsForZSet().add(zSetKey, rankDTO.getUserId(), rankDTO.getScore());
}
}
}
3. 关键技术点:确保实时性与可靠性
(1)Flink State后端选择
- 内存State:适合小状态(如10万用户),速度快但故障后数据丢失;
- RocksDB State:适合大状态(千万级用户),数据持久化到磁盘,故障后可恢复,支持增量Checkpoint。
(2)窗口策略选择
- 滚动窗口:固定时间间隔更新(如每5秒),适合“周期性更新”的排行榜;
- 滑动窗口:重叠时间窗口(如每1秒更新,窗口大小5秒),适合“近实时”的排行榜,更新更频繁。
(3)Exactly-Once语义
确保用户行为数据不重复计算、不丢失:
- Kafka消费者开启“自动提交offset”,配合Flink的Checkpoint,实现offset与State的一致性;
- RedisSink用“原子操作”(如
ZADD覆盖旧数据),避免重复写入。
4. 优缺点总结
| 优点 | 缺点 |
|---|---|
| 秒级实时更新,满足高实时性需求 | 架构复杂,需维护Kafka、Flink、Redis集群 |
| 可处理千万级以上数据,支持复杂计算逻辑 | 运维成本高,需专业流处理团队 |
| 故障可恢复,数据可靠性高(Checkpoint) | 资源消耗大(Flink需大量CPU和内存) |
八、方案对比与选型指南
| 方案 | 数据量 | 实时性 | 复杂度 | 运维成本 | 适用场景 |
|---|---|---|---|---|---|
| 数据库直接排序 | 万级以下 | 低(分钟级) | 低 | 低 | 内部系统、个人项目、小流量应用 |
| 缓存+定时任务 | 十万-百万级 | 中(分钟级) | 中 | 中 | 中小型电商、区域游戏、可接受延迟的排行榜 |
| Redis有序集合 | 百万级 | 高(秒级) | 中 | 中 | 大型游戏、高频更新的排行榜(如战力榜) |
| 分片+Redis集群 | 千万级以上 | 高(秒级) | 高 | 高 | 超大型游戏全服榜、头部电商全平台销量榜 |
| 预计算+分层缓存 | 百万-千万级 | 中(小时级) | 高 | 中 | 读多写少的排行榜(如日销量榜、周热度榜) |
| 实时计算+流处理 | 千万级以上 | 实时(秒级) | 极高 | 极高 | 社交平台实时话题榜、秒杀实时销量榜 |
选型优先级:
- 先看数据量:万级选方案一,十万-百万级选方案二/三,千万级以上选方案四/六;
- 再看实时性:非实时选方案一/二/五,准实时选方案三/四,实时选方案六;
- 最后看团队能力:小团队优先方案一/二/三,有分布式经验选方案四,有流处理经验选方案六。
九、实战案例:游戏排行榜的演进之路
以一款游戏的战力榜为例,看如何从初期到成熟期选择方案:
- 初期(内测,1万用户):用方案一(数据库直接排序),快速上线,无需额外组件;
- 中期(公测,10万用户):切换到方案二(缓存+定时任务),1分钟更新一次,减轻数据库压力;
- 成熟期(正式运营,100万用户):升级到方案三(Redis ZSet),实时更新战力,支持玩家实时查看排名;
- 爆发期(全服1000万用户):扩容到方案四(分片+Redis集群),按服务器分片,同时维护“单服榜”和“全服榜”;
- 巅峰期(多端运营,5000万用户):引入方案六(Flink实时计算),支持跨服战实时战力榜,秒级更新。
十、总结:没有最好,只有最适合
排行榜的设计没有“银弹”,关键是匹配业务需求与技术成本:
- 小业务别过度设计,数据库+简单缓存就能满足需求;
- 中业务优先用Redis ZSet,平衡性能与复杂度;
- 大业务需提前规划架构,分片、流处理等技术要尽早储备。
最后,无论选择哪种方案,都要做好性能测试与监控:
- 性能测试:用JMeter压测排行榜查询QPS,确保峰值时响应时间<100ms;
- 监控指标:数据库SQL执行时间、Redis ZSet操作耗时、缓存命中率、Flink任务延迟,及时发现瓶颈。
希望本文能帮你在实际业务中找到最适合的排行榜方案,避开所有坑!
除非注明,否则均为李锋镝的博客原创文章,转载必须以链接形式标明本文链接
文章评论