sh4.tech

Deep dive 7 min de leitura ClickHouse · armazenamento

Escolhendo codecs do ClickHouse pelo perfil de cada coluna

Uma tabela de 100 bilhões de linhas a 4,37 bytes por linha. O codec de cada coluna, o número que ele produziu e o loop para repetir o teste nos seus dados.

Publicado
Medido
, produção, system.parts_columns
Stack
ClickHouse 25.x, próprio, bare metal
Também em
English

A tabela

Esta é a tabela de auditoria de uma rede de mídia digital out-of-home: uma linha por exibição de anúncio, vinda de cerca de 15.000 telastamanho da frota na data da medição, arredondado em elevadores e shoppings. Cada linha é prova de faturamento, então nada é amostrado nem resumido. Os nomes abaixo são genéricos; o formato é o real.

playback_log.sql
CREATE TABLE playback_log
(
    uid          UUID      CODEC(ZSTD(1)) TTL started_at + toIntervalMonth(1),
    device_id    UInt32    CODEC(T64, ZSTD(1)),
    order_id     UInt32    CODEC(ZSTD(1)),
    asset_id     UInt32    CODEC(ZSTD(1)),
    started_at   DateTime  CODEC(DoubleDelta, ZSTD(1)),
    ended_at     DateTime  CODEC(DoubleDelta, ZSTD(1)),
    imported_at  DateTime  DEFAULT now('UTC') CODEC(DoubleDelta, ZSTD(1)),
    INDEX idx_imported imported_at TYPE minmax GRANULARITY 16
)
ENGINE = ReplacingMergeTree
PARTITION BY toYYYYMM(started_at)
ORDER BY (started_at, order_id, device_id)
TTL started_at + toIntervalYear(1)
SETTINGS index_granularity = 8192;

Três coisas para reparar antes de chegar aos codecs:

  • A chave de ordenação começa pelo timestamp. Isso decide mais do que qualquer codec. Guarde para a próxima seção.
  • Dois TTLs. A linha vive um ano. A coluna uid vive um mês, porque ela só existe para deduplicar, e depois de 30 dias não chega mais retransmissão de uma tela que ficou offline. Carregar um UUID em bilhões de linhas por um ano seria desperdício.
  • ReplacingMergeTree é a rede de proteção contra duplicatas, e ela só as colapsa durante os merges. Isso fica para outro artigo.

Quanto cada coluna comprime

No ClickHouse, CODEC(...) tem duas camadas. Primeiro uma codificação que muda a forma dos dados (Delta, DoubleDelta, Gorilla, T64 e alguns mais novos). Depois um compressor de uso geral que roda sobre o resultado (LZ4, ZSTD). A maioria das tabelas que eu vejo só define a segunda camada, ou nenhuma.

Isto é o que a tabela de produção reporta hoje, por coluna, a partir de system.parts_columns:

ColunaTipoCodecTaxaBytes / linha
started_atDateTimeDoubleDelta, ZSTD(1)269,6x0,01
ended_atDateTimeDoubleDelta, ZSTD(1)15,6x0,26
order_idUInt32ZSTD(1)10,5x0,38
asset_idUInt32ZSTD(1)6,1x0,65
imported_atDateTimeDoubleDelta, ZSTD(1)2,9x1,40
device_idUInt32T64, ZSTD(1)2,4x1,67
tabela inteira5,5x4,37

Medido em 2026-09-03 sobre 100,78 bilhões de linhas, 410,33 GiB em disco. uid não aparece: o TTL da coluna a descarta depois de 30 dias, então a maioria das partes já não a carrega.

Para ter o mesmo relatório na sua tabela, esta consulta só lê metadados, então é segura num servidor ocupado:

compressão por coluna
SELECT
    column,
    type,
    formatReadableSize(sum(column_data_uncompressed_bytes)) AS raw,
    formatReadableSize(sum(column_data_compressed_bytes))   AS on_disk,
    round(sum(column_data_uncompressed_bytes)
        / sum(column_data_compressed_bytes), 1)             AS ratio,
    round(sum(column_data_compressed_bytes) / sum(rows), 2) AS bytes_per_row
FROM system.parts_columns
WHERE database = currentDatabase()
  AND table = 'playback_log'
  AND active
GROUP BY column, type
ORDER BY ratio DESC;

Mesmo codec, mesmo tipo, 17x de diferença

started_at e ended_at são os dois DateTime com DoubleDelta, ZSTD(1). Um comprime 269,6x375 GiB brutos para 1,39 GiB, cerca de um centésimo de byte por linha, o outro 15,6x. A única diferença é que started_at é a primeira coluna da chave de ordenação.

DoubleDelta guarda a diferença das diferenças entre valores consecutivos. Numa coluna ordenada, o primeiro delta já é pequeno, e o segundo é quase sempre zero. A coluna vira uma longa sequência de zeros, e uma sequência de zeros é a melhor entrada que o ZSTD pode receber.

ended_at é started_at mais alguns segundos de exibição. Os deltas são pequenos, mas a coluna não está ordenada, então o delta de segunda ordem guarda algum ruído: 15,6x. imported_at é quando o lote chegou ao servidor. As telas mandam em lotes e às vezes ficam horas offline, então os intervalos são irregulares: 2,9x, com o mesmo codec.

Ou seja, o maior número desta tabela é, na verdade, um resultado da chave de ordenação com nome de codec. Decida o ORDER BY pelas suas consultas primeiro. Os codecs ficam com o que a ordenação deixa.

T64 no id do aparelho

