Skip to content

设计一个微信朋友圈/Feed 流系统

问题

朋友圈(Feed 流)的核心矛盾是写扩散 vs 读扩散。1000 万 DAU 下,用户的 Timeline 怎么存、怎么读、怎么保证实时性和一致性?

分析

Feed 流系统本质上解决的是一个用户如何看到自己关注的人发布的最新内容。这个问题看似简单,但在百万级关注关系、千万级 DAU 下,存储和计算的压力会爆炸。

推模式(写扩散)

用户发一条朋友圈,系统主动推送到所有粉丝的 Timeline 收件箱(Redis 或内存列表),粉丝读时直接取,速度极快。

优点:读延迟极低,适合高频读低频写的场景。

缺点:如果用户有 100 万粉丝(比如某个大 V 发了一条动态),就需要写 100 万次收件箱,写放大严重。粉丝越多,大 V 发一条动态的成本越高。

拉模式(读扩散)

用户发动态后只写自己的发件箱。粉丝刷新时,从所有关注用户的发件箱拉取最近 N 条动态,按时间归并排序。

优点:写操作极轻,大 V 发动态与普通用户发动态成本相同。

缺点:读操作重。如果用户关注了 500 人,每次刷新都要从 500 个发件箱拉取数据,做多路归并,长尾延迟高。

推拉结合

对普通用户(≤5000 粉丝)用推模式,对超大 V(>5000 粉丝)用拉模式。大 V 的动态仅在粉丝刷新时拉取,粉丝收件箱只存普通用户的动态。

优点:平衡了读写两端的压力,大部分用户的读体验好,大 V 的写压力可控。

缺点:架构复杂,需要区分普通用户和 V 用户,粉丝刷新时需要合并收件箱内容和临时拉取大 V 动态。

代码示例

1. 推模式:发动态时写入粉丝收件箱

python
import redis
import json
from typing import List

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

FEED_KEY_PREFIX = "timeline:"
INBOX_EXPIRE = 86400 * 30  # 30 天

def publish_post(user_id: str, post_id: str, fans: List[str], timestamp: int):
    """用户发动态,推送到所有粉丝收件箱"""
    feed_key = f"{FEED_KEY_PREFIX}{user_id}"
    # 对于 5000 粉丝以内的普通用户,直接推
    if len(fans) <= 5000:
        for fan_id in fans:
            inbox_key = f"inbox:{fan_id}"
            r.zadd(inbox_key, {post_id: timestamp})
            r.expire(inbox_key, INBOX_EXPIRE)
        # 同时写入自己的发件箱
        r.zadd(feed_key, {post_id: timestamp})
        r.expire(feed_key, INBOX_EXPIRE)
    else:
        # 大 V → 只写发件箱,粉丝拉模式
        r.zadd(feed_key, {post_id: timestamp})
        r.expire(feed_key, INBOX_EXPIRE)

2. 拉模式:刷新 Timeline

python
def get_timeline(user_id: str, following: List[str], cursor: int = None, limit: int = 20):
    """获取用户的 Timeline,支持游标分页"""
    inbox_key = f"inbox:{user_id}"
    timeline = []

    # 1. 从收件箱取(推模式的动态)
    if cursor:
        posts = r.zrevrangebyscore(inbox_key, cursor - 1, '-inf', start=0, num=limit)
    else:
        posts = r.zrevrange(inbox_key, 0, limit - 1)

    for post_id in posts:
        score = r.zscore(inbox_key, post_id)
        timeline.append((post_id, int(score)))

    # 2. 如果关注了大 V,从大 V 发件箱拉取并合并
    vip_users = [uid for uid in following if is_vip(uid)]
    for vip_id in vip_users:
        vip_key = f"timeline:{vip_id}"
        if cursor:
            vip_posts = r.zrevrangebyscore(vip_key, cursor - 1, '-inf', start=0, num=limit)
        else:
            vip_posts = r.zrevrange(vip_key, 0, limit - 1)
        for post_id in vip_posts:
            score = r.zscore(vip_key, post_id)
            timeline.append((post_id, int(score)))

    # 3. 按时间戳归并排序
    timeline.sort(key=lambda x: x[1], reverse=True)
    timeline = timeline[:limit]

    # 4. 返回游标(最后一条动态的时间戳)
    next_cursor = timeline[-1][1] if timeline else None
    return timeline, next_cursor


def is_vip(user_id: str) -> bool:
    """判断是否为大 V(粉丝数 > 5000)"""
    fan_count = r.scard(f"fans:{user_id}")
    return fan_count > 5000

3. 游标分页(Cursor-based Pagination)

python
def get_feed_with_cursor(user_id: str, cursor: int = None, limit: int = 20):
    """使用游标分页代替传统 offset/limit,避免重复和遗漏"""
    inbox_key = f"inbox:{user_id}"
    if cursor:
        # 取 score < cursor 的前 limit 条(不包含 cursor)
        posts = r.zrevrangebyscore(
            inbox_key, cursor - 1, '-inf',
            offset=0, count=limit
        )
    else:
        # 第一次请求,直接取最新的
        posts = r.zrevrange(inbox_key, 0, limit - 1)

    # 获取每条动态的详细信息
    feed = []
    for post_id in posts:
        post_data = r.hgetall(f"post:{post_id}")
        if post_data:
            feed.append(post_data)

    # 返回游标:最后一条动态的时间戳
    next_cursor = int(r.zscore(inbox_key, posts[-1])) if posts else None

    return {
        "feed": feed,
        "cursor": next_cursor,
        "has_more": len(posts) == limit
    }

总结

朋友圈/Feed 流系统的设计核心是读写成本的权衡

  • 推模式(写扩散)适合普通用户,写成本可控,读体验最好
  • 拉模式(读扩散)适合大 V,避免写爆炸,读时通过合并和缓存优化
  • 推拉结合是生产环境的主流方案,通过粉丝数阈值区分两种模式

Timeline 的存储选型:

  • Redis ZSet 是收件箱的首选,score = 时间戳,ZREVRANGE 取最新 N 条,O(log N) 复杂度
  • 每个用户一个 ZSet,只存最近 1000-2000 条动态 ID,全文用 MySQL 分表
  • 分页必须用游标分页(Cursor-based Pagination),避免传统 offset/limit 的重复和遗漏问题

高阶设计点:

  1. 可见性控制:3 天可见/半年可见 → 读时过滤 + 倒排可见列表缓存
  2. 收件箱容量:限制每个用户收件箱大小(如 1000 条),超出部分从 MySQL 拉取
  3. 冷启动问题:新用户关注 500 人,首次刷新需要从 MySQL 批量拉取最近动态预热收件箱
  4. 动态删除:用户删除动态后,需要广播删除事件,粉丝从收件箱移除该动态 ID

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。