从零构建实时排行榜:Redis Sorted Set 与百万级用户场景实战

深入讲解如何使用 Redis Sorted Set 构建高性能实时排行榜系统,涵盖基础实现、复合评分策略、时间衰减算法、防刷机制及百万级用户扩展方案,附完整 Node.js 代码。

后端开发 2026-06-11 12 分钟

排行榜(Leaderboard)是互联网产品中最高频的交互模式之一——从游戏段位排名、电商销量榜单,到社交平台热榜、开发者贡献排行,几乎所有用户增长型产品都离不开它。据统计,展示排行榜的产品页面用户停留时间平均提升 37%,次日留存率提升 12%。但排行榜看似简单,实际在百万级用户、实时更新、复合评分等场景下,设计不当会导致数据库崩溃、排名计算延迟甚至数据不一致。

本文将从零开始,用 Redis Sorted Set 构建一套生产级实时排行榜系统,覆盖基础实现、高级评分策略、防刷机制和水平扩展方案,所有代码基于 Node.js + ioredis,可直接运行。

🏗️ 一、排行榜架构设计与 Redis Sorted Set 核心

为什么选 Redis Sorted Set?

排行榜的核心操作是:写入分数 → 查询排名 → 获取 Top N。这三个操作在不同存储方案下的性能差异巨大:

存储方案 写入分数 查询排名 获取 Top N 内存占用 适用规模
MySQL + ORDER BY O(log n) O(n) O(n log n) < 10 万
MySQL + 覆盖索引 O(log n) O(log n) O(log n + k) < 50 万
Redis Sorted Set O(log n) O(log n) O(log n + k) 中高 百万+
内存 HashMap + 定时排序 O(1) O(n) O(n log n) < 5 万

⚠️ **警告:**千万不要用 MySQL 直接做实时排行榜的排名查询。当用户量超过 10 万时,SELECT * FROM scores ORDER BY score DESC LIMIT 1000000, 10 这种查询会导致全表扫描,响应时间从毫秒退化到秒级。

Redis Sorted Set(有序集合)底层采用 跳表(Skip List)+ 哈希表 的双数据结构,所有核心操作都是 O(log n) 时间复杂度。这是排行榜场景的理想选择。

核心命令速查

// 基础操作 —— Redis Sorted Set 排行榜核心命令
const Redis = require('ioredis');
const redis = new Redis({ host: '127.0.0.1', port: 6379 });

// 1. 写入/更新分数 —— O(log n)
await redis.zadd('leaderboard:game1', 9500, 'player:1001');
await redis.zadd('leaderboard:game1', 8700, 'player:1002');
await redis.zadd('leaderboard:game1', 12000, 'player:1003');

// 2. 查询某用户排名(分数从高到低,0-based)—— O(log n)
const rank = await redis.zrevrank('leaderboard:game1', 'player:1001');
console.log(`玩家 1001 排名: 第 ${rank + 1} 名`);

// 3. 获取 Top N(含分数)—— O(log n + m),m 为返回数量
const top10 = await redis.zrevrange('leaderboard:game1', 0, 9, 'WITHSCORES');
console.log('Top 10:', top10);

// 4. 获取某用户分数 —— O(1)
const score = await redis.zscore('leaderboard:game1', 'player:1001');

// 5. 给某用户加分 —— O(log n)
await redis.zincrby('leaderboard:game1', 500, 'player:1001');

// 6. 查询分数区间内用户数 —— O(log n + m)
const count = await redis.zcount('leaderboard:game1', 8000, 10000);

💡 提示:ZADD 命令在 key 不存在时会自动创建 Sorted Set,无需预先初始化。但如果用户量预估超过 100 万,建议提前用 CONFIG SET zset-max-ziplist-entries 128 调整底层编码阈值。

完整排行榜类封装

下面是生产可用的排行榜实现,包含分数更新、排名查询、分页获取和用户附近排名:

// 排行榜核心类 —— 基于 Redis Sorted Set 的生产级实现
class Leaderboard {
  constructor(redisClient, keyPrefix = 'lb') {
    this.redis = redisClient;
    this.prefix = keyPrefix;
  }

  _key(board) {
    return `${this.prefix}:${board}`;
  }

  // 更新用户分数(增量模式)
  async updateScore(board, userId, delta) {
    const key = this._key(board);
    const newScore = await this.redis.zincrby(key, delta, userId);
    return parseFloat(newScore);
  }

