Timeline Feed 服务
请使用电脑访问
https://myblog-ad4.pages.dev11.1 Feed 流的分类
Feed 流是互联网应用和社交平台中非常核心的信息展示形式。
它通常以时间线为基础,把用户可能感兴趣的内容聚合到 Feed 页面中。很多互联网产品的主页都是 Feed 页面,因此 Feed 流也常常是产品的“门面”。
按内容聚合维度,Feed 流可以分为几类:
Feed 流 |
1. 推荐 Feed 流
推荐 Feed 流按照用户的浏览兴趣聚合内容。
用户不一定认识内容发布者,但发布者的内容可能符合用户兴趣。它的重点在推荐算法。
2. 关注 Feed 流
关注 Feed 流聚合用户所关注的人发布的内容,并且按照内容发布时间从近到远展示。
因为它遵循时间线,所以关注 Feed 流也是一种 Timeline Feed 流。
典型例子:
微信朋友圈 |
3. 附近 Feed 流
它的重点在地理位置判断。
本章不讨论推荐 Feed 流和附近 Feed 流,因为推荐算法、地理位置判断涉及的技术差异很大,不具备通用性。
本章重点讨论的是:基于时间线的关注 Feed 流
11.2 Timeline Feed 流的功能特性
Timeline Feed 流提供的数据,是用户关注的人在指定时间段内发布的内容列表。
这些内容需要满足一个核心排序规则:
按照内容发布时间从近到远排序 |
用户浏览 Timeline Feed 页面时,常见操作有两种。
1. 下拉操作
下拉用于刷新 Feed 流,拉取当前时间最新的 N 条 Feed 内容。
用户首次进入 Timeline Feed 页面时,也应该展示当前时间最新的内容,所以首次进入页面和下拉刷新效果一致。
首次进入页面 = 下拉刷新 = 拉取最新 N 条内容 |
2. 上滑操作
上滑用于拉取更早时间的 N 条 Feed 内容。
用户不断下拉,是为了获取关注者最新发布的内容。如果一段时间内关注者没有新内容,就会返回空数据。
用户不断上滑,是为了获取更早的内容。但实际产品一般不会允许用户无限向前翻。微信朋友圈就是这么做的。
微信朋友圈不同用户能刷到的最大天数可能不同,一个合理猜测是:
朋友圈限制的是最多可上滑读取的动态总数,当刷出的动态数量超过一定的阈值时就禁止继续上滑。 |
如果某个用户好友动态很多,可能 12 天就达到阈值;如果好友动态较少,可能 28 天才达到阈值。
本节总结
Timeline Feed 流的核心功能是按时间线读取关注者发布的内容。
下拉:获取最新内容 |
实际产品通常会限制用户上滑读取的最大内容数量,避免无限拉取历史 Feed。
11.3 拉模式与用户发件箱
一种符合直觉的 Timeline Feed 实现方式是拉模式。
在拉模式下,每个内容发布者都有自己的“发件箱”。用户发布内容时,内容进入自己的发件箱;其他用户读取 Feed 流时,再去拉取关注者发件箱中的内容。
用户获取 Timeline Feed 流时,系统大致流程是:
读取用户关注列表 |
这里的“发件箱”只是形象说法,并不一定需要为 Timeline Feed 服务单独设计一个发件箱存储。
拉模式的优点
拉模式实现简单,不需要为 Timeline Feed 服务额外设计存储系统。
因此,在应用初期、用户规模较小时,拉模式很适合快速实现 Timeline Feed 功能。
拉模式的缺点:读扩散
拉模式的问题在于读取 Feed 流时会产生请求放大。
如果用户关注了 N 个人,那么一次刷新 Feed 流就需要读取 N 个用户的发件箱。
1 次用户 Feed 请求 |
这种模式称为:读扩散
如果用户量级较大、关注人数较多时,那么获取Feed流会是一个高并发场景,读扩散会带来两个问题:
1. 增加用户请求延迟 |
本节总结
拉模式的核心是:用户读取 Feed 时,临时去关注者的发件箱中拉取内容。
优点是实现简单、无需额外存储;缺点是读扩散严重。
拉模式 |
一句话总结:拉模式省存储、省写入,但会把压力集中到读取 Feed 的时候。
11.4 推模式与用户收件箱
另一种实现 Timeline Feed 的方式是推模式。
推模式和拉模式相反:每个用户都有一个“收件箱”。当某个用户成功发布内容时,系统会把这条内容推送到每个粉丝用户的收件箱中。
粉丝用户读取 Feed 流时,直接读取自己的收件箱即可。
推模式的基本流程是:
用户发布内容 |
推模式的优点
推模式下,用户读取 Feed 流的性能更好。
因为系统不需要在读取时再获取关注列表,也不需要遍历每个关注者的发件箱。
读 Feed = 读取自己的收件箱 |
推模式的缺点 1:存储压力大
推模式要求每个用户都有收件箱,因此需要为收件箱引入存储系统。
用户越多,收件箱占用的存储资源越多。
例如:
某用户有 100 万粉丝 |
这会带来明显的存储压力。
推模式的缺点 2:写扩散
如果某用户有 100 万粉丝,发布一条内容后,需要把这条内容写入 100 万个收件箱。
也就是说,系统内部产生了粉丝数倍数的写请求放大。
这种模式称为:写扩散
写扩散可能击垮收件箱所依赖的数据库。
本节总结
推模式的核心是:内容发布时主动推送到粉丝收件箱,用户读取时只读自己的收件箱。
推模式 |
推模式解决了读扩散,但带来了两个新问题:
1. 存储压力大 |
11.5 推拉结合模式
拉模式和推模式的优缺点正好互补。
拉模式:无额外存储压力,但读请求压力大 |
因此,可以把两者结合起来,形成推拉结合模式。
11.5.1 结合思路
对于拥有海量用户的互联网公司来说,拉模式虽然简单、无额外存储压力,但用户获取 Feed 流的性能表现通常无法接受。
这类公司更在意用户体验,也有足够的研发投入和存储资源,所以整体思路应该以推模式为基础,再针对推模式的问题做优化。
推模式的问题和内容发布者的粉丝数强相关:
粉丝越多 |
所以推拉结合模式的关键突破口是:
根据发布者是否是大 V 选择不同策略 |
1. 普通用户采用推模式
如果内容发布者不是大 V,就把内容推送到全部粉丝的收件箱中。
因为普通用户粉丝数较少,即使推模式会带来存储压力和写扩散,影响也相对可控。
2. 大 V 采用拉模式
如果内容发布者是大 V,则不把内容推送到所有粉丝的收件箱中。
用户读取 Timeline Feed 时,系统检查其关注列表中是否有大 V:
非大 V 内容:已经在用户收件箱中,直接读取 |
这种方式的效果是:
大 V 内容:避免巨大写扩散和存储放大 |
11.5.2 区分活跃用户
推拉结合模式的效果,依赖于用户关注列表中大 V 的数量。
如果用户关注的全是普通用户,那么推拉结合模式几乎等同于推模式,可以发挥读取快的优势。
如果用户关注的全是大 V,那么推拉结合模式会退化为拉模式,用户获取 Feed 的响应速度仍然可能很慢。
因此,大 V 发布内容后,也可以继续使用推模式,但只推送给部分活跃粉丝。
原因是:
活跃用户使用 Timeline Feed 的频率更高, |
对于长期未打开应用的非活跃用户,没有必要提前把大 V 内容写入他们的收件箱。
如果这些用户之后重新登录并读取 Timeline Feed,再通过推拉结合模式拉取内容即可。
最终规则可以总结为:
发布者是普通用户: |
11.6 实现 Timeline Feed 服务的关键技术细节
前面介绍的是推模式、拉模式和推拉结合模式的思路。
真正实现 Timeline Feed 服务,还需要解决内容推送、收件箱设计、读请求参数、存储选型和 Feed 数据合并等关键细节。
11.6.1 内容与用户收件箱的交互
在推模式下,用户发布内容后,系统需要把内容推送到粉丝收件箱。
一种简单但不推荐的做法是:在内容发布服务的发布流程后追加一段逻辑:
内容发布成功 |
这种做法有两个明显问题。
1. 服务职责耦合
Timeline Feed 的逻辑被写进内容发布服务,破坏了服务职责边界。
内容发布服务本应负责内容发布流程,不应该承担 Timeline Feed 的推送细节。
2. 推送可用性差
遍历推送发生在内容发布服务实例进程中。
如果内容发布服务升级、扩容,或者服务实例异常退出,正在进行的推送可能被中断。
例如:
需要推送 1000 个用户 |
使用消息队列解耦
更合适的方式,是在内容发布服务和 Timeline Feed 服务之间建立消息队列通道。
第 7 章已经提到,内容发布服务会通过消息队列把内容变更事件通知给内容分发渠道,主题是:
event_content_meta_change |
用户收件箱也可以看作一种内容分发渠道。
因此可以创建一个消费者服务,暂时称为 Timeline 消费者,负责订阅内容变更事件。
内容发布服务 |
内容与用户收件箱的交互架构如下图所示。

