11.1 Feed 流的分类

Feed 流是互联网应用和社交平台中非常核心的信息展示形式。

它通常以时间线为基础,把用户可能感兴趣的内容聚合到 Feed 页面中。很多互联网产品的主页都是 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 请求

读取 N 个关注者的发件箱

产生 N 倍读请求

这种模式称为:读扩散

如果用户量级较大、关注人数较多时,那么获取Feed流会是一个高并发场景,读扩散会带来两个问题:

1. 增加用户请求延迟
2. 可能击垮存储用户内容列表的数据库

本节总结

拉模式的核心是:用户读取 Feed 时,临时去关注者的发件箱中拉取内容。

优点是实现简单、无需额外存储;缺点是读扩散严重。

拉模式
├── 写内容:简单,只写发布者自己的内容列表
└── 读 Feed:复杂,要遍历关注者内容列表

一句话总结:拉模式省存储、省写入,但会把压力集中到读取 Feed 的时候。

11.4 推模式与用户收件箱

另一种实现 Timeline Feed 的方式是推模式。

推模式和拉模式相反:每个用户都有一个“收件箱”。当某个用户成功发布内容时,系统会把这条内容推送到每个粉丝用户的收件箱中。

粉丝用户读取 Feed 流时,直接读取自己的收件箱即可。

推模式的基本流程是:

用户发布内容

获取发布者的粉丝列表

把内容写入每个粉丝的收件箱

粉丝读取 Feed 时直接读自己的收件箱

推模式的优点

推模式下,用户读取 Feed 流的性能更好。

因为系统不需要在读取时再获取关注列表,也不需要遍历每个关注者的发件箱。

读 Feed = 读取自己的收件箱

推模式的缺点 1:存储压力大

推模式要求每个用户都有收件箱,因此需要为收件箱引入存储系统。

用户越多,收件箱占用的存储资源越多。

例如:

某用户有 100 万粉丝
发布 1 条内容
需要在 100 万个粉丝收件箱中保存这条内容

这会带来明显的存储压力。

推模式的缺点 2:写扩散

如果某用户有 100 万粉丝,发布一条内容后,需要把这条内容写入 100 万个收件箱。

也就是说,系统内部产生了粉丝数倍数的写请求放大。

这种模式称为:写扩散

写扩散可能击垮收件箱所依赖的数据库。


本节总结

推模式的核心是:内容发布时主动推送到粉丝收件箱,用户读取时只读自己的收件箱。

推模式
├── 写内容:复杂,要写入大量粉丝收件箱
└── 读 Feed:简单,直接读用户自己的收件箱

推模式解决了读扩散,但带来了两个新问题:

1. 存储压力大
2. 写扩散

11.5 推拉结合模式

拉模式和推模式的优缺点正好互补。

拉模式:无额外存储压力,但读请求压力大
推模式:读请求压力小,但写请求压力和存储压力大

因此,可以把两者结合起来,形成推拉结合模式。


11.5.1 结合思路

对于拥有海量用户的互联网公司来说,拉模式虽然简单、无额外存储压力,但用户获取 Feed 流的性能表现通常无法接受。

这类公司更在意用户体验,也有足够的研发投入和存储资源,所以整体思路应该以推模式为基础,再针对推模式的问题做优化。

推模式的问题和内容发布者的粉丝数强相关:

粉丝越多
├── 收件箱存储放大越严重
└── 写扩散越严重

所以推拉结合模式的关键突破口是:

根据发布者是否是大 V 选择不同策略

1. 普通用户采用推模式

如果内容发布者不是大 V,就把内容推送到全部粉丝的收件箱中。

因为普通用户粉丝数较少,即使推模式会带来存储压力和写扩散,影响也相对可控。

2. 大 V 采用拉模式

如果内容发布者是大 V,则不把内容推送到所有粉丝的收件箱中。

用户读取 Timeline Feed 时,系统检查其关注列表中是否有大 V:

非大 V 内容:已经在用户收件箱中,直接读取
大 V 内容:临时从大 V 的发件箱中拉取

这种方式的效果是:

大 V 内容:避免巨大写扩散和存储放大
普通用户内容:避免读取时产生严重读扩散

11.5.2 区分活跃用户

推拉结合模式的效果,依赖于用户关注列表中大 V 的数量。

如果用户关注的全是普通用户,那么推拉结合模式几乎等同于推模式,可以发挥读取快的优势。

如果用户关注的全是大 V,那么推拉结合模式会退化为拉模式,用户获取 Feed 的响应速度仍然可能很慢。

因此,大 V 发布内容后,也可以继续使用推模式,但只推送给部分活跃粉丝。

原因是:

活跃用户使用 Timeline Feed 的频率更高,
把大 V 内容提前推到他们的收件箱中,
更可能提升读取性能。

对于长期未打开应用的非活跃用户,没有必要提前把大 V 内容写入他们的收件箱。

如果这些用户之后重新登录并读取 Timeline Feed,再通过推拉结合模式拉取内容即可。

最终规则可以总结为:

发布者是普通用户:
完全采用推模式,推送到全部粉丝收件箱

发布者是大 V:
对活跃粉丝采用推模式
对非活跃粉丝采用拉模式

11.6 实现 Timeline Feed 服务的关键技术细节

