Quão eventual é a consistência eventual? A dedup do ReplacingMergeTree, medida
O ReplacingMergeTree só remove duplicata quando as partes fazem merge, numa hora que ninguém controla. Medi quanto tempo isso leva em produção: 1,998 depois de 2 minutos, 1,11 depois de uma hora, 1,0004 depois de 30 dias. E as cinco camadas que fazem a dedup de verdade.
- Quando aconteceu
- Publicado
- Medido
- , tabelas de auditoria em produção, bilhões de linhas
- Stack
- ClickHouse 25.8, próprio, bare metal
- Também em
- English
A pergunta
A documentação do ClickHouse é honesta. O ReplacingMergeTree remove linhas com a mesma chave de ordenação, mas "data deduplication occurs only during a merge. Merging occurs in the background at an unknown time, so you can't plan for it." E logo depois: ele "does not guarantee the absence of duplicates".
Quase todo mundo lê "eventual", concorda com a cabeça e usa o engine como se fosse uma constraint de unicidade. Eu também fiz isso por um tempo. O que ninguém publica é o formato desse "eventual": depois de um minuto, de uma hora, de um dia, quantas cópias ainda estão lá? Tive a chance de medir em tabelas de produção com bilhões de linhas, e a resposta foi mais lenta do que eu esperava.
Um experimento limpo, sem querer
Durante um período, um consumidor do nosso pipeline gravou cada evento duas vezes. Dois workers liam as mesmas partições do Kafka a partir do mesmo offset (o código usava assign() onde deveria usar subscribe(), então eles nunca entravam num consumer group), e uma linha de cron que o agendador ignorava sem dar erro fazia os dois subirem no mesmo segundo. O fator de duplicação no insert era exatamente 2,0.
Ruim para o dado. Para medir, era o cenário perfeito: eu sabia o começo (2,0), sabia a resposta certa (1,0), e tudo no meio era o merge trabalhando. Cada cópia entrava num insert separado, portanto numa parte separada, que é também o caso normal de uma retransmissão.
A curva
Fator de duplicação é o número de linhas dividido pelo número de chaves de ordenação distintas, pela idade do dado no momento da leitura:
| Idade do dado | Linhas | Chaves distintas | Fator |
|---|---|---|---|
| 2 minutos | · | · | 1,998 |
| 10 minutos | 72.504 | 40.718 | 1,78 |
| 1 hora | 1.708.738 | 1.539.344 | 1,11 |
| 24 horas | · | · | 1,010 |
| 30 dias | · | · | 1,0004 |
Produção, agosto de 2026, fator 2,0 no insert. As contagens de linhas que guardei são só as de 10 minutos e de 1 hora.
Lendo a tabela: dois minutos depois da escrita, nada fez merge ainda. Aos dez minutos, 78% das chaves ainda têm a sua cópia. Com uma hora, mais ou menos uma linha em cada dez ainda é duplicata. Com um dia inteiro, 1%, e depois de um mês ainda sobram 4 linhas em cada 10 mil.
Por que o fim da curva é tão lento? Leitura minha, essa parte eu não medi: o merge prefere partes de tamanho parecido, então parte pequena e nova junta rápido e parte grande e antiga junta pouco. Uma parte que chega no limite de tamanho para merge (max_bytes_to_merge_at_max_space_in_pool, 150 GiB por padrão) deixa de ser escolhida, e duas cópias em duas partes grandes podem ficar juntas por muito tempo. Linhas em partições diferentes nunca fazem merge, é regra do engine.
Num momento normal, a mesma tabela mostra a mesma coisa em escala menor. Uma hora de dado lida mais ou menos um dia depois: 2.300.003 linhas e 2.295.138 uid distintos, ou 0,21% ainda duplicado, exatamente onde a curva diz que deveria estar.
Meça na sua tabela
Esta consulta dá o fator por faixa de idade. Troque pela sua chave de ordenação dentro do uniqExact e mantenha a janela curta, porque uniqExact em bilhões de linhas pesa:
SELECT
multiIf(age < 600, '0-10 min',
age < 3600, '10-60 min',
age < 86400, '1-24 h',
'1-3 dias') AS faixa,
count() AS linhas,
uniqExact(started_at, order_id, device_id) AS chaves,
round(linhas / chaves, 4) AS fator_dup
FROM
(
SELECT started_at, order_id, device_id,
dateDiff('second', imported_at, now()) AS age
FROM playback_log
WHERE imported_at >= now() - INTERVAL 3 DAY
)
GROUP BY faixa
ORDER BY min(age);
Se a sua tabela não tem horário de importação, acrescente (DEFAULT now('UTC') com DoubleDelta custa quase nada). Sem ele não dá para saber a idade de uma duplicata, e a curva é justamente o ponto.
O preço de ler antes do merge
No nosso caso, a sumarização rodava a cada 10 minutos. Aos dez minutos o fator é 1,78. O job contava linhas físicas da tabela ReplacingMergeTree e gravava os totais numa SummingMergeTree. Soma não se desfaz quando a origem faz merge depois: a tabela crua se curou sozinha e a sumarizada guardou o erro para sempre.
Depois que corrigimos a escrita e recalculamos o histórico, as duas tabelas se aproximaram, e mesmo assim ficaram um pouco separadas:
| Mês | Sumarizada vs crua |
|---|---|
| 2026-05 | −2,76% |
| 2026-06 | −2,48% |
| 2026-07 | −1,85% |
| 2026-08 | −0,47% |
As duas estão certas sobre o que fazem com esses 0,5% a 2,8%. Um engine colapsa pela chave, o outro soma o que viu no momento da leitura, e eles vão discordar sobre o mesmo dado. Nenhuma configuração fecha essa diferença; você escolhe qual semântica o número precisa e monta a sumarização para ela.
Para a sumarização em si, esta foi a mudança que eu recomendei:
INSERT INTO plays_daily
SELECT toDate(started_at) AS day, order_id, device_id, count() AS plays
FROM playback_log FINAL
WHERE imported_at >= {from:DateTime}
AND imported_at < {to:DateTime}
GROUP BY day, order_id, device_id;
O FINAL colapsa na leitura, então a sumarização vê uma linha por chave mesmo sem o merge ter acontecido. Custa CPU, teste na sua janela mais pesada. Dois detalhes na mesma consulta: a janela é >= e <, nunca > dos dois lados, porque uma linha exatamente na fronteira não cai em janela nenhuma (tivemos essa também). E o FINAL só resolve as cópias que já estão na tabela quando a sumarização roda. Uma retransmissão que chega três dias depois cai em outra janela, e só as camadas antes do insert seguram isso.
Cinco camadas, e o engine é a última
O MySQL que usávamos antes do ClickHouse garantia que uma linha de log era única. Era lento e garantia. Quando levamos a auditoria para o ClickHouse, perdemos essa garantia que vinha de graça e a reconstruímos fora do banco, em camadas. O desenho era este:
- O dispositivo gera o id. Um UUID v4 por evento, criado no momento em que o evento acontece na tela.
-
O dispositivo guarda o evento até o servidor confirmar. Se o envio falha e ele reenvia, o mesmo
uidvolta, e a identidade do evento sobrevive à retransmissão. -
Dedup antes do insert. O importador descarta
uidvisto recentemente, dentro do lote e contra uma tabela pequena de chaves recentes. -
ReplacingMergeTreecomo rede de segurança para o que passar das três primeiras. -
TTL de coluna no
uid. A linha vive um ano, ouidvive um mês, porque depois de 30 dias retransmissão não chega mais.
A camada 3 tem este formato, simplificado:
-- só as chaves recentes; pequena e de vida curta
CREATE TABLE seen_uids
(
uid UUID,
seen_at DateTime DEFAULT now('UTC')
)
ENGINE = ReplacingMergeTree
ORDER BY uid
TTL seen_at + INTERVAL 7 DAY;
-- o lote entra só com uid que ninguém viu
INSERT INTO playback_log
SELECT b.*
FROM import_batch AS b
LEFT ANTI JOIN seen_uids AS s USING (uid);
INSERT INTO seen_uids (uid) SELECT DISTINCT uid FROM import_batch;E a camada 5 é uma linha no DDL. Medi ela funcionando: antes do dia 30 a coluna estava 100% preenchida, no dia da fronteira 50,6%, depois disso 0%. Ou seja, a janela em que ainda dá para auditar duplicata é de exatamente 30 dias, que é o outro lado de economizar esse espaço.
uid UUID CODEC(ZSTD(1)) TTL started_at + toIntervalMonth(1),Camadas somem em silêncio
Esta é a parte que eu não gostei de achar. Numa auditoria deste ano, li o importador linha por linha e a camada 3 estava desligada. Tinha sido desligada numa mudança dois anos antes, e o comentário em cima do código continuava dizendo que ela rodava. Na mesma auditoria: players antigos que não mandam uid ganhavam um gerado no servidor, a cada leitura do arquivo, então um arquivo reenviado parecia evento novo e a camada 4 não tinha o que colapsar.
Ninguém percebeu nada disso por dois anos, porque a rede de baixo segurava a maior parte. É para isso que serve a rede, e é também o risco dela: uma rede que funciona bem esconde a camada de cima que morreu. Teste cada camada sozinha. A consulta acima com um alerta resolve, por exemplo fator de duplicação acima de 1,01 em dado com mais de um dia, o que pela curva não deveria acontecer.
Checklist
- Gere o id onde o evento acontece. Um id criado no servidor na hora de ler o arquivo é um id novo a cada reenvio.
- Mande a confirmação só depois que o dado está seguro do seu lado, e mantenha o mesmo id no reenvio.
- Deduplique antes do insert. Conte com o engine só para o que escapar.
-
Leia dado recente com
FINAL,LIMIT 1 BY chaveouGROUP BYna chave.count()cru em dado fresco conta cópia. -
Não alimente uma
SummingMergeTreecom leitura de umaReplacingMergeTreeantes do merge. Leia comFINAL, ou sumarize só dado mais velho que a idade que a sua curva diz ser segura. - Guarde o horário de importação e meça a sua própria curva. A minha depende do tamanho das nossas partes e da nossa carga de merge; a sua vai ser outra.
Isto foi útil?
Obrigado. Isso me ajuda a decidir o que escrever depois.