億級排行榜設計

問題

假如有 10 億的用戶,每個用戶有自己的分數 (score),請你設計一個排行榜,可以根據用戶的分數進行排名,每個用戶可以知道自己在排行榜中的排名情況。

分析

一說到排行榜,我們的第一反應肯定是使用 Redis 的 ZSET 結構實現,那麼使用 ZSET 如何實現對 10 億用戶進行排序?

ZSET 單 key

第一種思路,使用 ZSET 單 key,也就是將 10 億用戶都放入一個 ZSET key 中,簡單明瞭,這樣設計是最簡單的,但查詢的性能是否可以接受?我們來計算一下:

ZSET 結構使用 SkipList 實現,查詢的時間複雜度:ZREVRANK:O(log(N))

查詢單個排名的理論時間複雜度:O(log(10^9)) ≈ 30 次比較

內存佔用分析:

public class PerformanceAnalysis {
    // 1. 內存佔用(假設每個成員)
    private static final long MEMORY_PER_MEMBER = 
        + 8    // score (double)
        + 20   // member (假設userId字符串)
        + 8    // 指針
        + 32;  // 跳錶節點開銷
    
    // 10億用戶預估內存
    private static final long TOTAL_MEMORY = 
        MEMORY_PER_MEMBER * 1_000_000_000L; // 約68GB
        
    // 2. 跳錶層數
    private static final int SKIP_LIST_LEVELS = 
        (int) Math.log(1_000_000_000L) / Math.log(2); // 約30層
}

假定每個成員對象約 68 字節,那麼 10 億用戶約需要 68GB 內存,當然實際場景下,可以考慮對單個成員對象再進行壓縮,內存佔用可以再小一點。

如果僅從查詢的效率考慮,如果僅查詢單個成員的排名,Redis 的查詢耗時是不用擔心的,但如果要進行範圍查詢,那麼性能則非常不樂觀了。

在實際的生產環境中,如此大的單 Key 我們一般稱爲 GodKey 或者 BigKey,是需要極力避免的,我們都知道 Redis 是純內存的數據庫,執行命令是單線程執行的,對於內存操作是非常的快的,但是如果單個 Key 值特別大時,容易阻塞線程,影響其他命令的執行,同時對於 Redis 服務器的網卡也是一個巨大的挑戰,試想如果對於 68G 的 ZSET Key 進行範圍查詢的話,如果 1W QPS,Redis 怕是直接會被打爆。

如此看來,這個使用單 Key 存儲全部的用戶數據,應該行不通的。

ZSET 多 Key

“一個籬笆三個樁,一個好漢三個幫”,那麼既然一個 key 搞不定,那我們就分多個 key 就好了吧?那麼我們該如何將 10 億用戶存儲到多個 key 中呢?

正常第一反應,我們就搞 10000 個 key,然後使用 userId 取模,路由到對應的子 key 就好了吧?但是我們的需求是,需要可以知道每個用戶在排行榜中的排名,顯然這種路由的方式,是無法滿足需求的。

拆分多個子 key,根據某種規則路由的思路是完全沒問題的,不能根據 userId 路由,那我們可否換一種思路,以其他維度進行路由?

換一個思路,如果以分數作爲分桶邏輯,初始化 N 個分桶,每個分桶的劃定範圍以分數區間劃定,當新用戶獲得分數後,根據其分數路由到所在的分桶中。

那麼,用戶的排名該如何計算?

計算用戶的排名,我們肯定已經知道用戶所在的分桶,可以直接獲得在當前分桶中的排名,同時比當前分桶分數大的分桶名稱我們肯定也可以知道,那麼將所有的前面的分桶中的總數量累加,再加上當前分桶的排名,就是該用戶的總排名。

問題

綜上,多個子 key 也就是數據分桶的邏輯,基本上是可以滿足我們的需求的,但是還有一個問題,就是數據分佈不均勻的問題,我們設定的多個分桶,但是每個分桶中的數據量一定不是均勻的,可能會出現數據傾斜的問題,如何解決?

對此,較爲簡單的處理方式,當某個數據桶的數據量超過一定的閾值後,可以進行拆分,縮小分桶的數值範圍,分割成兩個子桶,如此循環往復,直到無法再切分爲止。

實現 demo

下面是 Claude 給出的代碼實現 Demo,邏輯可能不嚴謹,僅作參考:

import lombok.Data;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

public class ScoreBucketRankingSystem {

    @Data
    public class UserScore {
        private long userId;
        private double score;
        private long timestamp;
    }

    @Data
    public class RankInfo {
        private long userId;
        private double score;
        private long rank;
        private String bucketKey;
    }

    @Data
    public class ScoreBucket {
        private String bucketKey;
        private double minScore;
        private double maxScore;
        private long userCount;
    }

