跳到主要内容
约 13 分钟6 张卡

Feed 流设计

Feed 流把用户可能需要查看的多条内容组织成一个列表。关注流从所关注作者的内容中取候选,推荐流还会引入其他候选并排序。无论列表怎样产生,系统都要协调内容发布、分发延迟、翻页顺序和当前可见性。

本文以关注关系下的时间倒序流为主,讨论推送与拉取的代价,再把点赞、评论作为关联功能处理。推荐排序的模型训练不在本文范围内,但排序变化对分页的影响必须说明。

1. 面试核心​

先记住这几条,再读正文
  • Feed 可以发布时把内容 ID 分发给读者,也可以读取时合并作者列表。推拉选择要结合粉丝分布、发布频率、活跃程度和读延迟。卡 · 综合题
  • 内容、关注关系与时间线索引分开保存。索引里有某条内容,不代表读者当前仍有权查看。卡 · 数据与容量约束
  • 时间倒序分页需要稳定的完整排序键,例如 (created_at, post_id)。只用时间戳会丢同一时刻的记录,偏移分页则会受前面插入或删除影响。卡 · 排序与游标分页
  • ZSet 可以保存评论或时间线的有序 ID,同分数按 member 字节序排序,score 也有整数精度边界。卡 · ZSet 与评论列表
  • 点赞要区分用户与内容的关系、当前状态和展示计数。重复点赞不能重复增加计数,取消与重投也要有明确规则。卡 · 点赞关系与计数
  • Redis 与数据库异步同步不是天然可靠。明确哪一侧是权威记录,再处理持久化、重复事件和乱序。卡 · 综合题

2. 数据与容量约束​

关注流至少涉及几类数据,它们有不同的查询方向:

数据主要用途一种键或索引
内容记录正文、作者、发布时间、删除与可见状态post_id
关注关系查一个人关注谁,或一个作者有哪些粉丝(follower_id, author_id),并按需要建立反向查询索引
作者列表读取某作者最近发布的内容(author_id, created_at, post_id)
用户时间线读取预先分发给某用户的候选(owner_id, created_at, post_id),同一用户的 post_id 去重
点赞关系判断某用户是否赞过某内容(post_id, user_id)
展示计数快速显示点赞数按 post_id 聚合的结果

时间线通常存内容 ID 和排序所需字段,正文再批量加载,避免把同一篇长文复制给每个粉丝。被缓存或预先分发的只是候选索引;读取时仍需过滤删除、拉黑、取关和可见范围变化。首次关注是否补历史、取关后是否立即隐藏旧内容,也要提前约定。

假设每秒有 1000 条发布,每条平均分发给 200 个目标读者,全量推送约产生 1000 × 200 = 20 万 次索引写入/秒,还没有计算副本、重试和索引维护。一名拥有 1000 万粉丝的作者发布一条内容,就可能单独产生 1000 万次投递任务,平均值掩盖不了这种突发。

读取侧则要看活跃用户的请求量、每人关注多少作者、每页大小和允许的发布到可见延迟。一个用户关注 1000 个作者,不应无条件串行访问 1000 个远程节点后才返回首屏。查询批量化、候选数量和并发上限,都需要纳入预算。这些数字只是示例,容量估算见容量规划。

3. 发布与推拉分发​

3.1 发布与投递事件​

发布成功首先表示权威内容已经提交,不一定表示所有粉丝的时间线都已更新。可以把内容与发布事件在同一数据库事务里保存,再由异步任务分批投递,避免“内容写好了,但进程崩溃导致事件永远没发出”的窗口,见 Transactional Outbox 与 CDC。

投递任务要能重试:对同一个接收者和 post_id 重复插入,不应产生两条时间线记录。投递进度也需要可恢复,不能任务处理到一半崩溃后既跳过剩余粉丝,又无法确定已处理范围。

消息延迟可能让旧内容晚到。排序应使用约定的内容发布时间或稳定排序键,不能简单按消息到达时间把晚到的旧文放到最新位置。内容删除或可见性变化,也要通过事件更新索引,同时保留读取时的最终可见性检查。

3.2 写扩散、读扩散与混合方式​

写扩散在发布时为多个读者更新候选列表,也叫推模式。读者打开列表时,已经有一份可直接读取的时间线,代价是发布产生多份写入,而且很多长期不活跃的读者可能从未使用这些结果。

读扩散在读者请求时,从关注作者的列表里取候选并合并,也叫拉模式。发布只维护作者侧数据,但阅读时需要访问多份列表、去重与排序,关注数多或某些分片缓慢时会影响尾延迟。