  // 设置用户分数(绝对值模式)
  async setScore(board, userId, score) {
    const key = this._key(board);
    await this.redis.zadd(key, score, userId);
  }

  // 获取用户排名(1-based)和分数
  async getRank(board, userId) {
    const key = this._key(board);
    const [rank, score] = await Promise.all([
      this.redis.zrevrank(key, userId),
      this.redis.zscore(key, userId)
    ]);
    if (rank === null) return null;
    return { rank: rank + 1, score: parseFloat(score) };
  }

  // 获取 Top N(带用户详情扩展点)
  async getTopN(board, start = 0, count = 10) {
    const key = this._key(board);
    const results = await this.redis.zrevrange(key, start, start + count - 1, 'WITHSCORES');
    const entries = [];
    for (let i = 0; i < results.length; i += 2) {
      entries.push({
        userId: results[i],
        score: parseFloat(results[i + 1]),
        rank: start + i / 2 + 1
      });
    }
    return entries;
  }

  // 获取某用户附近的排名(前后各 N 名)
  async getNeighbors(board, userId, range = 5) {
    const rank = await this.redis.zrevrank(this._key(board), userId);
    if (rank === null) return [];
    const start = Math.max(0, rank - range);
    const end = rank + range;
    return this.getTopN(board, start, end - start + 1);
  }

  // 获取总参与人数
  async getTotal(board) {
    return this.redis.zcard(this._key(board));
  }

  // 删除用户
  async removeUser(board, userId) {
    return this.redis.zrem(this._key(board), userId);
  }
}

// 使用示例
const lb = new Leaderboard(redis, 'game:season1');
await lb.setScore('game:season1', 'player:1001', 9500);
await lb.updateScore('game:season1', 'player:1001', 500);
const info = await lb.getRank('game:season1', 'player:1001');
console.log(info); // { rank: 1, score: 10000 }

🧮 二、复合评分策略与时间衰减算法

单纯的分数累加会导致老用户永远占据榜首,新用户毫无出头机会。生产环境的排行榜需要更复杂的评分策略。

策略一:时间衰减(Time-Decay)排行榜

核心思想:越近的行为权重越高,越早的行为逐渐"衰减"。常用于热榜、活跃度排行。

// 时间衰减排行榜 —— 使用指数衰减函数让旧分数自然消退
class TimeDecayLeaderboard extends Leaderboard {
  constructor(redis, keyPrefix, options = {}) {
    super(redis, keyPrefix);
    // halfLife: 半衰期(秒),即经过这么长时间后分数减半
    this.halfLife = options.halfLife || 7 * 24 * 3600; // 默认 7 天
    this.decayRate = Math.log(2) / this.halfLife;
  }

  // 计算衰减后的复合分数
  // scoreBits: 原始分数(如点赞数、阅读量)
  // timestamp: 行为发生的 Unix 时间戳
  _computeDecayedScore(scoreBits, timestamp) {
    const now = Math.floor(Date.now() / 1000);
    const age = now - timestamp;
    // 指数衰减:score * e^(-λt)
    const decayFactor = Math.exp(-this.decayRate * Math.max(0, age));
    // 将时间信息编码进分数:高位存衰减后分数,低位存时间戳
    // 这样在分数相同时,更新的排在前面
    const decayedScore = scoreBits * decayFactor;
    // 使用 50 位存分数 + 14 位存时间倒数(保证新的排前面)
    const timeComponent = (now - timestamp) / (365 * 24 * 3600); // 归一化到 0~1
    return decayedScore * 1e14 + (1 - timeComponent) * 1e10;
  }

  // 添加带时间戳的行为
  async addEvent(board, userId, scoreBits, timestamp = Date.now()) {
    const key = this._key(board);
    // 使用 Lua 脚本保证原子性
    const luaScript = `
      local key = KEYS[1]
      local userId = ARGV[1]
      local scoreBits = tonumber(ARGV[2])
      local timestamp = tonumber(ARGV[3])
      local halfLife = tonumber(ARGV[4])
      local now = tonumber(ARGV[5])
      
      local age = now - timestamp
      local decayRate = math.log(2) / halfLife
      local decayFactor = math.exp(-decayRate * math.max(0, age))
      local decayedScore = scoreBits * decayFactor
      
      local timeComponent = age / (365 * 24 * 3600)
      local finalScore = decayedScore * 1e14 + (1 - timeComponent) * 1e10
      
      redis.call('ZADD', key, finalScore, userId)
      return finalScore
    `;
    const now = Math.floor(Date.now() / 1000);
    return this.redis.eval(luaScript, 1, key, userId, scoreBits,
      Math.floor(timestamp / 1000), this.halfLife, now);
  }

