Teleportando toneladas de dados para PostgreSQL

Hoje vou compartilhar algumas soluções arquitetônicas úteis que surgiram durante o desenvolvimento de nossa ferramenta para análise em massa do desempenho de servidores PostgeSQL , e que nos ajudam agora a "encaixar" monitoramento e análise completos de mais de mil hosts no mesmo hardware, que a princípio mal dava para cem ...





Introdução



Deixe-me lembrá-lo de algumas notas introdutórias:



  • estamos construindo um serviço que recebe informações dos logs dos servidores PostgreSQL
  • coletando logs, queremos fazer algo com eles (analisar, analisar, solicitar informações adicionais) online
  • tudo coletado e "analisado" deve ser salvo em algum lugar


Vamos falar sobre o último ponto - como tudo isso pode ser entregue ao armazenamento PostgreSQL . Em nosso caso, esses dados são múltiplos do original - estatísticas de carga no contexto de um aplicativo específico e modelo de plano, consumo de recursos e cálculo de problemas derivados com precisão para um único nó de plano, bloqueios de monitoramento e muito mais.

Mais detalhadamente sobre os princípios do serviço pode ser visto no relatório de vídeo e lido no artigo "Otimização em massa de consultas PostgreSQL" .


empurrar vs puxar



Existem dois modelos principais para a obtenção de registros ou algumas outras métricas que chegam constantemente:



  • push - há muitos receptores ponto a ponto no serviço , nos servidores monitorados - algum agente local despeja periodicamente as informações acumuladas no serviço
  • pull - on the service, cada processo / thread / co-rotina / ... processa informações de apenas uma "própria" fonte , a recepção de dados a partir da qual é iniciada por si mesma


Cada um desses modelos tem lados positivos e negativos.



empurrar



A interação é iniciada pelo nó observado:



... é benéfico se:



  • você tem muitas fontes (centenas de milhares)
  • a carga neles não difere muito entre si e não excede ~ 1rps
  • algum processamento complicado não é necessário




Exemplo: o destinatário da operadora OFD recebendo cheques da caixa de cada cliente.



... causa problemas:



  • bloqueios / impasses ao tentar escrever dicionários / análises / agregados no contexto do objeto de monitoramento de diferentes fluxos
  • a pior utilização do cache de cada processo / conexão BL com o banco de dados - por exemplo, a mesma conexão com o banco de dados deve primeiro ser gravada em uma tabela ou segmento de índice e imediatamente em outro
  • um agente especial é necessário para ser colocado em cada fonte, o que aumenta a carga sobre ela
  • alta sobrecarga na interação de rede - os cabeçalhos devem "amarrar" o envio de cada pacote, e não a conexão inteira com a fonte como um todo


puxar



O iniciador é um host / processo / thread específico do coletor, que "liga" o nó a si mesmo e extrai dados independentemente do "destino":



... é benéfico se:



  • você tem poucas fontes (centenas-milhares)
  • quase sempre há uma carga deles, às vezes chega a 1Krps
  • requer processamento complexo com segmentação por fonte




Exemplo: carregador / analisador de negociações no contexto de cada plataforma de negociação.



... causa problemas:



  • limitar os recursos para processar uma fonte por um processo (núcleo da CPU), uma vez que não pode ser "espalhado" por dois destinatários
  • um coordenador é necessário para redistribuir dinamicamente a carga das fontes para os processos / threads / recursos existentes


Uma vez que nosso modelo de carga para monitoramento PostgreSQL gravitou claramente em torno do algoritmo pull, e os recursos de um processo e o núcleo de uma CPU moderna são suficientes para nós para uma fonte, decidimos por isso.



Logs pull-pull



A nossa comunicação com o servidor fornecido por muito muitos operações de rede e trabalhar com cadeias de texto slaboformatirovannymi , assim como um núcleo coletor de JavaScript foi perfeito em sua encarnação como um servidor Node.js .



A solução mais simples para obter dados do log do servidor era "espelhar" todo o arquivo de log para o console usando um comando linux simples tail -F <current.log>. Apenas nosso console não é simples, mas virtual - dentro de uma conexão segura com o servidor estendida sobre o protocolo SSH .



Portanto, sentado no segundo lado da conexão SSH, o coletor recebe uma cópia completa de todo o tráfego de log como entrada. E, se necessário, solicita ao servidor informações estendidas do sistema sobre o estado atual das coisas.



Por que não syslog



Há duas razões principais:



  1. syslog push-, . - «» , .



    «» / , .
  2. PostgreSQL, , «» (relation/page/tuple/...).

    Você pode ler mais sobre como resolver esse problema no artigo "DBA: Em busca de fechaduras voadoras" .


