设计微博系统

设计微博系统

架构

信息流聚合一般有三种架构:推模式拉模式以及推拉结合

针对关注的粉丝量大的用户采用拉模式,而对于一般用户来说,他们的粉丝量有限,采用推模式问题不大,这样的话一个用户要获取所有关注人的微博,一方面要请求粉丝量大的关注人的发件箱列表,另一方面要请求自己的收件箱列表,再把两者聚合在一起就可以得到完整的 Feed 了。虽然推拉结合的方式看似更加合理,但是由此带来的业务复杂度就比较高了,因为用户的粉丝数是不断变化的,所以对于哪些用户使用推模式,哪些用户使用拉模式,维护起来成本就很高了。所以综合考量下来,微博 Feed 采用了拉模式

前面提到采用拉模式的话,需要拉取所有关注人的发件箱,在关注人只有几十几百个的时候,获取效率还是非常高的。但是当关注人上千以后,耗时就会增加很多,实际验证获取超过 4000 个用户的发件箱,耗时要几百 ms,并且长尾请求(也就是单次请求耗时超过 1s)的概率也会大大增加。为了解决关注人数上千的用户拉取 Feed 效率低的问题,我们采用了分而治之的思想,在拉取之前把用户的关注人分为几组,并行拉取,这样的话就把一次性的聚合计算操作给分解成多次聚合计算操作,最后再把多次聚合计算操作的结果汇总在一起,类似于 MapReduce 的思路。经过我们的实际验证,通过这种方法可以有效地降级关注人数上千用户拉取 Feed 的耗时,长尾请求的数量也大大减少了。

存储

UID range 作为分片

UID hash 作为分片

关系的存储

(1) 最简单的只需要两张表就够了:

用户信息表

| user_id | user_info | ...

用户关系表,表示,follower_user 关注了 followee_user

| id | follower_user_id | followee_user_id |

查看 user_a 粉丝多少人:

SELECT COUNT(*) FROM table_relation WHERE followee_user = `user_a`;

查看 user_a 关注了多少人:

SELECT COUNT(*) FROM table_relation WHERE follower_user = `user_a`;

(2) 不过随着用户增长,比如达到1亿,那么平均一对用户关系可能就会有100条关系,那么将会扩展到百亿级别。所以必须水平拆分。用户表好选取拆分的键,就是 user_id。不过关系表,根据 follower_user_id 拆分,那么查询这个人关注了多少人好查询,但是查询某个人有多少粉丝,就需要去所有分片上查询汇总了,相反按照 followee_user_id 拆分,那么这个人查询关注了多少人,就不好查询了。也就是总有一半的场景查询效率低下

为了应对上面困难,我们可以垂直拆分,一个关系表拆分为 followerfollowee 两张表,然后再对这两张表进行水平拆分,问题就迎刃而解了。

不过这样带来的问题是,如果新增了关注关系,需要使用事务来保证两张表都插入成功,比如 B 关注了 C:

-- 计算 B, C 的分片索引
START TRANSACTION;
-- table_follower 按照 followerId 拆分即可
INSERT INTO table_follower(userId, followerId) VALUES ('B', 'C');
-- table_followeeId 按照 followeeId 拆分即可
INSERT INTO table_followee(userId, followeeId) VALUES ('B', 'C');
COMMIT;

(3) 问题引入。

  • 热点用户查询多少人关注了自己,需要扫描很多行。
  • 热点用户关注自己的人很多,那么需要分页展示,offset 越大,那么就越来越低效。

首先 DB 层的每个用户,增加 userInfo 表,存储这个用户关注了多少人,被多少人关注,这两个信息。然后在 DB 之上引入缓存,这个用户关注了谁,被谁关注了,这些用户 ID 列表可以放入缓存中。

帖子的存储

一级索引 postId,二级索引:userId + posttime,那么按照时间查询帖子列表,将会存在严重回表

SELECT * FROM POST WHERE userId = ? AND posttime BETWEEN ? AND ?

那么可以将 userIdposttime 冗余进 postId 中,去掉二级索引,减少回表:

postId 首 6 位为 userId,每一位是 0~9/A~Z 这 36 个字符中的某一个,6 位可以表示 21 亿不同的用户。后续时间戳可以精确到秒。单个用户每秒发的帖子不超过 seq 表达的最大值。其中 timeCompress 可以设计为:帖子发布的时间 - SNS 系统首次发布的时间的中间间隔,进行 36 进制编码。那么查询的 SQL 变成了:

SELECT * FROM post WHERE postId BETWEEN postId1 AND postId2;
-- 或者
SELECT * FROM post WHERE postId like '13A68%';

DB 中相同前缀的数据相邻存放,因此就可以用上 DB 的 range scan。

数据量大了一行,根据 postId 就可以水平切分。不过热点用户会路由到同一个分片上,传统做法是 1 写 N 读,读写分离存在数据延迟问题,98% 的数据一星期之后都不再访问。所以也可以使用缓存存储热点用户

缓存以什么为 key ,什么为 value,最自然的是以 postIdkey,帖子内容作为 value。但是,查询某个用户一段时间的帖子是一个常见操作,如果 <postId, content> 设计的话,那么需要 multiple-key 的查询,所以应该如何优化?

一个用户一天发帖通常不超过 10 条,平均 3 条,访问最频繁 1 周以内的帖子数很少超过 100 条,单条帖子长度有限,假设为 1KB,单个用户一周法的帖子很难超过 100KB,极端情况 1MB,远低于 Redis 单 value 的大小上限。因此设计如下:

userId + 时间戳(精确到星期) 作为 key,用 hash<postId, postContent> hash 类型作为 valueexpire 设置为 1 星期。

这样对某个用户一段时间范围的查找变为针对该用户本周时间戳的 hscan 命令。用户发帖操作同时同步更新 DB 和缓存,DB 变更操作保证一致性。


对于热点用户存在的单点问题,可以用本地缓存解决。

其中 keyuserIdvalue 是该用户近期发布的帖子。

时间线的存储

(1) push 模式

其中 postId 的格式由原来的 posterId + time + seq 改为 postId' = time + posterId + seq,我们更加侧重于查询。主键:userId + postId'

查询 timeline 的 SQL:

SELECT postId FROM timeline WHERE userId = ? AND postId BETWEEN 'time1%' AND 'time2%';

利用前缀进行范围查找

(2) pull 模式

传统的 pull 模式,每次刷新查询所有分片的做法效率比较低,每个用户的平均 500 个关注用户,分布在全部的 100 个(假设) DB 分片上,那么每次需要 100 次 DB 查询。而 push 模式,如果热点用户德华,那么帖子复制到所有的关注者的 timeline 中也未免太多。此处给出一种 pull/push 结合的方案:

某个用户发布了一个帖子,只同步到 100 个分片的某个表上,假设这张表叫做 post_rep,那么至少要包含三个字段,posterIdpostIdpostTime。如果数据库按照 timeline 所属用户进行分片,那么这个用户关注的所有用户的帖子全部落在同一个 DB 分片上,那么每次刷新 timeline 的时候,只需要一次 DB 查询。

不过这条 SQL 变得复杂了:

SELECT * FROM post_rep WHERE posterId IN (...平均 500  ID) and postTime BETWEEN ... AND ...;

参考