  // 定时刷新衰减分数(可选,Lazy 模式下不需要)
  async refreshDecay(board) {
    // 实际生产中用定时任务批量刷新,或用 Lua 脚本原子更新
    // 这里简化为全量刷新(大数据量时需要分批)
    const key = this._key(board);
    const members = await this.redis.zrange(key, 0, -1, 'WITHSCORES');
    // ... 批量重新计算衰减分数
  }
}

⚡ **关键结论:**指数衰减(Exponential Decay)是最常用的排行榜时间策略,Reddit Hot 排序和 Hacker News 排序算法都基于类似原理。半衰期设置为 7 天适合大多数场景,游戏赛季可缩短到 1-3 天。

策略二:复合评分公式

实际业务中,排行分数往往是多个维度的加权组合。以技术社区贡献度排行为例:

综合分 = 文章数 × 3 + 获赞数 × 1 + 收藏数 × 2 + 评论数 × 1.5 + 连续登录天数 × 5
// 复合评分排行榜 —— 多维度加权计算
class CompositeLeaderboard extends Leaderboard {
  // 权重配置
  static WEIGHTS = {
    articles: 3,
    likes: 1,
    favorites: 2,
    comments: 1.5,
    loginStreak: 5
  };

  // 根据各维度数据计算总分
  static computeScore(metrics) {
    let total = 0;
    for (const [key, weight] of Object.entries(this.WEIGHTS)) {
      total += (metrics[key] || 0) * weight;
    }
    return total;
  }

  // 更新用户指标并重算分数
  async updateMetrics(board, userId, metrics) {
    const score = CompositeLeaderboard.computeScore(metrics);
    await this.setScore(board, userId, score);

    // 同时将原始指标存入 Hash,便于展示详情
    const detailKey = `${this._key(board)}:detail:${userId}`;
    await this.redis.hset(detailKey, ...Object.entries(metrics).flat());
    await this.redis.expire(detailKey, 90 * 24 * 3600); // 90 天过期
  }

  // 获取用户排名 + 详细指标
  async getRankWithDetail(board, userId) {
    const [rankInfo, detail] = await Promise.all([
      this.getRank(board, userId),
      this.redis.hgetall(`${this._key(board)}:detail:${userId}`)
    ]);
    if (!rankInfo) return null;
    return { ...rankInfo, metrics: detail };
  }
}

💡 **提示:**复合评分公式中的权重应该通过配置中心或数据库存储,而不是硬编码在代码里。运营团队需要根据业务阶段动态调整权重——比如拉新期提高登录签到权重,内容期提高文章创作权重。

策略三:分区排行榜

全局排行榜在用户量大时 Top N 竞争激烈,分区排行榜可以提升参与感:

// 分区排行榜 —— 按地区/等级/赛季分区
class PartitionedLeaderboard {
  constructor(redis, basePrefix) {
    this.redis = redis;
    this.prefix = basePrefix;
  }

  // 写入时同时更新全局榜和分区榜
  async updateScore(userId, score, partitions = {}) {
    const pipeline = this.redis.pipeline();

    // 全局排行榜
    pipeline.zadd(`${this.prefix}:global`, score, userId);

    // 各分区排行榜
    for (const [type, value] of Object.entries(partitions)) {
      pipeline.zadd(`${this.prefix}:${type}:${value}`, score, userId);
    }

    await pipeline.exec();
  }

  // 查询可灵活指定分区
  async getTopN(partition = 'global', start = 0, count = 10) {
    return this.redis.zrevrange(
      `${this.prefix}:${partition}`, start, start + count - 1, 'WITHSCORES'
    );
  }
}

// 使用
const plb = new PartitionedLeaderboard(redis, 'game:s3');
await plb.updateScore('user:1001', 9500, {
  region: 'asia',
  tier: 'diamond',
  season: '2026s1'
});
// 查询亚洲区 Top 10
const asiaTop10 = await plb.getTopN('region:asia', 0, 10);