Configurando a base do receptor



Em princípio, outras soluções poderiam ser usadas como um SGBD para armazenar os dados analisados ​​do log, mas o volume de informações de entrada de 150-200 GB / dia não deixa muito espaço de manobra. Portanto, também escolhemos o PostgreSQL como armazenamento.



- PostgreSQL para armazenar logs? Seriamente?

- Em primeiro lugar, há longe de ser apenas e não tanto registros quanto várias representações analíticas . Em segundo lugar, "você simplesmente não sabe como cozinhá-los!" :)






Configurações do servidor



Este ponto é subjetivo e depende fortemente de seu hardware, mas criamos os seguintes princípios para nós mesmos para configurar o host PostgreSQL para gravação ativa.



Configurações do sistema de arquivos

O fator mais significativo que afeta o desempenho de gravação é a montagem [não] correta da partição de dados. Escolhemos as seguintes regras:



  • o diretório PGDATA é montado (no caso de ext4) com parâmetrosnoatime,nodiratime,barrier=0,errors=remount-ro,data=writeback,nobh
  • diretório PGDATA / pg_stat_tmp foi movido paratmpfs
  • o diretório PGDATA / pg_wal é movido para outro meio, se for razoável


veja PostgreSQL File System Tuning



Escolhendo o Agendador de E / S ideal

Por padrão, muitas distribuições selecionaram como o agendador de E / Scfq , aprimorado para uso "desktop", no RedHat e CentOS - noop. Mas acabou sendo mais útil para nós deadline.



veja PostgreSQL vs. Agendadores de I / O (cfq, noop e deadline)



Reduzindo o tamanho do cache "sujo"

Este parâmetro vm.dirty_background_bytesdefine o tamanho do cache em bytes, ao atingir o qual o sistema inicia o processo em background de descarregá-lo no disco. Existe um parâmetro semelhante, mas mutuamente exclusivovm.dirty_background_ratio - ele define o mesmo valor como uma porcentagem do tamanho total da memória - por padrão, ele é definido, e não "... bytes".



Na maioria das distribuições é de 10%, no CentOS é de 5%. Isso significa que, com uma memória total do servidor de 16 GB, o sistema pode tentar gravar mais de 850 MB no disco uma vez, resultando em um pico de carga de IOps.



Nós diminuímos experimentalmente até que os picos de registro comecem a suavizar. Por experiência, para evitar picos, o tamanho deve ser menor que a taxa de transferência máxima de mídia (em IOps) vezes o tamanho da página de memória. Isto é, por exemplo, para 7K IOps (~ 7000 x 4096) - cerca de 28 MB.



consulte Configurando Opções do Kernel do Linux para Configurações de Otimização PostgreSQL



em postgresql.conf

Quais parâmetros devem ser vistos, torcidos para acelerar a gravação. Tudo aqui é puramente individual, então darei apenas algumas reflexões sobre o assunto:



  • shared_buffers - deve ser menor, uma vez que com a gravação direcionada de dados "comuns" especialmente sobrepostos, os processos não surgem
  • synchronous_commit = off - você sempre pode desabilitar a espera pela gravação de commit se confiar na bateria do seu controlador RAID
  • fsync- se os dados não forem críticos, você pode tentar desligá-los - "no limite", você pode até obter um banco de dados na memória


Estrutura da tabela de banco de dados



Já publiquei alguns artigos sobre otimização do armazenamento físico de dados:





Mas sobre chaves diferentes nos dados - ainda não havia. Eu vou te contar sobre eles.



Chaves estrangeiras são ruins para sistemas pesados ​​de gravação. Na verdade, são "muletas" que não permitem que um programador descuidado escreva no banco de dados o que supostamente não deveria estar lá.



Muitos desenvolvedores estão acostumados ao fato de que entidades de negócios logicamente relacionadas no nível de descrição de tabelas de banco de dados devem ser vinculadas por meio de FK. Mas não é!



Claro, esse ponto depende muito dos objetivos que você definiu ao gravar dados no banco de dados. Se você não for um banco (e se também for um banco, não está processando!), Então a necessidade de FK em um banco de dados de gravação pesada é questionável.



"Tecnicamente" cada FK faz um SELECT separado ao inserir um registroda tabela referenciada. Agora olhe para a tabela onde você está escrevendo ativamente, onde você tem 2-3 FKs pendurados, e avalie se vale a pena para sua tarefa específica fornecer um tipo de queda de integridade no desempenho em 3-4 vezes ... Ou uma conexão lógica por valor é suficiente? Removemos todos os FKs aqui.



