利用Redis.Zset来实现实时排行榜

利用Redis.Zset来实现实时排行榜

使用 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年

本文由 在码圈 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
您可以自由的转载和修改,但请务必注明文章来源并且不可用于商业目的。
本站部分内容收集于互联网,如果有侵权内容、不妥之处,请联系我们删除。敬请谅解!
原文链接:https://www.bedebug.com/archives/redis-zset-hotlist
最后更新于:2025-08-24 16:33:34

请博主喝咖啡 ☕.