搜狐技术产品

时间线拉模式的具体应用

Feed 流(英文 “Feed” 原意为 “饲料”,引申为 “信息供给”)是基于用户关系、兴趣偏好或场景需求,以时间/算法排序为核心,持续向用户展示/推送结构化内容的动态信息展示形态。从产品架构来看,feed 流是 “内容生产-分发-消费-反馈” 闭环的核心枢纽:内容生产者(用户、PGC、UGC)产出的内容(图文、视频、动态、分享等),经分发策略(算法/人工/默认时间倒序等)处理后,以流式滚动/时间线的形式呈现给用户,用户的点击、点赞、评论、转发等行为会反向作为反馈信号,优化后续内容分发逻辑。

关注流/时间线就是用户关注的人或者账号(或发布源)所发布的feed内容按照时间顺序排列的流。实现关注流/时间线通常有三种方式:

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

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

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

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

01

为什么要自研支持sns feed的索引存储

推模式介绍

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

推模式示意图.png

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

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

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

 feed推送实际上是推送给每一位粉丝用户,当粉丝数量巨大时,例如大V的粉丝有1亿左右,如果大V发布了一个作品(一条feed),将这条内容推给上亿的用户,空间和时间的消耗可想而知,并且很可能会存在大V发feed通知不及时问题。通过区分活跃用户和非活跃用户,采用延迟推的方式,可能解决大V通知不及时的问题,但维护活跃和非活跃的用户池子相对来讲比较复杂。而且随着业务需求多样化,推模式弊端越来越明显,主要有:

  • 代码复杂度高(拉黑/不看Ta动态/关注流列表长度控制等实现复杂)

  • feed丢失恢复麻烦(需维护粉丝关系、丢失后需要重新推给活跃粉丝等)

  • 关注/取关用户后关注列表的增删有压力

  • 取关用户的feed查看问题

  • 关注流折叠降噪需求实现变得复杂

  • 占用存储空间大(主要是redis)

  • 其它

针对推模式这些弊端,需要探索另一条可行的方案,sns feed索引存储系统正是在这样的情况下诞生的。

拉模式介绍

推模式随着业务的发展,暴漏出来的问题(上面介绍的若干个弊端)比较多。为了满足多样化的业务需求以及避免推模式的“坑”,我们想尝试拉模式。

拉模式,也称为读扩散。其工作方式是:

1. 查询A关注的所有用户列表(例如,关注了B, C, D);

2. 分别去B、C、D的“发件箱”里拉取他们最近发布的feed内容;

3. 将这些feed内容聚合起来,按时间排序(目前搜狐新闻时间线倒序展示);

   4. 将最终结果返回给用户A,如下图所示

拉模式示意图.png
拉模式实现方案比较

根据拉模式的工作方式,数据库、redis、ES、rocksdb等存储方案是最直观清晰的存储方案。我们在测试环境上,分别用MySQL数据库、redis、ES以及MMap文件存储这四种方式简单的测试了feed拉取速度,测试过程说明如下:

1.只测试获取用户首页feed数据(10条)的耗时情况

2.随机从数据库里取3000个发过动态的用户(这些用户发布动态数>=10条),再随机从这3000用户里取出不同数量的用户作为采样点;

3.针对不同的关注人数,测试四种拉取方案的耗时情况,采样点有如下7个:关注10人、30人、50人、100人、1000人、1500人、3000人

在测试环境(12核 256G内存机器),qps=10的情况下,四种存储方案的耗时情况如图所示(MySQL数据库未用并发和IN等方式,每个用户都是串行的查询):

四种拉取方案性能比较.png

如上测试结果可能存在一定的误差,或者某些方案还有优化的空间,但从整体性能趋势上可以看到,MMap文件存储更具优势。其它存储为何不适合作为拉取方案或者不适合我们的业务,下面我们简单的分析一下。

MySQL数据库和redis作为Feed拉取方案的分析

对于时间线,其常见的核心特征是:

1.读多写少:用户刷信息流的频次可能远高于内容发布频次

2.时序性强:内容展示目前必须按时间倒序展示,且要支持分页、下拉刷新、上滑加载更多

3.数据量大:随着时间的推移,feed总量会越来越多

4.灵活性:需支持降噪、屏蔽、置顶、删除等精细化操作

