当 float64 遇上 Redis ZSet:一次排行榜排序失效的深度排查

57 阅读5分钟

前言

某一天的下午,我手头没什么事情,双眼迷离,正左手托着下巴空洞地盯着屏幕发呆。恍惚间,BUG 反馈群冷不丁冒了消息。我定下神来看——测试同学反馈了一个排行榜的排序问题:排行榜中相同分数的玩家,后达到分数的反而排在先到达的玩家前面

这实在匪夷所思。要知道,这个排行榜模块已经是"老古董"代码了,之前测试和线上都很正常,怎么突然就出了问题?

很可惜,我并不负责这一块,没能以第一视角去解决这个精彩的问题。只是在同事敲定问题根源之后,我才了解了事情的全貌。

背景情报

该业务并未自建排行榜,而是直接借用了 Redis 的 SortedSet(ZSet) 实现。ZSet 基于跳表结构,查询和修改性能优秀,是我们常用的"老朋友"。

但问题在于:ZSet 对相同 Score 的元素排序是不稳定的。官方文档明确说明:当 Score 相同时,按 Key 的字典序升序排列

而我们的业务需求是:相同分数下,先达成者排名靠前(即时间越早,排名越高)。因此,必须实现"双重排序":

  • 主排序:分数(越高越好)

  • 次排序:达成时间(越早越好)

然而,ZSet 的 Score 字段仅支持一个 float64 值,无法传入多个字段。于是,项目采用了业界常见做法:将分数和时间戳编码进同一个 float64

相关编码函数如下:

func (s *Rank) Encode(score int64) float64 {
    now := time.Now().Unix()
    tmp := float64(100000000) / float64(now)
    return float64(score) + tmp
}

逻辑看似完美:

  • 整数部分 = 玩家分数

  • 小数部分 = 1e8 / 时间戳(时间越小,小数越大 → 总值越大 → 排名越前)

但问题就藏在这"看似完美"的背后。

抽丝剥茧:还原现场

测试操作:让 player1player2player3 按顺序达成相同分数(86,548,213),结果 player3(最后达成)却排在了最前面!

为排除干扰,还加入了 player4(分数略低)作为对照。

PlayerScore
player386,548,213
player286,548,213
player186,548,213
player486,118,363

将分数和时间戳代入 Encode 函数,得到:

ScoreTimeResult (float64)
86,548,213176765760086548213.05657204
86,548,213176765760186548213.05657204
86,548,213176765761086548213.05657204

三个结果竟然完全一样!

但用计算器精确计算(保留15位小数):

Time小数部分(1e8 / Time)
17676576000.056572042006325
17676576010.056572041974321

明明不同!为何 float64 输出一致?

真相:float64 的精度陷阱

float64 遵循 IEEE 754 标准,其结构为:

  • 1 位:符号位

  • 11 位:指数位

  • 52 位:有效数字(尾数)

52 位二进制 ≈ 15~17 位十进制有效数字

在本例中:

  • 整数部分已达 8 位(86,548,213)

  • 剩余精度仅能表示 约 7~8 位小数

  • 而时间戳差异导致的小数变化在 第 9 位之后

  • 超出精度范围,被截断!

结果:不同时间戳生成了相同的 float64

于是,Redis ZSet 收到三个 Score 完全相同 的成员,转而按 Key 的字典序 排序。

执行测试命令:

ZADD test_key 86548213.05657204 player1
ZADD test_key 86548213.05657204 player2
ZADD test_key 86548213.05657204 player100
ZRANGE test_key 0 -1 WITHSCORES REV

返回结果(倒序):

player2
8.654821305657203e+7
player100
8.654821305657203e+7
player1
8.654821305657203e+7

原因:字典序 player1 < player100 < player2,倒序后 player2 排第一 —— 与测试现象完全吻合!

后续与反思

1、根本原因

  • 分数过大(>1e8),挤占了 float64 的小数精度空间

  • 时间戳差异无法在小数部分体现

  • Redis 回退到字典序排序,违背业务预期

2、潜在风险

  • 即使无时间问题,int64 分数存入 float64 也可能失真

    int64 最大 2⁶³,float64 仅 53 位有效精度)

  • 玩家看到的排行榜分数可能与实际不符

3、临时缓解方案

  • 若为限时活动排行榜,可用 (当前时间 - 活动开始时间) 作为小数部分,缩小时间戳量级

  • 但仍属"治标不治本"

4、终极建议

强业务相关的排行榜,应自研多字段排序系统,而非依赖 ZSet 的单 Score 机制。

同时,务必与策划确认分数上限,若可能超过 2^53(约 9e15),则必须更换方案。

总结

这次"灵异事件"再次提醒我们:

浮点数不是万能的,精度限制是硬约束。

在涉及排序、唯一性、金融计算等场景时,务必警惕 float64 的"温柔陷阱"。有时,看似巧妙的 hack,终会在边界条件下露出獠牙。

技术债不会消失,只会转移。早发现,早重构,方得始终。

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号 [DotNet技术匠] 社区,与其他热爱技术的同行一起交流心得,共同成长!

优秀是一种习惯,欢迎大家留言学习!

来源:www.cnblogs.com/Innsane/p/1…