Desafio Rinha de Backend 2025: Análise técnica detalhada e o uso da biblioteca de lock ReactiveLock

Michel Oliveira
13 min read
Desafio Rinha de Backend 2025: Análise técnica detalhada e o uso da biblioteca de lock ReactiveLock

Introdução

O desafio da Rinha de Backend 2025 foi um exercício de engenharia em condições de alta concorrência, limitação de recursos (350MB de memória, 1.5 CPUs totais), e instabilidade nos serviços.

Esta terceira edição do desafio consolidou-se como a mais interessante e desafiadora até então na minha opinião, tanto pela complexidade dos requisitos quanto pelo ambiente restritivo imposto aos participantes.

Podemos considerar, de forma ilustrativa, que a rinha de backend tinha um teste que representava centenas de pessoas realizando transferências financeiras. Nesse contexto, o próprio framework de testes da Rinha de Backend funcionava como um “Banco Central”, responsável por auditar e validar a integridade das operações realizadas, garantindo que os resultados refletissem corretamente a consistência e o desempenho de cada solução, no qual também disponibilizava as APIs para as transferências.

O desafio

O objetivo principal era processar pagamentos em duas APIs distintas - a principal (default) e a secundária (fallback) - com diferentes taxas e garantias de consistência. A taxa maior era aplicada ao fallback, enquanto a taxa menor correspondia à API default. Divergências nos pagamentos geravam multa de 35% sobre o saldo final, enquanto a latência impactava bônus de performance.

A partir dos pagamentos processados durante os testes, era calculado um score que representava o valor total de pagamentos realizados com sucesso, já com a dedução das taxas, penalidades e bônus por baixa latência. Esse score servia de base para a formação de um ranking entre os participantes, incentivando estratégias que maximizassem a eficiência e a consistência das soluções.

No teste final, 603 usuários virtuais acessavam os endpoints, simulando um cenário distribuído com load balancer e múltiplas instâncias de API em containers.

As soluções implementadas pelos participantes na maioria dos casos tinham caráter estritamente experimental e foram concebidas para o contexto específico do desafio. Elas não necessariamente correspondiam às melhores práticas para ambientes de produção - por exemplo, o uso de Redis/memória para armazenamento de dados críticos ou a persistência tardia, que poderiam introduzir riscos de consistência e durabilidade que exigiriam mecanismos mais robustos em sistemas reais como replicação, transações distribuídas ou persistência durável.

Apesar disso, excelentes soluções surgiram ao longo do desafio e no meu caso, criei uma biblioteca de lock distribuído projetada desde o início para uso em produção, com foco em confiabilidade, desempenho e facilidade de integração como pode ser conferido em Blog: ReactiveLock – Um case pronto para produção da Rinha de Backend 2025.

Arquitetura mínima da API

A API mínima exigida era:

  • GET /payments-summary: endpoint de sumário - retorna total de pagamentos por endpoint (default/fallback) e, se informado, por intervalo de datas.
  • POST /payments: endpoint de pagamentos - processa / enfileira um pagamento.
  • POST /purge-payments (opcional): endpoint de limpeza - limpa a base de pagamentos.

O desafio principal era garantir consistência absoluta, mesmo sob falhas temporárias e alta concorrência - algo que, na prática, assegurava maiores pontuações na maior parte dos casos, já que inconsistências impactavam diretamente no score e na posição no ranking devido à multa de 35% aplicada.

A solução deveria ser orquestrada via Docker Compose, com no mínimo duas instâncias da API e limite total de 1.5 vCPU e 350MB de RAM, com um load balancer configurado em round-robin que distribui as requisições de pagamentos e consultas de sumário entre as instâncias, exigindo que os participantes lidassem com tradeoffs de sistemas distribuídos.