方式主要优势主要代价
写扩散将候选准备工作提前,读取路径较短高粉丝作者写放大,冷用户也可能产生无效工作
读扩散减少预先分发,适合部分低频读取场景读取时合并成本与下游依赖更多
混合方式对不同作者、读者或关系采用不同策略需要维护策略状态,合并去重,并处理切换期间缺失或重复

例如普通作者对活跃粉丝推送,高粉丝作者或很少读取的用户通过拉取补充。这个例子不是固定行业规则,阈值要由发布与读取频率、分发成本和延迟目标决定。《Feeding Frenzy》也把推拉选择建模为发布者与订阅者之间的代价权衡,不能只用“粉丝超过某个数”概括全部条件。

混合读取时,将预先分发的候选与拉取候选按 post_id 去重,再按同一排序规则合并。策略由推改拉或由拉改推时,要有过渡窗口或进度水位,确保没有某段内容既没推送、又被排除在拉取范围之外。

4. 排序与游标分页​

4.1 完整排序键​

时间倒序流可以用 created_at DESC, post_id DESC 排序。同一毫秒内可能发布多条内容,因此时间戳不是唯一排序键;再加一个唯一 ID,才能确定同一时刻的先后。这里的 ID 用于打破并列,不表示它等同于全局事务提交顺序,见分布式 ID。

下面用 SQLite 的整数列演示查询边界,created_at 是简化时间值;同样的完整排序原则也适用于 MySQL 8.0。例子只包含已完成候选生成的一个用户列表,不实现关注过滤或分布式查询。

CREATE TABLE feed_index (
owner_id INTEGER NOT NULL,
post_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (owner_id, post_id)
);
CREATE INDEX feed_order
ON feed_index(owner_id, created_at DESC, post_id DESC);
INSERT INTO feed_index VALUES
(7, 105, 1000), (7, 104, 1000), (7, 103, 1000),
(7, 102, 999), (7, 101, 998);

第一页取两条:

SELECT post_id, created_at
FROM feed_index
WHERE owner_id = 7
ORDER BY created_at DESC, post_id DESC
LIMIT 2;

结果是 (105, 1000)、(104, 1000)。将最后一条的排序键 (1000, 104) 放入游标,下一页查询它之后的记录:

SELECT post_id, created_at
FROM feed_index
WHERE owner_id = 7
AND (created_at < 1000 OR (created_at = 1000 AND post_id < 104))
ORDER BY created_at DESC, post_id DESC
LIMIT 2;

下一页得到 (103, 1000)、(102, 999)。如果只写 created_at < 1000,就会遗漏与上一页末尾同一时刻的 103。真实接口通过参数绑定使用游标值,并校验游标属于当前用户、过滤条件和排序版本,不能直接信任客户端传来的身份或拼接 SQL。

4.2 偏移变化与快照边界​

偏移分页按“跳过前多少条”定位,前面的记录变化时,位置也会移动。第一页读到 105、104 后,新增更靠前的 106;下一次跳过前两条会从 104 开始,重复上一页的末尾。若前面的记录被删除,又可能跳过原本还没看到的内容。

游标按上一条的排序值继续,避免用变化的位置代替边界,但它不是快照。如果异步分发后来补入一条排序值比游标更大的内容,继续向后翻页就不会再看到它,需要刷新上方列表;如果补入的值位于游标后面,后续页仍可能出现这条新补入的内容。

产品若要求整次翻页固定在同一份候选集合,需要保存有期限的列表快照或查询会话等额外状态。仅记录首次看到的最大时间,也不排除随后补入的旧时间记录。推荐流的分数还会变化,通常需要绑定排序版本或候选快照,不能把不断变化的分数游标当作稳定顺序。

权限与删除检查仍应按业务要求执行,即使候选来自旧快照也不能因此放宽权限。过滤后页数不足时,可以在有限扫描预算内继续取候选;游标应随已扫描位置推进,避免反复读取同一批不可见记录。

5. ZSet 与评论列表​

Redis ZSet 的 member 唯一,每个 member 带一个 score,因此可以用评论 ID 作 member、时间或热度作 score,保存一个内容下的评论索引。评论正文、删除状态和权限另行保存,取出 ID 后再加载与过滤,不能把“在 ZSet 中”当作内容仍有效的证明。

下面的 Redis 命令使用固定宽度 ID,ZRANGE ... REV 需要 Redis 6.2 或 7:

ZADD comments:42 1000 000105 1000 000104 999 000103
ZRANGE comments:42 0 1 REV WITHSCORES
ZADD comments:42 1000 000105
ZCARD comments:42

第一条插入三个成员,读取结果依次是 000105、1000、000104、1000。再次写入同一个 member 不会增加成员数量,最后的 ZCARD 仍为 3;如果改了 score,则会更新该成员的位置。

同分数时,Redis 按 member 的字节序排序,反向读取时相应反转。普通字符串 "10" 和 "9" 的顺序不同于整数 10 和 9,所以需要固定宽度等明确编码,不能默认把文本 ID 当数字比较。

分页还要处理同分数边界。按 score 排他地取小于 1000 的记录,会漏掉仍未返回的其他 1000 分成员;仅携带 score 不足以表示完整游标。可以采用支持完整复合键的存储,或为 ZSet 设计包含同分数位置、去重与变更处理的分页协议。BYSCORE LIMIT offset count 里的 offset 并不会自动获得稳定快照。

score 使用双精度浮点数,连续整数可精确表示到 2^53。不要把任意 64 位 ID 直接放进 score,也不要把毫秒时间乘一个大系数再拼 ID,直到超过精度范围。若使用热度 score,点赞变化还会改变评论顺序,需要说明翻页期间允许什么重复或遗漏。数据结构机制见 Redis 数据结构。

6. 点赞关系与计数​

6.1 状态与原子变化​

点赞首先是一个用户与内容之间的关系,而不只是内容上的整数。可以在权威数据库用 (post_id, user_id) 唯一约束保存关系,展示计数则是该内容下有效点赞关系的数量或其汇总副本。

以数据库为权威时,关系变化与精确计数应在同一事务内完成:

  • 原本未点赞,成功新增关系后才加一。重复新增命中唯一约束,不再次加一。
  • 取消点赞时,只有确实删除了现有关系才减一。删除影响零行,不再减数。
  • 任何一步失败,整体回滚,避免关系存在而计数未更新,或计数变化但关系没有变化。

“请求成功执行了一次”与“用户最终想保持什么状态”也要区分。接口表达“设为已点赞”或“设为未点赞”,比盲目翻转状态更容易安全重试;同一个动作重发不会再把状态翻回来。

不过旧的点赞请求延迟到取消请求之后,仍可能把状态重新改成已点赞。要保留用户动作顺序,可以使用请求序号、关系版本条件或受控的串行处理,并明确多设备同时操作时怎样裁决。单纯的唯一关系约束不能判断某个请求在业务上是否已经过时。

6.2 Redis、消息与展示计数​

本文选择数据库关系作为权威记录,Redis 保存关系查询或展示计数的缓存。需要异步投影时,可把关系变化与事件放入同一事务,消费者重复收到事件不会重复改变结果。对“设为某状态”的事件,可以用单调版本拒绝旧版本;对“加一、减一”的增量事件,则必须逐事件去重并检查是否存在缺口,不能简单跳过旧版本号。

先写 Redis 再通过 MQ 写数据库是另一种设计,它降低同步数据库写入压力,却新增可靠性责任。Redis 成功后、消息持久化前进程崩溃,会出现已返回成功但没有后续落库任务的窗口;复制、持久化、可靠发送与消费者幂等都要有方案,不能仅凭“用了 MQ”声称数据不会丢。

展示计数允许短暂滞后时,可以异步聚合,定期与权威点赞关系对账修复。精确同步计数会让热门内容的计数行承受竞争,拆分计数或异步聚合是在一致性与成本之间做选择。当前用户的点赞状态与全站展示总数也可以采用不同的新鲜度要求,不能用近似总数推断某人是否已经点过赞。

消息去重与顺序的机制见消息幂等性。缓存副本丢失后的重建与回源保护见分布式缓存设计。

7. 故障与验证​

Feed 的故障不只有“读不出来”。消息重复导致重复候选、投递积压导致内容晚到、旧权限仍被缓存、热点作者挤占投递队列,都会影响用户看到的结果。

场景需要验证的结果
发布提交后投递进程退出事件可恢复,已投递用户不会重复出现同一内容
高粉丝作者发布队列有界,普通作者不被长期饿死,延迟可观察
翻页期间插入、删除和晚到与接口约定一致,不把游标称为完整快照
同一时刻多条内容或评论完整排序键不遗漏同值边界
点赞或取消重复、事务中途失败关系与计数保持约定的不变量
取消后收到旧点赞事件按版本或事件协议处理,不盲目覆盖新状态
内容删除、取关或拉黑读取时仍执行适用的可见性检查

