用数据库出排行榜为什么会垮
一句话总结
ORDER BY score DESC LIMIT 10询问一个人的排名的瞬间就会崩溃。ZSet始终保持排序状态,将排名恢复为O(log N)。
为什么需要这个?
排行榜的要求一般是这样。显示前10名,显示我的排名,显示我前后5名,分数会实时变化。
用RDB的话,第一个要求很容易。ORDER BY score DESC LIMIT 10只要挂上索引就可以了。从第二名开始就会崩溃。“我的排名”是计算比我分数更高的人的数量,SELECT COUNT(*) WHERE score > my_score即使有索引,也必须统计出所有范围。如果问用户100万人中下位权用户的排名,就会收到99万行。而且这个查询每个用户都会在每次请求中收到。
第三个要求是前后5人更差。因为需要知道偏移量,所以排序计算先行。第四个是实时更新,意味着该索引继续被写入,所以增加了锁竞争。
怎么行动
Redis的排序集合就像为解决这个问题而创造的,内部同时维护跳过列表和哈希表,这样找到成员的分数和按分数顺序遍历范围都能快速完成。
核心命令有五个。ZADD和分数一起放入,ZINCRBY用原子级别的相加,ZREVRANGE从中选出上位N,ZREVRANK获得特定成员的排名,ZSCORE看看分数。排名和范围查询都是O(log N)或O(log N + M)。无论是100万人还是1000万人,排名查询都不会超过毫秒。
平局处理是实务的第一关卡。Redis在分数相同时会按成员字符串的字典顺序排序。但是产品要求一般是“分数相同的话,先实现的人排在前面”。解决办法是复合分数。将分数和视觉结合在一个错误中。
composite = points + (1 - ts / 1e10)
# points 5000, ts 1795000000 -> 5000.8205
# 같은 points 라면 ts 가 작을수록(먼저 달성) composite 가 크다
因为小数部分总是处于0和1之间,所以不会超过整数界限,分数顺序保持不变,只有比分才会以视觉方式进行比较。
第二个关卡是按期间排名。将整体排名和每周排名分开,在每周关键字上加上TTL。lb:weekly:2026-W34如果在相同的键名中输入期限,就会自动整理到期。如果需要累计多个期限的话ZUNIONSTORE接受加权后一次性处理。
在现场相遇的样子
排名是 Redis 不是缓存而是成为 1 级存储器的罕见情况。因此,必须启用永续性,或者将原始事件(某个用户何时获得了多少分)留在其他地方,以便重建。作者的建议是后者——排名是衍生数据,所以必须随时可以重新创建。
而且在ZSet中使用页码偏移并不存在问题。ZREVRANGE key 99 108乘坐跳过列表直接过去。RDB的OFFSET 99哇,性格完全不一样。
处理大排名的时候
排序集合虽然很快,但**都会全部存储在内存中。**如果用户越来越多,这一事实开始左右设计。
一个成员占据的大小与成员字符串长度成正比,因此,只要保持输入的标识符的长度短,就会产生很大的差异。使用数字ID代替用户名,将该ID以可以将数字表示为整数的形式存储,而不是字符串的形式。而且,在很多情况下,不需要在排名中包含电源。如果没有人看到下位用户的准确排名,保持前几万人,将其余用户处理为“前N名以外”的方式要便宜得多。ZREMRANGEBYRANK定期剪掉就可以了。
一个命令花费很长时间也需要小心。Redis一次处理一个命令,所以一个范围广的查询会阻止所有其他请求。ZRANGE key 0 -1将整个代码带入的代码如果被100万成员的密钥锁定,那一刻整个服务就会冻结。强制将页面大小设为上限,需要浏览整个内容的工作是ZSCAN分为和循环是原则。
合并多个期间的ZUNIONSTORE同样出于这个原因,需要注意。如果把很多大聚集在一起,在那个时间段内会推迟其他请求。不要在实时请求路径上呼叫,最好提前计算好,只读结果,这样比较安全。
最后,排名也是最先尝试进行不正行为的位置。如果客户直接发送提高分数的请求,就会有人直接创建并发送该请求。分数需要由服务器计算,为了防止同一事件两次上升,前面看到的排名权重也在这里直接使用。排名价值源于准确性,一旦失去信任,该功能本身就变得毫无意义。
下次实习要做的事情
以2000件游戏记录创建200名排行榜。实现前10名、特定用户排名、100强页面、平局规则、每周排名和累计等,最后显示排名查询API,测量1000次查询时间。