前面介绍的是推模式、拉模式和推拉结合模式的思路。

真正实现 Timeline Feed 服务,还需要解决内容推送、收件箱设计、读请求参数、存储选型和 Feed 数据合并等关键细节。


11.6.1 内容与用户收件箱的交互

在推模式下,用户发布内容后,系统需要把内容推送到粉丝收件箱。

一种简单但不推荐的做法是:在内容发布服务的发布流程后追加一段逻辑:

内容发布成功

获取发布者粉丝列表

获取粉丝最近登录时间,筛选活跃粉丝

遍历粉丝,请求 Timeline Feed 服务写入收件箱

这种做法有两个明显问题。

1. 服务职责耦合

Timeline Feed 的逻辑被写进内容发布服务,破坏了服务职责边界。

内容发布服务本应负责内容发布流程,不应该承担 Timeline Feed 的推送细节。

2. 推送可用性差

遍历推送发生在内容发布服务实例进程中。

如果内容发布服务升级、扩容,或者服务实例异常退出,正在进行的推送可能被中断。

例如:

需要推送 1000 个用户
已推送 100 个用户时实例退出
剩余 900 个用户不会收到该内容

使用消息队列解耦

更合适的方式,是在内容发布服务和 Timeline Feed 服务之间建立消息队列通道。

第 7 章已经提到,内容发布服务会通过消息队列把内容变更事件通知给内容分发渠道,主题是:

event_content_meta_change

用户收件箱也可以看作一种内容分发渠道。

因此可以创建一个消费者服务,暂时称为 Timeline 消费者,负责订阅内容变更事件。

内容发布服务
↓ 发送内容变更事件
消息队列 event_content_meta_change

Timeline 消费者

写入用户收件箱

内容与用户收件箱的交互架构如下图所示。

image-20260530151946175

记录推送进度

为了提高推送可用性,Timeline 消费者需要记录每条内容的推送进度。

一种方式是按用户 ID 排序推送,并记录最近成功推送的用户 ID。

content_id → latest_user_id

推送一条内容时:

1. 将待推送用户按照 user_id 从小到大排序
2. 按顺序遍历推送
3. 每推送成功一个用户,就更新 latest_user_id
4. 如果失败或实例退出,消息队列触发重试
5. 重试时从 latest_user_id 之后继续推送

Timeline 消费者工作流程

Timeline 消费者主要流程如下:

1. 订阅 event_content_meta_change 事件   
2. 判断事件是否表示内容成功发布,不是则跳过
3. 根据 content_id 从数据库获取推送进度,获取到的结果记为 latest_user_id,未获取到则latest_user_id为 0
4. 获取内容发布者的粉丝列表
5. 如果粉丝数不大于推送阈值,则全部作为待推送用户
6. 如果粉丝数大于阈值,则根据最近登录时间筛选活跃粉丝(最近登录时间距现在小
于M天的)
7. 将待推送粉丝按 user_id 从小到大排序
8. 从 user_id 大于 latest_user_id 的位置继续遍历推送
9. 推送成功(成功向此用户收件箱插入内容)则继续,失败则抛错,由消息队列保证重试
10. 全部粉丝推送完成后,消费结束

11.6.2 推送子任务

Timeline 消费者如果串行遍历推送,面对大量粉丝时耗时会很长。

这样会导致内容发布服务和 Timeline 消费者之间的消息队列积压,进而造成内容投递到用户收件箱的延迟。

解决思路是:

把一次大规模推送拆分为多个并行执行的子任务

例如测试发现单个消费者推送不超过 1000 个粉丝耗时较小,那么就可以把 1000 作为拆分粒度。

假设内容 A 需要推送给 3000 个粉丝,粉丝 user_id 为 1~3000,可以拆成:

子任务 1:粉丝 1~1000
子任务 2:粉丝 1001~2000
子任务 3:粉丝 2001~3000

此时原来的 Timeline 消费者不再直接执行推送,而是负责分发推送子任务,所以可以称为 Timeline 分发器。

真正执行任务的是新的消费者,称为 Timeline 执行器。

Timeline 分发器
↓ 生成子任务
timeline_push 消息队列

Timeline 执行器

执行收件箱写入

推送子任务流程如下图所示。

image-20260530163806754

每个子任务消息需要包含:

内容 ID
发布时间
任务编号
目标粉丝 user_id 列表

Timeline 执行器执行子任务时,也要使用数据库记录推送进度。

由于同一内容会被拆成多个子任务,所以进度记录可以是 “内容ID-任务编号”

content_id + task_no → latest_user_id

这样某个子任务失败后,只需要从该子任务的进度位置继续重试。


11.6.3 收件箱保存什么数据

收件箱用于保存被推送来的内容,但不应该保存内容本身。

如果把完整内容复制到每个粉丝收件箱,会造成严重的存储浪费。

举例:

1 篇文章大小:100KB
粉丝数量:10000
如果复制完整文章到每个粉丝收件箱:
100KB × 10000 ≈ 1GB

这会把内容存储空间放大 10000 倍。

Timeline Feed 收件箱真正关心的是:

1. 用户 Feed 流中应该展示哪些内容
2. 这些内容能否按发布时间从近到远排序

所以收件箱只需要保存:

content_id:唯一标识一条内容
publish_time:内容发布时间,用于排序

内容原文、图片、视频等不放在收件箱中,后续再根据内容 ID 去内容发布服务获取。


11.6.4 读请求参数

Timeline Feed 服务的读请求,就是获取 Timeline Feed 流。

读取方式分为下拉和上滑。

1. 下拉操作需要的参数

下拉用于获取当前时间最新的 N 条内容。

从逻辑上看,只需要一个参数:

内容数量 N

2. 上滑操作需要的参数

上滑用于获取用户已经读到的最后一条内容之后的更早内容。

因此需要知道:

当前已读最后一条内容的发布时间 ts

但只使用时间戳还不够。

假设用户关注的 20 个用户在同一时间 1688576521 发布内容。

第一次展示了用户 1~10 的内容,继续上滑时如果只传:

ts = 1688576521
N = 10

系统可能仍然返回用户 1~10 的内容,而不是用户 11~20 的内容。

因此,上滑还需要传入已经展示的最后一条内容 ID:

last_content_id

上滑语义变成:

获取发布时间小于或等于 ts,
且排列在 last_content_id 之后的 N 条内容。

最终读请求参数包括:

操作类型:下拉还是上滑
时间戳 ts:当前 Feed 中最后一条内容的发布时间,上滑需要
内容数量 N:本次返回多少条内容
最后内容 ID last_content_id:当前已展示最后一条内容 ID,上滑需要

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
FROM inbox
WHERE user_id = 111
ORDER BY publish_time DESC
LIMIT N;

为了让这条 SQL 高效执行,需要创建联合索引:

idx_feed(user_id, publish_time, content_id)

这个索引有三个作用:

1. 通过 user_id 快速定位一个用户收件箱中的全部记录
2. 同一 user_id 下,publish_time 天然有序,避免额外排序
3. content_id 放在索引中,可以形成覆盖索引,避免回表

如果 inbox 表需要分库分表,可以按 user_id 拆分。

上滑 SQL

用户上滑时,需要根据当前已读最后一条内容的发布时间 ts 和内容 ID last_content_id 查询更早内容。

SELECT content_id, publish_time
FROM inbox
WHERE user_id = 111
AND (
publish_time < ts
OR (publish_time = ts AND content_id < last_content_id)
)
ORDER BY publish_time DESC
LIMIT N;

这里有两层筛选逻辑:

publish_time < ts:
获取发布时间更早的内容

publish_time = ts AND content_id < last_content_id:
处理多条内容发布时间相同的情况

之所以可以用 content_id < last_content_id 定位同一发布时间下的下一批内容,是因为联合索引保证了排序规则:

同一 user_id 下:
先按 publish_time 从小到大排列
publish_time 相同,再按 content_id 从小到大排列

而 Feed 展示时需要倒序读取:

publish_time 从大到小
publish_time 相同,content_id 从大到小

数据库可以在 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 条:

第一次下拉:
135、2025、1991

继续上滑,last_content_id=1991,ts=1678910150:
927、833、19671

继续上滑,last_content_id=19671,ts=1678723222:
10833、9266、1673

再继续上滑:
627、128、163

这个例子说明,联合索引配合反向扫描,可以稳定支持 Timeline Feed 的排序和翻页。

使用数据库的 EXPLAIN 查看SQL语句执行计划,可以重点关注:

Using Index:命中索引
Backward Index Scan:在索引上从后向前扫描

数据库方案的优劣

数据库实现收件箱的优势:

可以使用相对廉价的磁盘空间

劣势:

数据库应对高并发的能力有限

如果 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}
Member:content_id
Score:publish_time

例如,将发布时间为 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 流

具体做法是:

  1. 先执行 ZREVRANK 命令,获取 last_content_id 成员在 ZSET 中的排名,也就是 Rank
  2. 再执行 ZREVRANGE 命令,读取该排名后面的 N 条内容。

这两个命令需要原子执行,所以可以使用 Lua 脚本实现。

传入参数为:

KEYS = { inbox_用户ID }
ARGV = { last_content_id, N }

Lua 脚本如下:

-- 先获取最后内容的排名
local rank = redis.call('ZREVRANK', KEYS[1], ARGV[1])

-- 如果找不到成员,则返回空
if rank == false then
return {}
end

-- 从最后内容的下一位开始拉取 N 条内容
local res = redis.call('ZREVRANGE', KEYS[1], rank + 1, rank + ARGV[2], 'WITHSCORES')

return res

这种方案实现比较直接。

它的特点是:

  • 不需要依赖发布时间
  • 可以天然覆盖多条内容在同一时间发布的场景

但是,它的局限性也很大:

  • 仅适合拉模式
  • 不适合推拉结合模式

原因是:

在推拉结合模式下,获取到的 Timeline Feed 流数据中,某次 Feed 流的最后一条内容:

  • 不一定来自用户收件箱;
  • 也可能来自关注者的发件箱。

如果最后一条内容来自关注者的发件箱,继续上滑获取更早 Feed 流时,last_content_id 可能在用户收件箱中定位不到数据。

结果就是:

last_content_id 在用户收件箱中找不到
=> 拉取用户收件箱数据的结果永远为空

方案二:使用 tslast_content_id 共同定位

