社交平台双轨信息流设计:Following 快照与 For You 候选池

很多社交应用把首页做成两个标签:“正在关注”和“为你推荐”。如果只看 UI,它们都返回帖子卡片,因此最初很容易共用一条查询:前者按关注关系过滤,后者按热度排序。然而当用户规模和交互变多,两条时间线会暴露完全不同的问题。

“正在关注”强调关系确定性和时间顺序。用户希望刚关注的作者、刚发布的内容能出现,刷新后不应突然清空,连续翻页也不应重复或跳项。“为你推荐”强调相关性、新鲜度和探索性,需要候选召回、排序、去重、曝光抑制和持续重建。

因此,双轨信息流不是一个接口加 tab=following|for_you,而是两套读模型共享同一套卡片水化与权限门禁。本文从产品语义开始,设计 Following 快照与 For You 候选池,并重点讨论一个常见故障:明明权威库有帖子,刷新后两个标签却一起变空。

适用范围与规模假设

本文假设系统同时提供关系时间线和推荐时间线,单页读取需要经过删除、拉黑、社区和内容安全过滤,并要求游标分页稳定。排序模型可以是规则或机器学习;方案重点是候选生命周期、版本切换与读取正确性,而不是某个特定推荐算法。

一、两个标签的产品契约不同

维度正在关注 Following为你推荐 For You
核心目标不错过已关注作者内容最大化相关性、质量与探索
主要排序近似时间倒序个性化分数 + 新鲜度
可解释性强:来自关注关系中等:兴趣与互动推断
一致性偏好稳定分页、少重复候选可变、允许重排
刷新语义读取新快照或新增量获取新一版候选池
空池处理回填最近关注内容兜底公共内容并触发重建
曝光抑制较弱,避免短期重复即可较强,鼓励内容多样性

共同契约则包括:最终权限检查、删除过滤、卡片水化、稳定去重、游标签名、错误不能伪装为空数组。

二、总体架构:候选和卡片分开

信息流读取可拆成四层:

  1. 候选层:只回答“哪些 postId 可能出现在这一页,以及候选顺序是什么”。
  2. 可见性层:结合 viewer 当前关系、拉黑、社区成员和内容生命周期过滤。
  3. 水化层:批量将 ID 转成 PostCard,并保留候选顺序。
  4. 页面层:补位、去重、生成不透明游标、记录曝光和刷新提示。

将候选与卡片分开有两个好处。第一,推荐算法只处理轻量特征,不需要复制完整正文;第二,卡片投影升级时无需重建所有候选池。候选项可以很小:

type FeedCandidate = {
  postId: string;
  score: number;
  reason: "FOLLOWING" | "INTEREST" | "TREND" | "EXPLORATION";
  candidateAt: string;
  sourceVersion: string;
};

三、Following:为什么需要快照,而不是每次现查

最直接的查询是:获取关注作者集合,再查询这些作者最近的帖子并按发布时间倒序。小规模时完全可用,但有三个问题:

  1. 关注数多时,SQL 计划和分页成本上升;
  2. 用户翻页期间不断有新帖子插入,基于 offset 的页面漂移;
  3. 拉黑、删除和私密关系变化会造成大量候选被过滤,页面越来越短。

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 ratiobudget exhausted。如果连续多页过滤率过高,应触发候选池重建,而不是无限增加预算。

七、空池不是正常终点,而是一种需要分类的状态

当 For You 没有候选时,可能有四类原因:

  1. 新用户尚无个性化特征;
  2. 候选池未构建或 active pointer 缺失;
  3. 候选存在但全部过期/不可见;
  4. 依赖故障被错误吞成空结果。

对应策略不同:

状态同步响应后台动作
新用户冷启动返回高质量公共内容/选中兴趣内容初始化兴趣画像
指针或池缺失使用 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 成本。

十一、测试策略

  1. 快照稳定性:翻页期间插入新帖子,旧游标不重复不漏;刷新后新版本包含新增项。
  2. 版本原子性:B 构建到一半失败,读请求仍完整读取 A。
  3. 补位语义:前 30 个候选中 20 个不可见,页面仍尽量返回 limit,下一游标不重复扫描。
  4. 空池恢复:删除候选池或指针后,接口返回真实兜底并持久化重建命令。
  5. 故障区分:Mongo/Redis/Kafka 失败不能被转换成真实空列表。
  6. 前端刷新:请求中旧列表保留、按钮旋转、成功替换、失败恢复并停止动画。
  7. 排序属性测试:相同输入和版本产生确定顺序;分数为 NaN 或特征缺失时有明确默认值。

十二、方案权衡

Following 使用快照会牺牲极短时间内的实时性,但换来分页稳定与读取可控;通过 delta hint 和刷新可以补回体验。For You 使用版本池会增加存储和后台计算,却避免用户读到半成品。两者共享水化服务能减少重复代码,但水化服务必须批量、可取消,并保持候选顺序。

不要过早追求一套复杂机器学习平台。第一版推荐完全可以由可解释特征和规则打分开始,先把候选生命周期、权限补位、版本切换、曝光和恢复工具做正确。一个可回填、不会突然变空的朴素推荐流,比一个无法解释、无法恢复的高级模型更有产品价值。

结语

“正在关注”和“为你推荐”的共同输出是 PostCard,但它们背后的正确性标准不同。Following 需要稳定关系快照和时间顺序,For You 需要可替换候选池和个性化排序;它们只应在权限、水化、分页基础设施和展示层复用。

最关键的设计原则是:构建新版本,再切换;刷新期间保留旧版本;空池必须分类;候选过滤后必须补位;任何兜底都来自真实后端事实。做到这些,信息流才能从“能返回数组”升级为可长期运营的产品系统。

发表评论