flowchart LR subgraph Users[Teste Rinha de Backend] U[Usuários Virtuais - N - Max. 603 VUs] BC[Validação - Banco Central - 10/10 segundos] end LB[Load Balancer] subgraph Instancia_A[API - Instância A] A1[POST /payments] A2[GET /payments-summary] end subgraph Instancia_B[API - Instância B] B1[POST /payments] B2[GET /payments-summary] end subgraph Processors[APIs da Rinha de Backend] P1[API Default] P2[API Fallback] end %% Fluxo com Load Balancer U --> LB BC --> LB LB --> A1 LB --> B1 LB --> A2 LB --> B2 %% Instâncias chamando processadores A1 --> P1 A1 --> P2 B1 --> P1 B1 --> P2

Solução com Redis

Uma das minhas soluções utilizou Redis como backbone para pub/sub e armazenamento:

  • Enfileiramento de pagamentos com processamento em 10 workers contínuos por instância.
  • Armazenamento de pagamentos em lotes de 100 antes da persistência.
  • Sumário consultado diretamente do Redis consolidado.

Infraestrutura:

graph TD loadBalancer["<b>Load Balancer</b><br />(NGINX ou HAProxy)"] subgraph backendsGroup["<b>BACKENDS</b>"] backend1["<b>Backend 1 AOT</b><br />(.NET 10)"] backend2["<b>Backend 2 AOT</b><br />(.NET 10)"] end subgraph storageGroup["<b>ARMAZENAMENTO/MENSAGERIA</b>"] redis["<b>Redis</b><br />(versão 8)"] end loadBalancer --> backendsGroup backend1 --> storageGroup backend2 --> storageGroup

API:

flowchart TD Payments[POST /payments] --> PubSub[Redis Pub/Sub] PubSub --> Workers[Pool de Workers por instância] Workers --> Memoria[Lote em Memória] Memoria --> RedisPersist[Persistência em Redis] Summary[GET /payments-summary] --> RedisPersist

Mesmo com alto throughput, havia risco de inconsistência entre pagamentos pendentes na memória e sumários consultados, devido à concorrência entre gravações e leituras.

A versão incluindo PostgreSQL e otimizações em C# foi abordada neste post:
Blog: Meus Insights sobre o Desafio da Rinha de Backend 2025

Problemas enfrentados

Durante os testes, foram observados desafios críticos:

  1. Pagamentos pendentes: requisições em andamento poderiam não estar refletidas no sumário.
  2. Instabilidade dos serviços da rinha: delays de até 5 segundos para processar cada pagamento, com possibilidade de falha.
  3. Sumários frequentes: a Rinha consultava os valores a cada 10 segundos, exigindo dados precisos.
  4. Concorrência distribuída: múltiplas instâncias da API precisavam sincronizar acesso a recursos compartilhados.

Esses fatores exigiam uma sintonia fina entre workers, persistência e leitura de sumários.

Estratégias adotadas pelos participantes

Durante o desafio, os participantes exploraram algumas abordagens para lidar com a concorrência e a instabilidade dos serviços de pagamento:

  • Escolher o melhor meio de pagamento com menor latência: reduzindo o risco de inconsistência nas verificações do teste.
  • Persistir pagamentos o mais rápido possível: garantindo que os registros estivessem disponíveis imediatamente para consulta.
  • Sincronização de processos via lock distribuído: esta abordagem foi a que utilizei, criando a biblioteca ReactiveLock para gerenciar locks distribuídos de forma eficiente entre instâncias e processos concorrentes.

Introdução ao ReactiveLock

Para resolver os problemas de concorrência, utilizei a biblioteca ReactiveLock para gerenciar locks distribuídos.

Foram criados três tipos de locks:

  • Lock de requisições ativas: particionado por data, garantindo que sumários não fossem calculados enquanto pagamentos pendentes existissem.
  • Lock de registros pendentes: assegurava que todos os pagamentos em memória fossem persistidos antes da leitura.
  • Lock de sumário ativo: bloqueava leituras concorrentes conflitantes com gravações.
