News Feed——即用户关注的每个账号的帖子组成的排序或倒序时间流——是经典的"扇出(fan-out)"系统设计问题,而且难度被严重低估。界面很平凡(滚动一列帖子),但数据形态很残酷:一个作者的一条帖子必须到达其每一个粉丝的主时间线,而一次 Feed 加载又必须合并数百到数千个关注对象的最新帖子,再排序、分页,在 200 ms 内渲染出来。难点不在于存帖子,而在于扇出的写放大及其病态最坏情况——一个有 1 亿粉丝的账号发了一条帖子。扇出策略选错,你要么压垮写路径(push 到 1 亿条时间线),要么压垮读路径(每次刷新都 pull 并合并上千个作者)。本文走一遍经典设计:写时扇出 vs 读时扇出、名人热 key 问题、生产系统实际运行的混合模型、Redis 时间线缓存、Feed 排序,以及稳定分页。
- 是两条时间线,不是一条——用户时间线(单个作者自己的帖子)和主时间线(从你关注的所有人聚合而来)的扇出成本完全不同。主时间线才是难的那条。
- 写时扇出(push)——发帖时为每个粉丝预计算时间线。读是一次缓存读;写按粉丝数放大。
- 读时扇出(pull)——读取时合并关注对象的最新帖子来组装 Feed。写是 O(1);对关注上千人的用户读很贵。
- 名人热 key——对 1 亿粉丝账号纯 push 是写风暴;对关注 5,000 个账号的用户纯 pull 是读风暴。两个极端都会崩。
- 混合扇出取胜——普通作者 push、名人 pull,读取时合并。行业标准(Twitter、Instagram)。
- 缓存帖子 ID 指针,而非帖子本身——每条时间线条目约 20 字节(帖子 ID + 分数);内容在读取时补水(hydrate),让编辑/删除无需重写数百万条时间线。
- 游标分页——快照/游标键控在帖子 ID 上,绝不用 SQL OFFSET,这样新帖在游标上方流入时 Feed 仍稳定。
- 通过队列做异步扇出——发帖写入立即返回;Kafka 支撑的 worker 在热路径之外做沉重的时间线写入。
把用户时间线和主时间线分开。用混合扇出:把普通作者的帖子 push 进每个粉丝的 Redis 时间线(一份封顶的帖子 ID 指针列表),但对名人跳过扇出、在读取时 pull 其最新帖子并合并进 Feed。对合并后的候选集排序,从共享存储补水帖子内容,用稳定游标分页。通过 Kafka 异步驱动扇出,让发帖写入永不阻塞在粉丝数上。
Step 1 — 厘清需求
News Feed 这道题很宽。在画任何东西之前先把范围明确——最重要的澄清是你在建哪条 Feed,因为用户时间线和主时间线的成本画像截然相反。
功能需求
- 发布帖子(文本,可选媒体引用)给你的粉丝。
- 关注/取关其他账号(社交图)。
- 查看你的主时间线——聚合你关注的所有账号最近帖子的 Feed。
- 查看用户时间线——某一个账号发布的帖子,按时间倒序。
- 排序:同时支持时间倒序和排序版("热门帖")排序。
- 互动:点赞、回复、转发——每条帖子展示近似计数。
- 分页/无限滚动——随用户下滑分页加载 Feed。
非功能需求
- 读重——Feed 加载远多于发帖;目标渲染一页主时间线 p99 <200 ms。
- 高可用——Feed 必须总能渲染出东西;一个陈旧但存在的 Feed 胜过一个报错。倾向 AP 而非 CP。
- 可接受最终一致——新帖几秒后才出现在粉丝 Feed 里没问题;但帖子绝不能丢。
- 可扩展——数亿 DAU,幂律粉丝图(绝大多数账号很小,少数有 1 亿+ 粉丝)。
开场就点出用户时间线和主时间线的区别。用户时间线是简单的按作者查询(按作者分片、按时间排序)。主时间线才是聚合问题——扇出、排序、热 key——面试 90% 的内容都在这里。早早命名这个拆分,表明你理解难点真正在哪。
Step 2 — 容量估算
粗略数字为扇出讨论锚定基线。假设一个大型社交平台:2 亿 DAU,平均用户关注约 200 个账号、约有 200 个粉丝(均值;分布严重幂律)。
流量
- 发帖:假设约 0.2 帖/DAU/天 → 2 亿 × 0.2 = 每天 4,000 万帖 ≈ 460 帖/秒 平均;峰值约 3–5× → 约 2,000 帖/秒。
- Feed 读:假设每个 DAU 刷新约 10×/天 → 2 亿 × 10 = 每天 20 亿次 Feed 读 ≈ 23,000 读/秒 平均;峰值约 5× → 约 100,000 读/秒。
- 帖子层面的读写比 ≈ 50:1——明显读重,这让我们偏向预计算读(写时扇出)。
扇出写放大(症结)
- 在纯写时扇出下,每条帖子都插入每个粉丝的时间线:4,000 万帖 × 约 200 个平均粉丝 = 每天 80 亿次时间线插入 ≈ 93,000 扇出写/秒 平均。
- 峰值远比均值暗示的糟糕,因为均值掩盖了尾部:单条名人帖(1 亿粉丝)就是 1 亿次插入——比一整天来自单条写的平均扇出还多。
- 这就是纯 push 无法独立成立的原因:写放大在粉丝数上无上界,而粉丝分布有非常肥的尾巴。
存储
- 帖子记录:约 300 字节(post_id 8 B、author_id 8 B、文本 ≤280 字符、时间戳、计数)。4,000 万/天 × 300 B ≈ 12 GB/天 ≈ 4.4 TB/年 的帖子文本——分片存储轻松容纳;媒体放在 CDN 后的对象存储里。
- 时间线缓存:只存帖子 ID 指针——约 20 B/条(8 B 帖子 ID + 8 B 分数 + 开销),每用户封顶约 800 条。2 亿用户 × 800 × 20 B ≈ 约 3.2 TB,分散在 Redis 机群(分片)。
帖子文本存储又小又无聊(每年几 TB)。真正昂贵、定义设计的数字是 93,000+ 扇出写/秒 及其尾部。下面每一个架构决策——混合扇出、热 key 处理、封顶时间线、异步 worker——都是为了驯服这一个数字。
Step 3 — API 设计
一个小的 REST 面。对扇出最关键的两个端点是发帖和读主时间线。
# 发布帖子
POST /api/posts
Authorization: Bearer <token>
{ "text": "shipping the feed redesign 🚀",
"media_ids": ["m_91af"] } // 可选
→ 201 { "post_id": "189f3c2a01", "created_at": "2026-06-28T10:00:00Z" }
# 读主时间线(难的那条)——游标分页
GET /api/feed?limit=20&cursor=189f3c2a01
Authorization: Bearer <token>
→ { "items": [ {post}, {post}, ... ],
"next_cursor": "189f2b88f0" } // 取完返回 null
# 读单个用户的时间线(按作者,简单)
GET /api/users/{id}/posts?limit=20&cursor=...
# 社交图
POST /api/follow { "followee_id": "u_42" } → 204
DELETE /api/follow { "followee_id": "u_42" } → 204
# 互动(计数异步更新)
POST /api/posts/{id}/like → 204
GET /api/feed 响应带的是一个不透明的 next_cursor 而非页码——客户端把它传回来取下一页。游标编码最后一条已返回项的位置(通常是这一页里最小的帖子 ID),这让分页在新帖不断从顶部到达时仍然稳定。我们返回的是补水后的帖子对象(文本、作者、当前点赞/回复计数),在读取时从共享帖子存储组装,而不是帖子被扇出时缓存了什么就返回什么。
Step 4 — 数据模型
三个核心存储:帖子存储、社交图、每用户时间线缓存。它们的访问模式很不一样,分片方式也不同。
-- POSTS:内容的真相来源。按 post_id(Snowflake)分片。
CREATE TABLE posts (
post_id BIGINT PRIMARY KEY, -- Snowflake:可按时间排序
author_id BIGINT NOT NULL,
text VARCHAR(280),
media_ids JSON,
created_at TIMESTAMP NOT NULL,
like_count BIGINT DEFAULT 0, -- 近似,异步
reply_count BIGINT DEFAULT 0,
is_deleted BOOLEAN DEFAULT FALSE
);
-- FOLLOWS:社交图。按 follower_id 分片以支持"我关注了谁",
-- 再用 followee_id 上的二级索引/镜像表支持"谁关注了 X"。
CREATE TABLE follows (
follower_id BIGINT NOT NULL,
followee_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX idx_followee ON follows(followee_id); -- 扇出目标列表
-- AUTHOR STATS:驱动混合扇出的决策。
CREATE TABLE author_stats (
author_id BIGINT PRIMARY KEY,
follower_count BIGINT DEFAULT 0,
is_celebrity BOOLEAN DEFAULT FALSE -- follower_count > 阈值
);
-- 时间线缓存(Redis,非 SQL):每用户封顶的帖子 ID 指针列表。
-- Key: timeline:{user_id} Value: ZSET { post_id : score }
-- 通过 ZADD + ZREMRANGEBYRANK 封顶到最新约 800 条。
关键决策:post_id 是一个 Snowflake ID,所以它全局唯一且可按时间排序——给时间线排序就是给整数排序,这个 ID 还兼作分页游标。follows 表需要两条访问路径:"我关注了谁"(读路径,按 follower_id 分片)和"谁关注了 X"(扇出目标列表,按 followee_id 建索引);在规模上这会变成同一条边的两份物理独立、各自分片的表示。时间线缓存放在 Redis 里,是一个帖子 ID 指针的有序集合——绝不存完整帖子体。
Step 5 — 写时扇出 vs 读时扇出
这是整个设计的核心决策。当用户 A 发帖时,A 的粉丝最终怎么在主时间线里看到它?两个纯策略位于两个极端。
写时扇出(push 模型)
发帖时,查出 A 的所有粉丝,把新帖子 ID push 进每个粉丝的预计算时间线缓存。读主时间线于是是一次缓存读:ZREVRANGE timeline:{me} 0 19。读在关注账号数上是 O(1)——它已经为你物化好了。
读时扇出(pull 模型)
发帖时,除了把帖子存进作者自己的时间线外什么都不做。当一个粉丝加载主 Feed 时,pull 其关注的每个账号的最近帖子,即时归并排序。写是 O(1);读是 O(关注数),对关注上千账号的用户会很残酷——每次 Feed 加载都把读扇出到很多作者和分片。
| 维度 | 写时扇出(push) | 读时扇出(pull) |
|---|---|---|
| 读成本 | O(1)——对已就绪时间线一次缓存读 | O(关注数)——每次加载合并多个作者 |
| 写成本 | O(粉丝数)——插入每个粉丝 | O(1)——只存一次 |
| 存储 | 高——帖子 ID 在所有时间线里重复 | 低——每帖一份 |
| 最坏情况 | 名人发帖 = 写风暴 | 用户关注 5k 账号 = 读风暴 |
| 新鲜度 | 滞后于扇出延迟(数秒) | 读取时永远最新 |
| 最适合 | 普通作者、读重 Feed | 名人、不活跃用户 |
因为负载约 50:1 读重,默认直觉是 push——预计算那个贵的东西(读),在写时付出代价。这套路一直很美,直到你撞上一个有数千万粉丝的账号,那里写成本变得灾难性。在真实粉丝分布下没有哪个纯模型能幸存;生产答案是把两者结合。
别选边站。先说出读写比,解释为什么读重 Feed 默认 push,再立刻抛出打破纯 push、催生混合方案的名人反例。带着面试官从"push"→"但名人"→"所以混合"走一遍,正是他们在听的推理弧线。
Step 6 — 名人/热 key 问题
粉丝图是幂律的:绝大多数账号只有几百粉丝,但少数有 5,000 万–5 亿。纯写时扇出在这些账号上以两种不同方式崩溃:
- 写风暴——一条名人帖触发数千万次时间线插入。即便异步分散在 worker 池里,这也会淹没队列,延迟所有其他人排在它后面的扇出,可能要几分钟才排空——于是这条帖在某些粉丝的 Feed 里出现得远晚于其他人。
- 帖子上的热 key——名人的帖子在几秒内被数百万人读取。单条帖子记录(及其点赞/回复计数器)变成一个热 key,猛击帖子存储的某一个分片。
还有一个惊群交互:几个名人在同一时间窗内发帖能让整个扇出层饱和。修复办法是完全停止扇出名人帖、改在读侧处理,并给热帖记录单独的缓存待遇。
热 key 问题与任何热门对象在缓存里出现的形状一样:单个 key 收到不成比例的流量并压垮其分片。缓解手段也类似——把热 key 跨多个缓存节点复制、在共享缓存前加一层进程内 LRU(1–5 s TTL)吸收尖峰,并提供近似计数器,让点赞/回复的写回不在同一行上串行化。
Step 7 — 混合扇出
生产答案——被 Twitter、Instagram 以及基本上所有大型 Feed 采用——是混合扇出:按粉丝数逐作者选择 push 还是 pull。
- 普通作者(低于阈值)——push。他们的帖子在写时扇出进每个粉丝的时间线缓存。大多数作者、因而大多数帖子,走这条路。
- 名人(高于阈值)——不扇出。他们的帖子只存进自己的用户时间线。少数高于阈值的账号贡献零扇出写。
- 读取时合并——当用户加载主 Feed 时,系统读取其已 push 的时间线缓存(覆盖其关注的所有普通作者),同时 pull 其关注的名人的最近帖子,然后把两个集合合并并排序。
阈值(常在约 1 万–10 万粉丝附近,经验调优)在写放大与读合并成本之间权衡。任何单个用户关注的名人数都很小——你可能关注几百个普通账号和十几个名人——所以读取时 pull 有界:十几次便宜的"作者 X 的最近帖子"查找,每次本身都对缓存友好,因为那个名人的最近帖被数百万人读取、一直很热。
# 写路径——逐作者决定 push 还是跳过
def on_new_post(post):
store_post(post) # 真相来源
add_to_user_timeline(post.author_id, post.post_id)
if is_celebrity(post.author_id): # follower_count > 阈值
return # 跳过扇出——读取时 pull
# 普通作者:把异步扇出入队到粉丝
publish("fanout", { "post_id": post.post_id, "author_id": post.author_id })
# 读路径——合并已 push 时间线 + pull 的名人帖
def home_feed(me, cursor, limit):
pushed = zrevrange(f"timeline:{me}", cursor, limit) # O(1) 缓存读
celebs = get_followed_celebrities(me) # 小集合
pulled = [recent_posts(c, cursor, limit) for c in celebs] # 缓存热
merged = merge_by_score(pushed, flatten(pulled))
return hydrate(rank(merged)[:limit]) # 取内容 + 计数
一个微妙点:阈值不纯粹关于粉丝数,而是关于扇出的成本。一个很少发帖的账号即使粉丝众多也便宜 push;一个不停发帖的账号即使粉丝较少也昂贵。成熟系统会把发帖速率纳入考量,有些甚至按活跃粉丝数逐个决定 push-vs-pull(只扇出给最近在线过的粉丝),这能大幅缩小拥有大量沉睡粉丝的账号的扇出集。
Step 8 — 时间线缓存 (Redis)
已 push 的主时间线放在 Redis 里,是一个每用户的有序集合(sorted set),以帖子 ID 指针为成员、以分数排序。正是这个结构让读路径成为一次 O(log n) 范围查询。
- 指针,而非帖子——存
{ post_id : score },约 20 字节/条。绝不存完整帖子体:它们大、会变(点赞、编辑、删除),且会在数百万条时间线里重复。内容在读取时从共享帖子存储补水。 - 封顶长度——每用户只保留最新约 800 条(
ZADD后跟ZREMRANGEBYRANK timeline:{u} 0 -801)。没人会回滚 800 条帖子;更深的历史回退到较慢的读时扇出路径。封顶正是约束估算里那 3.2 TB 机群数字的手段。 - 分数 = 排序信号或时间戳——对时间倒序 Feed,分数就是 Snowflake 帖子 ID 本身(可按时间排序)。对排序版 Feed,分数是预计算的 rank,或缓存保持时间序、排序在读取之后发生。
- 冷/不活跃用户——不为几周没登录的用户维护时间线。跳过对他们的扇出,在下次登录时惰性重建其时间线(纯 pull)。光这一点就能去掉很大一部分浪费的扇出写。
# 扇出 worker 把一个帖子指针插入某粉丝的时间线
ZADD timeline:u_42 189f3c2a01 189f3c2a01 # 分数 = post_id(可按时间排序)
ZREMRANGEBYRANK timeline:u_42 0 -801 # 封顶到最新 800
# 读路径:最新 20 条,按游标(一个 post_id)分页
ZREVRANGEBYSCORE timeline:u_42 (189f2b88f0 -inf LIMIT 0 20
因为时间线按用户 ID 分片在 Redis 机群上,扇出写均匀散布在各分片——除了热帖记录本身,这正是名人被 pull(其帖子是一个被数百万人读取的热 key)而非 push(其扇出会写到数百万条冷时间线)的原因。
Step 9 — Feed 排序
时间倒序 Feed 是简单默认,但大多数大型 Feed 是排序版——按预测相关性排序,而非只按新鲜度。排序作为一个两阶段管道叠加在扇出机制之上:
- 候选生成——混合扇出得到的合并集(已 push 时间线 + pull 的名人帖)就是候选池,通常是几百条最近帖子。扇出的职责是便宜地产出候选;它不是最终顺序。
- 打分/排序——每个候选由一个模型打分,综合多种信号:亲密度(你与作者互动多少)、新鲜度(时间衰减)、互动(点赞/回复速率)、内容类型,以及预测的 P(点赞)、P(回复)、P(停留)。按分数前 N 的成为这一页。
一个简单、面试可用的打分函数无需祭出 ML 基础设施就能抓住要点:
def score(post, viewer):
affinity = edge_weight(viewer, post.author_id) # 过往互动
recency = exp(-AGE_DECAY * hours_since(post.created_at))
engagement = log1p(post.like_count + 2 * post.reply_count)
return W1 * affinity + W2 * recency + W3 * engagement
排序引入新鲜度与相关性的张力:纯排序 Feed 会埋没刚发的帖子(目前互动低)而显得陈旧,所以生产系统会混入一个新鲜度下限或为新内容预留位置。排序模型由独立的在线推理层提供;预计算特征(亲密度边、作者统计)被缓存,使给几百个候选打分仍在延迟预算内。
把排序放在候选生成之后,而非扇出内部。如果你把最终 rank 烤进已 push 时间线的分数里,当模型或观看者上下文变化时你就无法重排,而每个点赞都得重写数百万条时间线分数。扇出产出候选;排序在(或接近)读取时给它们定序。
Step 10 — 分页
对一个不断从顶部收到新帖的 Feed 做无限滚动,是经典的正确性陷阱。朴素做法——LIMIT 20 OFFSET 40——在这里是坏的:如果用户读第 1 页时来了三条新帖,offset 会偏移,第 2 页会重复用户已看过的项(或漏掉一些)。
修复办法是基于游标的分页,键控在一个稳定、单调的值——Snowflake post_id 上。游标是当前页最后一项的 ID;下一页请求 ID 严格小于游标的项:
- 稳定边界——到达游标上方的新帖不会移动它下方的页面。无论顶部来了什么,用户都在一个一致、越来越旧的序列里向下分页。
- Redis 上便宜——
ZREVRANGEBYSCORE timeline:{u} (cursor -inf LIMIT 0 20是 O(log n + 页大小) 的范围扫描,而非 offset 步进。 - "显示新帖"按钮——用户会话锚点上方的新帖通过一个单独的"N 条新帖"控件呈现,按需把它们插到前面,而非悄悄重排当前滚动位置。
- 排序版 Feed 的快照语义——当顺序不是单个单调字段时,游标编码一个会话快照(服务端物化的页序列,或一个 (score, post_id) 元组),这样滚动中途重排不会重复或丢项。
Step 11 — 核心架构与读/写路径
串起来:一条异步、队列驱动的写路径,和一条合并并补水的读路径。
-- 写路径(发布帖子)
POST /api/posts { text, media_ids? }
→ Post 服务:写帖子到帖子存储(按 post_id 分片)
→ 把 post_id 追加到作者自己的用户时间线
→ 查 author_stats.is_celebrity
名人 → 停止(不扇出;读取时 pull)
普通 → 把 { post_id, author_id } 发布到 Kafka "fanout" topic
→ 立即返回 201 (扇出在热路径之外发生)
扇出 worker(消费 Kafka "fanout")
→ 加载作者的 follower_ids (跳过不活跃粉丝)
→ 对每个粉丝:ZADD timeline:{follower} score post_id
ZREMRANGEBYRANK timeline:{follower} 0 -801
-- 读路径(加载主时间线)
GET /api/feed?cursor&limit
→ 读已 push 时间线:ZREVRANGEBYSCORE timeline:{me} (cursor -inf
→ pull 名人帖: 对每个关注的名人 recent_posts(c)
→ 合并 + 排序候选集
→ 补水:从帖子存储批量取帖子内容 + 实时点赞/回复计数
(mget,带缓存前置)
→ 过滤:丢弃已删除/已拉黑/已屏蔽
→ 返回 { items, next_cursor }
发帖写入在毫秒内返回,因为它永不等待扇出——扇出通过 Kafka 解耦,Kafka 缓冲突发(一阵无名人的普通帖尖峰)并让 worker 层独立伸缩。这与让任何热写路径保持快的异步管道推理一致:同步只做最少的事,把放大入队,让消费者用自己的背压去吸收。读取时补水意味着一秒前刚到的点赞会反映在计数里,即便时间线指针是几小时前写的。
Step 12 — 扩展与分片
各层独立伸缩,因为存储按不同的键分片。
分片各存储
- 帖子存储——按
post_id分片(Snowflake)。读是按 ID 的点查;补水批量 ID 并跨分片散播-收集。可按时间排序的 ID 让单个作者的帖子在时间上大致共置。 - 时间线缓存——按
user_id分片 Redis。扇出写和 Feed 读都对用户哈希,所以单个用户的时间线是一个分片、一次往返。 - 社交图——
follows按follower_id分片以支持读路径"我关注了谁",并维护一份独立的按 followee 建索引的表示供扇出"谁关注了 X"查找。两个查询访问键相反,所以分片键也相反。
扩展扇出
扇出 worker 是无状态的 Kafka 消费者——加 partition 和消费者就能扩吞吐。按 author_id 给扇出 topic 分区,让单个热作者的工作保持有序,并对大(但低于名人)的作者限流/分片其工作,让一个中腰部网红不阻塞队头。名人切线就是让非常肥的尾巴完全离开这一层的泄压阀。
扩展读
Feed 服务在负载均衡器后无状态;水平扩展。时间线缓存读吸收了绝大部分读负载。热帖记录(名人帖、病毒帖)额外获得一层进程内 LRU 加跨分片复制,这样单条病毒帖不会让一个 Redis 节点饱和。
Step 13 — 容错与边界情况
Feed 被允许最终一致,这给了很多余地——但边界情况正是正确性 bug 藏身之处。
- 已删除帖子——不要去追数百万条时间线条目来移除一条已删除帖。留下指针;读取时补水会跳过被标记
is_deleted的帖子(墓碑过滤)。陈旧指针会随封顶时间线自然老化掉。 - 取关——同样思路:不要把被取关作者的帖子从时间线缓存里擦掉。在读取时对当前关注集过滤掉它们,或干脆让它们老化掉。下次重建时正确;无昂贵的即时清理。
- 拉黑/屏蔽——作为对候选集的读取时过滤应用,绝不烤进扇出(拉黑可能在帖子已被扇出后才加上)。
- 新用户冷启动——全新账号谁都没关注,其已 push 时间线为空。通过从初始关注 pull 最近帖来回填,并混入一个"推荐"/发现 Feed,直到个性化 Feed 有了信号。
- 乱序扇出——异步 worker 可能晚插入一条帖;因为时间线按可时间排序的帖子 ID 打分,晚到的插入仍落在正确的时间序位置而非顶部。
- Redis 故障——丢失的时间线分片是可恢复的:时间线是缓存,不是真相来源。通过从帖子存储和社交图 pull(读时扇出回退)按需重建用户的时间线。Redis 带副本运行以缩小爆炸半径。
- 扇出滞后——扇出积压期间,普通作者的帖子可能晚几秒到几分钟才到达粉丝。按最终一致需求可接受;在滞后变得可见之前监控队列深度并自动扩容 worker。
贯穿这些边界情况的统一原则:时间线缓存可丢弃、是近似的;帖子存储和社交图才是真相来源。任何必须正确的东西(删除、拉黑、取关、实时计数)都在读取时补水/过滤里强制执行,而非通过改动数百万条缓存指针。这让写保持便宜,也让缓存随时可安全重建。
Step 14 — 关键取舍
| 决策 | 选择 | 接受的取舍 |
|---|---|---|
| 扇出策略 | 混合(普通 push、名人 pull) | 两条代码路径 + 一次读取时合并,换来有界写放大 |
| 时间线存储 | Redis 里帖子 ID 指针,封顶约 800 | 深历史需较慢的 pull 回退;内容单独补水 |
| 扇出执行 | 通过 Kafka worker 异步 | 帖子带几秒滞后才出现在 Feed(最终一致) |
| 排序 | 排序版(模型)而非纯时间序 | 更多基础设施 + 一个新鲜度下限避免埋没新帖 |
| 分页 | 基于 Snowflake post_id 的游标 | 无随机翻页;为向前无限滚动而设计 |
| 删除/取关 | 读取时过滤(墓碑) | 陈旧指针在缓存里逗留到老化掉 |
| 计数 | 近似,异步 | 点赞/回复计数可滞后数秒;避免热行争用 |
News Feed 设计是一门驯服写放大的学问。push 预计算读、在读重的常见情况里取胜;pull 为名人这条肥尾保住写路径;混合扇出不过是承认粉丝分布逼着你两者都做。其余的一切——只存指针的封顶时间线、Kafka 异步扇出、读取时补水与过滤、游标分页、候选生成之后再排序——都是为了把一个数字(扇出写/秒及其尾部)控制住,同时让 Feed 仍然渲染得新鲜、正确且快。
写时扇出还是读时扇出,选哪个? 都不单用。push 为每个粉丝预计算时间线以读快,但按粉丝数放大写;pull 写便宜,但对关注上千人的用户读慢。生产用混合:普通用户 push、名人 pull,读取时合并。
怎么处理名人/热 key 问题? 1 亿粉丝帖在纯 push 下是让扇出滞后数分钟的写风暴。把高粉丝量账号标记为名人、跳过其扇出,在读取时 pull 其最近帖。名人自己的帖子放在所有读者共享的热缓存里。
混合扇出到底是什么? 按粉丝数分类作者:低于阈值就 push 进每个粉丝的时间线缓存;高于阈值就不扇出——在请求时读时 pull 并合并。这既约束写放大,又让常见读路径保持一次缓存查找。
为什么时间线缓存存帖子 ID 而非完整帖子? 帖子大、可变,且在数百万条时间线里重复。存约 20 字节的帖子 ID 指针约束内存,并让你在读取时补水当前内容/计数,这样编辑或删除无需重写每条时间线。
对一直在变的 Feed 怎么分页? 用键控在单调帖子 ID 上的游标分页,绝不用 SQL OFFSET。游标标记最后一条已返回项,所以到达其上方的帖子不会移动页面边界——滚动时既不重复也不跳过。