Grandes apetites para pequenos amortecedores no Node.js

Eu já falei sobre o serviço de monitoramento de consultas ao PostgreSQL , para o qual implementamos um coletor de logs de servidores online, cuja principal tarefa é receber simultaneamente fluxos de logs de um grande número de hosts de uma só vez, analisá- los rapidamente em linhas , agrupá-los em pacotes de acordo com certas regras, processar e escrever resultam em armazenamento PostgreSQL .



No nosso caso, estamos falando de várias centenas de servidores e milhões de solicitações e planos que geram mais de 100 GB de logs por dia . Portanto, não foi surpresa quando descobrimos que a maior parte dos recursos é gasta precisamente nessas duas operações: analisando linhas e gravando no banco de dados.

Mergulhamos nas entranhas do criador de perfil e encontramos alguns recursos ao trabalhar com o BufferNode.js., cujo conhecimento pode economizar muito tempo e recursos do servidor.

Carga da CPU




A maior parte do tempo do processador foi gasta processando o fluxo de logs de entrada, o que é compreensível. Mas o que não ficou claro foi a intensidade de recursos do "fatiamento" primitivo do fluxo de entrada de blocos binários em linhas por \r\n: O



desenvolvedor atento notará imediatamente aqui um ciclo de bytes não tão eficiente através do buffer de entrada. Bem, como a linha pode ser “dividida” entre os blocos vizinhos, também existe um “anexo de cauda” funcional que sobrou do bloco processado anterior.

Tentando readline


Uma rápida revisão das soluções disponíveis nos levou ao módulo readline regular , exatamente com a funcionalidade necessária para dividir em linhas:



Depois de implementado, o "fatiamento" da parte superior do criador de perfil foi mais profundo:



mas, como se viu, o readline força a string para o UTF-8 internamente , o que é impossível. faça se a entrada de log (solicitação, plano, texto de erro) tiver uma codificação de origem diferente.

De fato, mesmo em um servidor PostgreSQL, vários bancos de dados podem estar ativos simultaneamente, cada um dos quais gera saída para um log de servidor comum exatamente em sua codificação original. Como resultado, os proprietários de bancos de dados no win-1251 (às vezes é conveniente usá-lo para economizar espaço em disco se um UNICODE multibyte "honesto" não for necessário) foram capazes de observar seus planos com aproximadamente os mesmos nomes "russos" de tabelas e índices:



Modificando a bicicleta


É um problema ... Ainda assim, você deve fazer o corte por conta própria, mas com otimizações como a verificação de Buffer.indexOf()"bytes":



tudo parece estar bem, a carga no ciclo de teste não aumentou, os nomes do win1251 foram reparados, estamos lançando a batalha ... Ta-dam! O uso da CPU ultrapassa periodicamente o teto em 100% :



como está? .. Acontece que é nossa culpa Buffer.concatpela qual “enfiamos a cauda” do bloco anterior:



mas só temos uma cola quando uma linha passa por um bloco , mas não devem ser muitos - realmente, realmente? .. Bem, quase. Somente agora, às vezes, vêm "strings" de várias centenas de segmentos de 16 KB :



Agradecemos aos colegas desenvolvedores que tiveram o cuidado de gerar isso. Isso acontece "raramente, mas com precisão", portanto não foi possível ver com antecedência no circuito de teste.

É claro que colar várias centenas de vezes no buffer por vários megabytes de pequenos pedaços é um caminho direto para o abismo das realocações de memória com o consumo de recursos da CPU, que observamos. Então, não vamos colar até a linha terminar completamente. Apenas colocaremos as "peças" em uma matriz até a hora de deixar a linha inteira "fora":



agora a carga retornou aos indicadores da linha de leitura.

Consumo de memória


Muitas pessoas que escreveram em idiomas com alocação dinâmica de memória sabem que um dos mais desagradáveis ​​"assassinos de desempenho" é a atividade em segundo plano do Garbage Collector (GC), que verifica os objetos criados na memória e exclui aqueles que são maiores. ninguém é necessário. Esse problema também nos ultrapassou - em algum momento, começamos a perceber que a atividade do GC era de alguma maneira excessiva e deslocada.



As "reviravoltas" tradicionais realmente não ajudaram ... "Se tudo mais falhar, despeje!" E a sabedoria popular não decepcionou - vimos uma nuvem de buffer de 8360 bytes com um tamanho total de 520MB ...



E eles foram gerados dentro do CopyBinaryStream - a situação começou a se esclarecer ...

CÓPIA ... DE STDIN COM BINÁRIO


Para reduzir a quantidade de tráfego transmitido ao banco de dados, usamos o formato binário COPY . De fato, para cada registro, você precisa enviar um buffer para o fluxo, consistindo em "partes" - o número de campos no registro (2 bytes) e, em seguida, a representação binária dos valores de cada coluna (4 bytes por tipo ID + dados).

Como essa linha da tabela quase sempre tem um comprimento variável "resumido", alocar imediatamente um buffer de um comprimento fixo não é uma opção ; a realocação se houver falta de tamanho "consome" facilmente o desempenho; já foi mais alto. Portanto, também vale a pena "colar de pedaços" usando Buffer.concat().

memorando


Bem, como temos muitas peças repetidas várias vezes (por exemplo, o número de campos nos registros da mesma tabela) - vamos apenas lembrá- los e, em seguida, pegar os prontos, gerados uma vez no primeiro acesso. Com base no formato COPY, existem poucas opções - peças típicas têm 1, 2 ou 4 bytes de comprimento:



E ... bam, chegou um ancinho!



Ou seja, sim, toda vez que você cria um buffer, uma parte de 8 KB de memória é alocada por padrão, para que pequenos buffers criados em uma linha possam ser empilhados "ao lado de" na memória já alocada. E nossa alocação funcionou “sob demanda” e acabou por não estar “próxima” - é por isso que cada um dos nossos 1-2-4 bytes de buffer ocupa o cabeçalho de 8KB + fisicamente ocupado - aqui estão eles, nossos 520MB!

memorando inteligente


Hmm ... Por que precisamos esperar até que este ou aquele buffer de 1/2 byte seja necessário? Com 4 bytes é uma questão separada, mas algumas dessas opções diferentes para um total de 256 + 65536. Então, deixe o nagenerimim sua linha de uma só vez ! Ao mesmo tempo, cortamos a condição para a existência de cada verificação - ela também funcionará mais rápido, pois a inicialização é realizada apenas no início do processo.



Ou seja, além dos buffers de 1/2 byte, inicializamos imediatamente os valores mais em execução (menos 2 bytes e -1) para os de 4 bytes. E - ajudou, apenas 10MB em vez de 520MB!


All Articles