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

Ler na fonte

A BigTon News é uma plataforma de estudo e atualização própria. A escolha das fontes é editorial; o resumo é automático. Não nos responsabilizamos pelas notícias nem pelos conteúdos das fontes. Não republicamos a matéria: leia o original.

Assinar newsletter · Termos e privacidade