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的主键回答“我关注谁”。Indexidx_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 结合动态明星阈值来吸收流量突增。