sequenceDiagram participant Client as Teste Rinha participant API as API Pagamentos participant Worker as Pool de Workers participant Redis as Redis participant Lock as ReactiveLock Client->>API: POST /payments API->>Redis: Enfileira pagamento Redis-->>API: OK Worker->>Lock: Lock 1 (Se requisição de sumário ativa, espera) Worker->>Lock: Lock 2 (Requisição de pagamento ativa) Worker->>Lock: Lock 3 (Lote em memória com dados ativo) Client->>API: GET /payments-summary API->>Lock: Lock 1 (Sumário sendo requisitado, lock ativo) API->>Lock: Lock 3 (Persiste lotes pendentes via lock distribuído) API->>Lock: Lock 2 (Espera por requisições pendentes do range de data) Redis->>API: Retorna totais consistentes

Código de inicialização dos locks:

  • Program.cs:
builder.Services.InitializeDistributedRedisReactiveLock(Dns.GetHostName());

var opts = builder.Configuration
    .Get<DefaultOptions>()!;

builder.Services.AddDistributedRedisReactiveLock(Constant.DEFAULT_PROCESSOR_ERROR_THRESHOLD_NAME,
                                                busyThreshold: opts.DEFAULT_PROCESSOR_CIRCUIT_ERROR_THRESHOLD_SECONDS);
builder.Services.AddDistributedRedisReactiveLock(Constant.REACTIVELOCK_HTTP_NAME);
builder.Services.AddDistributedRedisReactiveLock(Constant.REACTIVELOCK_REDIS_NAME);
builder.Services.AddDistributedRedisReactiveLock(Constant.REACTIVELOCK_API_PAYMENTS_SUMMARY_NAME, [
    async(sp) => {
        var summary = sp.GetRequiredService<PaymentSummaryService>();
        await summary.FlushWhileGateBlockedAsync();
    }
]);

var app = builder.Build();

await app.UseDistributedRedisReactiveLockAsync();

Código de controle dos locks por requisição particionado:

  • CountingHandler.cs:
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
    var shouldIncrement = true;
    if (request.Options.TryGetValue(new HttpRequestOptionsKey<DateTimeOffset>("RequestedAt"), out var requestedAt))
    {
        var currentRunningRanges = RunningPaymentsSummaryData.CurrentRanges.ToList();
        if (currentRunningRanges.Any())
        {
            bool requestIsNotInsideAnySummaryRange = !currentRunningRanges.Any(range =>
                (!range.from.HasValue || requestedAt >= range.from.Value) &&
                (!range.to.HasValue || requestedAt <= range.to.Value)
            );

            if (requestIsNotInsideAnySummaryRange)
            {
                shouldIncrement = false;
            }
        }
    }
    if (shouldIncrement)
        await ReactiveLockTrackerController.IncrementAsync().ConfigureAwait(false);

    try
    {
        return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
    }
    finally
    {
        if (shouldIncrement)
            await ReactiveLockTrackerController.DecrementAsync().ConfigureAwait(false);
    }
}

A infraestrutura após o ReactiveLock:

graph TD loadBalancer["<b>Load Balancer</b><br />(NGINX ou HAProxy)"] subgraph backendsGroup["<b>BACKENDS</b>"] backend1["<b>Backend 1 AOT</b><br />(.NET 10)"] backend2["<b>Backend 2 AOT</b><br />(.NET 10)"] reactiveLock["<b>Lock Reativo Distribuído</b><br/>(lib <b>ReactiveLock</b> para sincronia entre processos<br/>HTTP e API de Sumário)"] end subgraph storageGroup["<b>ARMAZENAMENTO/MENSAGERIA</b>"] redis["<b>Redis</b><br />(versão 8)"] end loadBalancer --> backendsGroup backend1 --> storageGroup backend2 --> storageGroup backend1 --> reactiveLock backend2 --> reactiveLock reactiveLock --> storageGroup

Para mais detalhes, consulte o código completo no GitHub:

Estratégia de espera e partição de locks

Para cada requisição de sumário:

  • Locks eram particionados por intervalo de datas, afetando apenas pagamentos relevantes.
  • Processos pendentes fora do intervalo de datas eram pausados temporariamente.
  • Máximo de 1,3 segundos de espera para o processamento total das requisições e lotes pendentes dentro do intervalo de datas.

Essa abordagem mitigava os efeitos do Teorema CAP, priorizando consistência sem comprometer totalmente a disponibilidade.

