Conception d'un fil d'actualité façon Facebook : fan-out on write ou on read
1. Vue d’ensemble
Le fil d’actualité est la page d’accueil où vous consultez les derniers posts des personnes que vous suivez. La question centrale lors de sa conception est : fusionner le fil au moment de l’écriture (fan-out on write) ou à la lecture (fan-out on read) ?
Cet article présente une étude de cas complète. Nous énumérerons les exigences et estimerons l’échelle, puis nous construirons le modèle de données et l’API. Ensuite, nous comparerons les deux stratégies de fan-out et choisirons un modèle hybride. Enfin, il y a les points délicats comme les influenceurs, la cohérence et les pics de charge. Après sa lecture, vous pourrez construire votre propre fil fonctionnel avec Spring Boot, PostgreSQL, Redis et Kafka.
2. Exigences du problème
2.1. Exigences fonctionnelles
- L’utilisateur publie un post (texte, peut inclure des images).
- L’utilisateur suit/ne suit plus d’autres personnes.
- L’utilisateur consulte le fil : les posts des personnes qu’il suit, les plus récents en premier, avec pagination (défilement infini).
2.2. Échelle et contraintes
Supposons les chiffres suivants :
DAU (utilisateurs actifs quotidiens) : 300 millions
Consultations du fil par personne/jour : 10 fois -> 3 milliards de lectures/jour ≈ 35K QPS, pic ~100K QPS
Nouveaux posts par personne/jour : 0,5 -> 150 millions de posts/jour ≈ 1,7K QPS, pic ~5K QPS
Nombre moyen d'abonnés : 300
Plus grand compte : > 50 millions d'abonnés
Il en découle quelques contraintes :
- Les lectures dépassent largement les écritures (environ 20 fois). La lecture du fil doit être rapide, p99 sous 200 ms.
- Accepter la cohérence finale : il est acceptable que les nouveaux posts apparaissent dans le fil de ses abonnés après quelques secondes.
- La distribution des abonnés est très asymétrique. La plupart en ont quelques centaines, quelques-uns en ont des dizaines de millions.
3. Modèle de données
La source de vérité réside dans 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, -- ID Snowflake
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);
- L’index
idx_posts_author_timerépond à la requête « récupérer les N posts les plus récents de l’auteur X ». - La clé primaire de
followsrépond à « qui suis-je en train de suivre ». L’indexidx_follows_followeerépond à « qui me suit », et le fan-out a précisément besoin de cette question.
Le fil fusionné réside dans Redis. Chaque utilisateur a un ensemble trié (sorted set) :
Clé : feed:{userId}
Membre : postId (sous forme de chaîne)
Score : createdAt en millisecondes epoch
Attention : N’utilisez pas d’ID Snowflake comme score. Le score de Redis est un
double, qui ne représente précisément les entiers que jusqu’à 2^53. Les Snowflakes 64-bit seront arrondis, et deux posts différents pourraient avoir le même score. Utilisez plutôt les millisecondes epoch comme score et gardez l’ID en tant que membre.
Le fil ne stocke que les ID, pas le contenu des posts. Le contenu est « hydraté » à partir du cache des posts lors de la lecture. Ainsi, pour modifier ou supprimer un post, vous ne devez le faire qu’à un seul endroit.
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) {}
Exemple de réponse :
GET /api/feed?limit=2
{
"items": [ { "id": 7253..., "authorId": 42, "content": "Hello" }, { ... } ],
"nextCursor": "1760245800123:7253..."
}
Nous utilisons la pagination basée sur un curseur (createdAtMillis:postId), pas offset. Le fil change continuellement. Avec offset, lorsqu’un nouveau post s’insère en haut, la page suivante répétera les posts déjà vus.
5. Fan-out on write (push)
L’idée : lorsque l’auteur publie, le système écrit immédiatement l’ID du post dans le fil de chaque abonné. À la lecture, une seule commande ZREVRANGEBYSCORE suffit.
Ajouter les dépendances :
<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 sauvegarde le post dans la base de données puis envoie un événement PostCreated à Kafka. Un consumer dédié effectue le 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; // influenceur : laisser la lecture le récupérer (voir section 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)); // garder seulement les 500 posts les plus récents
}
return null;
});
}
}
Explications :
findActiveFollowerIdsitère sur les abonnés avec la pagination de keyset (follower_id > lastId), 1 000 à la fois. Il ne récupère que les utilisateurs actifs au cours des 30 derniers jours.executePipelinedregroupe 2 000 commandes en un seul aller-retour, au lieu de 2 000 allers-retours distincts.zRemRange(key, 0, -501)supprime l’extrémité, en gardant au maximum 500 éléments dans le fil. La mémoire n’explose ainsi pas avec le temps.ZADDavec le même membre et le même score est idempotent. Si Kafka renvoie l’événement (at-least-once), le fil ne sera pas dupliqué.
La requête SQL correspondante :
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;
Avantages : les lectures sont très rapides, O(log N) sur une clé.
Inconvénients : l’écriture est amplifiée. 150 millions de posts × 300 abonnés ≈ 45 milliards de ZADD/jour. Un influenceur avec 50 millions d’abonnés qui publie un post génère 50 millions de commandes d’écriture.
6. Fan-out on read (pull)
L’idée inverse : ne rien pré-fusionner. À la lecture, le système récupère la liste des personnes que vous suivez, puis demande leurs posts les plus récents :
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;
Avantages : l’écriture est bon marché (juste un INSERT), les nouveaux posts s’affichent immédiatement, pas de RAM consommée pour le fil.
Inconvénients : chaque lecture doit accéder à des centaines d’auteurs, et cela arrive à 100K QPS. Lorsque les posts sont sharder par author_id, une lecture doit faire un scatter-gather à travers de nombreux shards. Il sera très difficile de maintenir le p99 sous 200 ms.
Résumé :
Fan-out on write Fan-out on read
Coût d'écriture Élevé (× nb abonnés) Faible
Coût de lecture Faible (1 clé Redis) Élevé (× nb de suivis)
Latence affichage Quelques sec. (async) Immédiat
Influenceur Catastrophique OK
Beaucoup de suivi OK Lent
7. Modèle hybride
Facebook, Twitter et la plupart des grands systèmes optent pour l’approche hybride :
- Auteur ordinaire (< 10K abonnés) : push dans le fil de ses abonnés.
- Influenceur (≥ 10K abonnés) : ne pas faire de push. À la lecture, le système pull les posts des influenceurs que vous suivez, puis les fusionne avec le fil déjà poussé.
Chaque utilisateur ne suit que quelques dizaines d’influenceurs, donc la partie pull est petite. Les posts récents d’un influenceur sont lus par des millions de personnes, donc le cache est très efficace.
Le cœur est la logique de fusion. Nous l’isolons dans une classe Java pure pour la testabilité :
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 est-il plus ancien que le curseur ?
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)ne garde que les posts plus anciens que le curseur. Lorsque deux posts ont la même milliseconde, lepostIdest utilisé pour les ordonner.toMap(..., (a, b) -> a)déduplique. Un post peut être présent dans les deux sources, par exemple si l’auteur vient de franchir le seuil d’influenceur.- La liste est ensuite triée du plus récent au plus ancien et coupée selon la
limit.
FeedService assemble les pièces :
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, ignorer les posts supprimés
String next = page.isEmpty() ? null
: page.getLast().createdAtMillis() + ":" + page.getLast().postId();
return new FeedPage(items, next);
}
La requête Redis utilise max inclus aux bornes, car il peut y avoir des posts avec la même milliseconde que le curseur. FeedMerger filtrera à nouveau précisément.
8. Tests de la logique de fusion
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);
}
}
Le post 3 est dans les deux sources mais n’apparaît qu’une fois. La deuxième page commence juste après le curseur, sans répétition, sans omission.
9. Points délicats et solutions
9.1. Hot key des influenceurs
Tous lisant celeb:{id}:recent concentrent la charge sur un nœud Redis. Il y a trois solutions :
- Ajouter un cache local (Caffeine) sur chaque instance avec TTL 2–5 secondes. 100K QPS se réduit ainsi à quelques centaines de requêtes/seconde vers Redis.
- Activer les répliques de lecture Redis pour ces clés.
- Lors de la publication d’un influenceur, écrire activement dans le cache de la chronologie, sans attendre un cache miss. Cette approche évite le problème du troupeau tonnerre.
9.2. Cohérence lors de la suppression de posts, arrêt de suivi, nouveau suivi
- Supprimer un post : pas besoin de scanner des millions de fils. Lors de l’hydratation,
postCacheomet les posts avecdeleted = true. Un job de nettoyage en arrière-plan supprimera progressivement les ID. - Arrêter de suivre : filtrer lors de la lecture en utilisant l’ensemble des comptes suivis, tout en envoyant un événement pour supprimer asynchroniquement les posts de cette personne de
feed:{userId}. - Nouveau suivi : exécuter un backfill, récupérer environ 20 posts récents de la personne nouvellement suivie, puis
ZADDdans le fil.
Comme le fil ne contient que des ID, tout écart réside dans la « liste de références ». Le contenu est toujours correct selon la source de vérité.
9.3. Perte d’événement entre DB et Kafka
Si vous effectuez un INSERT de post puis appelez kafkaTemplate.send(), l’envoi peut échouer après le commit de la transaction. Le post existe alors mais n’est jamais fan-outté.
Attention : Utilisez la boîte de sortie transactionnelle (transactional outbox). Écrivez le post et une ligne dans la table
outboxdans la même transaction. Un relais (ou CDC comme Debezium) litoutboxpuis pousse vers Kafka. Le côté consumer doit être idempotent, etZADDsatisfait déjà cela.
9.4. Pics de charge
Les grands événements comme le football ou le Nouvel An peuvent multiplier le volume de posts par 10. Les mesures :
- Kafka agit comme tampon. Le consumer traite selon sa capacité, le lag augmente, le fil a du retard de quelques secondes supplémentaires. Ceci est acceptable selon la contrainte de la section 2.
- Partitionner par
authorIdpour maintenir l’ordre des posts du même auteur. Scalez les consumers en augmentant le nombre de partitions. - Ignorer les inactifs. Ils ne reçoivent pas de push. Lors du retour et face à un cache miss, le fil est reconstruit via fan-out on read puis écrit dans Redis.
- Abaisser dynamiquement le seuil d’influenceur. Quand le lag dépasse un seuil, baissez
CELEBRITY_THRESHOLDpour basculer plus d’auteurs en mode pull.
9.5. Estimation de la mémoire Redis
~200 millions d'utilisateurs actifs × 500 entrées × ~60 octets ≈ 6 TB
Ce nombre nécessite un cluster Redis. Vous pouvez réduire FEED_MAX à 200–300, car peu de gens font défiler plus de quelques centaines de posts. Les défilements plus profonds basculent vers le pull.
10. Quand ne pas utiliser ce modèle
- Petite application (quelques dizaines de milliers d’utilisateurs) : fan-out on read avec une requête SQL indexée suffit. Ajouter Kafka et Redis augmente simplement la complexité.
- Fil classé par algorithme (ranking par ML) : le fil push joue seulement le rôle d’« ensemble de candidats ». L’étape de ranking s’exécute séparément lors de la lecture, et à ce moment, le score temporel n’est plus l’ordre final.
- Cohérence forte requise (le post doit apparaître immédiatement pour tous les abonnés) : le fan-out asynchrone ne peut pas le garantir. Vous devez faire du pull, ou insérer le post de l’auteur dans la réponse immédiatement après sa publication (read-your-writes).
11. Conclusion
Le fan-out on write optimise la lecture mais amplifie l’écriture. Le fan-out on read fait l’inverse. Avec une distribution d’abonnés asymétrique comme dans les réseaux sociaux, la réponse pratique est le modèle hybride : push pour les auteurs ordinaires, pull pour les influenceurs, puis fusion à la lecture.
Les points à retenir lors de la mise en œuvre :
- Le fil ne stocke que les ID, le score est en millisecondes epoch et non un ID Snowflake.
- Utilisez le curseur
(createdAt, postId)pour une pagination stable. - Les consumers doivent être idempotents, en combinaison avec la boîte de sortie pour ne pas perdre d’événements.
- Gérez la suppression de posts et les arrêts de suivi par filtrage à la lecture.
- Utilisez Kafka avec un seuil d’influenceur dynamique pour absorber les pics de charge.