搜狐技术产品

关注流推模式的具体应用

Feed 流(英文 “Feed” 原意为 “饲料”,引申为 “信息供给”)是基于用户关系、兴趣偏好或场景需求,以时间/算法排序为核心,持续向用户展示/推送结构化内容的动态信息展示形态。关注流就是用户关注的人或者账号(或发布源)所发布的feed内容按照时间顺序排列的流。实现关注流通常有三种方式:

推模式(Push,又称写扩散):当用户发布一条内容时,系统会立即将这条内容推送至所有关注者(粉丝)的关注流中;

拉模式(Pull,又称读扩散):当用户要查看自己的关注流时(或者用户收到红点或者数字气泡提醒时),系统实时地去拉取其所有关注对象的最新内容,然后时间倒序排序;

推拉结合(Hybrid混合模式):结合推拉两种模式,例如针对大V用户采用拉模式、普通用户采用推模式。

还有其它的一些实现(优化)方式,如按粉丝活跃度进行部分推、非活跃用户延迟推、拉缓存(非实时)等等。本文主要对推模式的实现进行介绍。

01

推模式早期实现

推模式介绍

用户A关注了用户B、C、D,用户B、C、D发布的feed内容会推送到用户A的关注列表中,如下图所示。

320 推模式示意图.png

用户A读取关注流的逻辑简单高效,假如用户E、用户F也关注了用户B,那么B发布的feed1同样也需要添加到E、F的关注流列表里,从这个角度讲,这是牺牲写性能,换取读性能;

另一方面,同一条feed(如feed1)可能存储在多个地方(比如A、E、F的关注流列表里),占用额外存储空间,从这个角度讲,这是用空间换时间;

从时间线整体的角度考虑,这相当于将用户读取关注列表这个逻辑所消耗的时间,平摊给了用户每次发布feed的逻辑中,从这个角度讲,这是平摊复杂度。

基于性能、资源方面等考量,我们采用redis缓存+数据库兜底的策略来实现推模式。

推模式的第一版实现

在推模式的第一版实现里,几乎所有redis缓存都未设置过期时间。推模式用到的主要缓存有:

• 用户关注列表缓存:按时间倒序存储关注人发布的feed简要信息

• feed详情缓存:存储feed的详细信息

• 用户关注人列表缓存:存储用户所有关注人的信息

• 粉丝列表缓存:存储用户的粉丝信息

• 其它缓存:存储不看Ta的动态信息等

推模式主要是通过以下三个功能模块来实现的,具体如下所示。

关注/取关模块的实现

用户关注其他用户后,我们异步获取关注人所发布的feed然后写入用户的关注列表里(这里需要并发写来保证性能。也可以同步写,但在feed量级过大时会导致关注接口超时等问题),同时同步操作关注关系缓存和粉丝缓存。用户取消关注其他用户后,我们异步删除关注列表缓存(也可以同步删除,但在feed量级过大时可能导致超时),同时操作关注关系缓存和粉丝缓存的删除。

推简单实现.png

用户发feed的实现

用户发布feed入库后,首先将feed详情写入缓存,然后异步读取发布人的粉丝,然后将此feed简要信息推送至粉丝的关注列表缓存中。

用户发feed最开始实现.png

用户读取关注流的实现

每个用户的关注列表里存储的是feed集合,集合按时间倒序。每条feed有一个唯一标识,feedid,可以通过feedid查询feed详情(缓存读取)。用户读取其关注流的逻辑是首先判断关注列表缓存是否异常,如果没有异常,则直接读取feed列表,拿到一页的feedid数据,之后通过feedid查询feed详情,查询后过滤一些已删除的feed等,最后返回。如果缓存出现异常,则用数据库进行兜底,具体:首先通过接口(或者缓存)获取关注人列表,然后循环拉取每个关注人在数据库里的一页数据,拉取完之后进行合并并重排序,得到一页的feed数据后进行feed解析,解析后再进行过滤等其它操作,最后返回。如果关注的人比较多,那么兜底逻辑的设计可能需要优先保证性能,其次再考虑数据的正确性。