MySQL不适合作为时间线拉取方案的核心原因是:MySQL是关系型数据库,设计核心是事务一致性、结构化存储等。而时间线的核心查询是:获取我关注的所有用户的内容、按发布时间倒序、分页展示。对应的MySQL实现逻辑通常有三种,均存在问题:

MySQL实现方案1:IN子查询+排序

SELECT content_id, author_id, content, create_time 
FROM feed_content 
WHERE author_id IN (SELECT followee_id FROM user_follow WHERE follower_id = '用户A')
ORDER BY create_time DESC 
LIMIT 10 OFFSET 100;

问题:

• 若用户关注人数超过100或者更多,IN子查询可能会触发全表/全索引扫描,ORDER BY还会产生临时表和文件排序,性能可能随关注人数指数级下降(如上面性能对比图)

• OFFSET分页在数据量大时(比如OFFSET 10000),需要扫描大量数据后丢地,效率极低

MySQL实现方案2:关联查询+排序

SELECT c.content_id, c.author_id, c.content, c.create_time 
FROM feed_content c
JOIN user_follow f ON c.author_id = f.followee_id
WHERE f.follower_id = '用户A'
ORDER BY c.create_time DESC 
LIMIT 10 OFFSET 100;

问题:

• 关联查询会产生大量中间结果集,排序成本极高

• 即使给作者和feed创建时间建复合索引,也无法规避 “多作者内容聚合排序” 的核心性能瓶颈

MySQL实现方案3:IN子查询+缓存

此方案可能需要进行活跃用户/非活跃用户以及大V用户/非大V用户的区分,针对不同用户进行不同的查询-大V发布内容从缓存里查询,非大V用户的feed从MySQL库里拉取。但此种方案实现复杂,且性能和收益存在一定的不确定性。

综上,MySQL 的连接数、QPS 上限远低于缓存 / 专用流存储:单台 MySQL 单机 QPS 通常在万级,而关注流场景下热点用户的读请求可能达到十万 / 百万级,需大量分库分表才能支撑,复杂度极高;且MySQL的索引维护成本高:feed创建时间是高频更新的排序字段,索引树频繁分裂,写入性能可能也会受影响;最后,因时间线常需支持降噪、置顶、按feed类型过滤等操作,在 MySQL 中需在查询中叠加大量条件,进一步降低查询效率;且无法高效支持 “时间范围 + 分页” 的组合查询(比如只看近 7 天的内容)。

redis不适合作为时间线拉取方案的核心原因是:

Redis 是内存型 KV 数据库,优势是高性能、支持丰富的数据结构,但它的设计缺陷使其无法适配时间线的核心诉求,常见的实现方案有两类,均存在一定的问题:

redis实现方案1:ZSET按时间戳存储每个用户的feed数据

# 当用户B发布内容时,给所有关注B的用户的ZSET中添加这条内容
ZADD feed:userA 1710000000 content:12345
# 拉取时按时间倒序分页
ZREVRANGE feed:userA 0 9 WITHSCORES

问题 1:内存成本与容量限制

拉取模式要求存储全量 Feed 数据(用户发布的动态),以便任何粉丝拉取时都能访问。redis 将所有数据驻留在内存中,成本极高。若日活用户数亿,每日动态数千万条,即使只保留最近 30 天的数据,内存需求也达到 TB 甚至 PB 级别,远超 redis 单机容量,且分布式集群成本难以承受

问题 2:缺乏高效的集合间查询能力

要拉取 feed,需要:

1. 获取所有关注用户的 ID

2. 对每个关注的ID,查询redis获取其最新动态列表

3. 在应用层合并、排序、分页

这种拉取可能带来的问题如下:

• 大量网络交互:如果用户关注了 1000 人,就需要执行 1000 次 redis 命令(或使用 pipeline 合并),但 pipeline 也会返回大量数据,增加网络开销和客户端内存占用。随着关注数增长,延迟线性增加(如上面性能对比图)

• 无法全局排序:redis 本身不支持跨 key 的排序操作,合并排序必须在客户端完成。当数据量大时,客户端需要加载大量动态到内存,排序性能差且容易 OOM

• 分页困难:要实现翻页,必须预先拉取足够多的数据才能保证全局顺序正确,这进一步加剧了内存和网络负担

