Telemetria com padrão de faturamento, vinda de 15 mil telas
Cada exibição de anúncio é prova de cobrança, e as telas perdem a rede por horas. Como funciona o pipeline de prova de exibição: cache no aparelho, envio em lote, ordenar antes de inserir, um MySQL particionado por semana com 2 bilhões de linhas, depois Kafka e ClickHouse.
- Quando aconteceu
- Publicado
- Medido
- , produção,
system.part_logdo ClickHouse e logs do importador, só leitura - Stack
- Players em campo, ingestão em PHP, shell (awk, tr, sort, Perl), MySQL, Kafka, ClickHouse
- Também em
- English
Aqui um log é uma linha de fatura
A rede vende tempo de anúncio em telas de elevadores de condomínios e de shoppings. Cada vez que um player mostra um anúncio, ele escreve uma linha: qual pedido, qual conteúdo, qual tela, início e fim. O anunciante paga pelo que de fato foi ao ar, e os relatórios de comprovação que ele recebe saem dessas linhas. Os logs são prova de cobrança, e vêm de aparelhos espalhados em milhares de prédios, cada um com a internet que aquele prédio tem.
Construí esse pipeline desde a primeira tela, e ele mudou bastante até chegarmos a cerca de 15 mil aparelhos. O que vem abaixo é a forma que ele tem hoje e os passos que levaram até ela.
No aparelho: primeiro memória, depois lote
Os players eram hardware barato, muitos com cartão SD velho. Gravar no cartão a cada exibição mata o cartão em meses, então o player guarda as linhas em memória e despeja num arquivo a cada 5 minutos. A cada 15 minutos uma rotina junta os arquivos pendentes, compacta e envia.
O aparelho só apaga um arquivo depois que o servidor confirma que recebeu, e essa é a regra que mais importa. Se a conexão cai no meio, nada acontece: o arquivo vai de novo no próximo ciclo. Uma tela que fica seis horas sem rede volta com seis horas de log na fila, e elas vão em ordem.
Cada linha também ganha um UUID gerado no próprio aparelho. Isso custa bytes e se paga depois, porque reenvio sempre pode acontecer, e o servidor precisa de um jeito de saber que já tem aquela exibição.
O servidor de ingestão
Os envios chegam por HTTP num endpoint em PHP. Hoje ele grava o envio inteiro como uma mensagem no Kafka e só responde ao player depois que o broker aceitou. Se o broker aceitou e a resposta se perdeu na volta, o player manda o mesmo arquivo de novo. Isso é at-least-once da tela até o Kafka, e eu prefiro assim: uma duplicata eu removo depois, uma exibição que nunca saiu do aparelho eu não recupero.
Antes do Kafka, o mesmo endpoint gravava arquivos em disco, e um job em lote pegava esses arquivos. Foi ali que entrou a maior parte da engenharia por anos.
Os anos de MySQL: ordenar antes de inserir
O LOAD DATA INFILE é muito rápido se você dá a ele um arquivo bom. A gente tinha o contrário: milhares de arquivos pequenos de players diferentes, dias diferentes, fora de ordem. Montei um pipeline em shell em que cada passo foi escolhido por benchmark, medindo cada comando em milissegundos. O tr para tirar caracteres é muito mais rápido que qualquer busca e troca, o sort é difícil de bater, e uma etapa era em Perl. A saída eram arquivos de 100 mil a 500 mil linhas, já ordenados na ordem do índice da tabela, e inserir dado pré-ordenado era ordens de grandeza mais rápido que inserir misturado.
Esta é uma versão posterior daquele script, que ainda estava no repositório em 2022, com nomes genéricos. A função MarkLap está ali porque eu media cada rodada:
LOCKFILE=/var/tmp/lock_import_logs
( set -o noclobber; echo "$$" > "$LOCKFILE" ) 2>/dev/null || exit 0 # uma rodada por vez
trap 'rm -f "$LOCKFILE"' INT TERM EXIT
MarkLap() { # benchmark de cada etapa
t1=$(date +%s.%N); echo "$1 ($(echo "$t1 - $t0" | bc)s)"; t0=$t1
}
# só caracteres de dado, descarta linha quebrada, ordena por boot e sequência
cat $PENDING | \
tr -c -d '[:alnum:][= =]-:;\n' | \
grep '[[:blank:]]' | \
sort -t';' -k1,1n -k2,2n > "$dir/plays.log"
mysql --local-infile -e "
LOAD DATA LOCAL INFILE '$dir/plays.log'
IGNORE INTO TABLE audit.play_log
FIELDS TERMINATED BY ';' LINES TERMINATED BY '\n'
(@boot, @seq, @order_id, @content_id, @type, @start, @end, @device_id)
SET device_id = @device_id, order_id = @order_id, content_id = @content_id,
boot_at = @boot, seq = @seq, start_at = @start, end_at = @end;"
MarkLap "play_log $(wc -l < "$dir/plays.log") rows"
O IGNORE era a deduplicação daquela época: uma linha com chave que já existe é pulada. Funciona, e o preço é o MySQL conferir o índice único em cada linha, o que pesava mais conforme a tabela crescia.
A tabela de auditoria passou de 2 bilhões de linhas. Era particionada por semana, então uma consulta de uma campanha tocava só as semanas de que precisava, e as tabelas de resumo eram recalculadas de forma incremental, porque o recálculo completo levava dias. Eu só rodava um completo quando não tinha outro jeito. Tunei muito o MySQL naqueles anos; também testei o Percona, e a diferença veio muito mais da configuração do que da distribuição.
Kafka e ClickHouse
Em 2019 comecei a testar o ClickHouse: uns 30 servidores de teste de tamanhos diferentes, primeiro um cluster de três na nuvem, depois uma máquina bare metal. Em 2022 ele virou o banco principal dos logs. O MySQL seguiu recebendo os mesmos dados por mais ou menos um ano, como backup caso algo desse errado, e por volta de 2023 parou de receber logs de vez. O Kafka entrou no mesmo período, entre o envio e o banco. Essa parte a gente desenhou junto, no time, e um colega escreveu e colocou no ar a versão final do consumidor.
O caminho de uma exibição hoje, da tela até os relatórios:
player (memória → arquivo a cada 5 min → envio a cada 15 min, UUID por linha)
→ envio HTTP → uma mensagem Kafka por envio (retenção do tópico: 3 dias)
→ "OK" ao player só depois que o broker aceitou
→ importador: lê um lote de mensagens, remove UUID repetido dentro do lote
→ tabela de stage com os últimos 3 dias → tabelas principais (ReplacingMergeTree)
→ resumos a cada 10 minutos → comprovação, faturamento, ocupaçãoTrês dias aparece duas vezes, e é de propósito. O tópico guarda 3 dias, então se o importador para, os envios esperam no Kafka e nada se perde; uma vez ele ficou parado umas 39 horas, e a fila só cresceu e depois desceu. A tabela de stage também guarda 3 dias, então uma tela que ficou sem rede no fim de semana e manda na segunda as exibições de sexta ainda encontra as linhas que chegaram antes, e as duplicatas são pegas ali.
Ordenar antes de inserir ainda vale aqui, de outra forma. O ClickHouse quer poucos inserts grandes, então o importador trabalha em lotes grandes. Rodadas recentes inseriram de 155 mil a 643 mil linhas em 7 a 20 segundos, uns 20 a 33 mil linhas por segundo por rodada, e um dia inteiro dá cerca de 200 milhões de linhas193,9 mi + 5,4 mi de linhas em 24 h, system.part_log, 2026-10-05.
Colocamos um TTL de um mês na coluna do UUID. Um UUID completo em bilhões de linhas é caro, e depois de um mês a chance de um reenvio atrasado é quase zero, então a chave expira e o disco fica razoável.
O que "padrão de faturamento" quer dizer aqui
Eu dizia que a gente nunca tinha perdido um log. Não é verdade, e parei de dizer. Em dez anos e 15 mil aparelhos em campo, houve duplicata e houve falta, algumas difíceis até de medir na origem. O que sempre houve foi o mapa. Para qualquer falta a gente sabia dizer qual tela e quais horas, e quando a culpa era nossa, o anunciante foi compensado. Nada se perdeu sem rastro, e toda fatura tinha as linhas por trás.
Tudo isso vem de decisões sem glamour, como apagar só depois do ack, uma chave por linha criada na borda, um buffer com dias de folga entre cada etapa e resumos que dá para recalcular a partir do dado bruto.
O que eu reusaria em qualquer lugar
- Em aparelho instável, grave em memória e envie em lote. Apague depois do ack, nunca antes.
- Crie a chave de deduplicação na origem. O servidor não consegue inventar depois.
- Deixe dias de folga entre as etapas (retenção do tópico, tabela de stage), para que uma parada vire fila.
- Ordene antes de inserir, em qualquer banco com índice ou chave de ordenação, e meça cada passo do pipeline.
- Diga a garantia honesta. "Rastreado e conciliado" aguenta uma pergunta de entrevista que "perda zero" não aguenta.
Isto foi útil?
Obrigado. Isso me ajuda a decidir o que escrever depois.