🛡️ 三、防刷机制、性能优化与百万级扩展

防刷与反作弊

排行榜是刷分重灾区。没有防护的排行榜会在上线几小时内被攻破。

// 防刷排行榜 —— 频率限制 + 分数校验 + 异常检测
class SecureLeaderboard extends Leaderboard {
  constructor(redis, keyPrefix, options = {}) {
    super(redis, keyPrefix);
    this.maxScorePerMinute = options.maxScorePerMinute || 1000;
    this.maxScorePerDay = options.maxScorePerDay || 50000;
    this.suspiciousThreshold = options.suspiciousThreshold || 100000;
  }

  // 安全更新分数(带频率限制和异常检测)
  async safeUpdateScore(board, userId, delta, metadata = {}) {
    const minuteKey = `rate:${board}:${userId}:${Math.floor(Date.now() / 60000)}`;
    const dayKey = `rate:${board}:${userId}:day:${Math.floor(Date.now() / 86400000)}`;

    // Lua 脚本保证原子性:频率检查 + 分数更新 + 异常标记
    const luaScript = `
      local boardKey = KEYS[1]
      local minuteKey = KEYS[2]
      local dayKey = KEYS[3]
      local suspectKey = KEYS[4]
      local userId = ARGV[1]
      local delta = tonumber(ARGV[2])
      local maxPerMinute = tonumber(ARGV[3])
      local maxPerDay = tonumber(ARGV[4])
      local suspectThreshold = tonumber(ARGV[5])

      -- 检查是否已被标记为异常
      local flagged = redis.call('SISMEMBER', suspectKey, userId)
      if flagged == 1 then
        return {err, 'SUSPENDED'}
      end

      -- 分钟频率检查
      local minuteCount = redis.call('INCR', minuteKey)
      if minuteCount == 1 then
        redis.call('EXPIRE', minuteKey, 60)
      end
      if minuteCount > maxPerMinute then
        return {err, 'RATE_LIMIT_MINUTE'}
      end

      -- 每日上限检查
      local dayCount = redis.call('INCRBY', dayKey, math.abs(delta))
      if dayCount == math.abs(delta) then
        redis.call('EXPIRE', dayKey, 86400)
      end
      if dayCount > maxPerDay then
        return {err, 'RATE_LIMIT_DAY'}
      end

      -- 更新分数
      local newScore = redis.call('ZINCRBY', boardKey, delta, userId)

      -- 异常检测:分数突增超过阈值则标记
      if newScore > suspectThreshold then
        local prevScore = newScore - delta
        if prevScore > 0 and delta > prevScore * 2 then
          redis.call('SADD', suspectKey, userId)
          return {err, 'SUSPICIOUS_SCORE_JUMP'}
        end
      end

      return {ok, newScore}
    `;

    const result = await this.redis.eval(
      luaScript, 4,
      this._key(board), minuteKey, dayKey,
      `${this._key(board)}:suspects`,
      userId, delta,
      this.maxScorePerMinute, this.maxScorePerDay, this.suspiciousThreshold
    );

    if (result[0] === 'err') {
      throw new Error(`Score update rejected: ${result[1]}`);
    }

    return parseFloat(result[1]);
  }
}

⚠️ **警告:**防刷机制必须用 Lua 脚本保证原子性。如果用"先检查再更新"的两步操作,在高并发下存在竞态条件(Race Condition),攻击者可以利用时间窗口绕过频率限制。

性能优化要点

优化手段 效果 实现复杂度 推荐度
Pipeline 批量操作 减少网络往返 90% ⭐ 低 ✅ 强烈推荐
Lua 脚本原子操作 避免竞态 + 减少往返 ⭐⭐ 中 ✅ 强烈推荐
本地缓存 Top N 减少 Redis 压力 80% ⭐ 低 ✅ 推荐
读写分离(Replica) 读吞吐量提升 3-5x ⭐⭐ 中 ✅ 推荐
分片(Sharding) 水平扩展到亿级 ⭐⭐⭐ 高 ⚠️ 按需
// 性能优化实践 —— Pipeline 批量 + 本地缓存 Top N
class OptimizedLeaderboard extends Leaderboard {
  constructor(redis, keyPrefix, options = {}) {
    super(redis, keyPrefix);
    this.cacheTTL = options.cacheTTL || 1000; // 本地缓存 1 秒
    this._topCache = new Map();
  }