redis实现方案2:List/Hash等数据结构

无法解决的问题:

• List 仅支持首尾操作,无法按时间排序和随机分页

• Hash 无法高效排序,且同样存在写放大问题

综上,redis的持久化(RDB/AOF)适合小数据量,但时间线的海量数据可能会导致持久化文件过大,恢复时间极长;且 redis 集群的主从切换可能导致少量数据丢失(可靠性不足),而时间线通常要求 “内容不丢”;再有,redis无法支持复杂的业务过滤与排序。所以,redis也不适合作为时间线拉取的存储方式。

推模式存在一些弊端,MySQL数据库拉取和redis拉取等方案也不太适合,是否有更优的拉取方案?通过对MMap文件存储的简单测试,我们发现MMap文件存储的性能是可以满足我们的业务需求的。至于其它方面MMap文件存储是否也优于MySQL和redis等方案呢?基于以下几个问题,我们对MMap文件存储进行了深入研究:

自研索引的难点.png

几种拉模式实现方案对比:

方案实时性性能(读取)实现复杂度可扩展性适用场景
MySQL数据库
实时
低(关注多时)
低
低
小规模,关注数少的场景
redis
实时
低(关注多时)
中
中
中等规模,关注数少的场景
ES
近实时
中(关注多时)
中
中,折叠等难实现
中等规模,关注数少的场景
MMap文件存储
实时
高(关注多时)
中
高
中高规模

02

MMap文件存储在时间线拉模式里的具体实现

通过对比,我们发现MMap文件存储方案可行。基于MMap文件存储,我们设计了sns feed索引存储系统,下面详细介绍系统的实现过程。

什么是索引

索引是一种元数据组织机制,旨在优化数据检索效率,其核心原理是通过空间换时间的权衡策略,在存储额外信息的基础上加速查询操作。例如书籍的索引是将书中关键词和主题按字母顺序排列,并标注它们出现的页码,以便读者快速找到相关内容,如下所示。

image-20251125184635960.png

通常,计算机文件系统使用索引来快速定位文件数据块的位置,例如NTFS使用B+树来管理文件和目录的索引。参照这种快速定位文件数据块位置的思想,我们研发了sns feed索引存储系统:一种能够快速检索出我关注人所发布的feed列表的系统。

sns feed索引整体设计思想

核心思想:采用二级索引的方式+全量数据放入内存+数据落地(不丢失)=以结果实时计算代替缓存来换取迭代灵活性和高性能,具体:

1. feed详情特征提取,将feed必要信息存储在文件中,每个月份一个文件,每个月份文件里存储此月份所有用户发布的feed必要信息。文件通过mmap方式映射到内存,从而允许应用程序像访问内存一样快速访问文件,以此来快速检索某个用户所发布的feed信息。每个月份文件里用户feed数据按时间排序且连续。

提取feed必要存储信息:

feed特征提取.png

月份文件内容:

level1文件存储形式.png

2. 为了快速查找到某一用户在哪些月份文件中有数据,以及数据在月份文件中的偏移量(通过偏移量能够快速定位到文件块,从而快速取出这一文件中用户所有feed信息),我们为所有月份文件构建了一个索引,并将此索引称之为第一级索引,第一级索引也是以文件形式落地,通过mmap方式映射到内存后读取。第一级索引文件结构如下:

<用户B,发过feed的总的月份数量,月份1,在月份1文件里的偏移量,月份2,在月份2文件里的偏移量,用户D,发过feed的总的月份数量,在月份n文件里的偏移量......用户E......用户C......>
第一级索引文件存储.png

3. 为了查找某一用户在第一级索引文件中具体信息,我们需要“扫描”整个文件。在关注人数多并且文件较大的情况下可能会存在性能问题,因此为了加速查找过程,我们又为第一级索引文件构建了索引,我们称之为第二级索引,第二级索引是放在内存中的,程序启动或者一级索引文件有变更时会重新生成第二级索引。第二级索引代码结构如下所示:

private Map<Long, Integer> secondIndexMap = new ConcurrentHashMap<>();
secondIndexMap的key: 用户唯一标识比如userid1、userid2等
secondIndexMap的value:用户在第一级索引文件中的offset

sns feed索引系统应用在拉模式里的时序图如下:

拉模型简单时序图.png

feed索引系统工作流程图如下:

