Desafios de Séries Temporais no Apache Cassandra 📊
A Netflix TimeSeries Abstraction foi projetada para escalar a ingestão e consulta de petabytes de dados temporais, mantendo latências na casa dos milissegundos. Para sustentar essa infraestrutura, utilizamos o Apache Cassandra 4.x como camada de armazenamento primária, fundamentada em três pilares estratégicos:
- Performance e Custo: O Cassandra oferece alta taxa de transferência e baixa latência para operações de leitura e escrita, mantendo a eficiência de custos necessária para volumes massivos.
- Maturidade Operacional: Nossa equipe de plataforma de dados possui vasta experiência na gestão e otimização de clusters Cassandra em escala de produção.
O Gargalo das Wide Partitions 🚧
Apesar da robustez do Cassandra, o uso em cargas de trabalho de séries temporais impõe desafios específicos. O problema central reside nas chamadas wide partitions (partições largas). Como os dados de séries temporais se acumulam continuamente, as partições podem crescer desproporcionalmente ao longo do tempo, violando as melhores práticas de modelagem de dados (bucketing) e excedendo os limites recomendados de 100MB por partição para evitar degradação de performance no compaction process.
Esse fenômeno gera um impacto negativo direto na performance:
- Degradação de Latência: Enquanto leituras saudáveis ocorrem em poucos milissegundos, partições excessivamente largas elevam o tempo de resposta para a casa dos segundos, especialmente em consultas de cauda (tail latency), devido ao custo de varredura (scan) de múltiplos SSTables.
- Timeouts e Instabilidade: O processamento de partições gigantescas frequentemente resulta em timeouts nas requisições. Em cenários extremos, o esforço de leitura sobrecarrega o sistema, provocando pausas excessivas de Garbage Collection (GC), picos de utilização de CPU e enfileiramento de threads, comprometendo a disponibilidade do cluster.
Embora o aumento vertical do cluster seja uma alternativa, nossa abordagem foca em soluções inteligentes de particionamento dinâmico para mitigar esses gargalos sem a necessidade de expansão desmedida de recursos.
O Impacto das Partições Largas 📉
Para a maioria dos nossos conjuntos de dados, observamos uma latência de leitura média na casa dos milissegundos. No entanto, à medida que as partições crescem excessivamente, o desempenho degrada drasticamente, resultando em latências de leitura que atingem a casa dos segundos, especialmente em consultas de cauda (tail latency).
Consequências Operacionais 📉
O acúmulo de dados em partições grandes gera um efeito cascata no sistema:
- Timeouts de Requisição: O tempo necessário para processar partições massivas frequentemente excede os limites configurados, resultando em falhas nas chamadas dos clientes.
- Pausas de Garbage Collection (GC): O processamento de grandes volumes de dados em memória exerce uma pressão significativa sobre o heap da JVM, provocando pausas de GC mais longas e frequentes, o que impacta a disponibilidade do cluster.
- Alta Utilização de CPU e Fila de Threads: A sobrecarga de leitura (read amplification) consome ciclos de CPU desproporcionais. Quando o sistema não consegue processar as requisições na velocidade em que chegam, as threads começam a enfileirar, levando à saturação do pool de threads e, eventualmente, à instabilidade do serviço.
Além da Escala Vertical ⚙️
Embora aumentar a capacidade do cluster Cassandra (escalar verticalmente) seja uma opção técnica viável, essa abordagem é frequentemente ineficiente e custosa. Em vez de simplesmente injetar mais recursos de hardware para mascarar o problema, buscamos alternativas inteligentes que tratem a causa raiz: a estrutura de armazenamento dos dados de séries temporais. O objetivo é otimizar a densidade das partições para manter a performance previsível, garantindo que o sistema permaneça resiliente mesmo sob alta carga de trabalho.
Estratégia de Particionamento e suas Limitações ⚙️
Para mitigar o problema das partições largas (wide partitions), a abstração de Séries Temporais da Netflix foi projetada para segmentar os dados em unidades discretas conhecidas como Time Slices (fatias de tempo). Esta estratégia permite que o sistema gerencie volumes massivos de dados dividindo-os em pedaços menores e gerenciáveis, facilitando não apenas a consulta eficiente, mas também a expiração e remoção de dados baseadas em tempo, evitando a sobrecarga causada por tombstones no Apache Cassandra.
O Modelo de Provisionamento 📊
No momento da criação de um namespace (dataset), os usuários definem as características esperadas da carga de trabalho. Esses parâmetros alimentam um pipeline de provisionamento que utiliza simulações de Monte Carlo para determinar a infraestrutura ideal e a configuração de particionamento. Embora eficaz, essa abordagem preditiva enfrenta desafios críticos em cenários dinâmicos:
Limitações da Abordagem Atual ⚠️
- Carga de trabalho desconhecida ou mal estimada: Em estágios iniciais de projetos, é comum que os usuários não possuam dados históricos confiáveis, resultando em estimativas imprecisas que levam a configurações de particionamento subótimas desde o primeiro dia.
- Evolução da carga de trabalho: Padrões de tráfego, comportamento de clientes e requisitos de produto são fluidos. Uma estratégia de particionamento que atende perfeitamente a um dataset hoje pode tornar-se ineficiente em poucos meses devido ao crescimento orgânico ou mudanças no uso da aplicação.
- Presença de outliers: Nem todos os
TimeSeries IDspossuem o mesmo comportamento. Frequentemente, uma pequena fração de IDs recebe um volume de eventos desproporcionalmente maior que o restante, criando “pontos quentes” que invalidam a estratégia de particionamento uniforme aplicada ao restante do dataset.
Embora a arquitetura baseada em Time Slices ofereça uma saída natural — permitindo que cada nova fatia de tempo utilize uma estratégia de particionamento diferente — o ajuste manual dessas configurações em uma frota composta por milhares de datasets torna-se insustentável. A necessidade de automação torna-se, portanto, o próximo passo lógico para manter a estabilidade do sistema.
Solução 1: Re-particionamento de Time Slice 🔄
O Apache Cassandra oferece APIs de introspecção valiosas para diagnosticar padrões de acesso e uso de dados. Ferramentas como o nodetool tablehistograms fornecem distribuições percentis cruciais sobre o tamanho das partições em uma tabela. Aproveitamos essa capacidade para implementar um mecanismo de ajuste automático, evitando tanto o sub-particionamento quanto o super-particionamento.
Monitoramento e Detecção 🔍
Desenvolvemos um background worker dedicado que monitora continuamente os histogramas de partição dos Time Slices associados a cada aplicação. Esses dados são expostos via tabelas virtuais do Cassandra. O worker avalia se a densidade dos dados está dentro dos limites operacionais configurados — tipicamente entre 2 MiB e 10 MiB por partição, dependendo das características da carga de trabalho.
Quando o worker identifica que as partições não atendem à densidade alvo, ele calcula um fator de ajuste. Por exemplo, em um cenário de super-particionamento, onde o pipeline de provisionamento selecionou intervalos de time_bucket muito pequenos, o worker detecta partições com menos de 10 KB, o que gera amplificação de leitura e enfileiramento de threads. O ajuste proposto seria:
DynamicTimeSliceConfigWorker: namespace: my_dataset_1 Observed: TimeSlices have p99 partitions below configured target of 10MB. Proposed: time_bucket interval: 60s -> 604800s
Automação do Ajuste ⚡
Após o cálculo do novo fator, o worker atualiza automaticamente os Time Slices futuros com a nova estratégia de particionamento. Esta abordagem resultou em melhorias tangíveis na latência de leitura e reduziu drasticamente os timeouts causados por contenção de threads.
Limitações e Estratégias Complementares 🛡️
Embora eficaz para tabelas onde a maioria dos dados exibe o mesmo comportamento, esta solução não resolve casos onde apenas uma fração dos IDs apresenta partições largas. Para esses cenários, adotamos medidas adicionais:
- Partial Returns: Interrompemos requisições em trânsito que violam o SLO de latência, retornando os dados coletados até aquele momento.
- Block IDs: Em casos extremos de dados espúrios ou de teste que comprometem a estabilidade, utilizamos uma configuração de bloqueio:
dgwts.config.block.Ids: "id1, id2, id3". - Monitoramento Passivo: Quando não há impacto observável nas métricas de alto nível da aplicação, optamos por não intervir, mantendo a configuração atual.
Solução 2: Particionamento Dinâmico por ID 🎯
Quando o re-particionamento em nível de tabela não é suficiente — especialmente quando apenas uma parcela dos IDs exibe um volume desproporcional de eventos —, implementamos uma arquitetura de particionamento dinâmico. Este pipeline assíncrono atua de forma granular, isolando e dividindo partições largas no nível de TimeSeries ID individual.
Detecção 📡
O processo inicia-se no caminho de leitura (read path). Cada operação de leitura monitora o volume de bytes acessados em uma determinada partição. Caso o limite configurado seja excedido, o servidor emite um evento de detecção para o Kafka contendo metadados essenciais, como o time_slice, o time_series_id e os identificadores de bucket. Optamos por detectar durante a leitura, em vez da escrita, pois a maioria dos dados não exige essa intervenção, minimizando o overhead operacional.
Planejamento e Divisão 🧩
O planejador processa o evento de detecção e realiza uma leitura completa da partição para calcular o plano de divisão ideal. Para garantir a integridade, utilizamos um sistema de checkpointing que permite retomar o processo caso a leitura falhe. O estado das transições e o roteamento futuro são armazenados em uma tabela de metadados chamada wide_row.
Durante a fase de divisão, o Splitter delega a tarefa a uma estratégia específica (ex: EventBucketPartitionSplitStrategy). Para garantir a precisão, comparamos um checksum pré-divisão com um pós-divisão; o status da operação só é marcado como concluído se ambos coincidirem. Isso permite que a carga de leitura seja distribuída entre múltiplas réplicas do Cassandra, mantendo a ordenação total dos dados.
Servindo Leituras 🚀
Para rotear as consultas de forma transparente, os servidores TimeSeries carregam as chaves de partição das divisões concluídas em filtros de Bloom mantidos em memória. O fluxo de leitura funciona da seguinte forma:
- A cada requisição, o servidor consulta o filtro de Bloom em microssegundos para verificar se a partição solicitada foi dividida.
- Em caso de acerto (hit), o sistema consulta a tabela de metadados
wide_rowpara identificar o novo mapeamento de leitura. - A leitura é então delegada ao
PartitionReader, que processa as N partições menores em paralelo, em vez de uma única partição massiva.
Como medida de segurança, a partição original nunca é deletada, servindo como um fallback em cenários de falha parcial ou inconsistência eventual, garantindo a resiliência do sistema sem comprometer a estabilidade do cluster.
Resultados e Lições Aprendidas 🚀
A implementação do particionamento dinâmico transformou drasticamente a estabilidade e a performance da nossa camada de abstração de séries temporais. Ao automatizar a fragmentação de partições que excediam os limites operacionais, alcançamos ganhos significativos em nossos indicadores de nível de serviço (SLOs).
Ganhos de Performance 📈
A transição de um modelo estático para um dinâmico permitiu que o sistema lidasse com volumes de dados que anteriormente causavam degradação severa. Os resultados observados incluem:
- Latência Média: Redução drástica nas leituras de partições largas, caindo de uma escala de segundos para baixos milissegundos (double-digit milliseconds).
- Latência de Cauda (Tail Latency): Otimização de vários segundos para patamares de 200 ms ou menos, mitigando drasticamente os timeouts de leitura.
- Estabilidade do Cluster: Observamos uma redução notável na utilização de CPU e na contenção de threads, eliminando gargalos que anteriormente exigiam intervenção manual ou escalonamento vertical dispendioso.
- Resiliência em Cargas Extremas: O sistema tornou-se capaz de processar consultas em partições superiores a 500 MB sem comprometer a disponibilidade do serviço, garantindo que mesmo volumes massivos de dados sejam entregues de forma paginada e eficiente.
Lições Aprendidas 🧠
Esta jornada reforçou princípios fundamentais para a engenharia de sistemas distribuídos de grande escala:
- Redução da Superfície de Mudança: Antes de buscar soluções complexas, é crucial explorar alternativas que minimizem o impacto operacional. Ao focar inicialmente em partições imutáveis, conseguimos entregar valor imediato com menor risco, expandindo a complexidade apenas quando necessário.
- Construção de Confiança (Building Confidence): Em sistemas onde a integridade dos dados é crítica, a confiança não deve ser presumida, mas validada. Investir em mecanismos como shadow mode para comparação de resultados entre o caminho de leitura antigo e o novo, aliado a estratégias de rollout faseado, foi determinante para garantir que a automação não introduzisse regressões silenciosas.
- Segurança Operacional: A decisão de manter as partições originais intactas como fallback provou ser uma estratégia de mitigação de riscos essencial. A pequena penalidade em termos de armazenamento é um custo irrisório frente à segurança operacional obtida em cenários de falhas parciais ou inconsistências eventuais.
Fonte: netflixtechblog.com
Curadoria e Insights: Redação YTI&W (Developers).