Infra e DevOps
Nginx: Por Que Menos Conexões Fizeram Meus Inserts SUBIREM? - Fabio Akita
há 1 h
O vídeo explica como o excesso de concorrência derrubou o desempenho de aplicações na Rinha de Backend. A analogia usada é a de uma lanchonete: a aplicação representa os caixas, as conexões do banco são os cozinheiros e o Nginx/Endnex é a porta de entrada. Escancarar a porta permite que milhares de requisições entrem de uma vez, mas elas ficam acumuladas diante de poucos caixas e de uma cozinha limitada por CPU e memória.
Cada conexão adicional do PostgreSQL consumia cerca de 3 MB de RAM e também disputava CPU. Com duas instâncias da aplicação e pools de até 100 ou 200 conexões, o sistema poderia tentar manter centenas de cozinheiros ativos, embora o ambiente tivesse cerca de 3 GB de RAM e menos de 1,5 CPU disponíveis. O resultado era mais contenção, maior tempo por operação e requisições abandonadas ou eliminadas pelo teste.
A correção foi controlar o fluxo:
- limitar o número de conexões aceitas pelo Nginx/Endnex, em vez de permitir dezenas de milhares simultaneamente;
- reduzir o número de workers e o tamanho dos pools de conexão;
- deixar requisições aguardarem de forma organizada, evitando que todas ocupassem recursos ao mesmo tempo.
O autor inicialmente usava uma configuração para aceitar até 10.000 conexões e pools de até 200 conexões por instância, mas depois ajustou o Endnex para uma faixa próxima de 1.000 workers/conexões e reduziu o pool para cerca de 40. O resultado subiu de aproximadamente 15.000 inserts para cerca de 39.000, quase alcançando a meta de 40.000. O autor conclui que o problema era I/O-bound, ligado ao gerenciamento do fluxo de entrada e saída, e não CPU-bound no banco.
Também havia um erro de configuração do PostgreSQL no Docker Compose. O arquivo de configuração era montado no contêiner, mas o comando de inicialização não usava -C com o nome do arquivo. Por isso, a configuração não era carregada e o PostgreSQL continuava com o limite padrão de 100 conexões, embora o autor acreditasse que o limite configurado fosse de 400. Essa falta de conexões disponíveis foi interpretada inicialmente como um problema a ser compensado no Nginx, gerando uma configuração excessiva.
O vídeo relaciona o caso a uma ideia geral de operações: atendimento eficiente não significa processar tudo simultaneamente, mas trabalhar com grupos organizados. Depois de controlar o fluxo, os caixas podem executar outras tarefas enquanto aguardam o banco, usando concorrência assíncrona e threads quando apropriado. O autor cita Node.js, Rails, Java, Kotlin, Rust, Crystal e Elixir como exemplos de plataformas que poderiam alcançar resultados próximos, desde que a configuração do fluxo e dos recursos fosse adequada.
Ajustes na versão Node.js
O autor modificou a implementação Node.js/Express de Lucas Vais, que obtinha cerca de 34.000 inserts na máquina dos organizadores e aproximadamente 27.000 na máquina do autor. A versão foi refatorada e recebeu mudanças específicas para o ambiente da competição:
- remoção do Redis usado como cache;
- reativação do recurso de cluster do Node.js;
- redução de três contêineres Node para dois, cada um com um fork adicional, totalizando quatro processos;
- redução do número de workers do Endnex de 20.000 para valores entre 10.000 e 1.024, com cerca de 1.000 considerado adequado para muitas aplicações;
- redução do pool de conexões de cerca de 200 para aproximadamente 45, possivelmente menos;
- distribuição mais adequada de CPU e RAM no Docker Compose;
- remoção de mensagens detalhadas de erro HTTP, que não eram necessárias para o teste de carga;
- redução de trabalho feito pelos handlers antes de acessar o banco.
O Redis não ajudava na pesquisa por termos porque o teste usava termos aleatórios, raramente repetidos. Assim, quase nenhuma consulta encontrava um resultado reutilizável. No endpoint de criação, o banco gerava a chave primária e a aplicação só depois gravava o resultado no cache; esse trabalho era sequencial e apenas aumentava a latência. Na consulta por ID, a busca pela chave primária no PostgreSQL já era rápida. O autor observa que um cache poderia ser mais útil se a aplicação gerasse previamente a chave com uma biblioteca de UUID, enviasse o insert ao banco e gravasse o mesmo valor no cache em paralelo.
O endpoint de pesquisa também fazia um SELECT com LIKE separado para apelido, nome e stack, ligado por OR. O autor considera essa abordagem menos eficiente e sugere concatenar os três campos em uma coluna durante o insert, criando um índice adequado com GiST e a extensão pg_trgm. Ele menciona que alguns participantes usaram GIN, mas avalia que GiST era melhor para esse desafio.
Outra otimização foi validar os dados antes de enviá-los ao banco. Em vez de inserir uma data inválida e esperar que o PostgreSQL devolvesse um erro, a aplicação poderia verificar o formato ou converter a string para uma data válida. O código usava Moment.js; o autor cita date-fns e o Date nativo do JavaScript como alternativas, mas adotou uma verificação simples do resultado do parsing.
Com esses ajustes, a versão Node.js passou de cerca de 27.000 inserts na máquina do autor, ou 34.000 no ambiente oficial, para a faixa de 40.000 inserts, colocando o projeto entre os cinco primeiros do ranking oficial.
Problema de inicialização no Elixir
Ao tentar executar a versão Elixir de Gabriel Oliveira, o autor encontrou contêineres que encerravam imediatamente, sem mensagem de erro. Para investigar, manteve o contêiner ativo com um comando equivalente a sleep infinity, entrou nele com docker compose exec e executou manualmente o binário da aplicação. O problema também ocorria ao iniciar apenas o iex e, depois, o erl, indicando que a falha não estava na aplicação em si.
A causa estava nos limites de portas detectados pelo Erlang no sistema do autor. No Erlang, portas representam recursos usados para comunicação externa, incluindo entrada e saída padrão, arquivos e conexões de rede. O limite poderia ser consultado com erlang:system_info(port_limit). O sistema informava mais de 1 milhão de portas, e a inicialização tentava reservar memória de acordo com esse limite, consumindo quase 1 GB e excedendo a memória disponível no contêiner.
A correção foi definir a variável de ambiente ERL_MAX_PORTS com um valor menor, como 2048, em vez de permitir mais de 1 milhão. Com isso, cada instância passou a consumir cerca de 160 MB e conseguiu iniciar sob as restrições da competição. O autor atribui o problema à configuração mais agressiva do Arch Linux em comparação com distribuições como Debian e Ubuntu, relacionando-o ao limite de descritores de arquivo do sistema.
A implementação Elixir usava o clustering nativo da máquina virtual Erlang para conectar as duas instâncias em contêineres diferentes. Isso permitia compartilhar dados em memória entre elas sem adicionar um contêiner Redis. Ainda assim, o autor reforça que, neste caso, o problema principal continuava sendo o fluxo de requisições e que adicionar cache poderia representar apenas mais consumo de recursos.
Com os ajustes no Endnex e no pool de conexões, a versão Elixir ultrapassou 41.000 inserts e entraria entre os cinco primeiros colocados.
O desfecho é que várias linguagens e frameworks considerados mais lentos poderiam atingir resultados próximos dos líderes quando o sistema fosse configurado corretamente. O autor conclui que a otimização inicial deveria ter sido controlar a entrada de requisições, dimensionar pools conforme CPU e memória disponíveis e tratar o problema como fluxo de I/O, em vez de aumentar indiscriminadamente conexões, adicionar cache ou atribuir o gargalo ao banco de dados.
Fontes
- YouTubeNginx: Por Que Menos Conexões Fizeram Meus Inserts SUBIREM? - Fabio Akita