用户读取关注流.png

02

推模式的优化

推模式早期的实现可能存在以下几个问题:

• 因缓存几乎都未设置过期时间,以及随着关注流用户越来越多,redis空间占用越来越大,需经常扩容

• 关注某个用户后,异步推可能存在时延,一定程度影响了用户体验

• 部分用户可能只浏览过关注流一次或者只存在关注行为而无浏览关注流的行为,为这部分用户推feed可能导致空间和其它资源的浪费

• 频繁发feed的用户,在一定程度上会造成“刷屏”,可能影响用户的体验

• 用户粉丝量超过千万时,feed推送可能有延迟

针对如上问题,我们通过以下方式一一解决。

懒加载

关注某个用户后,异步推可能存在时延,一定程度影响了用户体验。如果同步推的话,当推送的feed量级较大时,关注接口可能会超时。如果同步推送几页的数据是否可行?调研发现,同步推送近10页(约100条)左右的数据对性能影响不大。故关注时推feed的逻辑改为同步推送一定数量的feed。什么时机继续为用户推送其余的feed呢?为此我们设计了“懒加载”模块:当用户向上浏览其关注流数据时,我们判断列表剩余feed条数小于阈值的话,启用懒加载模式:获取关注人列表,如果有关注人则先加一个标记(此标记在后续的处理会用到),加标记后读库,读库时以关注列表里“最小”的feed时间t1为读取起点,依次循环读取关注人小于t1时间的feed信息,读取后通过一“降级通道”(kafka消息队列)将feed不断的推入到用户的关注列表缓存中,流程图如下所示:

懒加载模块设计.png
长度控制器

如果我们控制每一个用户关注列表的长度,那么redis空间的增长将会减缓。但是懒加载等操作可能使redis空间继续增长,基于此我们设计了长度控制器,用以裁剪用户关注列表,使其大小维持在一合理的范围。

长度控制器.png
延迟推-折叠策略的实施

为了防止“刷屏”,我们采用延迟推的方式。具体:当用户发布feed时,我们判断此用户是否是“易刷屏”用户,如果是则采用延迟推的方式。延迟推的时间可以自由设定,比如十分钟、半小时或者一小时。当此feed是延迟时间里“最后”一条feed则进行推送,否则计算出距离实际推送的时间、入延迟队列、写缓存(防止延迟队列数据因重启丢失),然后等待时间满足要求后再推。具体流程图如下:

延迟推模块.png
收敛

为了解决redis空间增长问题,我们为每一个用户的关注列表缓存都设置了过期时间。那么当缓存过期后怎么办?当用户访问其关注列表并且关注列表没有数据时(有关注人但无数据),我们采用的是“重新关注“(并不是真正的取关再关注,而是为了利用关注的一些功能)的方式,将feed的推送收敛到“关注”模块。

我们所说的收敛,是指一些和推feed相关的操作,最终都会“收敛”到“某一”模块。为了降低出错风险、增强可维护性和可扩展性等,我们将缓存过期后的操作、redis异常后的操作、reload恢复用户关注列表操作等都收敛到“关注”模块,由关注模块负责将关注人的feed推到用户的关注列表里。而懒加载、长度控制器、部分关注等操作都会“收敛”到另一个模块——“降级通道”(通过kafka消息)。这些“收敛”操作能起到功能统一、削峰、便于维护等作用,同时也为非活跃粉丝过滤系统打下基础。

03

非活跃粉丝过滤系统

经过懒加载、长度控制、延迟推等优化方式后,我们解决了推模式遇到的大部分问题。而当用户粉丝量超过千万时,feed推送可能有延迟:推送到关注列表的延迟以及发feed后产生的红点通知延迟。为了解决这一问题,我们对粉丝相关的逻辑进行了优化。

