某一天的下午我手头没什么事情双眼迷离正左手托着下巴空洞地盯着屏幕发呆。恍惚间BUG反馈群冷不丁冒了消息我定下神来看测试同学反馈了一个排行榜的排序问题排行榜中相同分数的玩家后达到分数的反而排在先到达的玩家前面。这实在匪夷所思要知道这个排行榜模块已经是老古董代码了之前测试和线上都很正常怎么突然之间就出了问题。很可惜我并不负责这一块没能以第一视角去解决这个精彩的问题只是在同事敲定问题根源之后我了解过后才晓得了事情的全貌。大致情报首先看下整体的背景这个业务并没有自建排行榜使用业务程序代码维护玩家的排行而是借用了 Redis 的 SortedSet 实现这是个跳表的结构查询和修改数据性能都很不错也是我们的老朋友了后面就简称 ZSet 吧。而此时的我也知道 ZSet 是不稳定的排序也就是说相同 Value 的 Key 并不是先来后到的规则所以排序因素除了得分之外还要加上时间戳也就是双重排序规则。但是 ZSet 数据结构的排序字段 Value 只是一个 float64不支持更多参数。因此在业务侧需要将分数和时间两个参数拼接成一个 float64 再 Add 到 ZSet 中去这也是大家常用的方式项目中相关的处理函数如下func (s *Rank) Encode(score int64) float64 { now : time.Now().Unix() tmp : float64(100000000) / float64(now) return float64(score) tmp }透过代码可以看到此处使用得分作为 float64 的整数部分用 100000000 除以当前时间戳作为小数部分合并起来的 float64 的数字大小符合双重排序规则也就是得分更高的数字更大而相同分数下时间戳越小小数越大数字越大就排在更前面。看上去逻辑毫无漏洞老油条可能已经发现了问题所在不过我先不揭晓此处埋个伏笔。解铃还须系铃人我们来还原一下现场过程很精彩大家不要眨眼哦。抽丝剥茧测试同学的操作是让 player1player2player3 按照先后顺序达成同一个分数然而排行榜中最后到达相同分数的 player3 反而排到了第一的位置。player4 的加入是为了对照缩小BUG的排查范围仅限于分数相同的情况下。PlayerScoreplayer386,548,213player286,548,213player186,548,213player486,118,363接下来将分数 Score 和时间戳 Time 带入上面的函数得到返回值 ResultScoreTimeResult86548213176765760086548213.0565720486548213176765760186548213.0565720486548213176765761086548213.05657204很怪异得出的结果竟然一样但是聪明的你一定马上反应过来了这几个值的小数部分戛然而止必有蹊跷那么真相只有一个除法处理过后的时间戳小数被截断了问题是为什么我使用计算器计算选择保留15位小数会发现结果其实并不相同小数部分远不止 8 位。TimeResult17676576000.05657204200632517676576010.056572041974321经过一番思索发现是因为一直忽略了 float64 本身的精确度也是有限的在 float64 的结构中只有 52 位是有效数值精度换算成 10 进制可以展示15 ~ 17个数字在当前这个例子中因为整数已经是10^8级的数字可留给表示小数的位就只剩下了 8 位左右因此超过的小数部分就被截断了。真没想到在我印象中包容天地万物的 float64 也有绠短汲深的一天。趁热打铁我赶忙查了下 float64 的构成和原理float64 顾名思义是由 64 个 bit 位构成的结构其中1 位是符号位用来表示数值的正负11 位是指数位用来表示数值的规模52 位是有效数字用来表示数值的的精度可以这样粗略地理解52 位有效数字是连续的紧挨着的52 个 0 和 1而指数位的数字就是在52 个 0 和 1后面加多少个 0 或者说小数点往后移动几位。由此一来 float64 可以表示非常非常大的数字可以达到接近2^2^10数量级当然具体可以表示多大的数字还要看 IEEE 754 是怎么规定的了。数量级大的代价是低位的精度就不够了比如 float64 就无法表示2^53 12^53数量级的数字个位和所有小数位只能都是 0。相近时间戳计算出的小数前 8 位相同而精度不够导致小数位只留下了 8 位第一时间确实很难想到这块。要是 ZSet 本身就支持按插入时间排序该多好想到这里我又去看了下 ZSet的排序规则文档中明确说明了相同 Value 下按照 Key 的 字典序 排序。看完背后冷汗直冒我想起了半年前很多家面试都被问到了这个问题当时都是说跳表不能保证顺序相同分数插入进去可前可后现在就是感觉非常遗憾。空口无凭马上来实践测试一下并增加 player100 和 player4 作为对照。ZADD test_key 86548213.05657204 player1 ZADD test_key 86548213.05657204 player2 ZADD test_key 86548213.05657204 player100 ZADD test_key 1 player4ZRANGE test_key 0 -1 WITHSCORES REVplayer2 8.654821305657203e7 player100 8.654821305657203e7 player1 8.654821305657203e7 player4 1如此看来player1 按照字典序会排在 palyer2 前面REV 倒序查询就落在了 player2 身后这个结果能很好地解释测试同学的情况究竟为何。不过相同分数这种情况出现的概率很小暂时不用亡羊补牢后面版本节点中我们使用自建排行榜的方案进行优化这个问题就此告一段落。痛定思痛