Mais detalhes em: Blog: O Teorema Cap na Rinha de Backend 2025

stateDiagram-v2 [*] --> Idle Idle --> EsperaProcessos : Sumário requisitado EsperaProcessos --> Timeout : Excedeu 1.3s EsperaProcessos --> Finalizado : Todos processos concluídos Timeout --> RetornaParcial : Retorno parcial consistente Finalizado --> RetornaCompleto : Retorno final completo

Código que garante a espera pelos locks no endpoint de sumário:

  • PaymentSummaryService.cs:
await WaitWithTimeoutAsync(async () =>
{
    await redisChannelBlockingGate.WaitIfBlockedAsync().ConfigureAwait(false);
    await channelBlockingGate.WaitIfBlockedAsync().ConfigureAwait(false);
}, timeout: TimeSpan.FromSeconds(1.3)).ConfigureAwait(false);

Código que garante a espera das requisições enquanto um endpoint de sumário estivesse sendo processado:

  • PaymentService.cs:
await ReactiveLockTrackerState.WaitIfBlockedAsync().ConfigureAwait(false);

(HttpResponseMessage response, string processor, DateTimeOffset requestedAt)
      = await PaymentProcessorService.ProcessPaymentAsync(request);

Para mais detalhes, consulte o código completo no GitHub:

Fluxo detalhado de processamento

  1. Recepção do pagamento: o POST /payments publica no Redis Pub/Sub.
  2. Worker enfileira: cada worker consome os pagamentos pendentes e mantém lotes em memória após as requisições da API da Rinha.
  3. Persistência controlada: antes do sumário, todos os lotes pendentes são gravados continuamente.
  4. Leitura do sumário: locks garantem que apenas dados consistentes sejam retornados.
flowchart TD Payments[POST /payments] -->|Publica no Redis| PubSub[Redis Pub/Sub] PubSub --> WorkerA[Worker A] PubSub --> WorkerB[Worker B] WorkerA --> MemoriaA[Lote em Memória] WorkerB --> MemoriaB[Lote em Memória] MemoriaA -->|Lock + Persistência| RedisPersist[Redis] MemoriaB -->|Lock + Persistência| RedisPersist Summary[GET /payments-summary] -->|Lock de sumário| RedisPersist

Código que gerenciava os lotes em memória, com notificação dos locks:

  • PaymentBatchInserterService.cs:
public async Task<int> AddAsync(PaymentInsertParameters payment)
{
    await ReactiveLockTrackerController.IncrementAsync().ConfigureAwait(false);
        
    Buffer.Enqueue(payment);

    if (Buffer.Count >= Options.BATCH_SIZE)
    {
        return await FlushBatchAsync().ConfigureAwait(false);
    }
    return 0;
}

public async Task<int> FlushBatchAsync()
{
    if (Buffer.IsEmpty)
        return 0;

    int totalInserted = 0;

    while (!Buffer.IsEmpty)
    {
        var batch = new List<PaymentInsertParameters>(Options.BATCH_SIZE);
        while (batch.Count < Options.BATCH_SIZE && Buffer.TryDequeue(out var item))
            batch.Add(item);

        if (batch.Count == 0)
            break;

        foreach (var payment in batch)
        {
            string json = JsonSerializer.Serialize(payment, JsonContext.Default.PaymentInsertParameters);
            await RedisDb.ListRightPushAsync(Constant.REDIS_PAYMENTS_BATCH_KEY, json);
        }

        totalInserted += batch.Count;

        await ReactiveLockTrackerController.DecrementAsync(batch.Count).ConfigureAwait(false);
    }

    return totalInserted;
}
  • Program.cs:
builder.Services.AddDistributedRedisReactiveLock(Constant.REACTIVELOCK_API_PAYMENTS_SUMMARY_NAME, [
    async(sp) => {
        var summary = sp.GetRequiredService<PaymentSummaryService>();
        await summary.FlushWhileGateBlockedAsync();
    }
]);

Para mais detalhes, consulte o código completo no GitHub:

Estratégia de fallback em instabilidade

