Retour au Blog

Votre backend de jeu perd des événements sous charge ? Voici la solution d'architecture de streaming durable

Publié le 6 octobre 2026
Votre backend de jeu perd des événements sous charge ? Voici la solution d'architecture de streaming durable Généré avec l'aide de l'IA

En bref

Découvrez ce runbook pour détecter et corriger les pertes d'événements backend de jeu grâce à une architecture de streaming durable en pratique.

Votre pipeline d'analytics vient de perdre 40 % des événements de mort de joueurs pendant un pic du week-end. Les classements sont obsolètes. Les rapports de crash ne sont jamais arrivés. Personne ne s'en est aperçu pendant trois jours.

C'est le tueur silencieux de la fiabilité des backends de jeu : les architectures producteur-consommateur couplées qui fonctionnent parfaitement à 100 requêtes par seconde et perdent des données en masse à 10 000. L'appel RPC de votre serveur de jeu vers votre consommateur d'analytics expire, la connexion se réinitialise, et l'événement disparaît tout simplement — ni erreur, ni nouvelle tentative, ni trace.

Ce runbook couvre ce qui casse, comment le détecter, le motif architectural qui le corrige, et comment éviter que cela ne se reproduise.

Ce qui casse : le mode de défaillance producteur-consommateur couplé

Les architectures RPC traditionnelles forcent les producteurs et les consommateurs à s'aligner à la fois en échelle et en temps. Quand votre serveur de jeu envoie un événement « player_killed » directement à un service d'analytics via HTTP :

  1. Désalignement d'échelle : si 5 000 joueurs meurent simultanément pendant un événement serveur, votre endpoint d'analytics reçoit un pic qu'il ne peut pas traiter. La connexion HTTP expire après 30 secondes. Les événements sont perdus.
  2. Couplage temporel : si votre service de détection de fraude déploie une nouvelle version et reste hors ligne pendant 90 secondes, chaque événement produit pendant cette fenêtre disparaît.
  3. Fan-out multi-consommateurs : le même événement « player_killed » doit atteindre trois systèmes indépendants — un service de mise à jour des classements, un pipeline d'analytics et un tableau de bord live-ops. Chaque consommateur a un débit différent. Le plus lent devient un goulot d'étranglement pour le producteur.

Voici à quoi cela ressemble en pratique :

[Game Server] --HTTP POST--> [Analytics Service]     ✓ works at 200 req/s
[Game Server] --HTTP POST--> [Analytics Service]     ✗ 40% drops at 8,000 req/s
[Game Server] --HTTP POST--> [Fraud Detection]       ✗ offline during deploy

Le résultat : une perte de données partielle et silencieuse qui corrompt les analytics, des classements obsolètes et des schémas de crash invisibles. Vous le découvrez des semaines plus tard quand les chiffres de votre entonnoir ne correspondent pas.

Chiffres concrets de défaillance