  // 批量更新分数(Pipeline)
  async batchUpdate(board, updates) {
    // updates: [{ userId: 'user:1', delta: 100 }, ...]
    const key = this._key(board);
    const pipeline = this.redis.pipeline();
    for (const { userId, delta } of updates) {
      pipeline.zincrby(key, delta, userId);
    }
    const results = await pipeline.exec();
    // 更新后清除缓存
    this._topCache.delete(board);
    return results.map(([err, val]) => err ? null : parseFloat(val));
  }

  // 带本地缓存的 Top N 查询
  async getTopNCached(board, count = 10) {
    const cached = this._topCache.get(board);
    if (cached && Date.now() - cached.ts < this.cacheTTL) {
      return cached.data.slice(0, count);
    }

    const data = await this.getTopN(board, 0, Math.max(count, 100));
    this._topCache.set(board, { data, ts: Date.now() });
    return data.slice(0, count);
  }

  // 从 Replica 读取(主从分离)
  async getTopNFromReplica(board, start = 0, count = 10) {
    const replica = this.redis.duplicate(); // 创建副本连接
    replica.options.role = 'slave';
    const key = this._key(board);
    return replica.zrevrange(key, start, start + count - 1, 'WITHSCORES');
  }
}

百万级用户扩展方案

当单个 Sorted Set 超过 500 万成员时,内存占用和操作延迟会显著上升。此时需要分片策略:

方案一:按分数段分片

lb:shard:0-9999        → 分数 0~9999 的用户
lb:shard:10000-19999   → 分数 10000~19999 的用户
lb:shard:20000+        → 分数 20000+ 的用户

查询 Top N 时,从最高分片开始取,不够再取下一片。复杂度从 O(log n) 降到 O(log(n/k)),其中 k 为分片数。

方案二:按用户 ID 哈希分片

将用户均匀分布到多个 Sorted Set 中,每个 set 维护相同的分数。查询时合并所有分片结果。适合分数范围均匀的场景。

方案三:Redis Cluster 模式

Redis Cluster 自动将 key 分散到不同节点。Sorted Set 的所有操作都落在单个 slot 上,所以天然支持水平扩展。只需确保同一个排行榜的 key 落在同一个 slot(用 Hash Tag):

{leaderboard}:game1   → 保证在同一个 slot
{leaderboard}:game2   → 另一个 slot

📌 **记住:**Redis Cluster 模式下,Lua 脚本操作的所有 key 必须在同一个 slot。排行榜场景天然满足这个约束(所有操作都在一个 key 上),所以 Cluster 模式是百万级排行榜的最佳选择。

生产环境 Checklist

  • ✅ 使用 Lua 脚本保证原子操作,避免竞态条件
  • ✅ Pipeline 批量操作减少网络往返
  • ✅ 本地缓存 Top N 结果(1-5 秒 TTL),减少 Redis 压力
  • ✅ 主从分离:写操作走 Master,读操作走 Replica
  • ✅ 配置 maxmemory-policynoeviction,防止排行榜数据被淘汰
  • ✅ 防刷:频率限制 + 分数突增检测 + 账号封禁机制
  • ✅ 数据持久化:开启 AOF(appendfsync everysec)防止数据丢失
  • ✅ 监控:ZSET 内存占用、ZADD/ZRANGE 延迟 P99、QPS
  • ❌ 不要用 KEYS * 命令遍历排行榜 key(会阻塞 Redis)
  • ❌ 不要在排行榜操作中包含大 JSON(用 Hash 存详情,Sorted Set 只存分数)
  • ❌ 不要每秒全量刷新衰减分数(用 Lazy 更新或后台定时任务)

💡 总结与工具推荐

实时排行榜是 Redis Sorted Set 最经典的应用场景。掌握了它,你就掌握了实时排名、时间衰减、复合评分、分区竞赛等一整套解决方案。关键要点:

  1. 数据结构选择:Redis Sorted Set 是排行榜的唯一正确选择,O(log n) 的读写性能远超关系型数据库
  2. 评分策略:根据业务场景选择纯分数、时间衰减或复合评分,权重要可配置
  3. 安全防护:防刷必须用 Lua 脚本原子化,频率限制 + 异常检测缺一不可
  4. 扩展路径:单机 → 主从读写分离 → Redis Cluster → 分片,按需逐步扩展

相关工具与资源:

📚 相关文章