Voltar ao Blog

Eventos do backend de jogos sendo descartados sob carga? Conheça a correção com arquitetura de streaming durável

Publicado em 6 de outubro de 2026
Eventos do backend de jogos sendo descartados sob carga? Conheça a correção com arquitetura de streaming durável Gerada com a ajuda de IA

Em resumo

Descubra como evitar a perda silenciosa de eventos no backend de jogos com uma arquitetura de streaming durável e desacoplamento produtor-consumidor.

Sua pipeline de analytics acabou de perder 40% dos eventos de morte de jogadores durante um pico de fim de semana. Os leaderboards estão desatualizados. Os relatórios de crash nunca chegaram. Ninguém notou por três dias.

Este é o assassino silencioso da confiabilidade de backend de jogos: arquiteturas acopladas de produtor-consumidor que funcionam bem a 100 requisições por segundo e sangram dados a 10.000. A chamada RPC do seu servidor de jogo para o seu consumidor de analytics estoura o tempo limite, a conexão é redefinida e o evento simplesmente desaparece — sem erro, sem retry, sem registro.

Este runbook aborda o que quebra, como detectar, o padrão arquitetural que corrige e como prevenir recorrência.

O Que Quebra: O Modo de Falha Acoplado Produtor-Consumidor

Arquiteturas RPC tradicionais forçam produtores e consumidores a se alinharem em escala e tempo. Quando seu servidor de jogo envia um evento "player_killed" diretamente para um serviço de analytics via HTTP:

  1. Incompatibilidade de escala: Se 5.000 jogadores morrem simultaneamente durante um evento no servidor, seu endpoint de analytics recebe uma rajada que não consegue processar. A conexão HTTP expira após 30 segundos. Eventos são descartados.
  2. Acoplamento temporal: Se seu serviço de detecção de fraude implanta uma nova versão e fica offline por 90 segundos, todo evento produzido nessa janela desaparece.
  3. Fan-out para múltiplos consumidores: O mesmo evento "player_killed" precisa chegar a três sistemas independentes — um atualizador de leaderboard, uma pipeline de analytics e um dashboard de live-ops. Cada consumidor tem uma vazão diferente. O mais lento vira gargalo para o produtor.

Veja como isso se parece na prática:

[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

O resultado: perda silenciosa e parcial de dados que corrompe analytics, leaderboards desatualizados e padrões de crash invisíveis. Você descobre semanas depois, quando os números do seu funil não batem.

Números Concretos de Falha

Em um backend multiplayer indie típico lidando com 5.000 jogadores simultâneos:

  • ~2.400 eventos de gameplay/segundo no pico (kills, atualizações de score, mudanças de inventário, transições de zona)
  • Payload médio do evento: ~200 bytes
  • Fan-out HTTP direto para 3 consumidores: 7.200 requisições de saída/segundo
  • Limite de timeout do consumidor: 30 segundos
  • Taxa de descarte observada no pico: 15–45% dependendo da saúde do consumidor

A matemática é brutal. Um único consumidor ficando não saudável por 60 segundos descarta 72.000 eventos. Esses eventos se foram, a menos que você tenha construído um buffer.

Como Detectar Perda Silenciosa de Eventos

Perda silenciosa de eventos é, por definição, difícil de detectar. Aqui está um runbook de detecção:

Passo 1: Instrumente Números de Sequência

Todo produtor deve carimbar cada evento com um número de sequência monotonicamente crescente por entidade de origem. Se seu servidor de jogo envia eventos para player_abc, a sequência vai 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!

Uma lacuna nos números de sequência no lado do consumidor significa perda de evento confirmada.

Passo 2: Monitore o Lag do Consumidor

Acompanhe a diferença entre a sequência mais recente produzida e a sequência mais recente consumida para cada consumidor. Limiares de alerta:

  • Lag < 1.000 eventos: Saudável
  • Lag 1.000–10.000 eventos: Aviso — o consumidor está ficando para trás
  • Lag > 10.000 eventos: Crítico — o consumidor está efetivamente offline ou sobrecarregado

Passo 3: Faça Validação Cruzada dos Totais

Compare as contagens de eventos entre os logs do produtor e as contagens de ingestão do consumidor a cada hora. Uma discrepância maior que 1% justifica investigação.

// 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);
    }
}

Se sua contagem produzida por hora é 8.400.000 e seu consumidor de analytics ingeriu 5.100.000, você perdeu 39% dos eventos. Esse é o seu sinal.

A Correção Arquitetural: Desacoplamento com um Log de Eventos Durável

A solução é desacoplar produtores de consumidores inserindo um buffer durável entre eles. Em vez de:

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

Você escreve em:

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