Durante o teste da Rinha de Backend, os serviços secundários apresentavam indisponibilidade intermitente, e o PaymentProcessorService precisava lidar com isso de forma consistente e eficiente. Um lock específico para a estratégia de fallback foi criado para coordenar essas tentativas. A estratégia adotada foi:

sequenceDiagram participant Serviço as API Pagamentos participant Lock as ReactiveLock participant Principal as Rinha API Principal participant Fallback as Rinha API Fallback Serviço->>Lock: Verifica se há bloqueio alt Serviço disponível Serviço->>Principal: Envia pagamento normalmente Principal-->>Serviço: Retorna resultado alt Sucesso Serviço-->>Lock: Decrementa lock else Falha Serviço-->>Lock: Incrementa lock end else Serviço instável / Intermitente note right of Serviço: A cada 2 tentativas, tenta Principal mesmo em fallback alt Tentativa no Fallback Serviço->>Fallback: Envia pagamento Fallback-->>Serviço: Sucesso fallback Serviço-->>Lock: Incrementa lock else Tentativa especial no Principal Serviço->>Principal: Envia pagamento Principal-->>Serviço: Sucesso principal Serviço-->>Lock: Decrementa lock end end
  • Circuit break: se o endpoint principal apresentasse falhas contínuas por 20 segundos, o sistema acionava o fallback automaticamente.
  • Verificação de bloqueio via ReactiveLock: antes de enviar qualquer pagamento, o serviço checa se há um lock para agir em circuit break.
  • Fallback intermitente: quando o serviço principal estava instável, os pagamentos eram enviados para o processador secundário (fallback).
  • Tentativa especial a cada 2 envios: mesmo em estado de fallback, a cada duas tentativas, o serviço tentava enviar para o processador principal, garantindo que nenhum pagamento ficasse permanentemente desviado.
  • Lock de fallback específico: gerenciava apenas a lógica de envio durante instabilidade.
  • Lock atualizado conforme resultado: Em caso de sucesso no principal, o lock era decrementado. Em caso de falha ou fallback, o lock era incrementado.
  • Prioridade por endpoint: sempre que possível, os pagamentos eram direcionados para a API principal para reduzir inconsistências.
  • Retries controlados: os workers repetiam a tentativa de persistência dos pagamentos até que fossem processados com sucesso.

Essa abordagem garante que, mesmo diante de instabilidade intermitente, os sumários retornam consistentes, mantendo a integridade dos dados e a confiabilidade do sistema.

Declaração do Threshold de 20 segundos para o lock do circuit break:

  • Program.cs:
builder.Services.AddDistributedRedisReactiveLock(Constant.DEFAULT_PROCESSOR_ERROR_THRESHOLD_NAME,
                                                busyThreshold: opts.DEFAULT_PROCESSOR_CIRCUIT_ERROR_THRESHOLD_SECONDS);
  • PaymentProcessorService.cs:
public async Task<(HttpResponseMessage response, string processor, DateTimeOffset requestedAt)> ProcessPaymentAsync(PaymentRequest request)
{
    var requestedAt = DateTimeOffset.UtcNow;
    string processor = Constant.DEFAULT_PROCESSOR_NAME;
    string jsonString = $@"{{
        ""amount"": {request.Amount},
        ""requestedAt"": ""{requestedAt:o}"",
        ""correlationId"": ""{request.CorrelationId}""
    }}";

    var content = new StringContent(jsonString, Encoding.UTF8, "application/json");

    var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/payments")
    {
        Content = content
    };

    httpRequest.Options.Set(new HttpRequestOptionsKey<DateTimeOffset>("RequestedAt"), requestedAt);

    HttpClient requestClient = HttpDefault;
    if (await ReactiveLockTrackerState.IsBlockedAsync())
    {
        if (ReactiveLockTrackerController.GetActualCount() % 2 == 0)
        {
            requestClient = HttpFallback;
        }
    }
    var message = await requestClient.SendAsync(httpRequest).ConfigureAwait(false);

    if (requestClient == HttpDefault)
    {
        var actualCount = ReactiveLockTrackerController.GetActualCount();
        if (message.IsSuccessStatusCode && actualCount > 0)
        {
            ConsoleWriterService.WriteLine($"Lock decremented.");
            await ReactiveLockTrackerController.DecrementAsync().ConfigureAwait(false);
        }
        else if (!message.IsSuccessStatusCode)
        {
            await IncrementLockIfPossible(actualCount).ConfigureAwait(false);
        }
    }
    if (requestClient == HttpFallback)
    {
        var actualCount = ReactiveLockTrackerController.GetActualCount();
        await IncrementLockIfPossible(actualCount).ConfigureAwait(false);
        processor = Constant.FALLBACK_PROCESSOR_NAME;
        ConsoleWriterService.WriteLine($"Fallback is called.");
    }
    return (message, processor, requestedAt);
}

