使用 Redis ZSET 实现分数排行榜,并在相同分数时按时间戳先来后到排序
主要应用于一些榜单场景 如:直播间礼物榜单、歌曲热度榜单、热点事件榜单等
一、使用 64 位整数,高位存分数,低位存时间戳
// 位运算组合分数和时间戳
func combineScoreAndTime(score int64, timestamp int64) float64 {
// 假设分数不超过 32 位,时间戳使用低 32 位
// 高32位存分数,低32位存负时间戳(保证时间早的排前面)
combined := (score << 32) | (0x7FFFFFFF - (timestamp & 0x7FFFFFFF))
return float64(combined)
}
// 分离分数和时间戳
func separateScoreAndTime(combined float64) (int64, int64) {
val := int64(combined)
score := val >> 32
timestamp := 0x7FFFFFFF - (val & 0x7FFFFFFF)
return score, timestamp
}
func AddScoreWithBitwise(ctx context.Context, rdb *redis.Client, userID string, score int64) error {
timestamp := time.Now().Unix()
finalScore := combineScoreAndTime(score, timestamp)
return rdb.ZAdd(ctx, leaderboardKey, &redis.Z{
Score: finalScore,
Member: userID,
}).Err()
}
二、详细拆解
详细解释为什么它能在分数更新的情况下保证"先达到分数的人排在前面"
1. 位运算组合原理
combined := (score << 32) | (0x7FFFFFFF - (timestamp & 0x7FFFFFFF))
这个公式将 64 位浮点数分为两部分:
- 高 32 位:存储主分数
- 低 32 位:存储"反转时间戳"
2. 时间戳反转
// 为什么要用 0x7FFFFFFF - timestamp?
0x7FFFFFFF = 2147483647 (32位最大正整数)
假设:
用户A: timestamp = 1000, 反转后 = 2147483647 - 1000 = 2147482647
用户B: timestamp = 2000, 反转后 = 2147483647 - 2000 = 2147481647
结果:时间戳越小(越早),反转后的值越大!
3. 实际运行示例
用具体例子说明:
func demonstrateBitwise() {
// 模拟场景:3个用户都达到100分,但时间不同
// 用户A: 100分,时间戳1000 (最早)
scoreA := int64(100)
timestampA := int64(1000)
combinedA := (scoreA << 32) | (0x7FFFFFFF - (timestampA & 0x7FFFFFFF))
// = (100 << 32) | (2147483647 - 1000)
// = 429496729600000000 | 2147482647
// = 429496729602147482647
// 用户B: 100分,时间戳2000 (较晚)
scoreB := int64(100)
timestampB := int64(2000)
combinedB := (scoreB << 32) | (0x7FFFFFFF - (timestampB & 0x7FFFFFFF))
// = 429496729600000000 | 2147481647
// = 429496729602147481647
// 用户C: 101分,时间戳3000 (更晚,但分数更高)
scoreC := int64(101)
timestampC := int64(3000)
combinedC := (scoreC << 32) | (0x7FFFFFFF - (timestampC & 0x7FFFFFFF))
// = 433791696896000000 | 2147480647
// = 433791696898147480647
fmt.Printf("用户A (100分,早): %d\n", combinedA) // 429496729602147482647
fmt.Printf("用户B (100分,晚): %d\n", combinedB) // 429496729602147481647
fmt.Printf("用户C (101分,晚): %d\n", combinedC) // 433791696898147480647
// 排序结果(从大到小):C > A > B
// 即:101分的C排第一,100分中时间早的A排在B前面
}
4. 为什么分数更新时仍能保持时间顺序?
场景分析
假设用户从 90 分更新到 100 分:
// 更新前:用户X,90分,时间戳1000
oldCombined := (90 << 32) | (0x7FFFFFFF - 1000)
// = 386547056640000000 | 2147482647
// = 386547056642147482647
// 更新后:用户X,100分,时间戳5000(更新时的新时间)
newCombined := (100 << 32) | (0x7FFFFFFF - 5000)
// = 429496729600000000 | 2147478647
// = 429496729602147478647
关键点:虽然用户X的时间戳变成了5000(较晚),但他的最终排名是基于:
100分这个分数等级
在100分这个等级内,按时间戳5000排序
如果另一个用户Y早在时间戳2000就达到了100分:
userY := (100 << 32) | (0x7FFFFFFF - 2000)
// = 429496729602147481647
// 比较结果:userY > userX (在100分等级内,Y更早达到)
三、后续优化项
1. 数据量问题
由于排行榜单会有海量数据存放,会知道redis 内存异常增大
但在实际的业务当中,给用户展示的数据不可能20w 100w个榜单,大多数场景都是展示前n个,所以这里可以在add`` 新值时或
启动定时任务``` 把范围外的数据清除
同时又有新的疑问,万一我清除的数据又更新怎么办?
这个也好办,清楚数据可以留一定buffer,比如只展示前10,单删数据时删1000名以后得内容,这样1000名随时更新还会变动榜单,对于已删除数据可以先check一下自己是否还在榜单中,如果不在则从存量历史表中获取一下```冷结果``,再加当前数据更新存入
需要注意时间戳溢出
// 需要注意时间戳溢出
timestamp & 0x7FFFFFFF // 只取低31位,避免负数
三、核心优势
分数绝对优先:高32位确保分数差异远大于时间戳差异
时间戳内部有序:在相同分数内,时间早的确实排前面
更新友好:每次分数更新都会重新计算time-based排序
精度保证:32位时间戳足够使用到2038年