O stream durável absorve as escritas na velocidade do produtor. Cada consumidor lê no seu próprio ritmo. Se um consumidor ficar offline por 5 minutos, os eventos se acumulam no stream e o consumidor retoma de onde parou quando voltar. Sem perda de dados.

Propriedades Essenciais de um Stream de Eventos Durável

Um stream de eventos de nível de produção para backends de jogos precisa destas propriedades:

Propriedade Por Que É Importante para Jogos
Ordenado dentro de uma partição Todos os eventos de um jogador precisam ser processados em sequência — um kill antes de uma atualização de score, não depois
Armazenamento durável Eventos sobrevivem a reinicializações de consumidores, janelas de deploy e falhas de infraestrutura
Offsets independentes por consumidor Seu consumidor de analytics e o consumidor de leaderboard leem em velocidades diferentes sem se bloquearem
Ordenação no nível da partição Você obtém paralelismo entre jogadores (partições diferentes) e consistência dentro de um jogador (mesma partição)

Estratégia de Particionamento para Backends de Jogos

A chave de partição determina quais eventos caem em qual log ordenado. Para backends de jogos, a chave de partição natural é 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]

Isso garante que todos os eventos de um único jogador sejam processados na ordem exata por cada consumidor, enquanto eventos de jogadores diferentes podem ser processados em paralelo.

Implementando Streaming de Eventos Durável: Um Passo a Passo Prático

Aqui está um padrão de implementação concreto. Você pode construir isso sobre armazenamento de objetos (como S3/R2), um serviço de streaming gerenciado ou um log baseado em banco de dados.

O Produtor: Fan-Out com Escrita Única

Seu servidor de jogo escreve um evento. A infraestrutura de streaming cuida da entrega para todos os consumidores.

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);
        }
    }
}

Decisões-chave de design neste código:

  • A chave de partição é playerId: Garante ordenação por jogador em todos os tipos de evento.
  • Chamada de escrita única: O produtor não sabe nem se importa com quantos consumidores existem. Adicionar um novo consumidor (por exemplo, um rastreador de eventos sazonais) exige zero mudanças no produtor.
  • Fila de retry local: Se o stream estiver temporariamente indisponível, os eventos entram em uma fila local e são enviados quando a conectividade é restaurada. Isso evita perda de dados durante oscilações de rede.

O Consumidor: Rastreamento Independente de Offset

Cada consumidor mantém seu próprio offset de leitura por partição. Este é o mecanismo central que permite velocidades de processamento independentes.

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
        }
    }
}

Detalhes críticos de implementação:

  • O offset avança depois do processamento, não antes. Se o consumidor cair no meio de um lote, ele reprocessa os mesmos eventos na reinicialização. Seus handlers devem ser idempotentes — processar o mesmo evento de kill duas vezes não deve contar duas vezes a entrada no leaderboard.
  • Tamanho de lote de 500 equilibra vazão com memória. Com eventos de 200 bytes, isso é ~100KB por lote — insignificante.
  • Intervalo de polling de 100ms significa que a latência no pior caso é de ~100ms da produção do evento até a atualização do leaderboard. Para a maioria dos casos de uso de leaderboard, isso é perfeitamente aceitável. Se você precisa de latência abaixo de 10ms, você está no território de transporte em tempo real, que é uma arquitetura completamente diferente.

Idempotência: A Rede de Segurança do Consumidor

Idempotência é inegociável nesta arquitetura. Aqui está um handler idempotente concreto:

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 }
            );
        });
    }
}

A tabela processed_events atua como um armazenamento de deduplicação. Custa uma escrita extra por evento, mas garante que reinicializações do consumidor nunca corrompam seus dados.

Lidando com Downtime do Consumidor: A Garantia de Buffer

A principal razão para usar streaming de eventos durável é sobreviver a downtime do consumidor sem perda de dados. Veja como funciona a matemática do buffer:

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

Com 144 MB, isso cabe trivialmente em qualquer sistema de armazenamento moderno. A percepção crítica: o tamanho do seu buffer é proporcional à sua taxa de eventos multiplicada pela sua tolerância máxima de downtime, não ao seu total de dados históricos.

Para retenção de longo prazo (30 dias de eventos para replay ou reprocessamento), os números crescem:

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

Isso está bem dentro da capacidade de backends de armazenamento de objetos. A estrutura de partições mantém o desempenho de leitura previsível mesmo nessa escala — você nunca varre o log inteiro; você lê de partições específicas em offsets específicos.

O Que o horizOn Oferece em uma Arquitetura Orientada a Eventos