    private final RedisTemplate<String, String> redisTemplate;
    private final String BUCKET_PREFIX = "rank:bucket:";
    private final String BUCKET_META_KEY = "rank:bucket:meta";
    
    // 緩存分桶信息
    private final ConcurrentHashMap<String, ScoreBucket> bucketCache = new ConcurrentHashMap<>();
    
    // 初始化分桶配置
    private final List<ScoreBucket> initBuckets = new ArrayList<>() ;

    public ScoreBucketRankingSystem(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
        initBuckets();
    }

    // 初始化分桶
    private void initBuckets() {
        for (ScoreBucket bucket : initBuckets) {
            String bucketKey = BUCKET_PREFIX + bucket.getBucketKey();
            bucketCache.put(bucketKey, bucket);
            // 存儲分桶元信息
            redisTemplate.opsForHash().put(
                BUCKET_META_KEY,
                bucketKey,
                JSON.toJSONString(bucket)
            );
        }
    }

    // 更新用戶分數
    public void updateScore(long userId, double newScore) {
        // 1. 找到用戶當前所在的分桶
        String oldBucketKey = findUserCurrentBucket(userId);
        
        // 2. 計算用戶應該在的分桶
        String newBucketKey = calculateBucketKey(newScore);
        
        // 3. 如果分桶發生變化,需要遷移
        if (oldBucketKey != null && !oldBucketKey.equals(newBucketKey)) {
            moveUserToBucket(userId, oldBucketKey, newBucketKey, newScore);
        } else {
            // 4. 直接更新分數
            updateUserScore(userId, newBucketKey, newScore);
        }
    }

    // 查找用戶當前所在的分桶
    private String findUserCurrentBucket(long userId) {
        for (String bucketKey : bucketCache.keySet()) {
            Double score = redisTemplate.opsForZSet().score(bucketKey, String.valueOf(userId));
            if (score != null) {
                return bucketKey;
            }
        }
        return null;
    }

    // 根據分數計算應該所在的分桶
    private String calculateBucketKey(double score) {
        for (ScoreBucket bucket : bucketCache.values()) {
            if (score >= bucket.getMinScore() && score <= bucket.getMaxScore()) {
                return BUCKET_PREFIX + bucket.getBucketKey();
            }
        }
        throw new IllegalArgumentException("Score out of range: " + score);
    }

    // 將用戶從舊分桶移動到新分桶
    private void moveUserToBucket(long userId, String oldBucketKey, String newBucketKey, double newScore) {
        redisTemplate.execute(new SessionCallback<List<Object>>() {
            @Override
            public List<Object> execute(RedisOperations operations) {
                operations.multi();
                
                // 從舊分桶刪除
                operations.opsForZSet().remove(oldBucketKey, String.valueOf(userId));
                
                // 更新分桶計數
                operations.opsForHash().increment(BUCKET_META_KEY, oldBucketKey + ":count", -1);
                operations.opsForHash().increment(BUCKET_META_KEY, newBucketKey + ":count", 1);
                
                // 添加到新分桶
                operations.opsForZSet().add(newBucketKey, String.valueOf(userId), newScore);
                
                return operations.exec();
            }
        });
    }

    // 更新用戶分數
    private void updateUserScore(long userId, String bucketKey, double newScore) {
        redisTemplate.opsForZSet().add(bucketKey, String.valueOf(userId), newScore);
    }

    // 獲取用戶排名信息
    public RankInfo getUserRankInfo(long userId) {
        String bucketKey = findUserCurrentBucket(userId);
        if (bucketKey == null) {
            return null;
        }

        Double score = redisTemplate.opsForZSet().score(bucketKey, String.valueOf(userId));
        Long rank = redisTemplate.opsForZSet().reverseRank(bucketKey, String.valueOf(userId));
        
        if (score == null || rank == null) {
            return null;
        }

        // 計算全局排名
        long globalRank = calculateGlobalRank(bucketKey, rank);

        RankInfo rankInfo = new RankInfo();
        rankInfo.setUserId(userId);
        rankInfo.setScore(score);
        rankInfo.setRank(globalRank);
        rankInfo.setBucketKey(bucketKey);
        
        return rankInfo;
    }

    // 計算全局排名
    private long calculateGlobalRank(String currentBucketKey, long rankInBucket) {
        long globalRank = rankInBucket;
        
        // 加上更高分數段的用戶數
        for (ScoreBucket bucket : bucketCache.values()) {
            String bucketKey = BUCKET_PREFIX + bucket.getBucketKey();
            if (bucket.getMaxScore() > bucketCache.get(currentBucketKey).getMaxScore()) {
                globalRank += bucket.getUserCount();
            }
        }
        
        return globalRank;
    }