Dans un backend multiplayer indie typique gérant 5 000 joueurs simultanés :

  • ~2 400 événements de gameplay/seconde au pic (kills, mises à jour de score, changements d'inventaire, transitions de zone)
  • Charge utile moyenne des événements : ~200 octets
  • Fan-out HTTP direct vers 3 consommateurs : 7 200 requêtes sortantes/seconde
  • Seuil de timeout du consommateur : 30 secondes
  • Taux de perte observé au pic : 15–45 % selon la santé du consommateur

Le calcul est impitoyable. Un seul consommateur en mauvaise santé pendant 60 secondes fait perdre 72 000 événements. Ces événements sont perdus à moins d'avoir construit un buffer.

Comment détecter une perte silencieuse d'événements

La perte silencieuse d'événements est, par définition, difficile à détecter. Voici un runbook de détection :

Étape 1 : instrumenter les numéros de séquence

Chaque producteur doit estampiller chaque événement avec un numéro de séquence monotone croissant par entité source. Si votre serveur de jeu envoie des événements pour player_abc, la séquence est 1, 2, 3, 4...

Event 1: { seq: 1, player: "abc", type: "kill", ts: 1719432000 }
Event 2: { seq: 2, player: "abc", type: "death", ts: 1719432001 }
Event 3: { seq: 4, player: "abc", type: "score", ts: 1719432005 }  // seq 3 missing!

Un écart dans les numéros de séquence côté consommateur signifie perte d'événements confirmée.

Étape 2 : surveiller le lag du consommateur

Suivez la différence entre la dernière séquence produite et la dernière séquence consommée pour chaque consommateur. Seuils d'alerte :

  • Lag < 1 000 événements : sain
  • Lag entre 1 000 et 10 000 événements : avertissement — le consommateur prend du retard
  • Lag > 10 000 événements : critique — le consommateur est effectivement hors ligne ou submergé

Étape 3 : recouper les totaux

Comparez les compteurs d'événements entre les journaux du producteur et les compteurs d'ingestion du consommateur toutes les heures. Un écart supérieur à 1 % justifie une enquête.

// Producer-side counter (emit to your monitoring system every 60s)
public class EventProducerMetrics
{
    private long _producedCount = 0;

    public void RecordProduced()
    {
        Interlocked.Increment(ref _producedCount);
    }

    public long GetAndResetCount()
    {
        return Interlocked.Exchange(ref _producedCount, 0);
    }
}

Si votre compteur produit par heure est de 8 400 000 et que votre consommateur d'analytics en a ingéré 5 100 000, vous avez perdu 39 % des événements. C'est votre signal.

Le correctif architectural : découplage avec un journal d'événements durable

La solution consiste à découpler les producteurs des consommateurs en insérant un buffer durable entre eux. Au lieu de :

Game Server --direct HTTP--> Analytics
Game Server --direct HTTP--> Leaderboard Service
Game Server --direct HTTP--> Crash Reporter

Vous écrivez vers :

Game Server --single write--> [Durable Event Stream] --independent reads--> Analytics
                                                    --independent reads--> Leaderboard Service
                                                    --independent reads--> Crash Reporter

Le flux durable absorbe les écritures à la vitesse du producteur. Chaque consommateur lit à son propre rythme. Si un consommateur est hors ligne pendant 5 minutes, les événements s'accumulent dans le flux et le consommateur reprend là où il s'était arrêté à son retour. Aucune perte de données.

Propriétés essentielles d'un flux d'événements durable

Un flux d'événements de qualité production pour les backends de jeu doit avoir ces propriétés :

Propriété Pourquoi c'est important pour les jeux
Ordonné au sein d'une partition Tous les événements d'un joueur doivent être traités en séquence — un kill avant une mise à jour de score, pas après
Stockage durable Les événements survivent aux redémarrages des consommateurs, aux fenêtres de déploiement et aux pannes d'infrastructure
Offsets de consommation indépendants Votre consommateur d'analytics et votre consommateur de classements lisent à des vitesses différentes sans se bloquer mutuellement
Ordonnancement au niveau de la partition Vous obtenez du parallélisme entre les joueurs (partitions différentes) et de la cohérence au sein d'un joueur (même partition)

Stratégie de partitionnement pour les backends de jeu

La clé de partition détermine quels événements atterrissent dans quel journal ordonné. Pour les backends de jeu, la clé de partition naturelle est playerId :

Partition 0: [player_abc kill#1] [player_abc death#2] [player_abc score#3]
Partition 1: [player_def zone#1] [player_def kill#2] [player_def loot#3]
Partition 2: [player_ghi death#1] [player_ghi spawn#2]

Cela garantit que tous les événements d'un seul joueur sont traités dans un ordre exact par chaque consommateur, tandis que les événements de différents joueurs peuvent être traités en parallèle.

Implémenter le streaming d'événements durable : une mise en pratique

Voici un motif d'implémentation concret. Vous pouvez le construire sur un stockage d'objets (comme S3/R2), un service de streaming géré, ou un journal adossé à une base de données.

Le producteur : fan-out en écriture unique

Votre serveur de jeu écrit un seul événement. L'infrastructure de streaming gère la livraison à tous les consommateurs.

public class GameEventProducer
{
    private readonly IEventStreamClient _stream;
    private readonly ILogger _logger;

    public GameEventProducer(IEventStreamClient stream, ILogger logger)
    {
        _stream = stream;
        _logger = logger;
    }

    public async Task EmitAsync(string playerId, string eventType, 
                                 Dictionary&lt;string, object> payload)
    {
        var gameEvent = new GameEvent
        {
            Id = Guid.NewGuid().ToString(),
            PlayerId = playerId,        // partition key
            EventType = eventType,
            Payload = payload,
            Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
        };

        try
        {
            // Single write — the stream handles fan-out to all consumers
            await _stream.AppendAsync(
                partitionKey: playerId,
                eventData: JsonSerializer.Serialize(gameEvent)
            );
        }
        catch (EventStreamException ex)
        {
            // Events that fail to write should be queued locally 
            // and retried, not silently dropped
            _logger.LogWarning(ex, 
                "Failed to emit event {EventType} for {Player}, queueing retry", 
                eventType, playerId);
            await _retryQueue.EnqueueAsync(gameEvent);
        }
    }
}

Décisions de conception clés dans ce code :

  • La clé de partition est playerId : garantit l'ordre par joueur pour tous les types d'événements.
  • Un seul appel d'écriture : le producteur ne sait pas et ne se soucie pas du nombre de consommateurs. Ajouter un nouveau consommateur (par exemple, un suivi d'événements saisonnier) ne nécessite aucune modification du producteur.
  • File de nouvelle tentative locale : si le flux est temporairement indisponible, les événements sont mis en file localement et sont vidés lorsque la connectivité est rétablie. Cela évite la perte de données pendant les micro-coupures réseau.

Le consommateur : suivi indépendant des offsets

Chaque consommateur maintient son propre offset de lecture par partition. C'est le mécanisme central qui permet des vitesses de traitement indépendantes.

public class LeaderboardConsumer
{
    private readonly IEventStreamClient _stream;
    private readonly ILeaderboardService _leaderboard;
    private readonly IOffsetStore _offsets;

    public async Task ProcessEventsAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            // Read the next batch from where we left off
            var lastOffset = await _offsets.GetOffsetAsync(
                consumerName: "leaderboard-updater",
                partitionId: 0
            );

            var batch = await _stream.ReadBatchAsync(
                partitionId: 0,
                fromOffset: lastOffset,
                maxBatchSize: 500
            );

            foreach (var evt in batch.Events)
            {
                var gameEvent = JsonSerializer.Deserialize&lt;GameEvent>(evt.Data);

                if (gameEvent.EventType == "player_killed")
                {
                    await _leaderboard.IncrementKillsAsync(
                        gameEvent.PlayerId, 
                        amount: 1
                    );
                }
                else if (gameEvent.EventType == "score_updated")
                {
                    await _leaderboard.UpdateScoreAsync(
                        gameEvent.PlayerId, 
                        gameEvent.Payload["score"].GetInt32()
                    );
                }

                // Advance offset AFTER successful processing
                await _offsets.SetOffsetAsync(
                    consumerName: "leaderboard-updater",
                    partitionId: 0,
                    offset: evt.Offset + 1
                );
            }

            await Task.Delay(100, ct); // Poll interval
        }
    }
}

Détails d'implémentation critiques :

  • L'offset avance après le traitement, pas avant. Si le consommateur plante au milieu d'un lot, il retraite les mêmes événements au redémarrage. Vos handlers doivent être idempotents — traiter deux fois le même événement de kill ne doit pas compter deux fois l'entrée du classement.
  • Une taille de lot de 500 équilibre le débit et la mémoire. Avec des événements de 200 octets, cela représente ~100 Ko par lot — négligeable.
  • Un intervalle de polling de 100 ms signifie que la latence dans le pire cas est d'environ 100 ms entre la production de l'événement et la mise à jour du classement. Pour la plupart des cas d'usage de classements, c'est parfaitement acceptable. Si vous avez besoin d'une latence inférieure à 10 ms, vous êtes dans le domaine du transport temps réel, ce qui est une architecture entièrement différente.

Idempotence : le filet de sécurité du consommateur

L'idempotence est non négociable dans cette architecture. Voici un handler idempotent concret :

public class IdempotentKillCounter
{
    private readonly IDatabase _db;

    public async Task ProcessKillAsync(string playerId, string eventId)
    {
        // Check if we already processed this event
        var alreadyProcessed = await _db.ExecuteScalarAsync&lt;bool>(
            "SELECT COUNT(*) > 0 FROM processed_events WHERE event_id = @id",
            new { id = eventId }
        );

        if (alreadyProcessed)
        {
            return; // Skip duplicate — this is the idempotency guard
        }

        // Process and record in a transaction
        await _db.ExecuteInTransactionAsync(async tx =>
        {
            await tx.ExecuteAsync(
                "UPDATE leaderboard SET kills = kills + 1 WHERE player_id = @pid",
                new { pid = playerId }
            );
            await tx.ExecuteAsync(
                "INSERT INTO processed_events (event_id, processed_at) VALUES (@id, @now)",
                new { id = eventId, now = DateTime.UtcNow }
            );
        });
    }
}

La table processed_events agit comme un stockage de déduplication. Elle coûte une écriture supplémentaire par événement, mais garantit que les redémarrages du consommateur ne corrompent jamais vos données.

Gérer l'indisponibilité du consommateur : la garantie de mise en mémoire tampon

La principale raison d'utiliser le streaming d'événements durable est de survivre à l'indisponibilité des consommateurs sans perte de données. Voici comment fonctionne le calcul du buffering :

Producer rate:           2,400 events/second
Consumer offline window: 5 minutes (300 seconds)
Events buffered:         720,000 events
Storage required:        720,000 × 200 bytes = ~144 MB

Avec 144 Mo, cela tient trivialement dans n'importe quel système de stockage moderne. Le point clé : la taille de votre buffer est proportionnelle à votre débit d'événements multiplié par votre tolérance maximale d'indisponibilité, et non à votre volume total de données historiques.

Pour une rétention à long terme (30 jours d'événements pour rejouer ou retraiter), les chiffres augmentent :

30 days × 86,400 seconds × 2,400 events/sec × 200 bytes = ~1.24 TB

C'est bien dans les capacités des backends de stockage d'objets. La structure de partition maintient des performances de lecture prévisibles même à cette échelle — vous ne scannez jamais tout le journal, vous lisez depuis des partitions spécifiques à des offsets spécifiques.

Ce que horizOn apporte dans une architecture événementielle

Une fois que votre flux d'événements livre les données de manière fiable, vous avez besoin de services qui consomment ces événements. horizOn fournit des primitives backend qui se branchent côté consommateur de cette architecture :

  • Les classements consomment les événements de kill, de score et de complétion pour mettre à jour les classements en temps réel
  • Les journaux utilisateur capturent les flux d'événements pour déboguer les problèmes signalés par les joueurs — quand un joueur dit « mon score a été réinitialisé », vous pouvez interroger son historique d'événements
  • Les rapports de crash ingèrent les événements de crash avec leur contexte complet, vous donnant des stack traces liées à la session du joueur

Le point : le flux d'événements achemine les données vers ces services de manière fiable. Les services eux-mêmes doivent être éprouvés. Vous pouvez lire comment nous avons architecturé l'une de nos plus grandes mises à jour backend dans cette analyse de la mise à jour du backend de jeu indie de horizOn, qui couvre les décisions d'infrastructure derrière l'ingestion fiable d'événements à grande échelle.

Runbook : prévenir la récurrence des pertes d'événements

Lorsque vous avez déjà subi une perte d'événements, voici la checklist pour éviter que cela ne se reproduise :

1. Auditer chaque couplage producteur-consommateur

Parcourez votre codebase et identifiez chaque endroit où un serveur de jeu effectue un appel HTTP synchrone direct vers un service backend. Chacun est un point de perte potentiel sous charge. Listez-les :

GameServer → AnalyticsService      (HTTP POST, no retry)     ← RISK
GameServer → LeaderboardService    (HTTP POST, no retry)     ← RISK
GameServer → CrashReporter         (UDP, fire-and-forget)    ← RISK

2. Introduire le flux d'événements comme intermédiaire

Remplacez chaque appel direct par une seule écriture dans le flux d'événements durable. Chaque service aval devient un consommateur indépendant avec son propre offset.

3. Implémenter la surveillance de la santé des consommateurs

Pour chaque consommateur, suivez :

  • Lag (événements de retard par rapport au producteur)
  • Taux de traitement (événements consommés par seconde)
  • Taux d'erreur (événements en échec de traitement par seconde)
  • Dernier offset réussi (détection de péremption)

Déclenchez une alerte lorsque le lag dépasse votre fenêtre de buffer calculée. Si votre flux conserve les données pendant 7 jours et que votre consommateur est down depuis 6 jours, il vous reste 24 heures avant le début de la perte de données.

4. Tester la reprise après redémarrage du consommateur

Mettez délibérément un consommateur hors ligne pendant 5 minutes, remettez-le en ligne, et vérifiez qu'il rattrape son retard sans doublons. C'est votre test de confiance que l'architecture fonctionne. Automatisez-le en CI :

[Test]
public async Task ConsumerResumesAfterDowntime()
{
    // Produce 10,000 events
    await ProduceEvents(count: 10_000);

    // Simulate consumer offline — skip reads for 30 seconds
    await Task.Delay(TimeSpan.FromSeconds(30));

    // Resume consumer
    var processed = await Consumer.ProcessUntilCaughtUp();

    // Verify: all events processed, no duplicates
    Assert.AreEqual(10_000, processed.UniqueEventCount);
    Assert.AreEqual(0, processed.DuplicateCount);
}

5. Mettre en place une file de lettres mortes

Les événements qui échouent après N nouvelles tentatives (généralement 3 à 5) sont déplacés vers une file de lettres mortes. Surveillez la taille de la DLQ. Une DLQ qui grandit signifie que votre consommateur a un bug, pas une panne transitoire.

Bonnes pratiques pour le streaming d'événements de backend de jeu

  1. Choisissez playerId comme clé de partition. Cela vous donne un ordre par joueur (critique pour l'inventaire, le score et les événements d'état) tout en permettant le parallélisme entre joueurs. Ne partitionnez pas par type d'événement — un « kill » et un « score_update » pour le même joueur doivent rester ordonnés.

  2. Gardez les événements petits et auto-descriptifs. Chaque événement doit faire entre 100 et 500 octets. Incluez le type d'événement, l'ID du joueur, l'horodatage et la charge utile minimale nécessaire. N'intégrez pas l'état complet du jeu — référencez-le par ID.

  3. Concevez les consommateurs pour qu'ils soient idempotents dès le premier jour. Utilisez des IDs d'événements et un stockage de déduplication. Supposez que chaque événement sera livré au moins une fois, et peut-être plus d'une fois en cas de bascule.

  4. Dimensionnez votre fenêtre de rétention en fonction de votre indisponibilité consommateur maximale acceptable. Si votre déploiement le plus long prend 15 minutes, conservez au moins 30 minutes d'événements chauds. Gardez 7 à 30 jours de stockage froid pour la relecture et le débogage.

  5. Surveillez le lag consommateur comme une métrique de première classe. Le lag est le cœur battant de votre architecture événementielle. Un consommateur qui prend un retard de plus de 50 % de sa fenêtre de rétention est une urgence de perte de données, pas un élément « on corrigera au prochain sprint ».

Prochaines étapes

Si vous utilisez actuellement des appels RPC directs depuis les serveurs de jeu vers les services backend, auditez ces connexions cette semaine. Comptez combien d'entre elles perdraient silencieusement des événements lors d'un pic de charge 10x. Ensuite, prototypez un flux d'événements durable entre vos producteurs et vos consommateurs — même un simple journal adossé à une base de données vaut mieux qu'un couplage direct.

Pour le côté consommateur de votre architecture événementielle — classements, rapports de crash, journaux utilisateur et configuration à distance — horizOn fournit ces services gérés afin que vous puissiez vous concentrer sur votre logique de jeu au lieu de réinventer chaque consommateur de zéro. Consultez la documentation de l'API pour voir quelles primitives correspondent à votre backend.


Source : Annonce de Cloudflare K2 : les flux d'événements serverless