feed索引工作流程图.png

下面我们来介绍一下详细的设计。

模块化设计

sns feed索引系统整体上可抽象成若干模块:初始化模块、索引生成模块(一级索引、二级索引)、数据合并模块、feed消息处理模块、列表查询模块、文件删除模块、离线数据生成模块。各模块之间简单的交互图如下:

模块交互图.png

feed索引存储工作流程

假设当前的时间为20251202,相关的文件名称说明如下:

每个月份文件都以leve1.月份.data标识,例如202511月份的文件名称是level1.20251130.data,202512月份的文件名称是level1.20251201.data,因为202512这个月份文件只有12月1号1天的数据,如果当前的时间为20251203,那么202512这个月份文件的名称会变为level1.20251202.data,包含了12月1号和12月2号两天的数据(当天的数据在第二天才会合并到当月的level1文件中);

天级文件存储当天所有用户发布的feed数据,比如当天的时间是20251202,那么天级文件的名称是level2.20251202.data;

第一级索引文件的文件名称是first.20251201.index。

各模块工作流程如下:

• 离线数据模块负责生成所有月份文件,即所有的level1文件(不包含当天)

• 消息处理模块负责feed的增加和状态更新(状态包含是否删除、是否私密等),新增的feed存入内存,需更新的feed更新内存或者对应的level1文件

• 初始化模块负责在程序重启时将当天20251202 00:00:00-23:59:59所有的feed数据查询出来放入内存,并异步调用索引生成模块生成第一级索引文件first.20251201.index

• 索引生成模块遍历所有level1文件然后构建第一级索引文件first.20251201.index,第一级索引文件构建完成后,mmap将其映射到内存后构建第二级索引内存结构

• 数据合并模块负责将当天内存里feed数据定期dump到level2.20251202.data文件中,并且在第二天的时候,将level2.20251202.data文件和level1.20251201.data文件合并,合并后的当月的月份文件变为level1.20251202.data,level1.20251202.data生成后调用索引生成模块重新生成first.20251202.index文件并重新构建第二级索引内存结构

• 文件删除模块负责定期清理已合并的文件以及不再用到的第一级索引文件

• 列表查询模块循环遍历每一个要查询的用户userid,每一个userid首先查询天级数据,如果天级数据满足一页则直接返回。如果天级数据不足一页,则查询第二级索引内存结构,根据第二级索引再查询第一级索引文件,根据第一级索引文件的结果再去查询相对应的月份文件,最终得到一页的结果。每一个用户按照此流程进行检索,得到的结果进行排序后返回一页的数据

用到的设计思想

为了满足业务方各种需求以及高性能,feed索引存储的设计用到了如下一些理念:

• 为了快速,引入了多级索引、多级文件的概念。level1.xx.data文件相当于是对feed整体数据的一个“索引数据”,first.xx.index文件是对所有level1.xx.data文件的索引,内存索引(第二级索引)结构是对first.xx.index文件的索引

• 为了高性能以及最大化减少存储空间,设计了feed状态位的概念,feed状态位对应level1文件里的status字段,共8位,其中后4位保存是否删除等可见状态,第5位(从右向左)保存私密状态,剩余3位预留

状态位代码截图.png
/**
     * 计算最终存储到文件中的status的值
     * @param curStatus 当前已有状态值
     * @param feedStateFlag 要修改feed的哪种状态
     * @param feedStateFlagVal feedStateFlag对应的实际值
     * @return 最终计算的结果
     */
    public static short calFinalState(int curStatus, FeedStateFlag feedStateFlag, int feedStateFlagVal) {
        //curStatus & feedStateFlag.getFlagClearValue()是先清掉原状态
        int finalState = (curStatus & feedStateFlag.getFlagClearValue()) ^ (feedStateFlagVal << feedStateFlag.getShiftBit());
        return (short) finalState;
    }

• feed数据按月划分(调研每个月、每年的数据量级,最终采用分月存储文件的方式),分而治之,借鉴了合并排序的思想,每天定时将level2文件和level1文件进行合并。同时,为了降低gc,通过服务操作shell cmd的方式在进程之外执行合并操作

• 识别变与不变的部分,将变的部分和不变的分开:sns feed索引单独一个服务,这个服务代码很少改动