    // 獲取排行榜區間
    public List<RankInfo> getRankRange(int start, int end) {
        List<RankInfo> result = new ArrayList<>();
        int currentPosition = 0;
        
        // 從高分到低分遍歷分桶
        for (ScoreBucket bucket : bucketCache.values()) {
            String bucketKey = BUCKET_PREFIX + bucket.getBucketKey();
            
            // 獲取分桶內的排名區間
            Set<ZSetOperations.TypedTuple<String>> ranksInBucket = 
                redisTemplate.opsForZSet().reverseRangeWithScores(
                    bucketKey, 
                    Math.max(0, start - currentPosition),
                    end - currentPosition
                );
                
            if (ranksInBucket != null) {
                for (ZSetOperations.TypedTuple<String> rank : ranksInBucket) {
                    if (currentPosition >= start && currentPosition <= end) {
                        RankInfo rankInfo = new RankInfo();
                        rankInfo.setUserId(Long.parseLong(rank.getValue()));
                        rankInfo.setScore(rank.getScore());
                        rankInfo.setRank(currentPosition + 1);
                        rankInfo.setBucketKey(bucketKey);
                        result.add(rankInfo);
                    }
                    currentPosition++;
                }
            }
            
            if (currentPosition > end) {
                break;
            }
        }
        
        return result;
    }

    // 獲取分桶統計信息
    public List<ScoreBucket> getBucketStats() {
        List<ScoreBucket> stats = new ArrayList<>();
        
        for (ScoreBucket bucket : bucketCache.values()) {
            String bucketKey = BUCKET_PREFIX + bucket.getBucketKey();
            Long count = redisTemplate.opsForZSet().zCard(bucketKey);
            
            ScoreBucket stat = new ScoreBucket();
            stat.setBucketKey(bucket.getBucketKey());
            stat.setMinScore(bucket.getMinScore());
            stat.setMaxScore(bucket.getMaxScore());
            stat.setUserCount(count != null ? count : 0);
            
            stats.add(stat);
        }
        
        return stats;
    }

    // 定期優化分桶
    public void optimizeBuckets() {
        List<ScoreBucket> stats = getBucketStats();
        
        // 檢查是否需要分裂或合併分桶
        for (ScoreBucket bucket : stats) {
            if (bucket.getUserCount() > 100_000_000) { // 1億用戶
                // 分裂分桶
                splitBucket(bucket);
            } else if (bucket.getUserCount() < 1_000_000) { // 100萬用戶
                // 考慮合併分桶
                mergeBucket(bucket);
            }
        }
    }

    // 分裂分桶
    private void splitBucket(ScoreBucket bucket) {
        double mid = (bucket.getMinScore() + bucket.getMaxScore()) / 2;
        
        ScoreBucket lowerBucket = new ScoreBucket();
        lowerBucket.setBucketKey(bucket.getBucketKey() + "_lower");
        lowerBucket.setMinScore(bucket.getMinScore());
        lowerBucket.setMaxScore(mid);
        
        ScoreBucket upperBucket = new ScoreBucket();
        upperBucket.setBucketKey(bucket.getBucketKey() + "_upper");
        upperBucket.setMinScore(mid + 1);
        upperBucket.setMaxScore(bucket.getMaxScore());
        
        // 重新分配用戶到新分桶
        redistributeUsers(bucket, lowerBucket, upperBucket);
    }

    // 合併分桶
    private void mergeBucket(ScoreBucket bucket) {
        // 實現合併邏輯
    }

    // 重新分配用戶
    private void redistributeUsers(ScoreBucket oldBucket, ScoreBucket lowerBucket, ScoreBucket upperBucket) {
        String oldBucketKey = BUCKET_PREFIX + oldBucket.getBucketKey();
        String lowerBucketKey = BUCKET_PREFIX + lowerBucket.getBucketKey();
        String upperBucketKey = BUCKET_PREFIX + upperBucket.getBucketKey();
        
        long cursor = 0;
        ScanOptions options = ScanOptions.scanOptions().count(1000).build();
        
        do {
            ScanResult<ZSetOperations.TypedTuple<String>> scanResult = 
                redisTemplate.opsForZSet().scan(oldBucketKey, options);
                
            for (ZSetOperations.TypedTuple<String> tuple : scanResult.getContent()) {
                String userId = tuple.getValue();
                double score = tuple.getScore();
                
                if (score <= lowerBucket.getMaxScore()) {
                    redisTemplate.opsForZSet().add(lowerBucketKey, userId, score);
                } else {
                    redisTemplate.opsForZSet().add(upperBucketKey, userId, score);
                }
            }
            
            cursor = scanResult.getCursor();
        } while (cursor != 0);
        
        // 更新分桶緩存
        bucketCache.put(lowerBucketKey, lowerBucket);
        bucketCache.put(upperBucketKey, upperBucket);
        bucketCache.remove(oldBucketKey);
        
        // 刪除舊分桶
        redisTemplate.delete(oldBucketKey);
    }
}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/RpSHmksqjCQbLgDUN-7eFQ