Quando seu stream de eventos estiver entregando dados de forma confiável, você precisa de serviços que consumam esses eventos. O horizOn fornece primitivos de backend que se conectam ao lado do consumidor desta arquitetura:

  • Leaderboards consomem eventos de kill, score e conclusão para atualizar rankings em tempo real
  • User logs capturam streams de eventos para depurar problemas relatados por jogadores — quando um jogador diz "meu score resetou", você pode consultar o histórico de eventos dele
  • Crash reports ingerem eventos de crash com contexto completo, fornecendo stack traces vinculados à sessão do jogador

O ponto: o stream de eventos leva dados para esses serviços de forma confiável. Os próprios serviços precisam ser testados em batalha. Você pode ler sobre como arquitetamos uma das nossas maiores atualizações de backend em esta análise da atualização do backend indie do horizOn, que cobre as decisões de infraestrutura por trás da ingestão confiável de eventos em escala.

Runbook: Prevenindo a Recorrência da Perda de Eventos

Quando você já experimentou perda de eventos uma vez, aqui está o checklist para evitar que aconteça novamente:

1. Audite Cada Acoplamento Produtor-Consumidor

Percorra seu código e identifique todos os lugares onde um servidor de jogo faz uma chamada HTTP síncrona direta para um serviço de backend. Cada um é um ponto potencial de descarte sob carga. Liste-os:

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

2. Introduza o Stream de Eventos como Intermediário

Substitua cada chamada direta por uma única escrita no stream de eventos durável. Cada serviço downstream se torna um consumidor independente com seu próprio offset.

3. Implemente Monitoramento de Saúde do Consumidor

Para cada consumidor, acompanhe:

  • Lag (eventos atrás do produtor)
  • Taxa de processamento (eventos consumidos por segundo)
  • Taxa de erro (eventos que falharam no processamento por segundo)
  • Último offset bem-sucedido (detecção de obsolescência)

Alerte quando o lag exceder sua janela de buffer calculada. Se seu stream retém 7 dias e seu consumidor ficou fora por 6 dias, você tem 24 horas antes que a perda de dados comece.

4. Teste a Recuperação Após Reinicialização do Consumidor

Deliberadamente deixe um consumidor offline por 5 minutos, traga-o de volta e verifique se ele alcança o progresso sem duplicatas. Este é o seu teste de confiança de que a arquitetura funciona. Automatize-o no 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. Configure uma Fila de Dead-Letter

Eventos que falham no processamento após N retries (normalmente 3–5) vão para uma fila de dead-letter. Monitore o tamanho da DLQ. Uma DLQ crescendo significa que seu consumidor tem um bug, não uma falha transitória.

Melhores Práticas para Streaming de Eventos em Backend de Jogos

  1. Escolha playerId como sua chave de partição. Isso dá a você ordenação por jogador (crítica para eventos de inventário, score e estado) enquanto permite paralelismo entre jogadores. Não faça partição por tipo de evento — um "kill" e um "score_update" para o mesmo jogador precisam permanecer ordenados.

  2. Mantenha os eventos pequenos e autodescritivos. Cada evento deve ter 100–500 bytes. Inclua o tipo de evento, o ID do jogador, o timestamp e o payload mínimo necessário. Não incorpore o estado completo do jogo — referencie-o por ID.

  3. Projete consumidores para serem idempotentes desde o primeiro dia. Use IDs de evento e um armazenamento de deduplicação. Presuma que todo evento será entregue pelo menos uma vez, e possivelmente mais de uma vez durante failover.

  4. Dimensione sua janela de retenção de acordo com o downtime máximo aceitável do consumidor. Se seu deploy mais longo leva 15 minutos, retenha pelo menos 30 minutos de eventos quentes. Mantenha 7–30 dias de armazenamento frio para replay e depuração.

  5. Monitore o lag do consumidor como uma métrica de primeira classe. O lag é o coração da sua arquitetura orientada a eventos. Um consumidor que fica para trás em mais de 50% da sua janela de retenção é uma emergência de perda de dados, não um item de "corrigimos na próxima sprint".

Próximos Passos

Se você está atualmente usando chamadas RPC diretas de servidores de jogo para serviços de backend, audite essas conexões esta semana. Conte quantas descartariam eventos silenciosamente sob um pico de carga de 10x. Depois, prototipe um stream de eventos durável entre seus produtores e consumidores — até mesmo um log simples baseado em banco de dados é melhor que acoplamento direto.

Para o lado do consumidor da sua arquitetura orientada a eventos — leaderboards, crash reporting, user logs e configuração remota — o horizOn oferece esses serviços gerenciados para que você possa focar na lógica do seu jogo em vez de reinventar cada consumidor do zero. Confira a documentação da API para ver quais primitivos se encaixam no seu backend.


Fonte: Anunciando o Cloudflare K2: streams de eventos serverless