采用推拉结合模式时,必须考虑内容发布时间

所以可以给出第二种方案:

像数据库实现方式一样,使用:

ts + last_content_id

共同获取上滑内容。


数据库中的实现思路

在数据库实现方式中,核心是依赖联合索引:

idx_feed(user_id, publish_time, content_id)

这个索引可以保证:

同一用户收件箱中的内容,先按照 publish_time 升序排列;
当 publish_time 相同时,再按照 content_id 升序排列。

数据库中的筛选逻辑可以理解为:

从筛选出的第一条满足:

publish_time < ts
或者
publish_time = ts 且 content_id < last_content_id

的记录开始,作为用户上滑应该看到的结果。

ZSET 也应该采用类似思路。

其中:

数据库中的 publish_time   对应 ZSET 中的 Score
数据库中的 content_id 对应 ZSET 中的 Member

ZSET 的排序规则

ZSET 中的成员排序规则如下:

1. 成员按照 Score 值从小到大排列。
2. 当 Score 值相同时,再按照 Member 值的字典序从小到大排列。

看起来,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 后:

字典序:
00000000000000000627
00000000000000001673
00000000000000010833
00000000000000019671

正好和数字顺序一致:

627、1673、10833、19671

也就是说:

补 0 后字符串的排序结果
=
整数的数字顺序排序结果

插入用户收件箱前格式化 content_id

这种方案要求:

在将内容插入用户收件箱之前,
先将内容 ID 格式化为 20 位长度的字符串。

对应的 Go 代码如下:

// 将内容插入用户收件箱中
// userId: 用户 ID
// ts: 内容发布时间
// contentId: 内容 ID
func (t *TimelineFeedService) InsertInbox(ctx context.Context, userId, ts, contentId int64) error {
// 拼接用户收件箱 Key
key := fmt.Sprintf("inbox_%d", userId)

// 将内容 ID 格式化为 20 位长度的字符串,以 0 开头
formatMember := fmt.Sprintf("%020d", contentId)

// Member 值为 20 位长度的字符串,Score 为内容发布时间
return redisClient.ZAdd(ctx, key, &redis.Z{
Score: float64(ts),
Member: formatMember,
}).Err()
}

插入 ZSET 后,含义变成:

Score  = 内容发布时间
Member = 20 位长度的内容 ID 字符串

格式化后的用户收件箱排序结果

按照这种方式插入内容后,用户 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 值相同时,
Member 确实是按照 content_id 的数字顺序排列的。

因此,ZSET 终于拥有了和数据库联合索引 idx_feed 相同的排序特性:

先按照 publish_time 排序;
publish_time 相同时,再按照 content_id 排序。

这样就可以使用和数据库实现方式相同的筛选条件,来获取下一刷 Feed 流的数据。


上滑获取下一刷 Feed 流的流程

具体流程如下。

1. 获取 Score 小于或等于 ts 的全部 Member

执行命令:

ZREVRANGEBYSCORE inbox_111 ts 0 WITHSCORES

含义是:

获取 inbox_111 中 Score <= ts 的全部成员,
并且同时返回 Score。

2. 从前向后遍历结果

遍历返回结果。

如果发现某个成员满足:

Score <= ts
并且 Member < last_content_id

则停止遍历。


3. 从停止遍历的位置开始截取最多 N 条记录

从停止遍历的位置开始,截取最多 N 条记录。

这些记录就是:

用户上滑操作需要展示的内容 ID 列表。

上滑操作的 Go 代码

下面是这种方案实现上滑操作的 Go 代码。

// 此结构体表示 Feed 流中的一条内容
type FeedContentInfo struct {
ContentID int64 // 内容 ID
PublishTime int64 // 内容发布时间
}

// GlideUp 函数:上滑操作获取 Feed 流
// userId: 用户 ID
// ts: 已读最后一条内容的发布时间
// last_content_id: 已读最后一条内容的内容 ID
// N: 获取几条内容
func (t *TimelineFeedService) GlideUp(
ctx context.Context,
userId, ts, last_content_id int64,
N int,
) (
feedList []FeedContentInfo,
err error,
) {
// 拼接 Key: inbox_用户ID
key := fmt.Sprintf("inbox_%d", userId)

// 执行 Redis 命令:
// ZREVRANGEBYSCORE inbox_用户ID ts 0 WITHSCORES
res := redisClient.ZRevRangeByScoreWithScores(ctx, key, &redis.ZRangeBy{
Min: "0",
Max: fmt.Sprint(ts),
})

if res.Err() != nil {
err = res.Err()
return
}

// 遍历结果
for i := 0; i < len(res.Val()); i++ {
// 定位到上次读取的最后一条内容
if res.Val()[i].Member.(string) == fmt.Sprint(last_content_id) {
// 截取之后的内容
cutRes := res.Val()[i+1:]

for _, item := range cutRes {
// 最多获取 N 条内容
if len(feedList) == N {
break
}

contentId, _ := strconv.ParseInt(item.Member.(string), 10, 16)

feedList = append(feedList, FeedContentInfo{
ContentID: contentId,
PublishTime: int64(item.Score),
})
}

return
}
}

return
}

为什么方案三是最终选择