Chaves UUID são boas . Como a probabilidade de uma colisão de UUIDs gerados em diferentes pontos não relacionados é extremamente pequena, essa carga (gerando alguns IDs substitutos) pode ser removida com segurança do banco de dados para o "consumidor". O uso de UUIDs é uma boa prática em sistemas distribuídos conectados e não sincronizados.

Você pode ler sobre outras variantes de identificadores únicos no PostgreSQL no artigo "PostgreSQL Antipatterns: Unique Identifiers ".


As chaves naturais também são boas , mesmo se consistirem em vários campos. Não se deve ter medo de chaves compostas, mas de um campo PK substituto extra e um índice nele em uma tabela carregada, que você pode facilmente dispensar.



Ao mesmo tempo, ninguém proíbe a combinação de abordagens. Por exemplo, temos um UUID substituto atribuído a um "lote" de registros de log sequenciais relacionados a uma transação original (uma vez que simplesmente não há chave natural), mas um par é usado como PK (pack::uuid, recno::int2), onde recnoé o número de sequência "natural" do registro dentro do lote.



Streams COPY "sem fim"



O PostgreSQL, assim como o OC, "não gosta" quando os dados são gravados em grandes lotes ( INSERTcomo 1000 linhas ). Mas COPYé muito mais tolerante com fluxos de gravação equilibrados (através ). Mas eles devem ser capazes de cozinhar com muito cuidado.



  1. Como no estágio anterior removemos todos os FKs , agora podemos escrever informações sobre ele mesmo packe um conjunto de outros relacionados reordem uma ordem arbitrária, de forma assíncrona . Nesse caso, é mais eficaz manter um canal constantemente ativoCOPY para cada tabela de destino .
  2. , , «», ( — COPY-) . , — 100, .
  3. , , . . .



    , , «» , . , .
  4. , node-pg, PostgreSQL Node.js, API — stream.write(data) COPY- true, , false, .





    , , « », COPY .
  5. COPY- LRU «». .




Aqui deve ser destacada a principal vantagem que obtivemos com este esquema de leitura e escrita de logs - em nosso banco de dados os “fatos” ficam disponíveis para análise quase online , após alguns segundos.



Refinamento com um arquivo



Tudo parece estar bem. Onde está o "rake" no esquema anterior? Vamos começar simples ...



Over-sync



Um dos grandes problemas dos sistemas carregados é a sincronização excessiva de algumas operações que não exigem isso. Às vezes “porque eles não perceberam”, às vezes “era mais fácil assim”, mas mais cedo ou mais tarde tem que se livrar disso.



Isso é fácil de conseguir. Já configuramos quase 1000 servidores para monitoramento, cada um é processado por um thread lógico separado, e cada thread despeja as informações acumuladas para envio ao banco de dados com uma certa frequência, como esta:



setInterval(writeDB, interval)


O problema aqui reside precisamente no fato de que todos os streams começam quase ao mesmo tempo, de modo que os momentos de envio quase sempre coincidem "até o ponto".





Felizmente, isso é fácil de corrigir - adicionando um intervalo de tempo "aleatório" tanto para o momento inicial quanto para o intervalo:



setInterval(writeDB, interval * (1 + 0.1 * (Math.random() - 0.5)))






Este método permite "distribuir" estatisticamente a carga na gravação, tornando-a quase uniforme.



Dimensionamento por núcleos de CPU



Um núcleo de processador claramente não é suficiente para toda a nossa carga, mas o módulo de cluster nos ajudará aqui , o que nos permite gerenciar facilmente a criação de processos filhos e nos comunicarmos com eles via IPC.



Agora temos 16 processos filho para 16 núcleos de processador - e isso é bom, podemos usar a CPU inteira! Mas, em cada processo, gravamos em 16 placas de destino e, quando chega o pico de carga, também abrimos canais COPY adicionais. Ou seja, com base em mais de 256 tópicos em constante escrita ... oh! Esse caos não teve um bom efeito no desempenho do disco e a base começou a queimar.



Isso foi especialmente triste ao tentar escrever alguns dicionários comuns - por exemplo, o mesmo texto de solicitação que veio de nós diferentes - bloqueios desnecessários, esperando ...





Vamos "inverter" a situação - ou seja, deixar que os processos filhos ainda colham e processem informações de suas fontes, mas não gravem no banco de dados! Em vez disso, deixe-os enviar uma mensagem via IPC para o mestre, e ele já escreve algo onde precisa estar:





Quem viu imediatamente o problema no esquema do parágrafo anterior - muito bem. Está exatamente no momento em que master também é um processo com recursos limitados. Portanto, em algum ponto, descobrimos que ele já estava começando a queimar - simplesmente parou de lidar com a mudança de todos os threads para a base, uma vez que também é limitado pelos recursos de um núcleo da CPU . Como resultado, deixamos a maioria dos fluxos de "dicionário" menos carregados para serem gravados por meio do mestre, e os mais carregados, mas sem exigir processamento adicional, retornaram aos trabalhadores:





Multicoletor



Mas mesmo um nó não é suficiente para atender a toda a carga disponível - é hora de pensar sobre o dimensionamento linear. A solução foi um multicoletor , autobalanceado de acordo com a carga, com um coordenador à frente.





Cada mestre despeja a carga atual de todos os seus trabalhadores para ele e, em resposta, recebe recomendações sobre qual monitoramento de nó deve ser transferido para outro trabalhador ou mesmo para outro coletor. Haverá um artigo separado sobre esses algoritmos de balanceamento.



Pooling e limite de fila



A próxima pergunta correta é o que fazer com fluxos de gravação quando há um pico de carga repentino .



Afinal, não podemos abrir mais e mais novas conexões com a base indefinidamente - é ineficaz e não vai ajudar. Uma solução trivial - vamos limitá-la para que não tenhamos mais de 16 threads ativos simultaneamente para cada uma das tabelas de destino. Mas o que fazer com os dados que ainda "não tivemos tempo" de escrever? ..



Se esse "pico" de carga for exatamente de pico, ou seja, de curto prazo , então podemos salvar temporariamente os dados da fila na memória do próprio coletor. Assim que algum canal para a base for liberado, recuperamos o registro da fila e o enviamos para o fluxo.



Sim, isso requer que o coletor tenha algum buffer para armazenar filas, mas é bastante pequeno e é liberado rapidamente:





Prioridades da fila



O leitor atento, olhando para a foto anterior, ficou novamente perplexo, “o que vai acontecer quando a memória se esgotar por completo ? ..” Já existem poucas opções - alguém terá que ser sacrificado.



Mas nem todos os registros que desejamos entregar ao banco de dados são "igualmente úteis". É do nosso interesse anotá-los o maior número possível, quantitativamente. A primitiva "priorização exponencial" pelo tamanho da string escrita nos ajudará com isso:



let priority = Math.trunc(Math.log2(line.length));
queue[priority].push(line);


Da mesma forma, ao gravar em um canal, sempre começamos a buscar nas filas "inferiores" - só que cada linha separada é mais curta lá e podemos enviá-las quantitativamente mais:



let qkeys = Object.keys(queue);
qkeys.sort((x, y) => x.valueOf() - y.valueOf()); // - - !


Derrotar bloqueios



Agora vamos voltar duas etapas. Até o momento decidimos deixar no máximo 16 tópicos para o endereço de uma mesa. Se a tabela de destino for "streaming", ou seja, os registros não se correlacionam entre si, está tudo bem. Máximo - teremos bloqueios "físicos" no nível do disco.



Mas se esta for uma tabela de agregados ou mesmo um "dicionário", então quando tentarmos escrever linhas com o mesmo PK de fluxos diferentes, receberemos uma espera no bloqueio, ou mesmo um deadlock. É triste ...



Mas afinal, o que escrever - nós nos definimos! O ponto chave não é tentar escrever um PK de lugares diferentes .



Ou seja, ao passar pela fila, imediatamente verificamos se algum thread já está gravando na mesma tabela (lembramos que todos eles estão no espaço de endereço comum de um processo) com tal PK. Do contrário, pegamos para nós mesmos e anotamos no dicionário da memória "para nós", se já for de outra pessoa, colocamos na fila.



No final da transação, simplesmente "limpamos" o anexo "a nós mesmos" do dicionário.



Uma pequena prova



Primeiro, com o LRU, as “primeiras” conexões e os processos PostgreSQL que os atendem estão quase sempre em execução o tempo todo. Isso significa que o SO os alterna entre os núcleos da CPU com muito menos frequência , minimizando o tempo de inatividade.





Em segundo lugar, se você trabalhar com os mesmos processos no lado do servidor quase o tempo todo, as chances de que alguns dois processos estejam ativos ao mesmo tempo são drasticamente reduzidas - consequentemente, a carga de pico na CPU como um todo diminui (área cinza no segundo gráfico da esquerda ) e LA cai porque menos processos aguardam sua vez.





Isso é tudo por hoje.



E deixe- me lembrá-lo de que, com a ajuda de explain.tensor.ru, você pode ver várias opções para visualizar o plano de execução de consulta, o que o ajudará a ver visualmente as áreas de problema.



All Articles