引言
Feed流是个人主页中的不断更新的消息列表,此处的消息可以包括视频,照片,链接,状态更新以及关注的人对他人的点赞,评论,转发。
常见的Feed流基于决定消息排序顺序的规则可以分两种:
- 使用推荐算法决定的TopK Feed,如抖音首页
- 使用关注关系和时间线的TimeLine Feed,如微信朋友圈,博客等
本文基于参加第六届字节跳动青训营开发极简版抖音的经历和系统设计学习经历,记录自己尝试开发的全过程(包括中途不好的实践),以学习视角谈一谈对Feed流的相关思考。如有错误,劳烦指正。
需求背景
在青训营极简版抖音提出的项目要求中,基础功能为:
- 视频Feed流:支持所有用户刷抖音,视频按投稿时间倒序推出
- 视频投稿:支持登录用户自己拍视频投稿
- 个人主页:支持查看用户基本信息和投稿列表,注册用户流程简化
拓展功能为:
- 喜欢列表:登录用户可以对视频点赞,在个人主页喜欢Tab下能够查看点赞视频列表
- 用户评论:支持未登录用户查看视频下的评论列表,登录用户能够发表评论
- 关注列表:登录用户可以关注其他用户,能够在个人主页查看本人的关注数和粉丝数,查看关注列表和粉丝列表
但是在实际实现过程中发现,由于视频Feed流作为打开APP的首页,除视频投稿外的所有功能都是基于Feed流返回的数据构建请求。因此实际上可以将需求划分为三个方向:
- 视频投稿
- 各种Feed流:默认首页Feed流,个人发布Feed流,关注Feed流
- 以Feed流延申出的:点赞操作,查看点赞列表,评论操作,查看评论列表,关注操作,查看关注列表等
在分析需求的时候,就应该分析可能的日活用户,峰值QPS,网络带宽和数据量,从而进行高层级的设计(组件,连接方式)。如数据量小的时候可以用关系型数据库BLOB类型存储,但是数据量大了之后需要选择合适的对象存储等。
由于在设计之初没有学习过系统设计的相关知识,所以在实现之初没有考虑到相关问题,这里放在具体的实现和拓展中分析。
最初的实现方式 -- 基于pull
Service 服务
根据需求,将业务拆分为用户,视频,关系,点赞,评论五种服务。
在调用关注Feed流时调用关系如下
Storage 存储
持久层 MySQL
以此为基础,创建索引作为数据库层次优化,其中:
- user
- id:自动创建的主键索引,用于查询用户信息所有相关场景
- username:唯一索引,用于用户注册时判断用户名是否重复
- video
- user_id:普通索引,用于查询用户发布的视频
- relation
- (user1_id, user2_id):复合索引,用于查询自身关注列表
- (user2_id, user1_id):复合索引,用于查询自身粉丝列表
持久层 文件系统
使用Linux的文件系统存储图片视频等静态资源,并使用Web框架搭建简易的静态资源服务器
缓存层 Redis
根据服务的调用关系和数据的特点,缓存设计如下
服务 | 数据信息 | 数据类型 | KEY | VALUE |
---|---|---|---|---|
relation | follow_list | set | relation:user_id :follow |
user_ids |
video | video_info | hash | video:video_id |
title, author... |
video | video_list | zset | video:user_id :feed |
video_ids |
comment | comment_list | zset | comment:video_id |
date, comment_info |
user | user_info | hash | user:user_id |
username, avatar... |
在实际调用过程中,可先判断要请求的时间戳是否位于video_list内,如果命中则直接返回。如果未命中则基于此时间戳查询数据库并返回一定条目的数据并插入到video_list
测试结果
功能测试
使用对接的app测试,所有信息正确返回,但是由于没有针对视频进行专门处理,视频加载速度慢,有无法加载的情况
性能测试
使用apifox的自动化测试对Feed接口进行多轮测试(本机)
第一轮 | 第二轮 | 第三轮 | |
---|---|---|---|
接口耗时 | 152ms | 31ms | 29ms |
指标分析
基于测试结果,除首次请求Feed流以外,各接口均达到了单机1k左右的QPS量级。 在实际的系统设计过程中,应在设计之初就考虑到对性能和可用性的要求,考虑数据存储的容量和网络带宽的消耗,这里列举出他人对Feed场景指标的描述:
问题与拓展
可靠性
由于每次刷新首页Feed流都是基于当前时间戳获取数据,以上设计的缓存必定未命中,需要重新进行数据库查找。在所有接口都可以达到50ms以下的接口耗时时,这个接口成为整个系统的瓶颈。
高延时增加超时的风险,同时对下游依赖的服务(数据库查询)带来压力。
可拓展性
因为耗时主要源于不可避免的数据库查询,因此水平扩展也难以优化。
改进的实现方式 -- 基于push
Service 服务
与pull模型相比,增加一个Feed服务,用于首页Feed、关注Feed和个人发布Feed,视频服务则只存储视频元数据。
在获取Feed流时,直接调用Feed服务,在Feed服务中获取video_id并与视频元信息,评论,关系等信息完成聚合然后返回。
在写入Feed时,首先完成视频元信息的存储,然后获取粉丝表,异步地向Feed服务中粉丝的Feed列表写入数据。
基于个人的抖音和推特的使用习惯和网络上的统计数据,读与写的比例要高于100:1,即两个数量级以上,所以在写入时多做一些工作可以减轻服务器压力。
Storage 存储
持久层 MySQL
与最初的实现相比,在关系表增加relation_type
字段,用于更明确地表示关系同时减少数据冗余。
将上传相关的数据段与视频元信息分离,增加项目的可拓展性同时为将来业务增加(如多人联合发布)做出准备。
缓存层 Redis
与之前的缓存设计相同,只不过在业务调用缓存层的逻辑上做出了改动。
问题与拓展
热点问题
也叫Lady Gaga问题。即知名人物(大V)拥有千万的粉丝量级,在发布新的内容时,需要先从关系表中获取千万级的粉丝列表(因为数量级过大无法使用缓存优化这一过程)。
然后发布时需要向粉丝的Feed收件箱中写入数据,这一过程虽然是异步过程不会阻塞,但是因为需要操作的数据量过大导致第一个写入的用户和最后一个写入的用户会有很大的时间间隔,且因为业务逻辑中是直接从Feed中读取而没有主动拉取,实际场景中会出现其他人已经接收到推送而某一用户刷新也刷不到希望看到的热点话题。
未来的继续拓展 -- 推拉结合
针对pull和push模型的缺点,和一些可能出现的可靠性问题,这里汇总一些用于拓展思考,但是受限于目前的个人实力,暂时没有具体的实践操作。
如何解决读取知名用户发布的时效性和同一数据存储千万份的数据冗余
方案:推拉结合
- 在发布时判断是否是大V,如果是则只保存数据;如果不是则推送到粉丝的Feed中
- 在拉取时先拉取Feed中个人的收件箱,然后读取关注列表中的大V,获取他们的发布数据然后合并返回
大V用户如何判定
方案:用户分级策略
- 针对大V用户,可以通过粉丝数等进行离线判定,将是否为大V作为用户属性进行存储。在活跃时期只能升级不能降级(降级需要回溯向所有粉丝收件箱发送)
- 针对普通用户,根据不同活跃度进行分级。非活跃用户可以退化为完全的pull模式,节约存储成本。针对某一大V的铁粉或非常活跃的用户,可以作为大V用户push的对象,减轻对大V用户发布列表服务的压力。