腾讯二面的场景题,设计微信运动的排行榜,使用 Redis 的 Zset 来设计。
问题
1.存储所有用户的微信步数,使用什么结构存储,key value 分别是啥?
2.不同用户有不同的好友,每个人要单独实现一个排行榜吗?
3.相同分数的如何排序?微信步数排序可能不太重要,但是在游戏中,相同的分数或者王者多少颗星,如果排序呢?
4.每天的数据都存储在 Redis 中吗?如何实现数据的冷热备份
Zset 底层跳表
Zset 底层结构在数据量比较小的时候,元素数量小于 128,元素的大小小于 64,使用的是 ziplist(压缩列表).大数据量的时候会变成字典和跳表 skiplist。
通过 dict 是实现 o1 的查询,通过 skiplist 实现 logn 的 socre 范围查询.
在原始的单链表上面向上建立索引.
https://blog.csdn.net/Appleeatingboy/article/details/119948340
问题分析
1.存储所有用户的微信步数,使用什么结构,key value 分别是啥?
因为要存储每一天的数据,key 可以是:业务名称加上日期step_rank:{date}。member 是用户的 ID、score 就是对应的步数。
基本的操作
# 更新用户步数
ZADD step_rank:20250415 15000 user:1001
# 获取排行榜前10名
ZREVRANGE step_rank:20231001 0 9 WITHSCORES
2.不同用户有不同的好友,每个人要单独实现一个排行榜吗?
不用,只需要维护一个排行榜,每一个用户都有自己的好友列表,可以用 Set 来存储,拿到好友列表之后,通过 ZScore 拿到好友 ID 的步数,在应用层排序之后返回给前端。
如果用户达到了上亿,那么一个 Redis 的主机是否能存下这么多呢?分片存储到不同的主机上面去。或者根据用户的 id 进行取模,存储到不同的主机上面去,查询的时候根据。
3.相同分数的如何排序?微信步数排序可能不太重要,但是在游戏中,相同的分数或者王者多少颗星,如果排序呢?
将 socre*1e13+(1e13-时间戳)+ 这样就不会有相同的 socre 存在了。排序的时候也就解决了这个问题,还可以定义其他的规则.
距离当前的时间越近,那么对应的分数值越小,也就是排名月考前面.
# 将时间戳编码到Score中(假设时间戳为13位)
score = actual_score * 1e13 + (1e13 - timestamp)
原理:
13位时间戳
指的是Unix时间戳,它表示自1970年1月1日00:00:00 UTC(协调世界时) 以来的总毫秒数;
13位时间戳是10位时间戳的扩展,将时间精度提高到了毫秒级,在需要高精度时间记录和分析的场景中尤为有用;
1. <font style="color:rgb(0, 0, 0);">actual_score * 1e13</font>
这里的 <font style="color:rgb(0, 0, 0);">1e13</font>
代表 <font style="color:rgb(0, 0, 0);">10</font>
的 <font style="color:rgb(0, 0, 0);">13</font>
次方,也就是 <font style="color:rgb(0, 0, 0);">10000000000000</font>
。
把 <font style="color:rgb(0, 0, 0);">actual_score</font>
乘以 <font style="color:rgb(0, 0, 0);">1e13</font>
之后,实际得分会左移 <font style="color:rgb(0, 0, 0);">13</font>
位。这么做的目的是为时间戳留出 <font style="color:rgb(0, 0, 0);">13</font>
位的空间。
2. <font style="color:rgb(0, 0, 0);">1e13 - timestamp</font>
时间戳 <font style="color:rgb(0, 0, 0);">timestamp</font>
通常是一个 <font style="color:rgb(0, 0, 0);">13</font>
位的整数,代表从某个固定时间点(像 1970 年 1 月 1 日 00:00:00 UTC)开始到当前时刻所经过的毫秒数。
用 <font style="color:rgb(0, 0, 0);">1e13</font>
减去 <font style="color:rgb(0, 0, 0);">timestamp</font>
,能得到一个新的时间戳编码值。这个编码值的特点是,时间越近,其值越小;时间越远,其值越大。
3. <font style="color:rgb(0, 0, 0);">actual_score * 1e13 + (1e13 - timestamp)</font>
把 <font style="color:rgb(0, 0, 0);">actual_score * 1e13</font>
和 <font style="color:rgb(0, 0, 0);">(1e13 - timestamp)</font>
相加,就得到了最终的得分 <font style="color:rgb(0, 0, 0);">score</font>
。
由于 <font style="color:rgb(0, 0, 0);">actual_score * 1e13</font>
占据了高位,<font style="color:rgb(0, 0, 0);">(1e13 - timestamp)</font>
占据了低位,所以最终的得分既包含了实际得分信息,又包含了时间戳信息。
更新排行榜 mq 消息队列
微信运动的排行榜对于业务的场景并不是要求实时的,比如间隔五分钟去实现更新。 可以将步数更新先放入到消息队列中, Zadd key member value 或者累加操作等,实现排行榜的变化。
当用户上传步数时,直接高频写入Redis可能对数据库造成压力(尤其在用户量激增时)。
消息队列方案
- 生产者:用户上传步数后,将更新请求发送到消息队列 kafka.
- 消费者:后台服务异步消费队列中的消息,批量更新Redis的Zset。
优势
- 流量削峰:缓冲突发流量,避免Redis写入过载。
- 批量处理:合并多个步数更新操作,减少Redis的
**<font style="color:rgb(64, 64, 64);background-color:rgb(236, 236, 236);">ZADD</font>**
命令调用次数。 - 解耦:步数采集服务与排行榜更新服务独立扩展。
冷热数据隔离
Redis 的内存容量也是有限的,不能直接存储所有天数的数据。可以保留近三天的数据,对于历史的数据都是固定了,可以保存到数据库里面去,在每天晚上的时候自动将三天前的数据备份到数据库中。如果用户查询历史的排行榜数据,直接从数据库中查询数据.
详细细节
获取用户的排行榜
1.每一个用户好友都用 Set 存储,key 为 friends:{userId}
1.获取用户123的所有好友ID
SMEMBERS friends:user123
批量的拿到好友的分数 拿到friend1和friend2的分数
ZSCORE step_rank:20231001 friend1
ZSCORE step_rank:20231001 friend2
查询出来分数存储到集合中在后端排序完成之后返回给前端进行展示