Otimização de string no ClickHouse. Relatório Yandex

O mecanismo de banco de dados analítico ClickHouse processa muitas linhas diferentes, consumindo recursos. Para acelerar o sistema, novas otimizações são adicionadas constantemente. Nikolay Kochetov, desenvolvedor do ClickHouse, fala sobre o tipo de dados da string, incluindo o novo tipo, LowCardinality, e explica como acelerar o trabalho com strings.


- Primeiro, vamos ver como você pode armazenar strings.



Temos tipos de dados de string. A string funciona bem por padrão, deve ser usada quase sempre. Possui uma pequena sobrecarga - 9 bytes por linha. Se queremos que o tamanho da linha seja corrigido e conhecido com antecedência, é melhor usar o FixedString. Nele, você pode definir o número de bytes que precisamos; é conveniente para dados como endereços IP ou funções de hash.



Claro, às vezes algo diminui a velocidade. Suponha que você esteja fazendo uma consulta em uma tabela. O ClickHouse lê uma quantidade bastante grande de dados, digamos, a uma velocidade de 100 GB / s, com poucas linhas sendo processadas. Temos duas tabelas que armazenam quase os mesmos dados. O ClickHouse lê os dados da segunda tabela em uma velocidade mais alta, mas lê três vezes menos linhas por segundo.



Se observarmos o tamanho dos dados compactados, será quase igual. De fato, os mesmos dados são gravados nas tabelas - o primeiro bilhão de números - somente na primeira coluna são gravados na forma de UInt64 e na segunda coluna em String. Por esse motivo, a segunda consulta lê os dados do disco por mais tempo e os descompacta.



Aqui está outro exemplo. Suponha que exista um conjunto predeterminado de linhas, que esteja limitado a uma constante de 1000 ou 10.000 e quase nunca mude. Nesse caso, o tipo de dados Enum é adequado para nós, no ClickHouse existem dois deles - Enum8 e Enum16. Devido ao armazenamento no Enum, processamos solicitações rapidamente.

O ClickHouse possui acelerações para GROUP BY, IN, DISTINCT e otimizações para algumas funções, por exemplo, para comparação com uma string constante. Obviamente, os números na string não são convertidos, mas, pelo contrário, a string constante é convertida no valor Enum. Depois disso, tudo é comparado rapidamente.

Mas também há desvantagens. Mesmo que conheçamos o conjunto exato de linhas, às vezes ele precisa ser reabastecido. Chegou uma nova linha - temos que fazer ALTER.



O ALTER para Enum no ClickHouse é implementado de maneira ideal. Não sobrescrevemos os dados no disco, mas ALTER pode ficar lento devido ao fato de que as estruturas Enum são armazenadas no esquema da própria tabela. Portanto, devemos aguardar solicitações de leitura da tabela, por exemplo.

A questão é: alguém pode fazer melhor? Talvez sim. Você pode salvar a estrutura Enum não no esquema da tabela, mas no ZooKeeper. No entanto, podem ocorrer problemas de sincronização. Por exemplo, uma réplica recebeu dados, a outra não, e se tiver um Enum antigo, algo será interrompido. (No ClickHouse, quase concluímos as solicitações ALTER sem bloqueio. Quando as concluímos completamente, não precisamos aguardar solicitações de leitura.)



Para não mexer com o ALTER Enum, você pode usar dicionários ClickHouse externos. Deixe-me lembrá-lo de que essa é uma estrutura de dados de valor-chave dentro do ClickHouse, com a qual você pode obter dados de fontes externas, por exemplo, de tabelas MySQL.

No dicionário ClickHouse, armazenamos muitas linhas diferentes e, na tabela, seus identificadores na forma de números. Se precisamos obter uma string, chamamos a função dictGet e trabalhamos com ela. Depois disso, não devemos fazer ALTER. Para adicionar algo ao Enum, inserimos isso na mesma tabela MySQL.

Mas existem outros problemas. Em primeiro lugar, a sintaxe estranha. Se queremos obter uma string, devemos chamar dictGet. Em segundo lugar, a falta de algumas otimizações. A comparação com a cadeia constante de dicionários também não é rápida.

Ainda pode haver problemas com a atualização. Suponha que solicitamos uma linha no dicionário de cache, mas ela não entrou no cache. Então temos que esperar até que os dados sejam carregados de uma fonte externa.



