Facebook 式 news feed 设计:fan-out on write 还是 on read

1. 概述

News feed 是主页,显示你关注的人发布的最新文章。设计它的核心问题是:在写时聚合 feed(fan-out on write)还是在读时聚合(fan-out on read)?

本文通过一个完整的案例研究来讲解。我们会提出需求和规模估计,然后构建数据模型和 API。之后我们比较两种 fan-out 策略并选择混合模型。最后是明星用户、一致性和流量突增等难点。阅读完毕,你可以用 Spring Boot、PostgreSQL、Redis 和 Kafka 自己构建一个可运行的 feed 系统。

2. 问题需求

2.1. 功能需求

  • 用户发布文章(文本,可带图片)。
  • 用户关注/取消关注他人。
  • 用户查看 feed:关注者的文章,最新的优先,支持分页(infinite scroll)。

2.2. 规模和约束

我们假设以下数字:

DAU(日活跃用户)                : 3 亿
每人每天打开 feed                : 10 次  → 每天 30 亿 次阅读 ≈ 35K QPS,峰值 ~100K QPS
每人每天新文章                   : 0.5    → 每天 1.5 亿篇文章 ≈ 1.7K QPS,峰值 ~5K QPS
平均粉丝数                       : 300
最大账号                         : > 5000 万粉丝

由此得出几个约束:

  • 读远多于写(约 20 倍)。读 feed 必须快速,p99 低于 200 ms。
  • 接受最终一致性:新文章在粉丝的 feed 中出现延迟几秒是可以的。
  • 粉丝分布极度倾斜。大多数有几百个粉丝,少数有几千万。

3. 数据模型

真实数据源(source of truth)在 PostgreSQL:

CREATE TABLE users (
    id              BIGINT PRIMARY KEY,
    name            TEXT   NOT NULL,
    follower_count  BIGINT NOT NULL DEFAULT 0
);

CREATE TABLE posts (
    id          BIGINT PRIMARY KEY,           -- Snowflake ID
    author_id   BIGINT      NOT NULL,
    content     TEXT        NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted     BOOLEAN     NOT NULL DEFAULT false
);
CREATE INDEX idx_posts_author_time ON posts (author_id, created_at DESC);

