很多社交应用把首页做成两个标签:“正在关注”和“为你推荐”。如果只看 UI,它们都返回帖子卡片,因此最初很容易共用一条查询:前者按关注关系过滤,后者按热度排序。然而当用户规模和交互变多,两条时间线会暴露完全不同的问题。
“正在关注”强调关系确定性和时间顺序。用户希望刚关注的作者、刚发布的内容能出现,刷新后不应突然清空,连续翻页也不应重复或跳项。“为你推荐”强调相关性、新鲜度和探索性,需要候选召回、排序、去重、曝光抑制和持续重建。
因此,双轨信息流不是一个接口加 tab=following|for_you,而是两套读模型共享同一套卡片水化与权限门禁。本文从产品语义开始,设计 Following 快照与 For You 候选池,并重点讨论一个常见故障:明明权威库有帖子,刷新后两个标签却一起变空。
适用范围与规模假设
本文假设系统同时提供关系时间线和推荐时间线,单页读取需要经过删除、拉黑、社区和内容安全过滤,并要求游标分页稳定。排序模型可以是规则或机器学习;方案重点是候选生命周期、版本切换与读取正确性,而不是某个特定推荐算法。
一、两个标签的产品契约不同
| 维度 | 正在关注 Following | 为你推荐 For You |
|---|---|---|
| 核心目标 | 不错过已关注作者内容 | 最大化相关性、质量与探索 |
| 主要排序 | 近似时间倒序 | 个性化分数 + 新鲜度 |
| 可解释性 | 强:来自关注关系 | 中等:兴趣与互动推断 |
| 一致性偏好 | 稳定分页、少重复 | 候选可变、允许重排 |
| 刷新语义 | 读取新快照或新增量 | 获取新一版候选池 |
| 空池处理 | 回填最近关注内容 | 兜底公共内容并触发重建 |
| 曝光抑制 | 较弱,避免短期重复即可 | 较强,鼓励内容多样性 |
共同契约则包括:最终权限检查、删除过滤、卡片水化、稳定去重、游标签名、错误不能伪装为空数组。
二、总体架构:候选和卡片分开

信息流读取可拆成四层:
- 候选层:只回答“哪些 postId 可能出现在这一页,以及候选顺序是什么”。
- 可见性层:结合 viewer 当前关系、拉黑、社区成员和内容生命周期过滤。
- 水化层:批量将 ID 转成 PostCard,并保留候选顺序。
- 页面层:补位、去重、生成不透明游标、记录曝光和刷新提示。
将候选与卡片分开有两个好处。第一,推荐算法只处理轻量特征,不需要复制完整正文;第二,卡片投影升级时无需重建所有候选池。候选项可以很小:
type FeedCandidate = {
postId: string;
score: number;
reason: "FOLLOWING" | "INTEREST" | "TREND" | "EXPLORATION";
candidateAt: string;
sourceVersion: string;
};
三、Following:为什么需要快照,而不是每次现查
最直接的查询是:获取关注作者集合,再查询这些作者最近的帖子并按发布时间倒序。小规模时完全可用,但有三个问题:
- 关注数多时,SQL 计划和分页成本上升;
- 用户翻页期间不断有新帖子插入,基于 offset 的页面漂移;
- 拉黑、删除和私密关系变化会造成大量候选被过滤,页面越来越短。
1. Fan-out on read、Fan-out on write 与混合模式
- 读时扇出:请求时从所有关注作者聚合。写轻读重,适合早期和低活跃用户。
- 写时扇出:作者发布时,把 postId 写入每位关注者 inbox。读快写重,超级大 V 会产生巨大扇出。
- 混合模式:普通作者写时扇出,超大账号读时合并;或先用读时查询生成用户快照,再增量维护。
一种可恢复的 Following 实现会保留 PostgreSQL inbox/控制事实与 MongoDB 快照读模型,使页面读取稳定,同时仍能从权威候选重建。
2. 快照版本与稳定游标
每次物化得到一个不可变版本:
{
"userId": "u_viewer",
"snapshotVersion": "fs_20260807_001",
"generatedAt": "2026-08-07T10:00:00Z",
"items": [
{ "postId": "p3", "publishedAt": "..." },
{ "postId": "p2", "publishedAt": "..." }
]
}
游标至少编码 scene + snapshotVersion + position + query fingerprint,并签名防篡改。用户翻页时固定在同一快照,不会因新内容插入而重复;新帖子到来只产生“有新内容”的 refresh hint,用户点击刷新后切换到新版本。
3. 刷新不应先清空旧列表
一个常见前端错误是:点击刷新立即 setItems([]),然后发请求。只要请求失败、返回空候选或中间依赖超时,原内容就永久消失。
正确状态转换是:
READY(oldItems)
└─ refresh → REFRESHING(oldItems still visible)
├─ success(newItems) → READY(newItems)
└─ failure → READY(oldItems) + retry message
刷新按钮在请求期间旋转并禁用重复点击;完成或失败后停止。新的首屏成功返回前,旧列表保持可见。服务端也应把“真实空列表”和“依赖故障”区分成不同响应,而不是一律 200 []。
四、For You:候选池需要版本指针
推荐流通常分为召回与排序。
1. 多路召回
候选来源可以包括:
- 用户显式兴趣、关注作者的二跳内容;
- 最近点赞、评论、停留内容的相似主题;
- 全站或分群热门内容;
- 新作者与低曝光内容的探索池;
- 社区成员关系和地理/语言约束下的内容。
召回阶段重覆盖,不追求精确排序。每一路都应设置配额,防止热门池吞掉所有小众兴趣。
2. 轻量打分示例
score =
0.30 * interestAffinity
+ 0.20 * authorAffinity
+ 0.18 * engagementQuality
+ 0.15 * freshnessDecay
+ 0.10 * contentCompleteness
+ 0.07 * explorationBonus
- riskPenalty
- repetitionPenalty
实际权重不是本文重点,重点是每个特征必须有版本、缺失默认值和观测。新鲜度可以使用指数衰减:
freshnessDecay = exp(-ageHours / halfLifeHours)
3. 构建新池,再原子切换
不要在用户正在读取的候选数组上原地清空再重建。推荐使用双版本:
active pointer: user U → poolVersion A
后台:构建 poolVersion B
→ 写完整候选
→ 校验数量、重复率与可用率
→ 原子切换 U 的 active pointer: A → B
→ 延迟清理 A
指针属于强一致控制事实,可放 PostgreSQL;候选池属于可重建读模型,可放 MongoDB。这样任何时刻读取者都指向完整版本,构建失败不会影响 A。