主要指标包括发布到可见延迟、投递积压、首屏与翻页延迟、过滤后返回不足率、候选重复率、点赞投影延迟和对账差异。用户看到空列表时,要区分确实没有内容、权限过滤、异步索引尚未完成与后端错误,避免把所有失败都包装成“暂无内容”。

8 · 面试题卡片​

本页题目与参考回答

8.1 朋友圈 Feed 流怎样实现?​

题型:综合题

考察重点:内容与关系模型、推拉代价、异步投递及可见性。

相关内容:数据与容量约束、发布与推拉分发。

参考回答

**先按关注关系生成候选,再选择发布时提前分发、读取时合并或混合方式。**内容、作者列表、关注关系和用户时间线分别保存,时间线主要保存内容 ID 与排序字段。

写扩散缩短读路径但放大发布写入,读扩散减少提前写入但增加读取合并成本。根据粉丝分布、活跃程度和延迟要求选择,异步投递用可靠事件与接收者、内容唯一约束恢复和去重;读取时继续过滤删除与权限变化。

8.2 游标分页怎样做,为什么不直接使用偏移分页?​

考察重点:完整排序键、并列记录、位置变化与快照区别。

相关内容:排序与游标分页。

参考回答

**使用上一页末尾的完整排序键定位下一页,避免以会变化的偏移量作边界。**例如按时间与 ID 倒序,下一页取时间更早,或时间相同且 ID 更小的记录。

只用时间会漏掉同一时刻的其他记录。游标减轻前方插入删除造成的位置漂移,但不会固定候选集合;晚到数据和动态推荐分数仍需要刷新、快照或排序版本等明确约定。

8.3 评论区怎样使用 Redis ZSet?​

考察重点:成员唯一性、排序分数、并列比较与分页边界。

相关内容:ZSet 与评论列表。

参考回答

**可以用评论 ID 作 member,时间或热度作 score,把 ZSet 当作有序评论索引。**正文和可见状态另外保存,取出 ID 后加载并过滤。同一 member 再次加入不会产生两条评论索引。

同分数按 member 字节序比较,score 又有浮点精度限制。分页要携带足以区分并列成员的边界;热度更新会改变顺序,也不能把一次范围查询当成整个翻页会话的快照。

8.4 点赞系统怎样实现,点赞数据存在哪里?​

考察重点:关系唯一性、状态变化、精确计数与展示副本。

相关内容:点赞关系与计数。

参考回答

**先保存用户与内容的点赞关系,再决定计数怎样聚合,不能只维护一个加减计数器。**例如用数据库中的 (post_id, user_id) 唯一约束保存权威关系,Redis 加速查询和展示。

新增关系成功才加一,确实取消关系才减一,关系与同步计数在同一事务提交。重复操作不重复计数,乱序动作则用版本或顺序协议处理。允许展示总数滞后时可以异步投影,并用权威关系对账。

8.5 点赞先写 Redis,再通过 MQ 写 MySQL,怎样分析一致性?​

题型:综合题

考察重点:权威状态、双写窗口、持久化、重投与乱序。

相关内容:Redis、消息与展示计数、Transactional Outbox 与 CDC。

参考回答

**先明确哪个写入代表已经承诺成功,再逐个处理它与消息、数据库之间的失败窗口。**Redis 成功而消息还没可靠记录时崩溃,不能指望 MQ 自动补出没有收到的消息;Redis 自身的持久化和复制范围也要满足承诺。

一种简化选择是数据库保存关系与 Outbox,Redis 作为可重建投影。若坚持 Redis 先确认,则需要可靠记录待落库操作并恢复发送,消费者处理重复和乱序,监控积压并对账。增量计数与状态覆盖事件的去重规则不同,不能笼统地说“最终一致”就省掉这些条件。

8.6 缓存时间线已经生成,内容删除或用户取关后还需要检查什么?​

考察重点:候选索引与当前可见性的区别,状态变更和过滤分页。

相关内容:数据与容量约束、偏移变化与快照边界。

参考回答

**需要按当前业务规则检查删除状态、关注与访问权限,旧候选不能作为授权凭据。**异步删除索引可以减少无效候选,但传播延迟期间仍要守住读取边界。

过滤后可以在有限预算内继续读取,并按已扫描位置推进游标。候选快照只能固定候选范围,不能让已失去权限的内容继续展示。

9. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明