记录推送进度
为了提高推送可用性,Timeline 消费者需要记录每条内容的推送进度。
一种方式是按用户 ID 排序推送,并记录最近成功推送的用户 ID。
content_id → latest_user_id |
推送一条内容时:
1. 将待推送用户按照 user_id 从小到大排序 |
Timeline 消费者工作流程
Timeline 消费者主要流程如下:
1. 订阅 event_content_meta_change 事件 |
11.6.2 推送子任务
Timeline 消费者如果串行遍历推送,面对大量粉丝时耗时会很长。
这样会导致内容发布服务和 Timeline 消费者之间的消息队列积压,进而造成内容投递到用户收件箱的延迟。
解决思路是:
把一次大规模推送拆分为多个并行执行的子任务 |
例如测试发现单个消费者推送不超过 1000 个粉丝耗时较小,那么就可以把 1000 作为拆分粒度。
假设内容 A 需要推送给 3000 个粉丝,粉丝 user_id 为 1~3000,可以拆成:
子任务 1:粉丝 1~1000 |
此时原来的 Timeline 消费者不再直接执行推送,而是负责分发推送子任务,所以可以称为 Timeline 分发器。
真正执行任务的是新的消费者,称为 Timeline 执行器。
Timeline 分发器 |
推送子任务流程如下图所示。