大V用户与非大V用户

大V用户的一种定义是粉丝数多、feed发布频繁、其粉丝大多是活跃的用户。我们暂且通过一种更为简单的方式来定义大V用户:粉丝量超过1千万的用户。优化的第一步:将大V用户的粉丝和非大V用户的粉丝分开存储,相当于存储层面的”解耦“。

活跃粉丝与非活跃粉丝

大V用户的粉丝单独存储后,我们又将大V粉丝的用户分为两类,一类是活跃用户,经常浏览关注流的,一类是不活跃用户,可能十天甚至几个月才打开APP一次。feed推送延迟问题一般出现在大V发feed后,如果我们采用“冷热分离”的理念、优先将feed推送给活跃用户,那活跃用户是否可以更快速的感知到新feed的产生?经调研和实际应用后,活跃用户的通知速度有所提高。那么如何筛选出活跃用户和非活跃用户是关键的问题,基于此,我们设计了非活跃粉丝过滤系统。

非活跃粉丝过滤系统

我们首先将全量粉丝缓存“拷贝”一份,拷贝的这一份用作“非活跃粉丝”缓存,原粉丝缓存用作“活跃粉丝”缓存。当用户访问关注流时,我们通过日志记录其今天处于“活跃”状态,这份日志在第二天的时候会参与到非活跃粉丝的分析与计算。具体流程如图所示:

非活跃粉丝过滤系统.png

非活跃粉丝过滤系统主要是将那些只关注不浏览关注流的用户放入到观察区,如果连续N天(N是一经验阈值)都是只关注不浏览,则将其“判定”为非活跃用户,删除其关注列表缓存等。

大V发feed后红点通知优化

通过非活跃粉丝过滤系统的过滤,加上布隆过滤器的应用(布隆过滤器可以提高大V发feed后红点通知的性能),大V发feed后红点通知的速度已有所提高,但最慢可能还需要近10分钟。查看大V活跃粉丝列表,发现活跃粉丝数仍在一个较高的量级(比如近千万),如果能进一步减少通知的粉丝量级,是否可以加快推送的速度?基于此,我们和上游讨论了一种更快的方式:大V发feed后,首先推给目前“在线”的用户,再在在线的用户里筛选大V的粉丝(这两步都由上游处理)。之后我们再“缓慢”推给全量的粉丝。经过这一操作,真正”活跃用户“的推送时延控制在1分钟以内。

04

展望与总结

拉模式的实际应用

推模式更适合“关注人多、粉丝数少”的情况,在这种情况下,用户发布feed后可以快速的推到其粉丝关注列表里,所以推模式在关注流”诞生“初期发挥着巨大的作用。在关注流这一领域发展一段时间后,出现了一些”粉丝数多、发feed又频繁“的大V用户。大V用户发布feed后,因其粉丝量巨大(千万级甚至超过1亿),一条feed内容如若推给这么多的粉丝,时间耗时可想而知。即使区分了活跃用户和非活跃用户,当粉丝大多都是活跃用户时,推送可能会产生延迟(通常分钟级)。并且为了满足业务方需求,推模式代码变得越来越复杂,扩展性、可维护性降低,且懒加载等模式可能对数据库造成读压力。针对这些不足,我们对拉模式进行了探索。

拉模式更适合“关注人数中等、粉丝数大”的情况。实际情况下,关注人数超过一定阈值的情况比较少,所以当关注人数规模可控时,拉模式可以发挥其优势。但采用何种“拉”,数据库、redis、ES还是其它方式?经过一番调研后,我们发现数据库、redis等方式在多次拉取时存在性能问题,于是我们自研了sns feed索引存储系统,详见另一篇文章“时间线拉模式的具体应用”。

推模式实现总结

关注流推模式完整的实现包含了若干模块:关注模块、feed生成模块、延迟推模块、懒加载模块等。各模块之间交互流程图如下:

Image

用户在搜狐新闻客户端访问关注频道、拉取关注列表内容,首先前置判断其是否有关注的人,如果没有直接返回。如果存在关注的人并且没有走兜底策略,则访问其关注列表。如果关注列表redis存在数据,则正常读取并尝试进行懒加载。如果关注列表redis无数据,则异步请求“关注”进行推,同时告知客户端要进行重试。其它模块主要作用是为了削峰、降低MySQL服务压力、最大化减少redis空间占用等。

在“关注人多、粉丝数少”的情况下,推模式可以充分发挥其性能优势,在关注流初创期推模式起到了巨大的作用。其实现过程也蕴藏着许多可以借鉴的设计思想,总结如下所示。

设计思想

• 牺牲写性能,换取读性能:用户读关注列表简单高效,这是以牺牲feed写换来的

• 用空间换时间:同一条feed会推送至“所有”粉丝的关注列表缓存里

• 平摊复杂度:用户读取关注流所消耗的时间平摊在feed发布的逻辑中

• 推feed入口收敛:增加代码可读性、减小数据库读压力等

• 冷热分离/活跃非活跃区分:降低推送时延,使活跃用户能够快速感知关注人新内容的产生。同时减少了不必要的推送、节省了redis存储空间

• 工厂模式进行feed解析:

class FeedParserFactory {
  public FeedParser createParser(int action) {// action为feed类型标识
    if (action == OriginalFeed.Action.getActionCode()) {
      return OriginalFeedParser;
    } else if () {...
    } else {...
    }
  }
}

• 延迟推防刷屏-折叠实现:部分用户feed发布频率较高,为了防止其“刷屏”,我们采用了延迟推这一策略

• 取消关注延迟处理:为了防止用户频繁的关注和取消关注同一人,我们采用延迟处理的方式——取关入队列,在其它有关联的交互逻辑中判断用户关注的人是否在队列中。防止队列重启丢失,将其加入至redis缓存中,取消关注整体逻辑处理完后操作真正的取关

• 布隆过滤器:粉丝红点推送时,如果粉丝量巨大,则将粉丝进行分片(比如2048个分片,分片号为0-2047),以用户的唯一标识+分片号作为redis的key,value是此用户的部分粉丝,存储到不同分片里。为了减少网络流量以及快速的进行红点推送,我们利用布隆过滤器存储了那些可能“存在的”分片,然后在查找时,过滤掉此用户不存在的粉丝分片

• Kryo序列化降低缓存大小:Kryo序列化的速度非常快,且数据体积也非常小,对于关注列表缓存redis空间不断增长这一问题来讲,序列化后再存储是一种权宜的选择

• 懒加载节约空间、保证性能:为了将用户关注列表缓存总大小控制在一个合理的范围内,避免经常性的扩容,我们设计了懒加载模块。懒加载模块工作时机是在用户的关注列表缓存快刷完时,比如剩余数据总页数小于5页的时候,启用懒加载异步拉取用户关注人列表并读取关注人在缓存之外的数据,然后推入用户的关注列表

• 长度裁剪器:列表长度裁剪模块和懒加载配合使用,懒加载可能加载了非常多的数据到缓存(超过“合理”范围),如果用户长期不再访问这些数据、用户的关注列表里又增加了若干新数据,那么有一部分数据可能在最近这段时间内永远不会被访问,所以我们可以将其列表进行裁剪,使关注列表缓存总长度在一个规定的范围内

• 数据库兜底:兜底策略保证用户的关注流不因缓存异常而空白。兜底策略设计的好坏可能会影响数据库的性能以及用户的体验,所以当触发兜底逻辑时,可能需要牺牲一定的正确性来优先保证用户可以看到关注人的feed数据

除了如上列举的这些,推模式还有一些其它的设计思想,随着时间的流逝,这些设计思想对于我们的系统设计仍有借鉴意义。