Para mais detalhes, consulte o código completo no GitHub:

Benefícios observados

  • Consistência garantida: leituras do sumário refletiam a realidade.
  • Baixo impacto de latência: locks particionados minimizaram bloqueios.
  • Escalabilidade: distribuição de locks entre instâncias evitou gargalos.
  • Resiliência: instabilidade de APIs secundárias não causava divergência no sumário.
  • Simplicidade na implementação: ReactiveLock abstrai toda a complexidade de sincronização distribuída.

Riscos potenciais

  • Lock dessincronizado: o ReactiveLock, apesar de replicar próximo de tempo real, não é em tempo real e algum atraso na propagação do lock poderia deixá-lo fora de sincronia, gerando inconsistências temporárias. O uso recomendado da biblioteca é sempre em conjunto com outra camada de lock (geralmente de maior custo, justamente para aumentar a performance) como abordo no case em: Blog: ReactiveLock – Um case pronto para produção da Rinha de Backend 2025.
  • Persistência lenta: se a gravação dos pagamentos for mais demorada que o esperado, o sumário poderia não refletir o estado real, como aconteceu potencialmente com algumas versões em PostgreSQL e gRPC que enviei.
  • Falha parcial de instâncias: se algumas instâncias da API ou workers ficarem indisponíveis por um período, mesmo com locks distribuídos, podem ocorrer situações em que parte dos pagamentos não é processada ou o sumário fica incompleto, exigindo mecanismos de reprocessamento ou conciliação. A biblioteca atualmente suporta recuperação gerenciada em falhas de qualquer tipo.

Minhas posições

Ranking

No teste final, concluí em 6º lugar na categoria Microsoft e 34º no geral com 1.579.966,64 pontos, considerando apenas participantes únicos, que foram mais de 300 em aproximadamente 48 linguagens de programação. Na prévia, fiquei em 1º lugar na categoria Microsoft e 17º no ranking geral. Foi possível alcançar 2~3ms de P99 entre os rankings.

Ao longo do desafio, enviei 8 versões diferentes utilizando combinações de C# em .NET 10 com Redis, gRPC e Postgres com Nginx / HAProxy em 48 pull requests ao repositório oficial, buscando otimizar tanto desempenho de I/O quanto a escolha dos meios de pagamento, onde priorizei o meio de pagamento principal mesmo diante de incertezas no teste final, mas ainda utilizando o meio secundário, embora de forma otimista.

Ranking Parcial: Ranking Ranking Final Agrupado: Ranking Ranking Final - Todas as soluções: Ranking

Considerações finais

O Desafio Rinha de Backend 2025 mostrou que:

  • Alta concorrência, performance e consistência podem coexistir quando há uso de locks distribuídos.
  • Mesmo o teste da Rinha de Backend não representando um cenário totalmente real que ocorreria em produção, foi um excelente laboratório para testar arquiteturas distribuídas sob alta carga e validar estratégias de consistência e resiliência.
  • O ReactiveLock foi eficiente em sincronizar os processos assíncronos e distribuídos.
  • A combinação Redis + ReactiveLock ofereceu uma solução simples, performática e resiliente, gerando um case pronto para produção.

Referências

Conecte-se para transformar sua tecnologia!

Saiba mais e entre em contato: