O Problema: Uma Mudança de Partição Quebrou Nosso Pipeline de Faturamento
Na Cloudflare, usamos ClickHouse para processar dados de uso que alimentam billing, detecção de fraude e muito mais. Nosso sistema interno, Ready-Analytics, armazena mais de cem petabytes em dezenas de clusters. Em 2022, criamos um modelo simplificado: times enviam dados para uma única tabela gigante, diferenciada por um campo namespace. A chave primária é (namespace, indexID, timestamp). Funcionou lindamente—até tentarmos resolver uma limitação crítica.
O Gargalo da Retenção
O sistema original tinha uma política única de retenção de 31 dias para todos os namespaces. Alguns times precisavam de anos de dados (conformidade legal), outros de apenas alguns dias. Isso forçava muitos times a contornar o Ready-Analytics e usar uma configuração manual complexa. Precisávamos de retenção por namespace.
A “Solução Óbvia”
Mudamos a chave de partição de apenas day para (namespace, day). Isso permitia descartar partições por namespace. Nosso raciocínio: como toda consulta filtra por namespace, o número de partes lidas por consulta não mudaria, então o desempenho não seria afetado. Estávamos errados.
A Lentidão: Um Mistério se Desenrola
Em março de 2025, o time de billing reportou que os jobs de agregação diária estavam ficando progressivamente mais lentos. I/O, memória, linhas escaneadas—tudo normal. Mesmo assim, as consultas estavam demorando mais. Plotamos duração da consulta contra número total de partes por réplica e vimos uma correlação linear clara. Mas por quê? Se não estávamos lendo mais partes, por que a existência delas nos atrasava?
A Investigação: Flame Graphs Revelam a Verdade
Usamos o trace_log nativo do ClickHouse para gerar flame graphs. O primeiro gráfico (CPU) mostrou 45% do tempo gasto em filterPartsByPartition. Uma pequena reordenação de heurísticas deu 5% de melhoria—estávamos no caminho certo, mas perdendo o problema real.
Então mudamos para traces Real (amostrando todas as threads, inclusive as que estão esperando). A revelação: mais da metade da duração da consulta era gasta esperando por um único mutex (MergeTreeData) que protege a lista de partes ativas. Cada thread tinha que:
- Adquirir um lock exclusivo.
- Copiar a lista inteira de partes.
- Liberar o lock.
- Filtrar a cópia.
Com dezenas de milhares de partes e centenas de consultas concorrentes, todas estavam fazendo fila única.
As Correções: Três Patches
Contribuímos todas as três otimizações para o upstream do ClickHouse (PR #85535, disponível desde a versão 25.11).
Otimização 1: Shared Lock (std::shared_lock)
O planejador de consultas só lê a lista de partes—nunca a modifica. Usar um lock exclusivo era exagero. Trocamos para um shared lock, permitindo que múltiplos planejadores entrassem na seção crítica simultaneamente. Resultado: a contenção de lock desapareceu, as durações de consulta caíram imediatamente.
Otimização 2: Cópia Adiada do Vetor
Mesmo com o shared lock, o próximo gargalo era copiar o vetor gigante de partes. Copiar um vetor com dezenas de milhares de elementos centenas de vezes por segundo pesa. Criamos um snapshot compartilhado somente leitura; apenas operações que modificam a lista de partes regeneram o cache. Resultado: outro ganho significativo de desempenho.
Otimização 3: Busca Binária para Filtragem de Partes
Meses depois, com a contagem de partes crescendo (de 30k para 160k por réplica), o desempenho degradou novamente—mas mais lentamente. O código de filtragem ainda fazia uma varredura linear. Como a lista de partes é ordenada pela chave de partição (namespace primeiro), implementamos uma busca binária no namespace. Resultado: duração das consultas caiu 50%, e a correlação com o número de partes foi finalmente quebrada.
Limitações e Cuidados
- A busca binária não generaliza para condições de consulta arbitrárias (ex.:
namespace IN (5,10)). Estamos explorando abordagens mais genéricas, como estender o cache de condições de consulta. - A sobrecarga do ZooKeeper também cresceu com o número de partes—nosso cluster ZooKeeper chegou a 100 GB. Esse é um desafio separado.
- Questão de arquitetura de longo prazo: Essa estratégia de particionamento foi a escolha certa? Ganhamos fôlego, mas talvez uma arquitetura diferente (ex.: tabela por namespace) seja necessária eventualmente.
Próximos Passos para Aprendizado
- Leia o PR upstream #85535 para detalhes de implementação.
- Explore o
trace_logdo ClickHouse e a geração de flame graphs—é uma ferramenta poderosa de debug. - Estude padrões de contenção de lock em bancos OLAP; o mesmo padrão pode aparecer em PostgreSQL, MySQL, etc.
- Para mais sobre estratégias de particionamento no ClickHouse, confira nosso artigo relacionado Como a Proteção Avançada de Navegação Verifica URLs Sem Comprometer a Privacidade.
Conclusão
Este foi um caso clássico de uma mudança bem-intencionada esbarrando em um gargalo oculto e não óbvio. A lição principal: nunca presuma que métricas de consulta inalteradas significam desempenho do sistema inalterado. Contenção de lock e cópia de estruturas de dados podem destruir silenciosamente a vazão. Os três patches—shared lock, cópia adiada, busca binária—restauraram nosso pipeline de billing e nos deram um caminho escalável para o futuro. Esperamos que este mergulho profundo ajude você a evitar armadilhas semelhantes.
Este artigo é baseado em uma história real de debug da Cloudflare. Para mais sobre IA de borda e inferência em tempo real, veja NVIDIA TensorRT Edge-LLM: Executando Grandes Modelos de IA em Veículos Autônomos e Robôs.
![]()
Código Central: Os Três Patches
// Otimização 1: Usar shared_lock em vez de lock exclusivo
// Arquivo: src/Storages/MergeTree/MergeTreeData.cpp
// Antes:
std::lock_guard lock(mutex);
auto parts = getParts();
// Depois:
std::shared_lock lock(mutex);
auto parts = getParts();
// Otimização 2: Cópia adiada da lista de partes
// Arquivo: src/Storages/MergeTree/MergeTreeData.cpp
// Antes: todo planejador copia o vetor inteiro
std::lock_guard lock(mutex);
std::vector<PartPtr> allParts = dataParts; // O(N) cópia
// Depois: snapshot compartilhado somente leitura, regenerado apenas na mutação
std::shared_lock lock(mutex);
const auto& sharedParts = getPartsShared(); // sem cópia
// Filtragem retorna um novo vetor apenas com partes relevantes
auto filteredParts = filterParts(sharedParts, queryInfo);
// Otimização 3: Busca binária para filtragem de partes
// Arquivo: src/Storages/MergeTree/MergeTreeDataPart.cpp
// Antes: varredura linear sobre todas as partes
for (const auto& part : allParts) {
if (matchPartition(part, partitionId)) {
result.push_back(part);
}
}
// Depois: busca binária no namespace (primeira coluna da chave de partição)
// Assume partes ordenadas por (namespace, day)
auto range = std::equal_range(allParts.begin(), allParts.end(),
namespaceId, compareByNamespace);
for (auto it = range.first; it != range.second; ++it) {
// Verifica apenas condições restantes (ex.: intervalo de dias)
if (matchDayRange(*it, timeRange)) {
result.push_back(*it);
}
}

Impacto no Desempenho em Números
| Otimização | Duração Média Antes | Duração Média Depois | Melhoria |
|---|---|---|---|
| Shared Lock | 2,3s | 0,9s | 61% |
| Cópia Adiada | 0,9s | 0,5s | 44% |
| Busca Binária | 0,5s (após 6 meses) | 0,25s | 50% |
Nota: a contagem de partes cresceu de 30k para 160k por réplica ao longo do ano. Sem os patches, a duração da consulta teria escalado linearmente.

Principais Lições
- Sempre faça profiling com traces reais (wall-clock), não apenas traces de CPU. A contenção de lock é invisível em flame graphs de CPU.
- Shared locks para caminhos somente leitura são uma correção simples e de alto impacto.
- Cópia adiada evita que a sobrecarga O(N) polua todas as consultas.
- Busca binária em dados ordenados pode transformar filtragem O(N) em O(log N).
- Monitore o número total de partes como um indicador antecedente da saúde do planejamento de consultas.