每个子任务消息需要包含:
内容 ID |
Timeline 执行器执行子任务时,也要使用数据库记录推送进度。
由于同一内容会被拆成多个子任务,所以进度记录可以是 “内容ID-任务编号”
content_id + task_no → latest_user_id |
这样某个子任务失败后,只需要从该子任务的进度位置继续重试。
11.6.3 收件箱保存什么数据
收件箱用于保存被推送来的内容,但不应该保存内容本身。
如果把完整内容复制到每个粉丝收件箱,会造成严重的存储浪费。
举例:
1 篇文章大小:100KB |
这会把内容存储空间放大 10000 倍。
Timeline Feed 收件箱真正关心的是:
1. 用户 Feed 流中应该展示哪些内容 |
所以收件箱只需要保存:
content_id:唯一标识一条内容 |
内容原文、图片、视频等不放在收件箱中,后续再根据内容 ID 去内容发布服务获取。
11.6.4 读请求参数
Timeline Feed 服务的读请求,就是获取 Timeline Feed 流。
读取方式分为下拉和上滑。
1. 下拉操作需要的参数
下拉用于获取当前时间最新的 N 条内容。
从逻辑上看,只需要一个参数:
内容数量 N |
2. 上滑操作需要的参数
上滑用于获取用户已经读到的最后一条内容之后的更早内容。
因此需要知道:
当前已读最后一条内容的发布时间 ts |
但只使用时间戳还不够。
假设用户关注的 20 个用户在同一时间 1688576521 发布内容。
第一次展示了用户 1~10 的内容,继续上滑时如果只传:
ts = 1688576521 |
系统可能仍然返回用户 1~10 的内容,而不是用户 11~20 的内容。
因此,上滑还需要传入已经展示的最后一条内容 ID:
last_content_id |
上滑语义变成:
获取发布时间小于或等于 ts, |
最终读请求参数包括:
操作类型:下拉还是上滑 |
11.6.5 使用数据库实现收件箱
可以使用数据库实现用户收件箱。
表结构如下:
| 字段名 | 类型 | 含义 |
|---|---|---|
| id | BIGINT | 自增主键,无特殊含义 |
| user_id | BIGINT | 用户 ID |
| content_id | BIGINT | 内容 ID |
| publish_time | DATE | 内容发布时间 |
将内容插入收件箱,就是新增一条记录。
下拉 SQL
用户 111 下拉读取最新 Feed,就是读取该用户 publish_time 最大的 N 条记录。
SELECT content_id, publish_time |
为了让这条 SQL 高效执行,需要创建联合索引:
idx_feed(user_id, publish_time, content_id) |
这个索引有三个作用:
1. 通过 user_id 快速定位一个用户收件箱中的全部记录 |
如果 inbox 表需要分库分表,可以按 user_id 拆分。
上滑 SQL
用户上滑时,需要根据当前已读最后一条内容的发布时间 ts 和内容 ID last_content_id 查询更早内容。
SELECT content_id, publish_time |
这里有两层筛选逻辑:
publish_time < ts: |
之所以可以用 content_id < last_content_id 定位同一发布时间下的下一批内容,是因为联合索引保证了排序规则:
同一 user_id 下: |
而 Feed 展示时需要倒序读取:
publish_time 从大到小 |
数据库可以在 idx_feed 索引上从后向前扫描。
示例理解
假设用户 111 的收件箱在联合索引上排列如下:
| user_id | publish_time | content_id |
|---|---|---|
| 111 | 1678722102 | 163 |
| 111 | 1678722105 | 128 |
| 111 | 1678723222 | 627 |
| 111 | 1678723222 | 1673 |
| 111 | 1678723222 | 9266 |
| 111 | 1678723222 | 10833 |
| 111 | 1678723222 | 19671 |
| 111 | 1678910100 | 833 |
| 111 | 1678910150 | 927 |
| 111 | 1678910150 | 1991 |
| 111 | 1678910150 | 2025 |
| 111 | 1678952612 | 135 |
如果每次读取 3 条:
第一次下拉: |
这个例子说明,联合索引配合反向扫描,可以稳定支持 Timeline Feed 的排序和翻页。
使用数据库的 EXPLAIN 查看SQL语句执行计划,可以重点关注:
Using Index:命中索引 |
数据库方案的优劣
数据库实现收件箱的优势:
可以使用相对廉价的磁盘空间 |
劣势:
数据库应对高并发的能力有限 |
如果 Timeline Feed 场景流量巨大,那么数据库不一定有与之匹配的处理能力,只能通过横向进一步分库分表来解决高并发问题。
假设Timeline Feed场景有10万QPS的流量,而目前按照 user_id 维度仅将inbox表分为10个子表,那么一个子表平均会承受10000 QPS的流量;只有将inbox表重新扩展为100个子表,才能将每个子表的访问压力平均降低到 1000 QPS。
11.6.6 使用 Redis ZSET 实现收件箱
用户收件箱保存的是内容 ID,并且内容 ID 要按照发布时间排序。
Redis ZSET 很适合这个场景。
直观设计如下:
Key:inbox_{用户ID} |
例如,将发布时间为 1688659398 的内容 999 写入用户 111 的收件箱:
ZADD inbox_111 1688659398 999 |
用户下拉读取最新 N 条内容,即获取 Score 最大的 N 个 Member:
ZREVRANGE inbox_111 0 N-1 WITHSCORES |
难点在上滑读取更早内容。用户在进行上滑操作获取更早的Feed流时,需要使用最后已读内容的发布时间(ts)和内容ID(last_content_id)来筛选下一次应该读取的内容。ZSET的实现方式比较灵活,下面给出三种可行的方案。
方案一:直接使用 last_content_id 定位更早的 Feed 流
具体做法是:
- 先执行
ZREVRANK命令,获取last_content_id成员在 ZSET 中的排名,也就是Rank。 - 再执行
ZREVRANGE命令,读取该排名后面的N条内容。
这两个命令需要原子执行,所以可以使用 Lua 脚本实现。
传入参数为:
KEYS = { inbox_用户ID } |
Lua 脚本如下:
-- 先获取最后内容的排名 |
这种方案实现比较直接。
它的特点是:
- 不需要依赖发布时间。
- 可以天然覆盖多条内容在同一时间发布的场景。
但是,它的局限性也很大:
- 它仅适合拉模式。
- 它不适合推拉结合模式。
原因是:
在推拉结合模式下,获取到的 Timeline Feed 流数据中,某次 Feed 流的最后一条内容:
- 不一定来自用户收件箱;
- 也可能来自关注者的发件箱。
如果最后一条内容来自关注者的发件箱,继续上滑获取更早 Feed 流时,last_content_id 可能在用户收件箱中定位不到数据。
结果就是:
last_content_id 在用户收件箱中找不到 |
方案二:使用 ts 和 last_content_id 共同定位
采用推拉结合模式时,必须考虑内容发布时间。
所以可以给出第二种方案:
像数据库实现方式一样,使用:
ts + last_content_id |
共同获取上滑内容。
数据库中的实现思路
在数据库实现方式中,核心是依赖联合索引:
idx_feed(user_id, publish_time, content_id) |
这个索引可以保证:
同一用户收件箱中的内容,先按照 publish_time 升序排列; |
数据库中的筛选逻辑可以理解为:
从筛选出的第一条满足: |
ZSET 也应该采用类似思路。
其中:
数据库中的 publish_time 对应 ZSET 中的 Score |
ZSET 的排序规则
ZSET 中的成员排序规则如下:
1. 成员按照 Score 值从小到大排列。 |
看起来,ZSET 的排序规则和数据库联合索引 idx_feed 好像是一样的。
但是问题在于:
ZSET 会把 Member 视为字符串, |
直接把数字 content_id 作为 Member 的问题
以用户 111 收件箱中的内容为例,ZSET 产生的排列结果如下。
| Member(content_id) | Score(publish_time) |
|---|---|
| 163 | 1678722102 |
| 128 | 1678722105 |
| 10833 | 1678723222 |
| 1673 | 1678723222 |
| 19671 | 1678723222 |
| 627 | 1678723222 |
| 9266 | 1678723222 |
| 833 | 1678910100 |
| 1991 | 1678910150 |
| 2025 | 1678910150 |
| 927 | 1678910150 |
| 135 | 1678952612 |
看表中第 3 ~ 6 行:
10833、1673、19671、627 |
虽然这几个成员的 Score 相同,但是 Member 的排列结果是:
10833、1673、19671、627 |
原因是:
ZSET 将 Member 值视为字符串类型, |
如果按照数字顺序,应该是:
627、1673、10833、19671 |
因此,此时 ZSET 无法使用和数据库实现方式相同的筛选条件,来获取下一刷 Feed 流的数据。
方案三:将 content_id 格式化为 20 位字符串
为了解决字典序和数字顺序不一致的问题,可以让字典序的执行结果和数字顺序一致。
方法是:
将所有成员的 Member 值都格式化为等长字符串。 |
内容 ID 是 64 位整数,它可以表示的最大整数是:
18446744073709551615 |
这个数字转换为字符串后的长度是:
20 位 |
因此,可以先将内容 ID 转换为字符串,然后向前补 0,直到字符串长度正好达到 20 位。
补 0 后的排序效果
| 内容 ID | Member 值 | 字典序 |
|---|---|---|
| 10833 | 00000000000000010833 | 00000000000000000627 |
| 1673 | 00000000000000001673 | 00000000000000001673 |
| 19671 | 00000000000000019671 | 00000000000000010833 |
| 627 | 00000000000000000627 | 00000000000000019671 |
可以看到,补 0 后:
字典序: |
正好和数字顺序一致:
627、1673、10833、19671 |
也就是说:
补 0 后字符串的排序结果 |
插入用户收件箱前格式化 content_id
这种方案要求:
在将内容插入用户收件箱之前, |
对应的 Go 代码如下:
// 将内容插入用户收件箱中 |
插入 ZSET 后,含义变成:
Score = 内容发布时间 |
格式化后的用户收件箱排序结果
按照这种方式插入内容后,用户 111 收件箱中的 ZSET 成员排列情况如下。
| Member(content_id) | Score(publish_time) |
|---|---|
| 163 | 1678722102 |
| 128 | 1678722105 |
| 627 | 1678723222 |
| 1673 | 1678723222 |
| 9266 | 1678723222 |
| 10833 | 1678723222 |
| 19671 | 1678723222 |
| 833 | 1678910100 |
| 927 | 1678910150 |
| 1991 | 1678910150 |
| 2025 | 1678910150 |
| 135 | 1678952612 |
此时可以看到:
当 Score 值相同时, |
因此,ZSET 终于拥有了和数据库联合索引 idx_feed 相同的排序特性:
先按照 publish_time 排序; |
这样就可以使用和数据库实现方式相同的筛选条件,来获取下一刷 Feed 流的数据。
上滑获取下一刷 Feed 流的流程
具体流程如下。
1. 获取 Score 小于或等于 ts 的全部 Member
执行命令:
ZREVRANGEBYSCORE inbox_111 ts 0 WITHSCORES |
含义是:
获取 inbox_111 中 Score <= ts 的全部成员, |
2. 从前向后遍历结果
遍历返回结果。
如果发现某个成员满足:
Score <= ts |
则停止遍历。
3. 从停止遍历的位置开始截取最多 N 条记录
从停止遍历的位置开始,截取最多 N 条记录。
这些记录就是:
用户上滑操作需要展示的内容 ID 列表。 |
上滑操作的 Go 代码
下面是这种方案实现上滑操作的 Go 代码。
// 此结构体表示 Feed 流中的一条内容 |
为什么方案三是最终选择
经过特殊处理后,ZSET 的排序规则和数据库联合索引 idx_feed 的排序规则保持一致。
也就是说:
ZSET 的第一个满足: |
因此,这种方案可以保证:
在推拉结合模式下, |
所以它是最终选择。
使用 Redis 实现用户收件箱的优劣
使用 Redis 实现用户收件箱的优势是:
可以依靠 Redis 的高性能, |
但是劣势也很明显:
用户收件箱会占用昂贵的内存资源, |
11.6.7 通过推拉结合模式构建 Timeline Feed 数据
从 11.6.1 节的介绍可以看出,推拉结合模式对于内容发布侧来说非常清晰:
粉丝少的用户:采用推模式 |
那么,对于用户读取侧呢?用户读取 Timeline Feed 流时,怎样才能体现推拉结合模式?
读取侧如何体现推拉结合模式
当用户读取 Timeline Feed 流时:
如果拉取关注列表中所有用户的发件箱,那么就是拉模式。 |
关键问题是:需要拉取哪些用户的发件箱?
答案是:排除收件箱中内容的发布者。
例如:
用户 A 关注了用户 B 和用户 C。 |
这说明:
用户 C 发布的内容没有被推送到用户 A 的收件箱中; |
所以,只需要对用户 C 采用拉模式。
推拉结合模式的边界点
最终,推拉结合模式的边界点是:
收件箱中内容的发布者:在发布内容时采用了推模式。 |
所以,用户在读取 Timeline Feed 流时,应该通过如下流程进行选择。
1. 拉取用户的关注列表。 |
推送数据和拉取数据的合并
明确了推拉结合模式的执行条件后,还需要分析如何将推送的数据和拉取得到的内容组合起来,作为用户下一刷 Feed 流的内容。
这时需要对:
发件箱中的内容列表 |
进行合并操作。
假设现在要拉取 M 个关注者发件箱中的内容,则流程如下。
1. 从收件箱中读取 N 条内容。 |
下拉获取最新 Feed 流的工作流程
当用户下拉获取最新的 Feed 流时,推拉结合模式的工作流程如下。
1. 拉取用户的关注列表。 |
其中,第 5 步的合并规则是:
① 对比每个内容列表的首条内容,选出发布时间值最大的内容。 |
上滑获取更早 Feed 流的工作流程
当用户上滑获取更早的 Feed 流时,需要依赖:
last_content_id:最后一条内容的内容 ID |
推拉结合模式的工作流程如下。
1. 拉取用户的关注列表,同样检查关注列表中哪些关注者向用户收件箱中投递了内容。 |
完整例子:用户 111 的 Timeline Feed 构建过程
假设用户 111 关注了 5 个用户:
用户 200 |
而在用户 111 的收件箱中,有下面这些用户推送的内容:
用户 222 |
接下来看看用户 111:
进入 Timeline Feed 页面,也就是下拉; |
第 1 次请求:进入 Timeline Feed 页面
当用户 111 进入 Timeline Feed 页面时,Timeline Feed 服务收到下拉类型的获取 Feed 流请求。
此时处理逻辑如下。
1. 获取用户 111 的关注列表和收件箱。 |
用户 200 发件箱:
| 内容 ID | 发布时间 |
|---|---|
| 32850 | 1689089522 |
| 16020 | 1688986368 |
| 19732 | 1688905999 |
| 61186 | 1688718647 |
| 80723 | 1688616936 |
用户 211 发件箱:
| 内容 ID | 发布时间 |
|---|---|
| 50015 | 1689087139 |
| 71658 | 1688986368 |
| 18253 | 1688975221 |
| 73798 | 1688803287 |
| 92090 | 1688617305 |
| 82553 | 1685305893 |
用户 111 收件箱:
| 内容 ID | 发布时间 |
|---|---|
| 25218 | 1689087991 |
| 38376 | 1689087139 |
| 12572 | 1688986368 |
| 75256 | 1688803287 |
| 81709 | 1688718647 |
| 13320 | 1688617305 |
| 39203 | 1688616936 |
第 1 次请求:拉取用户 200 和用户 211 的最近 3 条内容
从内容发布服务中拉取用户 200 和用户 211 最近发布的 3 条内容。
分别得到一个内容列表。
list_200 = [ |
其中:
list_200:用户 200 最近发布的 3 条内容 |
第 1 次请求:从用户 111 收件箱中读取最近 3 条内容
从用户 111 的收件箱中读取最近 3 条内容,得到内容列表:
list_111 = [ |
第 1 次请求:合并 3 个内容列表
接下来,将推拉结合模式下得到的这 3 个内容列表进行合并操作。
第 1 轮合并
对比:
list_200 的首元素:(32850, 1689089522) |
发现内容 32850 的发布时间值最大。
所以它作为此次 Feed 流的第 1 条内容。
同时将 list_200 更新为:
list_200 = [ |
第 2 轮合并
继续对比:
list_200 的首元素:(16020, 1688986368) |
发现内容 25218 的发布时间值最大。
所以它作为此次 Feed 流的第 2 条内容。
同时将 list_111 更新为:
list_111 = [ |
第 3 轮合并
再次对比:
list_200 的首元素:(16020, 1688986368) |
发现内容 38376 和内容 50015 的发布时间一样,都是最大的。
但是在数值上:
50015 > 38376 |
所以将内容 50015 作为此次 Feed 流的第 3 条内容。
此时 Feed 流的内容已经达到 3 条,合并操作结束。
第 1 次请求:最终得到的 Feed 流
用户刷出 Feed 流的内容,最终数据依次为:
(32850, 1689089522) |
第 2 次请求:用户第 1 次上滑
用户 111 看完这 3 条内容后,选择上滑获取更多的 Feed 流。
目前此用户已读的最后一条内容是:
内容 ID:50015 |
Timeline Feed 服务收到上滑请求,开始构建更早的 3 条内容。
第 2 次请求:获取关注列表和收件箱
1. 获取用户 111 的关注列表和收件箱。 |
第 2 次请求:从内容发布服务中拉取用户 200 和用户 211 的内容
从内容发布服务中拉取用户 200 和用户 211 的内容。
条件是:
发布时间 <= 1689087139 |
因为本次需要获取 3 条内容,所以每个内容列表拉取:
N + 1 = 4 条内容 |
得到:
list_200 = [ |
第 2 次请求:删除已经读过的内容
list_211 的首元素内容发布时间为:
1689087139 |
并且正好是已读内容 50015 的发布时间。
因此,将其从内容列表中删除。
list_211 被更新为:
list_211 = [ |
第 2 次请求:从用户收件箱中获取内容
在用户收件箱中,获取满足下面条件的最近 N 条内容:
publish_time < ts |
得到:
list_111 = [ |
第 2 次请求:合并 3 个内容列表
第 1 轮合并
对比:
list_200 的首元素:(16020, 1688986368) |
发现内容 38376 的发布时间值最大。
所以它作为此次 Feed 流的第 1 条内容。
同时将 list_111 更新为:
list_111 = [ |
第 2 轮合并
继续对比:
list_200 的首元素:(16020, 1688986368) |
发现所有首元素内容的发布时间值都相同。
此时选择内容 ID 更大的内容:
71658 |
作为 Feed 流的第 2 条内容。
同时将 list_211 更新为:
list_211 = [ |
第 3 轮合并
再次对比:
list_200 的首元素 |
发现内容 16020 和内容 12572 的发布时间值一样,都是最大的。
此时选择数值较大的内容:
16020 |
作为 Feed 流的第 3 条内容。
合并操作完成。
第 2 次请求:最终得到的 Feed 流
用户上滑得到的 Feed 流数据依次为:
(38376, 1689087139) |
第 3 次请求:用户第 2 次上滑
用户 111 继续进行上滑操作。
此时此用户已读的最后一条内容是:
内容 ID:16020 |
Timeline Feed 服务执行的逻辑如下。
第 3 次请求:获取关注列表和收件箱
1. 获取用户 111 的关注列表和收件箱。 |
第 3 次请求:从内容发布服务中拉取用户 200 和用户 211 的内容
从内容发布服务中拉取用户 200 和用户 211 的内容。
条件是:
发布时间不大于 1688986368 |
每个内容列表拉取:
N + 1 = 4 条内容 |
得到:
list_200 = [ |
第 3 次请求:删除已经读过的内容
list_200 的首元素内容发布时间为:
1688986368 |
并且内容 ID 为:
16020 |
list_211 的首元素内容发布时间也为:
1688986368 |
并且内容 ID 大于:
16020 |
所以需要删除这两个内容列表的首元素。
删除后,两个内容列表更新如下。
list_200 = [ |
第 3 次请求:从用户收件箱中获取内容
在用户收件箱中,获取满足下面条件的最近 N 条内容:
publish_time < ts |
得到:
list_111 = [ |
第 3 次请求:合并 3 个内容列表
第 1 轮合并
对比:
list_200 的首元素:(19732, 1688905999) |
发现内容 12572 的发布时间值最大。
所以它作为此次 Feed 流的第 1 条内容。
同时将 list_111 更新为:
list_111 = [ |
第 2 轮合并
继续对比:
list_200 的首元素 |
发现内容 18253 的发布时间值最大。
所以它作为此次 Feed 流的第 2 条内容。
同时将 list_211 更新为:
list_211 = [ |
第 3 轮合并
再次对比:
list_200 的首元素 |
发现内容 19732 的发布时间值最大。
所以它作为此次 Feed 流的第 3 条内容。
合并操作完成。
第 3 次请求:最终得到的 Feed 流
用户上滑得到的 Feed 流数据依次为:
(12572, 1688986368) |
第 4 次请求:用户第 3 次上滑
用户 111 第 3 次进行上滑操作。
此时此用户已读的最后一条内容是:
内容 ID:19732 |
Timeline Feed 服务执行的逻辑如下。
第 4 次请求:获取关注列表和收件箱
1. 获取用户 111 的关注列表和收件箱。 |
第 4 次请求:从内容发布服务中拉取用户 200 和用户 211 的内容
从内容发布服务中拉取用户 200 和用户 211 的内容。
条件是:
发布时间不大于 1688905999 |
每个内容列表拉取:
N + 1 = 4 条内容 |
得到:
list_200 = [ |
第 4 次请求:删除已经读过的内容
list_200 的首元素内容发布时间为:
1688905999 |
并且内容 ID 正好为:
19732 |
所以将其从内容列表中删除。
list_200 被更新为:
list_200 = [ |
第 4 次请求:从用户收件箱中获取内容
在用户收件箱中,获取满足下面条件的最近 N 条内容:
publish_time < ts |
得到:
list_111 = [ |
第 4 次请求:合并 3 个内容列表
第 1 轮合并
对比:
list_200 的首元素 |
发现内容 73798 和内容 75256 的发布时间一样,都是最大的。
此时选择数值较大的内容:
75256 |
作为此次 Feed 流的第 1 条内容。
第 2 轮合并
再次对比:
list_200 的首元素 |
发现内容 73798 的发布时间值最大。
所以它作为此次 Feed 流的第 2 条内容。
第 3 轮合并
依然对比:
list_200 的首元素 |
发现内容 61186 和内容 81709 的发布时间一样,都是当前最大的。
此时选择数值较大的内容:
81709 |
作为此次 Feed 流的第 3 条内容。
合并操作结束。
第 4 次请求:最终得到的 Feed 流
用户上滑得到的 Feed 流数据依次为:
(75256, 1688803287) |
用户进入页面并上滑 3 次后读取的内容
最终,用户进入 Timeline Feed 页面并上滑 3 次,一共读取的内容如下。
| 内容 ID | 内容发布时间 |
|---|---|
| 32850 | 1689089522 |
| 25218 | 1689087991 |
| 50015 | 1689087139 |
| 38376 | 1689087139 |
| 71658 | 1688986368 |
| 16020 | 1688986368 |
| 12572 | 1688986368 |
| 18253 | 1688975221 |
| 19732 | 1688905999 |
| 75256 | 1688803287 |
| 73798 | 1688803287 |
| 81709 | 1688718647 |
可以看到,用户获取的 Feed 流内容满足:
1. 所有关注者发布的内容, |
这可以充分证明:
我们实现获取 Feed 流的方案是正确的。 |
11.6.8 收尾工作
前面解决的是:本次 Feed 流应该展示哪些内容 ID。
但用户真正看到的不是内容 ID,而是完整内容。
所以拿到内容 ID 列表后,还需要继续组装用户可读的 Feed 数据。
需要获取的信息包括:
内容发布服务: |
得到这些信息后,Feed 流才能完整呈现在用户面前。
另外,对于大 V 发布的内容,Timeline Feed 服务适合做本地缓存。
原因是大 V 的内容会被很多用户刷到,如果每次都通过 RPC 请求内容发布服务、用户服务打包详情,会带来大量重复请求。
本地缓存可以减少这些重复调用:
大 V 内容详情命中本地缓存 |
本节总结
实现 Timeline Feed 服务,需要把推拉结合模式落到多个技术细节上。
内容发布侧:
内容发布服务不直接遍历推送 |
推送可用性:
记录推送进度 |
收件箱数据:
只保存 content_id 和 publish_time |
读请求:
下拉:最新 N 条 |
收件箱存储可以用数据库或 Redis ZSET:
数据库:依赖 idx_feed(user_id, publish_time, content_id) 联合索引 |
读取侧通过合并收件箱列表和部分关注者发件箱列表,构建最终 Feed 流。
11.7 本章小结
本章详细介绍了应该如何实现一个推拉结合模式的 Timeline Feed 服务。
推模式和拉模式结合的重点在于内容发布者的粉丝数和粉丝活跃情况。
用户在发布内容后,系统先检查用户的粉丝数:
- 如果粉丝数小于阈值,则将内容推送到每个粉丝的收件箱中;
- 如果粉丝数大于阈值,则只向部分活跃粉丝推送内容;
- 对于其他粉丝,他们在刷新 Feed 流时采用拉模式获取内容。
按照粉丝数将推送任务拆分为多个子任务,可以提高内容推送效率。
Timeline Feed 流数据要求内容按照发布时间从近到远排序。
对于发布时间相同的内容,我们需要制定固定的排序规则,本章使用的排序规则是:
按照内容 ID 从大到小排列 |
在此基础上,用户下拉和上滑读取 Feed 流的两种操作方式的语义可以被描述如下。
下拉操作:获取在当前时间下最新的
N条内容。上滑操作:根据当前已刷到的最后一条内容的发布时间
ts和内容 IDlast_content_idpublish_time < ts
或者
publish_time = ts 且 content_id < last_content_id
用户收件箱的实现方案
我们介绍了使用数据库和 Redis ZSET 实现用户收件箱的方案。
1. 数据库方案
对于数据库方案来说,重点是创建联合索引:
idx_feed(user_id, publish_time, content_id) |
在此索引上,每个用户的收件箱内容都会按照:
publish_time 从小到大排列 |
当 publish_time 相同时,再进一步按照:
content_id 从小到大排列 |
所以,从后向前扫描索引正好与 Timeline Feed 流内容的排序规则相吻合。
2. Redis ZSET 方案
对于 Redis ZSET 方案来说,我们给出了三种方案:
方案一
第一种方案是完全根据 last_content_id 直接定位下一条内容。
但这种方案不适合推拉结合模式。
方案二
第二种方案是先获取发布时间小于或等于 ts 的全部内容,再过滤筛选。
方案三
第三种方案则是对第二种方案的优化:
把内容 ID 格式化为 20 位长度的字符串用作 Member 字段 |
这样可以保证在 ZSET 中,发布时间相同的内容按照内容 ID 的数值从小到大排列。
这样一来,在 ZSET 中从后向前扫描就与 Timeline Feed 流内容的排序规则相吻合了。
推拉结合模式下 Feed 流数据的合并
在推拉结合模式下,需要对:
M个关注者的内容列表;- 来自用户收件箱的内容列表;
进行合并来构建 Feed 流数据。
合并过程如下(简化版,少了内容):
- 循环对比这些内容列表的首元素;
- 看哪条内容的发布时间值最大;
- 如果有多条内容的发布时间一样都是最大的,则选出内容 ID 更大的那一条内容;
- 然后将所得到的内容从内容列表中删除,并加入 Feed 流中;
- 当 Feed 流内容达到指定的数量时,或者这些内容列表都为空时,循环结束。