São cerca de 15.000 aparelhos guardados num UInt32, um tipo que comporta quatro bilhões. O T64 transpõe um bloco de 64 inteiros para que os bits da mesma posição fiquem juntos. Os bits altos são zero em todas as linhas e, depois da transposição, se compactam até quase nada.

Mesmo assim ela terminou como a pior coluna da tabela, 2,4x. Dentro de um grânulo (8.192 linhas ordenadas por tempo) o id do aparelho fica pulando, porque cada exibição vem de uma tela diferente. Ordenar primeiro pelo aparelho consertaria essa coluna e estragaria o timestamp. A chave de ordenação serve às consultas, que filtram por período e pedido, então o id do aparelho fica com seus 1,67 bytes.

Onde o ZSTD puro ganhou

order_id e asset_id não são monotônicos e a faixa deles não é curta o bastante para o T64 fazer diferença. Testei as codificações neles também. Nenhuma superou o ZSTD(1) puro, então fica o ZSTD(1) puro. Descobrir que o codec especializado não ajuda também é resultado, e vale anotar para ninguém testar de novo no ano que vem.

Nível 1 em tudo. Níveis maiores de ZSTD são outro teste, com a sua própria carga de merges, e não vou afirmar números sobre isso aqui.

O que isso significa para o seu disco

Bytes por linha é o número que sobrevive à mudança de uma tabela para outra. Multiplique pelo seu volume e pela sua retenção e você tem o disco de que precisa, antes de rodar um único teste. A largura bruta desta linha de sete colunas é 40 bytes (16 do UUID, 12 dos três UInt32, 12 dos três DateTime); 4,37 é o que sobra depois dos codecs e do TTL da coluna.

Estimativa de disco

Seu volume, sua retenção e um valor de bytes por linha. Comece com os 4,37 medidos e troque pelo seu quando rodar a consulta acima.

—em disco com os seus bytes por linha
—a 40 bytes por linha, a largura bruta deste esquema

Estimativa, não medição: linhas × dias × bytes por linha, em unidades binárias. Merges e o primeiro mês da coluna UUID pedem folga por cima.

Como eu testei

A documentação diz o que cada codec faz. Ela não diz qual ganha nos seus dados. Cheguei a esta configuração carregando milhões de linhas reais em tabelas de teste e medindo, coluna por coluna, cerca de 60 variaçõesbenchmark meu; contagem de memória, aproximada no total. Duas medidas por variação: bytes em disco e o tempo da consulta mais pesada que rodava em produção. Algumas variações ganham no disco e perdem na consulta. Você quer os dois números lado a lado.

O loop é curto. Uma variação é uma tabela, e você só muda o que está testando:

uma variação
-- mesma estrutura, engine e chave de ordenação da produção
CREATE TABLE codec_test_v12 AS playback_log;

-- muda só o codec em teste
ALTER TABLE codec_test_v12
    MODIFY COLUMN device_id UInt32 CODEC(Delta, ZSTD(1));

-- milhões de linhas, para atravessar muitos grânulos e várias partes
INSERT INTO codec_test_v12
SELECT * FROM playback_log
WHERE started_at >= '2026-08-01' AND started_at < '2026-08-08';

-- mede partes já mescladas, não inserts recentes
OPTIMIZE TABLE codec_test_v12 FINAL;

-- depois roda a consulta de parts_columns acima, e a sua consulta mais pesada 3 vezes

Três detalhes que mudaram resultados para mim:

  1. Milhares de linhas não bastam. Os codecs trabalham por bloco, e a chave de ordenação só mostra o efeito quando as linhas se espalham por muitos grânulos. Use milhões.
  2. Rode OPTIMIZE ... FINAL antes de medir. Inserts recentes ficam em partes pequenas; a taxa com que você vai conviver é a de depois dos merges.
  3. Mantenha a consulta pesada no loop. Disco é a metade barata da troca. Um codec que economiza 10% de disco e acrescenta 30% na sua pior consulta é um mau negócio na maioria dos orçamentos.

Hoje a instância inteira roda com três combinações: ZSTD(1) em 257 colunascenso de system.columns, 2026-08-26: 257 / 74 / 35 colunas, DoubleDelta, ZSTD(1) em 74, T64, ZSTD(1) em 35. Sobraram três combinações depois de umas sessenta tabelas de teste.

O que isso não diz

  • Se o seu timestamp não é a chave de ordenação, espere algo como o ended_at, perto de 15x, e fique feliz com isso.
  • Os 5,5x do total são puxados para baixo pelo device_id e pelo uid no primeiro mês dele. Uma tabela estreita sem UUID ficaria melhor, e também perderia a deduplicação.
  • Codec é assunto de disco e de I/O. O teto das consultas do ClickHouse nessa carga era RAM, não disco. Um bom codec nunca fez uma consulta caber na memória.
  • É uma tabela, uma carga, uma data de medição. Copie o método antes de copiar o DDL.
Se levar uma coisa sóOrdene a tabela pelas suas consultas e depois teste os codecs com milhões de linhas reais e a sua pior consulta no loop. A documentação dá o nome dos codecs; os seus dados escolhem o vencedor.

Arquivado pelo problema que resolve

Luciano Serra
Luciano Serra Founder-engineer · cofundador e CTO, construiu do zero uma rede de 15.000 telas, 2015–2024 · São Paulo, remoto

Desenho sistemas de produção do zero e assumo os que já estão rodando. Nos dois casos eles precisam aguentar quando o negócio cresce. Cofundei uma empresa de mídia out-of-home e construí a tecnologia dela por dez anos; meus sócios captaram o dinheiro e cuidaram do comercial.

Seu negócio está crescendo mais rápido que os sistemas? Vamos conversar →