经过特殊处理后,ZSET 的排序规则和数据库联合索引 idx_feed 的排序规则保持一致。

也就是说:

ZSET 的第一个满足:

Score <= ts
并且 Member < last_content_id

的成员,就是下一刷 Feed 流的首条内容。

因此,这种方案可以保证:

在推拉结合模式下,
也能准确获取到下一刷 Feed 流数据。

所以它是最终选择。


使用 Redis 实现用户收件箱的优劣

使用 Redis 实现用户收件箱的优势是:

可以依靠 Redis 的高性能,
应对高并发的 Timeline Feed 场景。

但是劣势也很明显:

用户收件箱会占用昂贵的内存资源,
公司付出的资源成本较高。

11.6.7 通过推拉结合模式构建 Timeline Feed 数据

11.6.1 节的介绍可以看出,推拉结合模式对于内容发布侧来说非常清晰:

粉丝少的用户:采用推模式
粉丝多的用户:对活跃粉丝采用推模式
其他用户:采用拉模式

那么,对于用户读取侧呢?用户读取 Timeline Feed 流时,怎样才能体现推拉结合模式?


读取侧如何体现推拉结合模式

当用户读取 Timeline Feed 流时:

如果拉取关注列表中所有用户的发件箱,那么就是拉模式。

如果只读取收件箱中的内容,那么就是推模式。

如果只选择拉取关注列表中一部分用户的发件箱,那么就是推拉结合模式。

关键问题是:需要拉取哪些用户的发件箱?

答案是:排除收件箱中内容的发布者。

例如:

用户 A 关注了用户 B 和用户 C。

但是用户 A 的收件箱中只有用户 B 发布的内容。

这说明:

用户 C 发布的内容没有被推送到用户 A 的收件箱中;
或者用户 C 压根没有发布内容。

所以,只需要对用户 C 采用拉模式


推拉结合模式的边界点

最终,推拉结合模式的边界点是:

收件箱中内容的发布者:在发布内容时采用了推模式。

关注列表中,但未向收件箱中投递内容的用户:采用拉模式。

所以,用户在读取 Timeline Feed 流时,应该通过如下流程进行选择。

1. 拉取用户的关注列表。

2. 读取收件箱中的内容,并从内容发布服务中获取内容发布者的用户 ID。

3. 计算在用户关注列表中,但并不属于收件箱中内容发布者的用户,然后拉取这些用户的内容。

推送数据和拉取数据的合并

明确了推拉结合模式的执行条件后,还需要分析如何将推送的数据和拉取得到的内容组合起来,作为用户下一刷 Feed 流的内容。

这时需要对:

发件箱中的内容列表
收件箱中的内容列表

进行合并操作

假设现在要拉取 M 个关注者发件箱中的内容,则流程如下。

1. 从收件箱中读取 N 条内容。

2. 从 M 个关注者的发件箱中分别拉取 N 条内容。

3. 对这 M + 1 个内容列表进行合并操作,保证:
① 内容按照发布时间从近到远排序;
② 对于发布时间相同的内容,按照内容 ID 从大到小排序。

4. 合并排序得到前 N 条内容,
这样用户就获取到了 Timeline Feed 流。

下拉获取最新 Feed 流的工作流程

当用户下拉获取最新的 Feed 流时,推拉结合模式的工作流程如下。

1. 拉取用户的关注列表。

2. 检查关注列表中哪些关注者向用户收件箱中投递了内容,
将没有投递内容的关注者作为拉取内容的目标,
假设人数为 M。

3. 从内容发布服务中拉取这 M 个关注者发布内容的时间
小于或等于当前时间的最近 N 条内容,
得到 M 个内容列表。

4. 从用户收件箱中获取最近 N 条内容。

5. 使用这 M + 1 个内容列表构建 Timeline Feed 流的结果。

其中,第 5 步的合并规则是:

① 对比每个内容列表的首条内容,选出发布时间值最大的内容。
如果有多条内容的发布时间相同,则选择内容 ID 最大的那条内容。

② 将该内容从其所属的内容列表中移除,并插入 Timeline Feed 流的尾部。

③ 如果 Timeline Feed 流的长度不足 N,则继续循环对比内容列表的首条内容,直到所有内容列表均为空。

上滑获取更早 Feed 流的工作流程

当用户上滑获取更早的 Feed 流时,需要依赖:

last_content_id:最后一条内容的内容 ID
ts:最后一条内容的发布时间

推拉结合模式的工作流程如下。

1. 拉取用户的关注列表,同样检查关注列表中哪些关注者向用户收件箱中投递了内容。

2. 对于未投递内容的 M 个关注者,从内容发布服务中拉取每个关注者在 <=ts 时发布的最近 N + 1 条内容,得到 M 个内容列表。

3. 检查这 M 个内容列表中,哪个内容列表的首条内容发布时间等于 ts,且内容 ID 大于或等于 last_content_id。

如果在某个内容列表中找到了这样的内容,则说明用户之前上滑读取 Feed 流时已经读过了这条内容。(为什么是 "内容 ID >= last_content_id" ? 因为查出来的东西排序规则是先按发布时间从大到小排序;发布时间相同,再按内容 ID 从大到小排序。所以发布时间等于ts且 内容ID 大于等于 last_content_id的都是读过的)