• 为了高性能,引入了“步长”概念。步长要解决的问题是单个用户、单次查询什么时候结束的问题。如果不设置这个步长,那么每个用户可能需要查出来一页的数据才停止,而不管这一页的数据最终是否会返回给请求方。引入步长的概念后,我们可以根据关注人数的多少自动设置步长的值,比如当关注人数>20时,步长设置为1小时,也就是每一个用户以每自然小时为单位进行查询,所有用户在某一个小时内都查完之后,判断是否满足一页,如果不满足,再查询下一个小时。如果满足一页的数据,则直接排序后返回,不必再继续查下去。如果步长设置为1天,那就按天进行查询,而非每一个用户都查询到一页的数据后再停止。

如何解决推模式的各种坑

推模式弊端主要有:

• 代码复杂度高(拉黑/不看Ta动态/关注列表长度控制等实现复杂)

• feed丢失恢复麻烦(需维护粉丝关系、丢失后需要重新推给活跃粉丝等)

• 关注/取关用户后关注列表的增删有压力

• 取关用户的feed查看问题等

• 时间线折叠降噪需求实现变得复杂

• 占用存储空间大(主要是redis)

sns feed索引存储几乎解决了推模式的所有坑。因实时拉取,故不用考虑关注/取关后列表相关问题;引入步长的概念后,每小时等折叠降噪需求容易实现;通过将变的部分和不变的部分分开,最大化降低代码复杂度;占用内存空间方面,未来我们设计一种防止内存无限增大的方案来避免空间问题。

总结

以MMap文件存储为基础的sns feed索引存储系统,其核心思想是通过两级索引+mmap内存映射+数据落地=以结果实时计算代替缓存来换取迭代灵活性(高可扩展性)和高性能。相比于MySQL和redis,sns feed索引存储系统更适合我们的业务。目前sns feed索引存储系统已成功运行5年之久。

03

结论

启发与感悟
MMap文件存储带来的收益:

通过MMap文件存储的方式,我们完成了时间线推模式到拉模式的改造,改造有一定的成效,部分指标的对比如下:

改造前改造后
大V发feed后查看不及时
最慢300秒->近乎实时
懒加载等对DB造成压力
慢查询个数减少10%
占用redis空间大
100G降为28G左右,下降72G(超50%)
缓存种类繁多
20+降到6种不同种类的缓存
非活跃用户首次下拉刷新无数据
约1%/天->几乎为0/天
redis异常导致数据错误
错误率超50%->几乎为0
扩展性低,折叠等降噪需求难以满足
1种->4+种折叠策略
取消关注延迟处理问题
最慢3秒+的延迟->近乎实时
高耦合
低耦高内聚
代码复杂度高等问题…
推改拉+服务化拆分—>代码易读,维护量↓30%…

由推模式到拉模式的心路历程:

推改拉的心路历程.png

成功自研索引带给我们的启发:

在实际应用场景中,如果存储、耗时等可接受,我们可以选择用“实时计算”来换取“架构简单”及“迭代灵活性”

展望与优化

降低内存-索引文件冷热分离

为了避免机器所占内存越来越大,我们采用“冷热分离”的思想,将“冷”索引文件单独存放,并采用类似“并发”的方式获取“冷”索引数据,尽可能不增加原接口的耗时。目前此项优化已完成,结果符合预期。

推拉结合

目前已有一些推拉结合的方式,如果这些方式能够满足我们业务方的需求,后续我们考虑使用推拉结合的方式来进一步优化我们的服务。

通用化

我们通过抽象,将这种索引存储方式通用化成一个平台——SNS-FileStorage,并成功应用在我们的另一领域。SNS-FileStorage (初版)是一个通用KV的文件存储平台,采用二进制流存储,包括key,value,status三个字段。可以作为嵌入式存储,也可以启用为单独的服务。适用于读多写少的场景。

SNS-FileStorage的基本架构:

sns-filestorage基本架构.png

SNS-FileStorage文件整理流程:

文件整理流程.png

SNS-FileStorage平台优点:

1. 使用方便:做成了springboot-starter的jar包形式,引入jar包即可直接使用;

2. 可配置: 通过yml配置文件配置:文件路径,扩容因子,整理任务执行时间,文件数量;

3. 减小项目对内存的依赖;

4. 可以根据实际情况进行定制化存储。