Rinha de Backend: Estratégias de Performance e Tuning com .NET
- #C#
- #.NET
Participar da Rinha de Backend não é apenas sobre escrever código que funciona, é sobre escrever código que aguenta pressão. Durante a última edição, testei diversas abordagens para espremer cada milissegundo de performance da minha API em .NET.
Neste artigo rápido, vou compartilhar as escolhas arquiteturais e de infraestrutura que fizeram meu p99 sair da casa dos segundos para um desempenho competitivo, e mostrar como a configuração correta do Program.cs foi crucia pra conquistar o Segundo Lugar.
Limites: 350Ram e 1.5vCPU e mínimo de 2 instância do backend e um balanceador de carga
1. O Erro Clássico: Abrir e Fechar Conexões (Redis)
Um dos primeiros gargalos que muitos enfrentam é o gerenciamento de conexões com o banco de dados ou cache. No início, pode parecer inofensivo instanciar uma conexão, usar e fechar. Porém, sob carga massiva, mas especificamente de até 91mil requisições, o handshake TCP se torna extremamente custoso.
A Solução: Singleton. Na minha implementação, registrei o IConnectionMultiplexer do Redis como Singleton. Isso garante que a aplicação reaproveite a mesma conexão física para todas as requisições, eliminando o overhead de abrir novos sockets repetidamente.
2. O Mistério do p99 em 1500ms (Infraestrutura)
Durante os testes de carga, notei algo estranho: meu p99 (o tempo de resposta dos 1% mais lentos) estava batendo 1500ms. O código parecia otimizado, o banco estava respondendo rápido. Onde estava o problema?
A culpa não era do C#, mas da memória do Nginx. O load balancer estava ficando sem memória suficiente para gerenciar o volume de conexões simultâneas e o buffering, causando latência na entrega das requisições para a API. Ajustar os recursos da infraestrutura foi tão importante quanto mexer no código.
3. Producer-Consumer e o "Número Mágico" de Workers
Para o processamento de pagamentos, optei por não processar tudo na thread da requisição HTTP. Utilizei System.Threading.Channels para criar um modelo de Producer-Consumer. A requisição chega, joga o trabalho no canal (memória) e libera a thread.
Do outro lado, precisei definir quantos consumidores (workers) processariam esses dados.
Cenário 1: Vou colocar 16 workers pra aumentar a vazão
A realidade: Performance degradada.
O excesso de workers aumentou a concorrência por recursos (CPU/Context Switching) sem ganho real. Após vários testes de estresse, cheguei ao número ideal de 8 workers por instância de backend. Mais que isso não mostrava ganho de desempenho e, às vezes, até piorava o throughput.
4. Native AOT: Matando o Cold Start e Otimizando Recursos
Uma das escolhas mais impactantes foi compilar a aplicação usando Native AOT (Ahead-of-Time). Diferente da compilação tradicional, que depende do JIT (Just-In-Time) ao iniciar, o AOT gera código de máquina nativo.
Isso explicou duas escolhas fundamentais no meu Program.cs:
- WebApplication.CreateSlimBuilder(args): Essa versão do builder carrega apenas o essencial do ASP.NET Core, removendo funcionalidades que dependem de Reflection (que é custoso ou incompatível com AOT) e tornando a inicialização quase instantânea.
- JSON Source Generators: O trecho options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); não é estético. No modo AOT, o System.Text.Json não pode usar Reflection para serializar objetos. Eu precisei usar geradores de código para "ensinar" o compilador como tratar meus JSONs em tempo de build.
O Resultado? Um Cold Start inexistente e uma aplicação consumindo muito menos memória RAM, o que permitiu que os containers rodassem mais leves e sobrasse recurso para o que importa: processar requisições.
Abaixo, a configuração completa unindo o AOT com o tuning de HTTP:
zanfranceschi/rinha-de-backend-2025: Rinha de Backend - Terceira Edição
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
string redisHost = Environment.GetEnvironmentVariable("REDIS_HOST") ?? "localhost:6379";
builder.Services.AddSingleton<IConnectionMultiplexer>(
_ => ConnectionMultiplexer.Connect(redisHost)
);
builder.Services.AddSingleton<IDatabase>(sp => sp.GetRequiredService<IConnectionMultiplexer>().GetDatabase());
builder.Services.AddScoped<GetPaymentsByDateService>();
builder.Services.AddHttpClient<PaymentProcessorService>()
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
MaxConnectionsPerServer = 20,
PooledConnectionLifetime = TimeSpan.FromMinutes(10),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(4),
UseCookies = false,
EnableMultipleHttp2Connections = true
})
.ConfigureHttpClient(client =>
{
client.Timeout = TimeSpan.FromSeconds(3);
client.DefaultRequestHeaders.ConnectionClose = false;
client.DefaultRequestHeaders.Add("Connection", "keep-alive");
client.DefaultRequestHeaders.Add("Keep-Alive", "timeout=30, max=100");
});
builder.Services.Configure<LoggerFilterOptions>(options =>
{
options.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
});
builder.Services.AddSingleton<DistributedHealthCheckService>();
builder.Services.AddSingleton<PaymentWorkerService>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<PaymentWorkerService>());
var app = builder.Build();



