排行榜(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-policy为noeviction,防止排行榜数据被淘汰 - ✅ 防刷:频率限制 + 分数突增检测 + 账号封禁机制
- ✅ 数据持久化:开启 AOF(appendfsync everysec)防止数据丢失
- ✅ 监控:ZSET 内存占用、ZADD/ZRANGE 延迟 P99、QPS
- ❌ 不要用
KEYS *命令遍历排行榜 key(会阻塞 Redis) - ❌ 不要在排行榜操作中包含大 JSON(用 Hash 存详情,Sorted Set 只存分数)
- ❌ 不要每秒全量刷新衰减分数(用 Lazy 更新或后台定时任务)
💡 总结与工具推荐
实时排行榜是 Redis Sorted Set 最经典的应用场景。掌握了它,你就掌握了实时排名、时间衰减、复合评分、分区竞赛等一整套解决方案。关键要点:
- 数据结构选择:Redis Sorted Set 是排行榜的唯一正确选择,O(log n) 的读写性能远超关系型数据库
- 评分策略:根据业务场景选择纯分数、时间衰减或复合评分,权重要可配置
- 安全防护:防刷必须用 Lua 脚本原子化,频率限制 + 异常检测缺一不可
- 扩展路径:单机 → 主从读写分离 → Redis Cluster → 分片,按需逐步扩展
相关工具与资源:
- 🔧 jsjson.com JSON 格式化工具 —— 处理排行榜 API 的 JSON 响应数据
- 🔧 Redis 官方文档 - Sorted Sets —— 命令参考
- 🔧 ioredis —— Node.js Redis 客户端(支持 Cluster、Pipeline、Lua)
- 🔧 RedisInsight —— Redis 可视化管理工具