CREATE TABLE follows (
    follower_id BIGINT NOT NULL,
    followee_id BIGINT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX idx_follows_followee ON follows (followee_id, follower_id);
  • Index idx_posts_author_time 用于查询“获取作者 X 最新的 N 篇文章”。
  • follows 的主键回答“我关注谁”。Index idx_follows_followee 回答“谁关注我”,fan-out 正好需要这个查询。

预先聚合的 feed 在 Redis 中。每个用户有一个 sorted set:

Key    : feed:{userId}
Member : postId(字符串形式)
Score  : createdAt(以 epoch 毫秒计)

注意: 不要用 Snowflake ID 作为 score。Redis score 是 double,只能精确表示到 2^53 的整数。64 位 Snowflake ID 会被舍入,两篇不同的文章可能分数相同。应该用 epoch 毫秒作为 score,将 ID 保留在 member 中。

Feed 只存储 ID,不存储文章内容。内容在读取时从文章缓存“加载”。这样,编辑或删除文章时,只需修改一处。

4. API

@RestController
@RequestMapping("/api")
public class FeedController {

    private final PostService postService;
    private final FeedService feedService;

    public FeedController(PostService postService, FeedService feedService) {
        this.postService = postService;
        this.feedService = feedService;
    }

    @PostMapping("/posts")
    public ResponseEntity<PostDto> create(@RequestHeader("X-User-Id") long userId,
                                          @RequestBody CreatePostRequest req) {
        return ResponseEntity.status(HttpStatus.CREATED).body(postService.create(userId, req.content()));
    }

    @GetMapping("/feed")
    public FeedPage feed(@RequestHeader("X-User-Id") long userId,
                         @RequestParam(required = false) String cursor,
                         @RequestParam(defaultValue = "20") int limit) {
        return feedService.getFeed(userId, Cursor.parse(cursor), Math.min(limit, 50));
    }
}

public record CreatePostRequest(String content) {}
public record FeedPage(List<PostDto> items, String nextCursor) {}

示例响应:

GET /api/feed?limit=2
{
  "items": [ { "id": 7253..., "authorId": 42, "content": "Hello" }, { ... } ],
  "nextCursor": "1760245800123:7253..."
}

我们使用基于游标的分页 (createdAtMillis:postId),而不是 offset。Feed 不断变化。使用 offset 时,当新文章插入顶部时,下一页会重复已看过的文章。

5. Fan-out on write(推送)

思想:当作者发布文章时,系统立即将文章 ID 写入每个粉丝的 feed。读取时只需一条 ZREVRANGEBYSCORE 命令。

添加依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

PostService 将文章保存到 DB,然后将 PostCreated 事件发布到 Kafka。一个单独的 consumer 执行 fan-out:

public record PostCreatedEvent(long postId, long authorId, long createdAtMillis, long followerCount) {}

@Component
public class FanoutConsumer {

    static final int FEED_MAX = 500;
    static final long CELEBRITY_THRESHOLD = 10_000;
    private static final int BATCH = 1_000;

    private final FollowRepository follows;
    private final StringRedisTemplate redis;

    public FanoutConsumer(FollowRepository follows, StringRedisTemplate redis) {
        this.follows = follows;
        this.redis = redis;
    }

    @KafkaListener(topics = "post-created", groupId = "feed-fanout")
    public void onPostCreated(PostCreatedEvent e) {
        if (e.followerCount() >= CELEBRITY_THRESHOLD) {
            return; // 明星用户:由读取路径自行获取(见第 7 节)
        }
        long lastId = 0;
        List<Long> batch;
        do {
            batch = follows.findActiveFollowerIds(e.authorId(), lastId, BATCH);
            pushToFeeds(batch, e);
            if (!batch.isEmpty()) lastId = batch.getLast();
        } while (batch.size() == BATCH);
    }

    private void pushToFeeds(List<Long> followerIds, PostCreatedEvent e) {
        redis.executePipelined((RedisCallback<Object>) conn -> {
            StringRedisConnection c = (StringRedisConnection) conn;
            for (Long followerId : followerIds) {
                String key = "feed:" + followerId;
                c.zAdd(key, e.createdAtMillis(), String.valueOf(e.postId()));
                c.zRemRange(key, 0, -(FEED_MAX + 1)); // 只保留最新 500 篇
            }
            return null;
        });
    }
}

解释:

  • findActiveFollowerIds 按键集分页方式遍历粉丝(follower_id > lastId),每次 1,000 人。它只获取过去 30 天内活跃的用户。
  • executePipelined 将 2,000 条命令分组为一次往返,而不是 2,000 次单独的往返。
  • zRemRange(key, 0, -501) 修剪尾部,保持 feed 最多 500 个元素。这样内存不会随时间增长。
  • 使用相同 member 和 score 的 ZADD 是幂等的。如果 Kafka 重新传递事件(at-least-once),feed 不会重复。

对应的 SQL:

SELECT f.follower_id
FROM follows f
JOIN user_activity a ON a.user_id = f.follower_id
WHERE f.followee_id = :authorId
  AND f.follower_id > :lastId
  AND a.last_seen_at > now() - interval '30 days'
ORDER BY f.follower_id
LIMIT :batch;

优点: 读取极快,单个 key 上 O(log N)。 缺点: 写入放大。1.5 亿篇文章 × 300 粉丝 ≈ 每天 450 亿次 ZADD。一个 5000 万粉丝的明星用户发布一篇文章会产生 5000 万条写入命令。

6. Fan-out on read(拉取)

思想相反:不提前聚合任何东西。读取时,系统获取关注者列表,然后查询他们的最新文章:

SELECT p.id, p.created_at
FROM posts p
WHERE p.author_id IN (SELECT followee_id FROM follows WHERE follower_id = :userId)
  AND p.created_at < :before
  AND p.deleted = false
ORDER BY p.created_at DESC
LIMIT 20;

优点: 写入便宜(只有一条 INSERT),新文章立即显示,不消耗 RAM 存储 feed。 缺点: 每次读取都要接触数百位作者,且这发生在 100K QPS。当 posts 按 author_id 分片时,单次读取必须通过 scatter-gather 跨多个分片。很难将 p99 保持在 200 ms 以下。

总结:

                    Fan-out on write          Fan-out on read
写入成本             高(× 粉丝数)            低
读取成本             低(1 个 Redis key)      高(× 关注数)
显示延迟             几秒(异步)              即时
明星用户             灾难                      可以
关注很多人           可以                      慢

7. 混合模型(Hybrid)

Facebook、Twitter 和大多数大型系统都采用混合方式:

  • 普通作者(< 10K 粉丝):推送到粉丝的 feed。
  • 明星用户(≥ 10K 粉丝):不推送。读取时,系统拉取用户关注的明星的文章,然后与已推送的 feed 混合。

每个用户只关注几十个明星,所以拉取部分很小。明星最近的文章被数百万人阅读,所以缓存非常有效。

核心是混合逻辑。我们将其分离为纯 Java 层以便于测试:

public record FeedEntry(long postId, long createdAtMillis) {}

public record Cursor(long createdAtMillis, long postId) {
    static final Cursor START = new Cursor(Long.MAX_VALUE, Long.MAX_VALUE);

    public static Cursor parse(String raw) {
        if (raw == null || raw.isBlank()) return START;
        String[] p = raw.split(":");
        return new Cursor(Long.parseLong(p[0]), Long.parseLong(p[1]));
    }

    boolean isAfter(FeedEntry e) { // e 是否比游标更旧?
        return e.createdAtMillis() < createdAtMillis
            || (e.createdAtMillis() == createdAtMillis && e.postId() < postId);
    }
}

public final class FeedMerger {

    private static final Comparator<FeedEntry> NEWEST_FIRST =
        Comparator.comparingLong(FeedEntry::createdAtMillis)
                  .thenComparingLong(FeedEntry::postId)
                  .reversed();

    public static List<FeedEntry> merge(List<FeedEntry> pushed, List<FeedEntry> pulled,
                                        Cursor cursor, int limit) {
        return Stream.concat(pushed.stream(), pulled.stream())
            .filter(cursor::isAfter)
            .collect(Collectors.toMap(FeedEntry::postId, e -> e, (a, b) -> a))
            .values().stream()
            .sorted(NEWEST_FIRST)
            .limit(limit)
            .toList();
    }
}
  • filter(cursor::isAfter) 只保留比游标更旧的文章。当两篇文章时间戳相同时,使用 postId 来判定顺序。
  • toMap(..., (a, b) -> a) 去重。一篇文章可能同时出现在两个源中,例如当作者刚刚超过明星阈值时。
  • 然后列表按最新优先排序并按 limit 裁剪。

FeedService 整合各部分:

public FeedPage getFeed(long userId, Cursor cursor, int limit) {
    double max = cursor == Cursor.START ? Double.POSITIVE_INFINITY : cursor.createdAtMillis();

    List<FeedEntry> pushed = redis.opsForZSet()
        .reverseRangeByScoreWithScores("feed:" + userId, 0, max, 0, limit + 1)
        .stream()
        .map(t -> new FeedEntry(Long.parseLong(t.getValue()), t.getScore().longValue()))
        .toList();

    List<Long> celebs = followRepository.findCelebrityFolloweeIds(userId);
    List<FeedEntry> pulled = celebrityTimelineCache.recentPosts(celebs, cursor, limit);

    List<FeedEntry> page = FeedMerger.merge(pushed, pulled, cursor, limit);
    List<PostDto> items = postCache.hydrate(page); // multi-get,移除已删除的文章
    String next = page.isEmpty() ? null
        : page.getLast().createdAtMillis() + ":" + page.getLast().postId();
    return new FeedPage(items, next);
}

Redis 查询使用 max包含边界,因为可能有与游标时间戳相同的文章。FeedMerger 会再次进行精确过滤。

8. 测试混合逻辑

class FeedMergerTest {

    @Test
    void mergesSortsDedupesAndRespectsCursor() {
        List<FeedEntry> pushed = List.of(
            new FeedEntry(5, 5_000), new FeedEntry(3, 3_000), new FeedEntry(1, 1_000));
        List<FeedEntry> pulled = List.of(
            new FeedEntry(4, 4_000), new FeedEntry(3, 3_000), new FeedEntry(2, 2_000));

        List<FeedEntry> firstPage = FeedMerger.merge(pushed, pulled, Cursor.START, 3);

        assertThat(firstPage).extracting(FeedEntry::postId).containsExactly(5L, 4L, 3L);

        Cursor next = new Cursor(3_000, 3);
        List<FeedEntry> secondPage = FeedMerger.merge(pushed, pulled, next, 3);

        assertThat(secondPage).extracting(FeedEntry::postId).containsExactly(2L, 1L);
    }
}

文章 3 在两个源中都有但只出现一次。第二页从游标之后开始,没有重复,也没有遗漏。

9. 难点和解决方案

9.1. 明星用户的热 key

每个人都读取 celeb:{id}:recent 会将负载集中到单个 Redis 节点。有三种处理方式:

  • 在每个实例上添加本地缓存(Caffeine),TTL 2-5 秒。100K QPS 下,这样只有几百个请求/秒到达 Redis。
  • 为这些 key 启用 Redis 读副本。
  • 当明星发布文章时,主动写入 timeline 缓存,不等待缓存未命中。这种方式避免了雷群效应。

9.2. 删除文章、取消关注、新关注时的一致性

  • 删除文章: 不需要扫描数百万个 feed。在加载时,postCache 跳过 deleted = true 的文章。后台 job 会逐渐清理 ID。
  • 取消关注: 读取时使用用户的关注集进行过滤,同时发出事件以异步删除该人的文章从 feed:{userId}。
  • 新关注: 运行回填,拉取新关注者最近约 20 篇文章,然后 ZADD 到 feed。

由于 feed 只包含 ID,所有偏差都在“引用列表”中。内容始终根据真实源正确。

9.3. DB 和 Kafka 之间的事件丢失

如果你 INSERT 文章然后调用 kafkaTemplate.send(),发送可能在事务提交后失败。此时文章存在但永远不会被 fan-out。

注意: 使用事务性 outbox。在同一事务中将文章和一行写入 outbox 表。一个 relay(或 CDC 如 Debezium)读取 outbox 然后推送到 Kafka。消费者方面必须是幂等的,而 ZADD 已经满足这一点。

9.4. 流量突增

足球或新年之类的大事件可能导致文章量增加 10 倍。应对措施:

  • Kafka 作为缓冲。 Consumer 按能力处理,lag 增加则 feed 延迟增加几秒。根据第 2 节的约束,这是可接受的。
  • 按 authorId 分区以保持同一作者文章的顺序。通过增加分区数来扩展消费者。
  • 跳过非活跃用户。 他们不被推送。当他们回归并遇到缓存未命中时,feed 通过 fan-out on read 重建然后写入 Redis。
  • 动态降低明星阈值。 当 lag 超过阈值时,降低 CELEBRITY_THRESHOLD 以将更多作者转换为拉取模式。

9.5. Redis 内存估算

~2 亿活跃用户 × 500 条目 × ~60 字节 ≈ 6 TB

这个数字需要 Redis Cluster。你可以将 FEED_MAX 降低到 200-300,因为很少有人滚动超过几百篇文章。更深的滚动则回退到拉取。

10. 何时不应使用此模型

  • 小型应用程序(几万用户):有索引的 fan-out on read 配合一条 SQL 查询就足够了。添加 Kafka 和 Redis 只会增加复杂性。
  • 算法排序的 feed(通过 ML 排名):推送的 feed 只作为“候选集”。排名步骤在读取时单独运行,此时时间戳分数不再是最终顺序。
  • 强一致性需求(文章必须立即对所有粉丝可见):异步 fan-out 无法保证这一点。你必须 pull,或者在发布后立即在响应中插入作者自己的文章(read-your-writes)。

11. 结论

Fan-out on write 对读取优化但放大写入。Fan-out on read 则相反。对于像社交网络这样的倾斜粉丝分布,实际答案是混合模型:为普通作者推送,为明星拉取,然后在读取时混合。

实施时需要记住的要点:

  • Feed 只存储 ID,score 是 epoch 毫秒而不是 Snowflake ID。
  • 使用游标 (createdAt, postId) 实现稳定的分页。
  • Consumer 必须是幂等的,结合 outbox 以防止事件丢失。
  • 通过在读取时过滤来处理删除文章和取消关注。
  • 使用 Kafka 结合动态明星阈值来吸收流量突增。
system-designrediskafkaspring-bootcase-study