所以要将此内容从内容列表中移除。

这时内容列表的长度为 N,(没有移除内容的列表长度还是N + 1)
这也是第 2 步拉取 N + 1 条内容的原因。

4. 从用户收件箱中获取发布时间小于或等于 ts,
且内容 ID 小于 last_content_id 的 N 条内容。


5. 使用这 M + 1 个内容列表构建 Timeline Feed 流的结果。
其流程与用户下拉获取最新的 Feed 流时一致。

完整例子:用户 111 的 Timeline Feed 构建过程

假设用户 111 关注了 5 个用户:

用户 200
用户 211
用户 222
用户 233
用户 244

而在用户 111 的收件箱中,有下面这些用户推送的内容:

用户 222
用户 233
用户 244

接下来看看用户 111

进入 Timeline Feed 页面,也就是下拉;
以及之后 3 次上滑;
这 4 次请求过程中,Timeline Feed 服务分别做了什么事情。

第 1 次请求:进入 Timeline Feed 页面

当用户 111 进入 Timeline Feed 页面时,Timeline Feed 服务收到下拉类型的获取 Feed 流请求。

此时处理逻辑如下。

1. 获取用户 111 的关注列表和收件箱。

2. 发现需要从用户 200 和用户 211 的发件箱中拉取内容。

3. 为了方便,假设用户 200、用户 211 的发件箱,
以及用户 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 = [
(32850, 1689089522),
(16020, 1688986368),
(19732, 1688905999)
]
list_211 = [
(50015, 1689087139),
(71658, 1688986368),
(18253, 1688975221)
]

其中:

list_200:用户 200 最近发布的 3 条内容
list_211:用户 211 最近发布的 3 条内容

第 1 次请求:从用户 111 收件箱中读取最近 3 条内容

从用户 111 的收件箱中读取最近 3 条内容,得到内容列表:

list_111 = [
(25218, 1689087991),
(38376, 1689087139),
(12572, 1688986368)
]

第 1 次请求:合并 3 个内容列表

接下来,将推拉结合模式下得到的这 3 个内容列表进行合并操作。

第 1 轮合并

对比:

list_200 的首元素:(32850, 1689089522)
list_211 的首元素:(50015, 1689087139)
list_111 的首元素:(25218, 1689087991)

发现内容 32850 的发布时间值最大。

所以它作为此次 Feed 流的第 1 条内容。

同时将 list_200 更新为:

list_200 = [
(16020, 1688986368),
(19732, 1688905999)
]
第 2 轮合并

继续对比:

list_200 的首元素:(16020, 1688986368)
list_211 的首元素:(50015, 1689087139)
list_111 的首元素:(25218, 1689087991)

发现内容 25218 的发布时间值最大。

所以它作为此次 Feed 流的第 2 条内容。

同时将 list_111 更新为:

list_111 = [
(38376, 1689087139),
(12572, 1688986368)
]
第 3 轮合并

再次对比:

list_200 的首元素:(16020, 1688986368)
list_211 的首元素:(50015, 1689087139)
list_111 的首元素:(38376, 1689087139)

发现内容 38376 和内容 50015 的发布时间一样,都是最大的。

但是在数值上:

50015 > 38376

所以将内容 50015 作为此次 Feed 流的第 3 条内容。

此时 Feed 流的内容已经达到 3 条,合并操作结束。


第 1 次请求:最终得到的 Feed 流

用户刷出 Feed 流的内容,最终数据依次为:

(32850, 1689089522)
(25218, 1689087991)
(50015, 1689087139)

第 2 次请求:用户第 1 次上滑

用户 111 看完这 3 条内容后,选择上滑获取更多的 Feed 流

目前此用户已读的最后一条内容是:

内容 ID:50015
发布时间:1689087139

Timeline Feed 服务收到上滑请求,开始构建更早的 3 条内容。


第 2 次请求:获取关注列表和收件箱

1. 获取用户 111 的关注列表和收件箱。

2. 发现需要从用户 200 和用户 211 的发件箱中拉取内容。

第 2 次请求:从内容发布服务中拉取用户 200 和用户 211 的内容

从内容发布服务中拉取用户 200 和用户 211 的内容。

条件是:

发布时间  <= 1689087139

因为本次需要获取 3 条内容,所以每个内容列表拉取:

N + 1 = 4 条内容

得到:

list_200 = [
(16020, 1688986368),
(19732, 1688905999),
(61186, 1688718647),
(80723, 1688616936)
]
list_211 = [
(50015, 1689087139),
(71658, 1688986368),
(18253, 1688975221),
(73798, 1688803287)
]

第 2 次请求:删除已经读过的内容

list_211 的首元素内容发布时间为:

1689087139

并且正好是已读内容 50015 的发布时间。

因此,将其从内容列表中删除。

list_211 被更新为:

list_211 = [
(71658, 1688986368),
(18253, 1688975221),
(73798, 1688803287)
]

第 2 次请求:从用户收件箱中获取内容

在用户收件箱中,获取满足下面条件的最近 N 条内容:

publish_time < ts
或者
publish_time = ts 且 content_id < last_content_id

得到:

list_111 = [
(38376, 1689087139),
(12572, 1688986368),
(75256, 1688803287)
]

