前言
某一天的下午,我手头没什么事情,双眼迷离,正左手托着下巴空洞地盯着屏幕发呆。恍惚间,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 / 时间戳(时间越小,小数越大 → 总值越大 → 排名越前)
但问题就藏在这"看似完美"的背后。
抽丝剥茧:还原现场
测试操作:让 player1、player2、player3 按顺序达成相同分数(86,548,213),结果 player3(最后达成)却排在了最前面!
为排除干扰,还加入了 player4(分数略低)作为对照。
| Player | Score |
|---|---|
| player3 | 86,548,213 |
| player2 | 86,548,213 |
| player1 | 86,548,213 |
| player4 | 86,118,363 |
将分数和时间戳代入 Encode 函数,得到:
| Score | Time | Result (float64) |
|---|---|---|
| 86,548,213 | 1767657600 | 86548213.05657204 |
| 86,548,213 | 1767657601 | 86548213.05657204 |
| 86,548,213 | 1767657610 | 86548213.05657204 |
三个结果竟然完全一样!
但用计算器精确计算(保留15位小数):
| Time | 小数部分(1e8 / Time) |
|---|---|
| 1767657600 | 0.056572042006325 |
| 1767657601 | 0.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技术匠] 社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!