场景设计:类微信朋友圈时间序 Feed 系统
本文讨论通用社交时间线设计,不代表微信真实内部实现。
1. 先明确题目:这是“时间序 Feed”,不是推荐流
朋友圈和短视频推荐流的系统目标不同。
时间序 Feed 的核心语义是:
1 | |
推荐流则可能根据兴趣、互动概率、商业价值重新排序。
因此,本题重点不是复杂推荐算法,而是:
- 如何快速得到好友动态集合;
- 如何维持稳定时间顺序;
- 如何处理好友关系和可见性;
- 如何在发布写放大与读取计算之间取舍;
- 如何保证删除、屏蔽、权限变化能及时生效;
- 如何支持稳定的向下翻页。
2. 需求拆解
2.1 功能需求
- 用户发布文字、图片、视频、位置等动态;
- 好友能按时间倒序查看;
- 支持部分可见、不给谁看、仅自己可见;
- 支持拉黑、屏蔽某人朋友圈、删除好友;
- 支持点赞、评论和回复;
- 支持动态删除;
- 支持下拉刷新和向下翻页;
- 支持多设备登录;
- 可选支持“三天可见”“半年可见”等时间范围策略。
2.2 非功能需求
- 读请求远多于写请求;
- 首页读取要快;
- 发布不能因好友过多无限阻塞;
- 允许部分派生索引最终一致,但隐私校验不能被缓存绕过;
- 删除和权限变更要尽快收敛;
- 分页不能大量重复、跳项;
- 图片和视频走对象存储/CDN,Feed 服务不承载大文件。
3. 核心数据模型
3.1 动态主表
1 | |
3.2 媒体资源表
1 | |
Feed 中只存资源引用,客户端通过 CDN 获取媒体。
3.3 可见性规则
1 | |
常见表达:
1 | |
关系型数据库可以保存事实,在线鉴权可使用关系缓存、位图、集合或图服务加速。
4. 总体架构
flowchart LR
APP[客户端] --> GW[API Gateway]
GW --> POST[动态发布服务]
GW --> FEED[Feed 读取服务]
GW --> SOCIAL[关系与隐私服务]
GW --> INTERACT[点赞评论服务]
POST --> DB[(动态主库)]
POST --> MEDIA[对象存储]
POST --> OUTBOX[(Outbox / CDC)]
OUTBOX --> MQ[事件总线]
MQ --> FANOUT[Feed 扇出服务]
FANOUT --> INBOX[(用户收件箱索引)]
MQ --> SEARCH[审核/搜索/数据处理]
FEED --> INBOX
FEED --> DB
FEED --> SOCIAL
FEED --> CACHE[(内容与关系缓存)]
FEED --> INTERACT
MEDIA --> CDN[CDN]
APP --> CDN
5. 三种 Feed 模式
Feed 的经典选择是:
1 | |
5.1 推模式:Fanout on Write
用户 A 发布动态后,系统把 post_id 写入所有可见好友的 Feed 收件箱。
sequenceDiagram
participant A as 发布者 A
participant Post as 发布服务
participant DB as 动态库
participant MQ as 消息队列
participant Fanout as 扇出服务
participant Inbox as 好友 Feed Inbox
A->>Post: 发布动态
Post->>DB: 保存 post
Post->>DB: 保存 Outbox 事件
DB-->>Post: 提交成功
Post-->>A: 发布成功
MQ->>Fanout: PostPublished
Fanout->>Fanout: 查询可见好友并分批
Fanout->>Inbox: 写入 postId + sortKey
优点:
- 首页读取快;
- 读取只需访问自己的收件箱;
- 很适合关系规模有限、读多写少的社交场景。
缺点:
- 写放大明显;
- 用户好友很多时扇出任务重;
- 删除、权限变化需要传播;
- 不活跃用户的收件箱也可能被持续写入。
5.2 拉模式:Fanout on Read
用户打开 Feed 时:
- 获取好友列表;
- 查询每个好友最近动态;
- 多路归并;
- 做可见性过滤;
- 返回 Top N。
flowchart LR
U[用户打开 Feed] --> F[读取好友列表]
F --> P1[好友 A 时间线]
F --> P2[好友 B 时间线]
F --> P3[好友 C 时间线]
P1 --> M[多路归并]
P2 --> M
P3 --> M
M --> V[可见性过滤]
V --> R[返回 Top N]
优点:
- 发布成本低;
- 删除和权限更新更容易实时生效;
- 不需要为长期不活跃用户维护大收件箱。
缺点:
- 读放大;
- 好友多时聚合延迟高;
- 跨分片多路查询复杂;
- 热门时段首页压力大。
5.3 混合模式
生产系统通常采用混合模式:
- 普通用户:发布时推给好友;
- 高好友数或高粉丝用户:不做全量推送,读取时再拉取;
- 长期不活跃用户:停止实时扇出,回归时按需补建;
- 首页近段数据使用推模式,深历史使用拉模式或归档查询。
flowchart TD
P[用户发布动态] --> C{关系规模是否超过阈值}
C -- 否 --> W[Fanout on Write]
C -- 是 --> M[写入作者时间线]
W --> I[好友 Inbox]
M --> H[标记为 Pull Source]
R[用户读取 Feed] --> I2[读取 Inbox]
R --> S[读取特殊作者时间线]
I2 --> Merge[合并并去重]
S --> Merge
Merge --> V[最终可见性校验]
6. Feed 收件箱怎么存
6.1 Redis Sorted Set
短期热点 Feed 可使用 Redis ZSet:
1 | |
1 | |
Redis Sorted Set 的 member 唯一。如果同一个动态重复扇出,ZADD 会更新已有 member,天然具备一定幂等性。
但要注意:
- Score 使用毫秒时间戳时可能碰撞;
- 单独依靠时间戳不足以形成全序;
- Redis 不应保存无限历史;
- ZSet 深分页成本需要控制;
- 最终详情仍应从内容缓存或数据库批量读取。
6.2 持久化 Inbox 表
1 | |
可按 user_id 分片。最近数据进入 Redis,较旧数据存宽表、KV、LSM 数据库或分片关系库。
6.3 不要把动态全文复制到每个收件箱
收件箱通常只保存轻量索引:
1 | |
动态正文和媒体只保存一份,否则好友数越多,存储放大越严重,编辑和删除也更困难。
7. 时间顺序如何保证
7.1 不使用客户端时间
客户端时间可能:
- 被用户修改;
- 时区错误;
- 设备时钟漂移;
- 离线发布后延迟上传;
- 被攻击者伪造。
排序时间应由服务端确定。
7.2 时间戳仍然不够
同一毫秒内可能发布多个动态。稳定排序需要复合键:
1 | |
或者生成一个单调可排序的 feed_sequence。
7.3 发布 ID
可以使用趋势递增 ID:
1 | |
但需要明确:
- ID 趋势递增不等于全球严格线性一致;
- 时钟回拨必须处理;
- 如果只要求用户 Feed 内稳定时间序,通常不需要全世界所有动态共用一个全局序列器。
7.4 离线发布如何排序
需要定义产品语义:
- 按服务端真正接收时间排序;
- 或保留拍摄时间,但 Feed 排序仍按发布时间;
- 草稿恢复后发布,不应插入数小时前的位置,除非产品明确要求。
8. 游标分页设计
时间序 Feed 不应使用:
1 | |
推荐游标:
1 | |
请求:
1 | |
数据库条件:
1 | |
8.1 下拉刷新和向下翻页不是同一个方向
- 下拉刷新:查询
sort_key > current_top_sort_key的新内容; - 向下翻页:查询
< last_sort_key的旧内容。
客户端应维护:
1 | |
8.2 动态插入时分页是否稳定
游标按上一页最后一条继续向下查,新发布内容只会出现在顶部,不会改变旧游标的方向,因此比 OFFSET 稳定。
删除仍可能让下一页数量不足,服务端可适度过取并过滤后补齐。
9. 发布流程
sequenceDiagram
participant App as 客户端
participant Upload as 上传服务
participant OSS as 对象存储
participant Post as 发布服务
participant DB as 动态数据库
participant MQ as 事件总线
participant Fanout as 扇出服务
App->>Upload: 获取上传凭证
Upload-->>App: 临时上传凭证
App->>OSS: 直传图片/视频
OSS-->>App: objectKey
App->>Post: 发布内容 + objectKeys + 可见性
Post->>Post: 鉴权、内容校验、幂等校验
Post->>DB: 写 post + media + visibility + outbox
DB-->>Post: 事务提交
Post-->>App: 发布成功
MQ->>Fanout: PostPublished
Fanout->>Fanout: 分批读取可见好友
Fanout->>Fanout: 幂等写入 Inbox
发布接口应携带客户端幂等 ID,避免超时重试生成两个相同动态。
10. 读取流程
sequenceDiagram
participant App as 客户端
participant Feed as Feed 服务
participant Inbox as Inbox 存储
participant Relation as 关系/隐私服务
participant Cache as 动态内容缓存
participant Interact as 互动服务
App->>Feed: GET /feed?cursor=...
Feed->>Inbox: 过取候选 postId
Inbox-->>Feed: 候选索引
Feed->>Relation: 批量校验好友/屏蔽/可见性
Relation-->>Feed: 可见结果
Feed->>Cache: 批量获取动态摘要
Cache-->>Feed: 内容、媒体、状态
Feed->>Interact: 批量获取点赞评论摘要
Interact-->>Feed: 点赞状态、评论摘要
Feed->>Feed: 合并、去重、过滤、补齐
Feed-->>App: items + nextCursor
关键点:
即使发布时已经做过可见性扇出,读取时仍要做关键权限校验。
原因是好友关系和屏蔽状态会在发布后变化,旧 Inbox 可能尚未清理。
11. 好友关系和隐私语义
11.1 发布时快照还是读取时实时关系
两种可能语义:
方案 A:发布时快照
“发布那一刻是好友的人,可以看到。”
优点:行为稳定;缺点:删除好友后可能仍可看到旧内容,不一定符合产品预期。
方案 B:读取时实时关系
“现在仍有可见关系,才可以看到。”
优点:隐私更强;缺点:读取成本更高,需要实时关系校验。
大多数隐私敏感产品会在读取时做最终校验,并让删除好友、拉黑、屏蔽尽快生效。
11.2 可见性表达
判断函数可以抽象为:
1 | |
硬性校验包括:
- 动态未删除;
- 作者账号和内容状态允许展示;
- 当前关系满足产品规则;
- 不在 deny list;
- 如果是 allow list,则必须命中;
- 不超过作者设置的历史可见范围;
- viewer 没有屏蔽作者;
- 内容审核状态允许展示。
不能只依靠前端隐藏。
12. 删除动态怎么处理
删除要同时影响:
- 动态主记录;
- 内容缓存;
- 好友 Inbox 索引;
- 搜索/审核索引;
- CDN 访问策略;
- 点赞评论展示;
- 数据分析中的业务口径。
推荐流程:
flowchart LR
D[用户删除动态] --> DB[(主库状态改为 DELETED)]
DB --> O[Outbox: PostDeleted]
O --> MQ[事件总线]
MQ --> C[失效内容缓存]
MQ --> I[异步清理 Inbox]
MQ --> S[删除搜索索引]
MQ --> M[媒体进入延迟清理]
读取时必须先检查主状态或缓存中的 tombstone。即使 Inbox 尚未异步清完,也不能再展示。
物理删除可延迟执行,避免影响审计、申诉和数据恢复。
13. 修改可见范围如何处理
假设动态从“所有好友可见”改为“仅部分好友可见”。有两种策略:
- 立即重建/清理所有收件箱;
- 更新主规则,读取时即时生效,后台异步清理冗余索引。
通常采用第二种:
1 | |
这体现一个原则:
派生索引可以最终一致,隐私决策必须以事实源或可信实时缓存为准。
14. 点赞和评论
点赞、评论不应直接嵌入动态主表大 JSON 反复更新。
14.1 点赞
1 | |
主键保证同一用户对同一动态只有一条有效关系。取消点赞可以更新状态或删除。
14.2 评论
1 | |
Feed 首页只展示评论摘要和少量最新评论,完整评论按需加载。
14.3 计数一致性
点赞数、评论数属于可重建派生数据,可接受短暂最终一致:
- 关系表是事实;
- Redis 计数用于快速展示;
- 消息异步聚合;
- 定期对账修复。
用户自己的“是否点赞”比总计数更敏感,应通过批量关系查询准确返回。
15. 扇出任务如何扩展
15.1 分批处理
不能一次加载几十万好友并在一个事务中写完。
1 | |
15.2 幂等
幂等键:
1 | |
即使消息重复消费,也不会在 Inbox 中产生多条相同动态。
15.3 扇出顺序
不同动态的扇出任务可能并发完成,不能用“写入 Inbox 的时间”作为 Feed 时间。Inbox 中应保存原始 post_sort_key,这样后到的旧任务仍会排在正确位置。
15.4 积压降级
如果 MQ 积压:
- 发布仍以主库提交为成功;
- 首页可临时混合拉取好友最新动态;
- 对活跃用户优先扇出;
- 扇出恢复后幂等补写;
- 监控从发布到 Inbox 可见的延迟。
16. 热点用户问题
朋友圈通常是好友关系,不像公开微博那样容易出现亿级粉丝,但仍应设计极端情况。
对于关系规模超阈值的账号:
- 不做全量推送;
- 只写作者时间线;
- 读取者打开 Feed 时拉取该作者最新内容;
- 通过作者类型或关系计数维护 Pull Source 列表;
- 对热点作者时间线做缓存。
这就是混合推拉的关键。
17. 缓存设计
17.1 可缓存内容
- 动态摘要;
- 媒体元数据;
- 作者基础信息;
- 点赞评论计数;
- 用户短期 Inbox;
- 关系版本和屏蔽集合;
- tombstone 删除标记。
17.2 不要缓存完整最终 Feed 太久
最终 Feed 受以下因素影响:
- 新动态;
- 删除;
- 关系变化;
- 屏蔽变化;
- 可见性修改;
- 点赞评论变化。
可以缓存第一页候选索引,但最终结果仍需轻量权限检查。缓存 TTL 宜短,并通过事件主动失效。
17.3 防缓存穿透
动态被删除后,如果每次都回源查询,应写短期 tombstone:
1 | |
避免热门已删除动态反复穿透数据库。
18. 分库分表
18.1 动态主表
按 author_id 分片,优点:
- 查询某个作者时间线集中;
- 发布写入定位稳定。
但通过 post_id 查询详情时,需要 ID 中包含分片信息或维护路由表。
18.2 Feed Inbox
按 viewer_user_id 分片,因为读取总是“查我的 Feed”。
18.3 评论和点赞
可按 post_id 分片,便于查询某条动态的互动。
这会形成不同聚合键:
1 | |
不能强行让所有数据使用同一个分片键,否则某些主访问路径会非常低效。
19. 容量估算示例
假设:
- 1 亿日活;
- 人均每天读取 Feed 20 次;
- 每次返回 20 条;
- 人均每天发布 0.2 条;
- 平均好友数 200;
每日读取请求:
1 | |
每日发布:
1 | |
若全部推模式,Inbox 写入量约:
1 | |
这个估算说明:
- 读规模巨大,首页必须依赖索引和缓存;
- 推模式会产生显著写放大;
- 不活跃用户和超大关系用户需要特殊策略;
- 每条 Inbox 记录必须足够小。
20. 一致性选择
| 数据 | 一致性要求 |
|---|---|
| 发布成功后作者自己可见 | 较强,最好读己之写 |
| 好友收到新动态 | 可短暂最终一致 |
| 删除动态后不可再看 | 应快速强制生效 |
| 拉黑/权限变更 | 隐私优先,读取时实时校验 |
| 点赞评论计数 | 最终一致可接受 |
| 用户自己的点赞状态 | 应尽量准确 |
| Feed 排序 | 用户维度稳定顺序 |
系统设计不是“全部强一致”或“全部最终一致”,而是按业务不变量分级。
21. 常见错误回答
错误一:每次读取都查所有好友动态再排序
当好友数和 QPS 增长时,读放大不可控。
错误二:所有用户都做全量推送
超大关系用户和不活跃用户会造成巨大无效写入。
错误三:仅在发布时做权限过滤
发布后关系变化可能导致隐私泄漏,读取时仍需最终校验。
错误四:使用 OFFSET 分页
动态插入和删除会造成重复、遗漏和高成本深分页。
错误五:用客户端时间排序
客户端时钟不可信,也无法形成稳定服务端顺序。
错误六:删除只删主表
旧 Inbox、缓存和搜索索引仍可能让动态继续出现。
22. 面试总结话术
时间序朋友圈可以使用混合 Feed 架构。普通用户发布时通过 Outbox 事件异步扇出到好友 Inbox,热点关系用户只写作者时间线,读取时再合并。Inbox 只保存 postId 和稳定 sortKey,正文只存一份。排序使用服务端发布时间加 postId 形成全序,分页使用游标而不是 OFFSET。好友关系、拉黑和可见性在读取时做最终校验,保证隐私不依赖最终一致的派生索引。删除和权限变化先更新事实源并写 tombstone,随后异步清理 Inbox、缓存和搜索索引。首页近期数据可放 Redis ZSet,历史数据进入持久化 Feed 存储。
23. 延伸追问
- 作者发布后立刻刷新,怎样保证自己马上看到?
- 扇出消息积压时如何降级?
- 关系从好友变为非好友后,旧动态是否可见?
- 如何支持“三天可见”?
- 如何避免 Redis Feed Inbox 无限增长?
- 评论权限是否与动态权限完全相同?
- 如何支持跨地域多活和用户迁移分片?
- 如何让时间序 Feed 插入广告但不破坏游标?
参考资料
- Redis Sorted Sets: https://redis.io/docs/latest/develop/data-types/sorted-sets/
- Redis ZADD: https://redis.io/docs/latest/commands/zadd/
- Transactional Outbox: https://microservices.io/patterns/data/transactional-outbox.html
- Apache Kafka Documentation: https://kafka.apache.org/documentation/