Uma desvantagem comum de ambos os métodos é que armazenamos todas as chaves em um único local e as sincronizamos. Então, por que não armazenar dicionários localmente? Sem sincronização - não há problema. Você pode armazenar o dicionário localmente em uma peça no disco. Ou seja, fizemos Inserção, gravamos um dicionário. Se trabalharmos com dados na memória, podemos escrever um dicionário em um bloco de dados ou em um pedaço de uma coluna ou em algum cache para acelerar os cálculos.

Codificação de Cadeia de Vocabulário


Então chegamos à criação de um novo tipo de dados no ClickHouse - LowCardinality. Este é um formato para armazenar dados: como eles são gravados em disco e como são lidos, como são apresentados na memória e o esquema de seu processamento.



Existem duas colunas no slide. À direita, as strings são armazenadas de maneira padrão no tipo String. Pode-se ver que esses são alguns tipos de modelos de telefones celulares. À esquerda, há exatamente a mesma coluna, apenas no tipo LowCardinality. Consiste em um dicionário com muitas linhas diferentes (linhas da coluna à direita) e uma lista de posições (números de linhas).

Usando essas duas estruturas, você pode restaurar a coluna original. Há também um índice inverso inverso - uma tabela de hash que ajuda a encontrar a posição no dicionário por linha. É necessário acelerar algumas consultas. Por exemplo, se quisermos comparar, procure uma linha em nossa coluna ou junte-as.

LowCardinality é um tipo de dados paramétrico. Pode ser um número, ou algo que é armazenado como um número, ou uma sequência ou Anulável a partir deles.



A peculiaridade do LowCardinality é que ele pode ser salvo para algumas funções. Um exemplo de solicitação é mostrado no slide. Na primeira linha, criei uma coluna do tipo LowCardinality da String, com o nome S. Em seguida, perguntei o nome dela - ClickHouse disse que era LowCardinality da String. Tudo certo.

A terceira linha é quase a mesma, apenas chamamos de função length. No ClickHouse, a função length retorna o tipo de dados UInt64. Mas obtivemos LowCardinality do UInt64. Qual é o objetivo?



O dicionário contém os nomes dos telefones celulares, aplicamos a função length. Agora, temos um dicionário semelhante, composto apenas por números, esses são os comprimentos das strings. A coluna com as posições não mudou. Como resultado, processamos menos dados, economizamos no tempo de solicitação.

Pode haver outras otimizações, como adicionar um cache simples. Ao calcular o valor de uma função, você pode lembrá-la e torná-la a mesma, não recalcule.

A otimização de GROUP BY também pode ser feita, porque nossa coluna com o dicionário já está parcialmente agregada - você pode calcular rapidamente o valor das funções de hash e encontrar aproximadamente o intervalo onde colocar a próxima linha. Você também pode especializar algumas funções agregadas, por exemplo, uniq, porque você pode enviar apenas um dicionário para ele e deixar as posições intocadas - dessa forma, tudo funcionará mais rápido. As duas primeiras otimizações que já adicionamos ao ClickHouse.



Mas e se criarmos uma coluna com nosso tipo de dados e inserirmos muitas linhas diferentes incorretas? Nossa memória está cheia? Não, existem duas configurações especiais para isso no ClickHouse. O primeiro é low_cardinality_max_dictionary_size. Esse é o tamanho máximo de um dicionário que pode ser gravado no disco. A inserção ocorre da seguinte forma: quando inserimos os dados, um fluxo de linhas chega até nós, a partir deles formamos um grande dicionário geral. Se o dicionário se tornar maior que o valor definido, gravamos o dicionário atual no disco e o restante das linhas em algum lugar do lado, próximo aos índices. Como resultado, nunca recontaremos um dicionário grande e não obteremos problemas de memória.

A segunda configuração é chamada low_cardinality_use_single_dictionary_for_part. Imagine que no esquema anterior, quando inserimos os dados, nosso dicionário estava cheio e os escrevemos em disco. Surge a pergunta: por que não formar outro exatamente exatamente o mesmo dicionário?

Quando ela estourar, novamente a gravaremos no disco e começaremos a formar uma terceira. Essa configuração apenas desativa esse recurso por padrão.