五、最容易被忽略的两件事:去重和曝光
1. 跨召回源去重
同一帖子可能同时来自关注、兴趣和热门召回。最终候选按 postId 去重,而不是按候选记录 ID。多个原因可保留为 reason set,但只能生成一张卡片。
转发场景还要明确产品口径:同一原帖的多个转发是否都展示?通常可保留不同动作记录,但短时间内需要按 sourcePostId 做弱去重,避免屏幕被同一内容占满。
2. 曝光不是浏览量
“进入候选列表”“返回客户端”“真正进入视口”和“打开详情”是不同事件。推荐曝光抑制至少应使用客户端可视曝光,而不是服务端返回即算已看。Redis 可保存短期 suppression set:
feed:exposure-suppress:{userId}:{scene} = {postId...}, TTL 24h
MongoDB 或分析存储可保留长期曝光事实,用于推荐训练与审计。Redis 丢失只会带来短期重复,不应破坏主业务。
六、权限过滤之后必须补位
候选生成时可见,不代表读取时仍可见。作者可能删除帖子,viewer 可能拉黑作者,社区可能变私密。因此页面服务要采用“扫描—过滤—补位”:
while (returned.length < pageSize && scanned < scanBudget) {
const batch = await candidateStore.next(batchSize);
const visibleIds = await visibilityGate.filter(viewerId, batch);
const cards = await cardReader.hydrate(visibleIds);
appendInCandidateOrder(returned, cards);
}
这里必须维护两个位置:候选物理位置和已返回数量。下一游标应推进到最后一个已扫描候选,而不是最后一个返回卡片,否则被过滤候选会在下一页反复出现。
scanBudget 防止一页请求无限向后扫描。预算耗尽时允许返回短页,并记录 filtered ratio 和 budget exhausted。如果连续多页过滤率过高,应触发候选池重建,而不是无限增加预算。
七、空池不是正常终点,而是一种需要分类的状态
当 For You 没有候选时,可能有四类原因:
- 新用户尚无个性化特征;
- 候选池未构建或 active pointer 缺失;
- 候选存在但全部过期/不可见;
- 依赖故障被错误吞成空结果。
对应策略不同:
| 状态 | 同步响应 | 后台动作 |
|---|---|---|
| 新用户冷启动 | 返回高质量公共内容/选中兴趣内容 | 初始化兴趣画像 |
| 指针或池缺失 | 使用 Explore/最近公开帖子兜底 | 持久化重建命令 |
| 高过滤率 | 向后补位,必要时返回短页 | 立即重建候选 |
| 基础设施错误 | 明确可重试错误,保留旧列表 | 告警与重试 |
兜底内容也必须来自真实后端事实。不能在前端硬编码“热门帖子”,否则会出现卡片无法点击、作者不存在、计数永远不变等问题。
Low Watermark
系统可为每个池设置低水位,例如剩余可用候选小于 40 条时异步刷新。在用户真正读空之前完成 B 版本构建,体验会平滑得多。刷新命令需要 debounce,避免每个请求都提交一次重建。
八、API 契约与刷新语义
GET /api/feed/following?cursor=...&limit=20
GET /api/feed/for-you?cursor=...&limit=20
POST /api/feed/following/refresh
POST /api/feed/for-you/refresh
也可以把 refresh 合并到首屏请求的参数中,但响应必须表达版本:
{
"items": [],
"nextCursor": "opaque-token",
"hasMore": true,
"feedVersion": "fy_01J...",
"generatedAt": "2026-08-07T10:00:00Z",
"refresh": {
"accepted": false,
"reason": "CURRENT_VERSION_FRESH"
}
}
点击刷新不一定要同步等待整个推荐重建。可以返回当前可用版本,并告知重建已接受;前端轮询或通过实时事件收到 FeedVersionChanged 后再切换。若产品希望“点击即换一批”,服务端应从当前池选择另一窗口,而不是删除池后重算。
九、缓存键必须包含场景和版本
错误的缓存键 feed:{userId}:{cursor} 会让 Following 与 For You 相互污染,也会在版本切换后返回旧页。推荐:
feed-page:{scene}:{userId}:{feedVersion}:{queryFingerprint}:{cursorHash}
queryFingerprint 包括 limit、语言、内容安全级别等会影响结果的参数。缓存只保存已完成页面;请求取消或水化失败不能写入空页面缓存。
版本切换后无需枚举删除全部旧页,旧 key 自然过期。实体卡片本身可以单独缓存,并在 Post 事件后精确失效。
十、可观测性与容量指标
信息流至少需要以下指标:
- 首屏与翻页 P50/P95/P99;
- 候选读取、权限过滤、卡片水化各阶段耗时;
- 每页
physicalScanned / returned比值; - 删除、拉黑、社区权限、投影缺失各过滤原因;
- Following snapshot age、For You pool age 与剩余候选量;
- active pointer 缺失率、空池率、兜底使用率;
- 重建队列积压、耗时、失败率和 debounce 命中;
- 曝光重复率、作者多样性、主题多样性;
- 前端刷新成功率、保留旧列表比例、转圈持续时间。
容量规划不能只看 DAU。需要估算平均关注数、发帖率、粉丝分布长尾、每用户候选池大小、快照保留版本数和平均曝光写入量。超级账号应单独建模,否则平均值会严重误导 fan-out 成本。
十一、测试策略
- 快照稳定性:翻页期间插入新帖子,旧游标不重复不漏;刷新后新版本包含新增项。
- 版本原子性:B 构建到一半失败,读请求仍完整读取 A。
- 补位语义:前 30 个候选中 20 个不可见,页面仍尽量返回 limit,下一游标不重复扫描。
- 空池恢复:删除候选池或指针后,接口返回真实兜底并持久化重建命令。
- 故障区分:Mongo/Redis/Kafka 失败不能被转换成真实空列表。
- 前端刷新:请求中旧列表保留、按钮旋转、成功替换、失败恢复并停止动画。
- 排序属性测试:相同输入和版本产生确定顺序;分数为 NaN 或特征缺失时有明确默认值。
十二、方案权衡
Following 使用快照会牺牲极短时间内的实时性,但换来分页稳定与读取可控;通过 delta hint 和刷新可以补回体验。For You 使用版本池会增加存储和后台计算,却避免用户读到半成品。两者共享水化服务能减少重复代码,但水化服务必须批量、可取消,并保持候选顺序。
不要过早追求一套复杂机器学习平台。第一版推荐完全可以由可解释特征和规则打分开始,先把候选生命周期、权限补位、版本切换、曝光和恢复工具做正确。一个可回填、不会突然变空的朴素推荐流,比一个无法解释、无法恢复的高级模型更有产品价值。
结语
“正在关注”和“为你推荐”的共同输出是 PostCard,但它们背后的正确性标准不同。Following 需要稳定关系快照和时间顺序,For You 需要可替换候选池和个性化排序;它们只应在权限、水化、分页基础设施和展示层复用。
最关键的设计原则是:构建新版本,再切换;刷新期间保留旧版本;空池必须分类;候选过滤后必须补位;任何兜底都来自真实后端事实。做到这些,信息流才能从“能返回数组”升级为可长期运营的产品系统。