第 2 次请求:合并 3 个内容列表

第 1 轮合并

对比:

list_200 的首元素:(16020, 1688986368)
list_211 的首元素:(71658, 1688986368)
list_111 的首元素:(38376, 1689087139)

发现内容 38376 的发布时间值最大。

所以它作为此次 Feed 流的第 1 条内容。

同时将 list_111 更新为:

list_111 = [
(12572, 1688986368),
(75256, 1688803287)
]
第 2 轮合并

继续对比:

list_200 的首元素:(16020, 1688986368)
list_211 的首元素:(71658, 1688986368)
list_111 的首元素:(12572, 1688986368)

发现所有首元素内容的发布时间值都相同。

此时选择内容 ID 更大的内容:

71658

作为 Feed 流的第 2 条内容。

同时将 list_211 更新为:

list_211 = [
(18253, 1688975221),
(73798, 1688803287)
]
第 3 轮合并

再次对比:

list_200 的首元素
list_211 的首元素
list_111 的首元素

发现内容 16020 和内容 12572 的发布时间值一样,都是最大的。

此时选择数值较大的内容:

16020

作为 Feed 流的第 3 条内容。

合并操作完成。


第 2 次请求:最终得到的 Feed 流

用户上滑得到的 Feed 流数据依次为:

(38376, 1689087139)
(71658, 1688986368)
(16020, 1688986368)

第 3 次请求:用户第 2 次上滑

用户 111 继续进行上滑操作。

此时此用户已读的最后一条内容是:

内容 ID:16020
发布时间:1688986368

Timeline Feed 服务执行的逻辑如下。


第 3 次请求:获取关注列表和收件箱

1. 获取用户 111 的关注列表和收件箱。

2. 发现需要从用户 200 和用户 211 的发件箱中拉取内容。

第 3 次请求:从内容发布服务中拉取用户 200 和用户 211 的内容

从内容发布服务中拉取用户 200 和用户 211 的内容。

条件是:

发布时间不大于 1688986368

每个内容列表拉取:

N + 1 = 4 条内容

得到:

list_200 = [
(16020, 1688986368),
(19732, 1688905999),
(61186, 1688718647),
(80723, 1688616936)
]
list_211 = [
(71658, 1688986368),
(18253, 1688975221),
(73798, 1688803287),
(92090, 1688617305)
]

第 3 次请求:删除已经读过的内容

list_200 的首元素内容发布时间为:

1688986368

并且内容 ID 为:

16020

list_211 的首元素内容发布时间也为:

1688986368

并且内容 ID 大于:

16020

所以需要删除这两个内容列表的首元素。

删除后,两个内容列表更新如下。

list_200 = [
(19732, 1688905999),
(61186, 1688718647),
(80723, 1688616936)
]
list_211 = [
(18253, 1688975221),
(73798, 1688803287),
(92090, 1688617305)
]

第 3 次请求:从用户收件箱中获取内容

在用户收件箱中,获取满足下面条件的最近 N 条内容:

publish_time < ts
或者
publish_time = ts 且 content_id < last_content_id

得到:

list_111 = [
(12572, 1688986368),
(75256, 1688803287),
(81709, 1688718647)
]

第 3 次请求:合并 3 个内容列表

第 1 轮合并

对比:

list_200 的首元素:(19732, 1688905999)
list_211 的首元素:(18253, 1688975221)
list_111 的首元素:(12572, 1688986368)

发现内容 12572 的发布时间值最大。

所以它作为此次 Feed 流的第 1 条内容。

同时将 list_111 更新为:

list_111 = [
(75256, 1688803287),
(81709, 1688718647)
]
第 2 轮合并

继续对比:

list_200 的首元素
list_211 的首元素
list_111 的首元素

发现内容 18253 的发布时间值最大。

所以它作为此次 Feed 流的第 2 条内容。

同时将 list_211 更新为:

list_211 = [
(73798, 1688803287),
(92090, 1688617305)
]
第 3 轮合并

再次对比:

list_200 的首元素
list_211 的首元素
list_111 的首元素

发现内容 19732 的发布时间值最大。

所以它作为此次 Feed 流的第 3 条内容。

合并操作完成。


第 3 次请求:最终得到的 Feed 流

用户上滑得到的 Feed 流数据依次为:

(12572, 1688986368)
(18253, 1688975221)
(19732, 1688905999)

第 4 次请求:用户第 3 次上滑

用户 1113 次进行上滑操作。

此时此用户已读的最后一条内容是:

内容 ID:19732
发布时间:1688905999

Timeline Feed 服务执行的逻辑如下。


第 4 次请求:获取关注列表和收件箱

1. 获取用户 111 的关注列表和收件箱。

2. 发现需要从用户 200 和用户 211 的发件箱中拉取内容。

第 4 次请求:从内容发布服务中拉取用户 200 和用户 211 的内容

从内容发布服务中拉取用户 200 和用户 211 的内容。

条件是:

发布时间不大于 1688905999

每个内容列表拉取:

N + 1 = 4 条内容

得到:

list_200 = [
(19732, 1688905999),
(61186, 1688718647),
(80723, 1688616936)
]
list_211 = [
(73798, 1688803287),
(92090, 1688617305),
(82553, 1685305893)
]

