排行榜的五種方案!
引言
在工作的這些年中,我見(jiàn)證過(guò)太多團(tuán)隊(duì)在實(shí)現(xiàn)排行榜功能時(shí)踩過(guò)的坑。
今天我想和大家分享 6 種不同的排行榜實(shí)現(xiàn)方案,從簡(jiǎn)單到復(fù)雜,從單機(jī)到分布式,希望能幫助大家在實(shí)際工作中做出更合適的選擇。
有些小伙伴在工作中可能會(huì)覺(jué)得:不就是個(gè)排行榜嗎?搞個(gè)數(shù)據(jù)庫(kù)排序不就完了?
但實(shí)際情況遠(yuǎn)比這復(fù)雜得多。
當(dāng)數(shù)據(jù)量達(dá)到百萬(wàn)級(jí)、千萬(wàn)級(jí)時(shí),簡(jiǎn)單的數(shù)據(jù)庫(kù)查詢(xún)可能就會(huì)成為系統(tǒng)的瓶頸。
接下來(lái),我將為大家詳細(xì)剖析 6 種不同的實(shí)現(xiàn)方案,希望對(duì)你會(huì)有所幫助。
方案一:數(shù)據(jù)庫(kù)直接排序
適用場(chǎng)景:數(shù)據(jù)量小(萬(wàn)級(jí)以下),實(shí)時(shí)性要求不高
這是最簡(jiǎn)單直接的方案,幾乎每個(gè)開(kāi)發(fā)者最先想到的方法。
示例代碼如下:
public List<UserScore> getRankingList() {
String sql = "SELECT user_id, score FROM user_scores ORDER BY score DESC LIMIT 100";
return jdbcTemplate.query(sql, new UserScoreRowMapper());
}
優(yōu)點(diǎn):
- 實(shí)現(xiàn)簡(jiǎn)單
- 代碼維護(hù)成本低
- 適合數(shù)據(jù)量小的場(chǎng)景
缺點(diǎn):
- 數(shù)據(jù)量大時(shí)性能急劇下降
- 每次查詢(xún)都需要全表掃描
- 高并發(fā)下數(shù)據(jù)庫(kù)壓力大
架構(gòu)圖如下:
圖片
方案二:緩存+定時(shí)任務(wù)
適用場(chǎng)景:數(shù)據(jù)量中等(十萬(wàn)級(jí)),可以接受分鐘級(jí)延遲
這個(gè)方案在方案一的基礎(chǔ)上引入了緩存機(jī)制。
示例代碼如下:
@Scheduled(fixedRate = 60000) // 每分鐘執(zhí)行一次
public void updateRankingCache() {
List<UserScore> rankings = userScoreDao.getTop1000Scores();
redisTemplate.opsForValue().set("ranking_list", rankings);
}
public List<UserScore> getRankingList() {
return (List<UserScore>) redisTemplate.opsForValue().get("ranking_list");
}
優(yōu)點(diǎn):
- 減輕數(shù)據(jù)庫(kù)壓力
- 查詢(xún)速度快(O(1))
- 實(shí)現(xiàn)相對(duì)簡(jiǎn)單
缺點(diǎn):
- 數(shù)據(jù)有延遲(取決于定時(shí)任務(wù)頻率)
- 內(nèi)存占用較高
- 排行榜更新不及時(shí)
架構(gòu)圖如下:
圖片
方案三:Redis有序集合
適用場(chǎng)景:數(shù)據(jù)量大(百萬(wàn)級(jí)),需要實(shí)時(shí)更新
Redis的有序集合(Sorted Set)是實(shí)現(xiàn)排行榜的利器。
示例代碼如下:
public void addUserScore(String userId, double score) {
redisTemplate.opsForZSet().add("ranking", userId, score);
}
public List<String> getTopUsers(int topN) {
return redisTemplate.opsForZSet().reverseRange("ranking", 0, topN - 1);
}
public Long getUserRank(String userId) {
return redisTemplate.opsForZSet().reverseRank("ranking", userId) + 1;
}
優(yōu)點(diǎn):
- 高性能(O(log(N))時(shí)間復(fù)雜度)
- 支持實(shí)時(shí)更新
- 天然支持分頁(yè)
- 可以獲取用戶(hù)排名
缺點(diǎn):
- 單機(jī)Redis內(nèi)存有限
- 需要考慮Redis持久化
- 分布式環(huán)境下需要額外處理
架構(gòu)圖如下:
圖片
方案四:分片+Redis集群
適用場(chǎng)景:超大規(guī)模數(shù)據(jù)(千萬(wàn)級(jí)以上),高并發(fā)場(chǎng)景
當(dāng)單機(jī)Redis無(wú)法滿(mǎn)足需求時(shí),可以采用分片方案。
示例代碼如下:
//
public void addUserScore(String userId, double score) {
RScoredSortedSet<String> set = redisson.getScoredSortedSet("ranking:" + getShard(userId));
set.add(score, userId);
}
private String getShard(String userId) {
// 簡(jiǎn)單哈希分片
int shard = Math.abs(userId.hashCode()) % 16;
return "shard_" + shard;
}
在這里我們以Redisson客戶(hù)端為例。
優(yōu)點(diǎn):
- 水平擴(kuò)展能力強(qiáng)
- 可以支持超大規(guī)模數(shù)據(jù)
- 高并發(fā)下性能穩(wěn)定
缺點(diǎn):
- 架構(gòu)復(fù)雜度高
- 跨分片查詢(xún)困難
- 需要維護(hù)分片策略
架構(gòu)圖如下:
圖片
方案五:預(yù)計(jì)算+分層緩存
適用場(chǎng)景:排行榜更新不頻繁,但訪(fǎng)問(wèn)量極大
這種方案結(jié)合了預(yù)計(jì)算和多級(jí)緩存。
示例代碼如下:
@Scheduled(cron = "0 0 * * * ?") // 每小時(shí)計(jì)算一次
public void precomputeRanking() {
Map<String, Integer> rankings = calculateRankings();
redisTemplate.opsForHash().putAll("ranking:hourly", rankings);
// 同步到本地緩存
localCache.putAll(rankings);
}
public Integer getUserRank(String userId) {
// 1. 先查本地緩存
Integer rank = localCache.get(userId);
if (rank != null) return rank;
// 2. 再查Redis
rank = (Integer) redisTemplate.opsForHash().get("ranking:hourly", userId);
if (rank != null) {
localCache.put(userId, rank); // 回填本地緩存
return rank;
}
// 3. 最后查DB
return userScoreDao.getUserRank(userId);
}
優(yōu)點(diǎn):
- 訪(fǎng)問(wèn)性能極高(本地緩存O(1))
- 減輕Redis壓力
- 適合讀多寫(xiě)少場(chǎng)景
缺點(diǎn):
- 數(shù)據(jù)實(shí)時(shí)性差
- 預(yù)計(jì)算資源消耗大
- 實(shí)現(xiàn)復(fù)雜度高
架構(gòu)圖如下:
圖片
方案六:實(shí)時(shí)計(jì)算+流處理
適用場(chǎng)景:需要實(shí)時(shí)更新且數(shù)據(jù)量極大的社交平臺(tái)
這種方案采用流處理技術(shù)實(shí)現(xiàn)實(shí)時(shí)排行榜。
使用Apache Flink示例如下:
DataStream<UserAction> actions = env.addSource(new UserActionSource());
DataStream<Tuple2<String, Double>> scores = actions
.keyBy(UserAction::getUserId)
.process(new ProcessFunction<UserAction, Tuple2<String, Double>>() {
private MapState<String, Double> userScores;
public void open(Configuration parameters) {
MapStateDescriptor<String, Double> descriptor =
new MapStateDescriptor<>("userScores", String.class, Double.class);
userScores = getRuntimeContext().getMapState(descriptor);
}
public void processElement(UserAction action, Context ctx, Collector<Tuple2<String, Double>> out) {
double newScore = userScores.getOrDefault(action.getUserId(), 0.0) + calculateScore(action);
userScores.put(action.getUserId(), newScore);
out.collect(new Tuple2<>(action.getUserId(), newScore));
}
});
scores.keyBy(0)
.process(new RankProcessFunction())
.addSink(new RankingSink());
優(yōu)點(diǎn):
- 真正的實(shí)時(shí)更新
- 可處理超高并發(fā)
- 支持復(fù)雜計(jì)算邏輯
缺點(diǎn):
- 架構(gòu)復(fù)雜度高
- 運(yùn)維成本高
- 需要專(zhuān)業(yè)團(tuán)隊(duì)維護(hù)
架構(gòu)圖如下:
圖片
方案對(duì)比與選擇
方案 | 數(shù)據(jù)量 | 實(shí)時(shí)性 | 復(fù)雜度 | 適用場(chǎng)景 |
數(shù)據(jù)庫(kù)排序 | 小 | 低 | 低 | 個(gè)人項(xiàng)目、小規(guī)模應(yīng)用 |
緩存+定時(shí)任務(wù) | 中 | 中 | 中 | 中小型應(yīng)用,可接受延遲 |
Redis有序集合 | 大 | 高 | 中 | 大型應(yīng)用,需要實(shí)時(shí)更新 |
分片+Redis集群 | 超大 | 高 | 高 | 超大型應(yīng)用,超高并發(fā) |
預(yù)計(jì)算+分層緩存 | 大 | 中高 | 高 | 讀多寫(xiě)少,訪(fǎng)問(wèn)量極大 |
實(shí)時(shí)計(jì)算+流處理 | 超大 | 實(shí)時(shí) | 極高 | 社交平臺(tái),需要實(shí)時(shí)排名 |
總結(jié)
在選擇排行榜實(shí)現(xiàn)方案時(shí),我們需要綜合考慮以下幾個(gè)因素:
- 數(shù)據(jù)規(guī)模:數(shù)據(jù)量大小直接決定了我們選擇哪種方案
- 實(shí)時(shí)性要求:是否需要秒級(jí)更新,還是分鐘級(jí)甚至小時(shí)級(jí)都可以接受
- 并發(fā)量:系統(tǒng)的預(yù)期訪(fǎng)問(wèn)量是多少
- 開(kāi)發(fā)資源:團(tuán)隊(duì)是否有足夠的技術(shù)能力維護(hù)復(fù)雜方案
- 業(yè)務(wù)需求:排行榜的計(jì)算邏輯是否復(fù)雜
對(duì)于大多數(shù)中小型應(yīng)用,方案二(緩存+定時(shí)任務(wù))或方案三(Redis有序集合)已經(jīng)足夠。如
果業(yè)務(wù)增長(zhǎng)迅速,可以逐步演進(jìn)到方案四(分片+Redis集群)。
而對(duì)于社交平臺(tái)等需要實(shí)時(shí)更新的場(chǎng)景,則需要考慮方案五(預(yù)計(jì)算+分層緩存)或方案六(實(shí)時(shí)計(jì)算+流處理),但要做好技術(shù)儲(chǔ)備和架構(gòu)設(shè)計(jì)。
最后,無(wú)論選擇哪種方案,都要做好監(jiān)控和性能測(cè)試。排行榜作為高頻訪(fǎng)問(wèn)的功能,其性能直接影響用戶(hù)體驗(yàn)。
建議在實(shí)際環(huán)境中進(jìn)行壓測(cè),根據(jù)測(cè)試結(jié)果調(diào)整方案。
希望這六種方案的詳細(xì)解析能幫助大家在工作中做出更合適的選擇。
記住,沒(méi)有最好的方案,只有最適合的方案。