De fato, muitos dicionários podem ser úteis se quisermos inserir algum conjunto de linhas, mas inserir "lixo" acidentalmente. Digamos que primeiro inserimos as linhas ruins e depois inserimos as boas. Em seguida, o dicionário será dividido em muitos pequenos dicionários. Alguns deles estarão com lixo, mas o último estará com boas linhas. E se lermos, digamos, apenas o último pellet, tudo também funcionará rapidamente.



Antes de falar sobre as vantagens do LowCardinality, direi imediatamente que é improvável que obtenhamos dados reduzidos no disco (embora isso possa acontecer), porque o ClickHouse compacta os dados. Existe uma opção padrão - LZ4. Você também pode fazer a compactação usando o ZSTD. Como os dois algoritmos já implementam a compactação de dicionário, nosso dicionário ClickHouse externo não ajuda muito.

Para não ser infundado, peguei alguns dados da métrica - String, LowCardinality (String) e Enum - e os salvei em diferentes tipos de dados. Foram três colunas, onde um bilhão de linhas é gravado. A primeira coluna, CodePage, possui um total de 62 valores. E você pode ver que em LowCardinality (String), eles os pressionavam melhor. A string é um pouco pior, mas isso provavelmente ocorre porque as strings são curtas, armazenamos seus comprimentos e elas ocupam muito espaço e não são compactadas bem.

Se você usar o PhoneModel, existem mais de 48 mil e quase não há diferenças entre String e LowCardinality (String). Para o URL, também salvamos apenas 2 GB - acho que você não deve confiar nisso.

Estimativa da velocidade do trabalho



Link do slide

Agora vamos avaliar a velocidade do trabalho. Para avaliar, usei um conjunto de dados descrevendo viagens de táxi em Nova York. Estádisponívelno GitHub. Tem um pouco mais de um bilhão de viagens. Ele mostra a localização, o horário de início e término da viagem, a forma de pagamento, o número de passageiros e até o tipo de táxi - verde, amarelo e Uber.



Fiz a primeira solicitação bastante simples - perguntei onde os táxis são mais pedidos. Para fazer isso, é necessário escolher o local de onde você solicitou, fazer GROUP BY e calcular a função de contagem. Aqui ClickHouse dá algo.



Para medir a velocidade do processamento de consultas, criei três tabelas com os mesmos dados, mas usei três tipos de dados diferentes para a nossa localização inicial - String, LowCardinality e Enum. LowCardinality e Enum são cinco vezes mais rápidos que String. O enum é mais rápido porque funciona com números. Baixa cardinalidade - porque a otimização GROUP BY está implementada.



Vamos complicar a solicitação - pergunte onde fica o parque mais popular de Nova York. Novamente, mediremos isso onde o táxi é mais frequentemente solicitado, mas, ao mesmo tempo, filtraremos apenas os locais onde a palavra "parque" está disponível. Adicione também uma função similar.



Observamos o momento - vemos que Enum de repente começou a desacelerar. E funciona ainda mais lentamente que o tipo de dados String padrão. Isso ocorre porque a função like não é totalmente otimizada para o Enum. Temos que converter nossas linhas de Enum em linhas regulares - trabalhamos mais. LowCardinality (String) também não é otimizado por padrão, mas funciona como no dicionário, portanto, a consulta é mais rápida em comparação com String.

Há um problema mais global com o Enum. Se queremos otimizá-lo, devemos fazê-lo em todos os lugares do código. Suponha que escrevemos uma nova função - você deve definitivamente criar otimizações para o Enum. E no LowCardinality, tudo é otimizado por padrão.



Vejamos o último pedido, mais artificial. Simplesmente calcularemos a função hash da nossa localização. A função hash é uma solicitação bastante lenta, leva muito tempo e, portanto, tudo fica mais lento três vezes.



A baixa cardinalidade ainda é mais rápida, embora não haja filtragem. Isso se deve ao fato de que nossas funções funcionam apenas no dicionário. A função de cálculo de hash possui um argumento - pode processar menos dados e também pode retornar LowCardinality.



Nosso plano global é atingir uma velocidade não inferior à de String em qualquer caso e economizar aceleração. E talvez um dia substituamos String por LowCardinality, você atualize o ClickHouse e tudo funcionará um pouco mais rápido.

All Articles