第 4 次请求:删除已经读过的内容

list_200 的首元素内容发布时间为:

1688905999

并且内容 ID 正好为:

19732

所以将其从内容列表中删除。

list_200 被更新为:

list_200 = [
(61186, 1688718647),
(80723, 1688616936)
]

第 4 次请求:从用户收件箱中获取内容

在用户收件箱中,获取满足下面条件的最近 N 条内容:

publish_time < ts
或者
publish_time = ts 且 content_id < last_content_id

得到:

list_111 = [
(75256, 1688803287),
(81709, 1688718647),
(13320, 1688617305)
]

第 4 次请求:合并 3 个内容列表

第 1 轮合并

对比:

list_200 的首元素
list_211 的首元素
list_111 的首元素

发现内容 73798 和内容 75256 的发布时间一样,都是最大的。

此时选择数值较大的内容:

75256

作为此次 Feed 流的第 1 条内容。

第 2 轮合并

再次对比:

list_200 的首元素
list_211 的首元素
list_111 的首元素

发现内容 73798 的发布时间值最大。

所以它作为此次 Feed 流的第 2 条内容。

第 3 轮合并

依然对比:

list_200 的首元素
list_211 的首元素
list_111 的首元素

发现内容 61186 和内容 81709 的发布时间一样,都是当前最大的。

此时选择数值较大的内容:

81709

作为此次 Feed 流的第 3 条内容。

合并操作结束。


第 4 次请求:最终得到的 Feed 流

用户上滑得到的 Feed 流数据依次为:

(75256, 1688803287)
(73798, 1688803287)
(81709, 1688718647)

用户进入页面并上滑 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. 所有关注者发布的内容,
按照发布时间从大到小排列。

2. 当发布时间相同时,
按照内容 ID 从大到小排列。

这可以充分证明:

我们实现获取 Feed 流的方案是正确的。

11.6.8 收尾工作

前面解决的是:本次 Feed 流应该展示哪些内容 ID。

但用户真正看到的不是内容 ID,而是完整内容。

所以拿到内容 ID 列表后,还需要继续组装用户可读的 Feed 数据。

需要获取的信息包括:

内容发布服务:
获取内容原文,包括文本、图片、视频等

计数服务:
获取评论数、点赞数、转发数、收藏数等

用户服务:
获取发布者头像、昵称等用户信息

得到这些信息后,Feed 流才能完整呈现在用户面前。

另外,对于大 V 发布的内容,Timeline Feed 服务适合做本地缓存。

原因是大 V 的内容会被很多用户刷到,如果每次都通过 RPC 请求内容发布服务、用户服务打包详情,会带来大量重复请求。

本地缓存可以减少这些重复调用:

大 V 内容详情命中本地缓存

不再重复调用内容发布服务和用户服务

提升 Feed 打包性能

本节总结

实现 Timeline Feed 服务,需要把推拉结合模式落到多个技术细节上。

内容发布侧:

内容发布服务不直接遍历推送
而是通过消息队列发送内容变更事件
Timeline 分发器/执行器负责推送到收件箱

推送可用性:

记录推送进度
失败后依赖消息队列重试
粉丝多时拆分推送子任务并行执行

收件箱数据:

只保存 content_id 和 publish_time
不保存完整内容

读请求:

下拉:最新 N 条
上滑:基于 ts 和 last_content_id 读取更早 N 条

收件箱存储可以用数据库或 Redis ZSET:

数据库:依赖 idx_feed(user_id, publish_time, content_id) 联合索引
Redis ZSET:依赖 Score=publish_time,Member=20 位 content_id 字符串

读取侧通过合并收件箱列表和部分关注者发件箱列表,构建最终 Feed 流。

11.7 本章小结

本章详细介绍了应该如何实现一个推拉结合模式的 Timeline Feed 服务


推模式和拉模式结合的重点在于内容发布者的粉丝数和粉丝活跃情况。

用户在发布内容后,系统先检查用户的粉丝数:

  • 如果粉丝数小于阈值,则将内容推送到每个粉丝的收件箱中;
  • 如果粉丝数大于阈值,则只向部分活跃粉丝推送内容;
  • 对于其他粉丝,他们在刷新 Feed 流时采用拉模式获取内容。

按照粉丝数将推送任务拆分为多个子任务,可以提高内容推送效率。


Timeline Feed 流数据要求内容按照发布时间从近到远排序

对于发布时间相同的内容,我们需要制定固定的排序规则,本章使用的排序规则是:

按照内容 ID 从大到小排列

在此基础上,用户下拉和上滑读取 Feed 流的两种操作方式的语义可以被描述如下。

  • 下拉操作:获取在当前时间下最新的 N 条内容。

  • 上滑操作:根据当前已刷到的最后一条内容的发布时间 ts 和内容 ID last_content_id

    publish_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 流数据。

合并过程如下(简化版,少了内容):

  1. 循环对比这些内容列表的首元素;
  2. 看哪条内容的发布时间值最大;
  3. 如果有多条内容的发布时间一样都是最大的,则选出内容 ID 更大的那一条内容;
  4. 然后将所得到的内容从内容列表中删除,并加入 Feed 流中;
  5. 当 Feed 流内容达到指定的数量时,或者这些内容列表都为空时,循环结束。