Redis ZSet 如何实现排行版呢?
前言
在做游戏排行榜时,Redis 的 ZSet(有序集合)是一个极其高效的数据结构。ZSet 的成员值(value)是唯一的,而分数(score)可以用来定义排名的优先级。这使得 ZSet 成为实现排行榜功能的理想选择。
但在实际应用中,当多个成员的 score 相同时,ZSet 并不是先到先得的。如何解决这种问题,是实现稳定排行榜的关键点。
下面我们将探讨如何使用 Redis ZSet 实现一个功能齐全且稳定的排行榜。
当 score 相同时如何处理?
当多个成员的 score 相同时,Redis 在遇到分数相同时是按照集合成员自身的字典顺序来排序,这里即是按照”member2″和”member3″这两个字符串进行排序,以逆序排序的话 member3 自然排到了前面。为了避免同分值的排序问题,我们需要设计一种机制来确保排序稳定。
解决方案
我们可以将时间戳引入到 score 的计算中,确保成员按以下优先级排序:
- 首先按 score 值排序(分数越大排名越高)。
- 如果 score 相同,则按时间顺序排序(插入时间早的排在前面)。
公式如下
.
- 通过组合分数:。
- 高位:用来存储实际的 score。
- 低位:用来存储反向时间戳(越早插入的时间戳越小)。
公式推导
- 当前时间越大 => 的结果就越小。
- 当前时间越小 => 的结果就越大。
排序结果:
- 在 Redis ZSet 中,分数越大排名越高。
- 所以,如果时间戳越早,我们希望反向时间戳占的权重越大。
- 使用 的方式,确保较早的时间有更大的贡献,达到排序目的。
实现代码
/* eslint-disable no-console */
/* eslint-disable no-magic-numbers */
const redis = require('redis');
const redisCenter = redis.createClient({
url: 'redis://' + '127.0.0.1:20004',
});
async function updateZSetScore() {
console.log('正在连接到 Redis...');
await redisCenter.connect();
let now = ~~(Date.now() / 1000); // 当前时间戳(秒级)
const key = 'magic.realm.role.rank.40001';
// 获取 ZSet 的所有成员及其分数
// 为啥我不用 ZREVRANGE 在 node-redis 是 4.6.8 版本; v4 中,许多旧版本中常用的命令( ZREVRANGE)并没有直接暴露为 API 方法。
// 当然可以用 sendCommand 去实现;
// const membersWithScore = await redisCenter.sendCommand([
// 'ZREVRANGE',
// key,
// '0',
// '-1',
// 'WITHSCORES'
// ]);
const membersWithScore = await redisCenter.zRangeWithScores(key, 0, -1);
// 反转并更新分数
const updates = membersWithScore.reverse().map(({ score, value }, j) => ({
score: processScore(score, now + j), // 时间越大,分数越低
value,
}));
await redisCenter.zAdd(key, updates);
// 新增一条测试记录
await redisCenter.zAdd(key, {
value: '200006.999999999',
score: processScore(83, now),
});
// 验证结果
const memberResults = await redisCenter.zRangeWithScores(key, 0, -1);
// 这里是反转的, zRange 默认 score 从小到到去排序
const processedResults = memberResults.reverse().map(i => ({
value: i.value,
score: i.score,
actualScore: processActualScore(i.score), // 恢复实际分数
}));
console.log(
JSON.stringify({ members: membersWithScore, processedResults })
);
console.log('完成');
process.exit();
}
/**
* 计算存储分数(分数高位 + 反向时间戳低位)
* @param {bigint} score
* @param {bigint} now
* @returns
*/
function processScore(score, now) {
// *按照时间最大整数位
const maxTimestamp = BigInt(9999999999); // 最大时间戳基准
const normalizedTimestamp = maxTimestamp - BigInt(now); // 反向时间戳
const storedScore = BigInt(score) * BigInt(1e10) + normalizedTimestamp; // 高位分数 + 低位时间戳
return storedScore;
}
/**
* 还原真实分数
* @param {number|bigint} score
* @returns
*/
function processActualScore(score) {
const actualScore = BigInt(score) / BigInt(1e10); // 提取高位部分
return Number(actualScore);
}
updateZSetScore();