Quebrando barreiras para escalar: como otimizamos o uso do Elasticsearch na Intercom

Publicados: 2022-09-22

O Elasticsearch é uma parte indispensável do Intercom.

Ele sustenta os principais recursos do Intercom, como Caixa de entrada, Exibições de caixa de entrada, API, Artigos, lista de usuários, relatórios, Bot de resolução e nossos sistemas internos de registro. Nossos clusters Elasticsearch contêm mais de 350 TB de dados de clientes, armazenam mais de 300 bilhões de documentos e atendem a mais de 60 mil solicitações por segundo no pico.

À medida que o uso do Elasticsearch da Intercom aumenta, precisamos garantir a escalabilidade de nossos sistemas para dar suporte ao nosso crescimento contínuo. Com o recente lançamento de nossa caixa de entrada de última geração, a confiabilidade do Elasticsearch é mais crítica do que nunca.

Decidimos resolver um problema com nossa configuração do Elasticsearch que representava um risco de disponibilidade e ameaçava tempo de inatividade futuro: distribuição desigual de tráfego/trabalho entre os nós em nossos clusters do Elasticsearch.

Sinais iniciais de ineficiência: desequilíbrio de carga

O Elasticsearch permite dimensionar horizontalmente aumentando o número de nós que armazenam dados (nós de dados). Começamos a notar um desequilíbrio de carga entre esses nós de dados: alguns deles estavam sob mais pressão (ou “mais quentes”) do que outros devido ao maior uso de disco ou CPU.

Fig. 1 Desequilíbrio no uso da CPU
(Fig. 1) Desequilíbrio no uso da CPU: Dois nós quentes com utilização de CPU ~20% maior do que a média.

A lógica de posicionamento de shards integrada do Elasticsearch toma decisões com base em um cálculo que estima aproximadamente o espaço em disco disponível em cada nó e o número de shards de um índice por nó. A utilização de recursos por estilhaço não é considerada nesse cálculo. Como resultado, alguns nós podem receber mais fragmentos que consomem mais recursos e se tornar “quentes”. Cada solicitação de pesquisa é processada por vários nós de dados. Um nó ativo que ultrapassa seus limites durante o tráfego de pico pode causar degradação do desempenho de todo o cluster.

Um motivo comum para nós ativos é a lógica de posicionamento de estilhaços que atribui estilhaços grandes (com base na utilização do disco) a clusters, tornando menos provável uma alocação balanceada. Normalmente, um nó pode ser atribuído a um shard grande a mais do que aos outros, tornando-o mais quente na utilização do disco. A presença de fragmentos grandes também dificulta nossa capacidade de dimensionar o cluster de forma incremental, pois adicionar um nó de dados não garante a redução de carga de todos os nós ativos (Fig. 2).

Fig. 4 Adicionando nós

(Fig. 2) Adicionar um nó de dados não resultou em redução de carga no Host A. Adicionar outro nó reduziria a carga no Host A, mas o cluster ainda terá uma distribuição de carga desigual.

Por outro lado, ter fragmentos menores ajuda a reduzir a carga em todos os nós de dados à medida que o cluster é dimensionado – incluindo os “quentes” (Fig. 3).

Fig. 3 Muitos fragmentos menores

(Fig. 3) Ter muitos fragmentos menores ajuda a reduzir a carga em todos os nós de dados.

Observação: o problema não se limita a clusters com estilhaços de tamanho grande. Observaríamos um comportamento semelhante se substituíssemos “tamanho” por “utilização de CPU” ou “tráfego de pesquisa”, mas a comparação de tamanhos facilita a visualização.

Além de afetar a estabilidade do cluster, o desequilíbrio de carga afeta nossa capacidade de dimensionar com economia. Sempre teremos que adicionar mais capacidade do que o necessário para manter os nós mais quentes abaixo dos níveis perigosos. Corrigir esse problema significaria melhor disponibilidade e economia significativa de custos ao utilizar nossa infraestrutura com mais eficiência.

Nossa profunda compreensão do problema nos ajudou a perceber que a carga poderia ser distribuída de forma mais uniforme se tivéssemos:

  • Mais estilhaços em relação ao número de nós de dados. Isso garantiria que a maioria dos nós recebesse um número igual de fragmentos.
  • Fragmentos menores em relação ao tamanho dos nós de dados. Se alguns nós recebessem alguns fragmentos extras, isso não resultaria em nenhum aumento significativo na carga para esses nós.

Solução de cupcake: menos nós maiores

Essa proporção do número de shards para o número de nós de dados e o tamanho dos shards para o tamanho dos nós de dados pode ser ajustada com um número maior de shards menores. Mas pode ser ajustado mais facilmente movendo-se para menos nós de dados, porém maiores.

Decidimos começar com um cupcake para verificar essa hipótese. Migramos alguns de nossos clusters para instâncias maiores e mais poderosas com menos nós – preservando a mesma capacidade total. Por exemplo, movemos um cluster de 40 instâncias 4xlarge para 10 instâncias 16xlarge, reduzindo o desequilíbrio de carga distribuindo os fragmentos de maneira mais uniforme.

Fig. 4 Melhor distribuição de carga entre disco e CPU

(Fig. 4) Melhor distribuição de carga entre disco e CPU, movendo-se para menos nós maiores.

A menor mitigação de nós maiores validou nossas suposições de que ajustar o número e o tamanho dos nós de dados pode melhorar a distribuição de carga. Poderíamos ter parado por aí, mas havia algumas desvantagens na abordagem:

  • Sabíamos que o desequilíbrio de carga surgiria novamente à medida que os estilhaços aumentassem ao longo do tempo ou se mais nós fossem adicionados ao cluster para explicar o aumento do tráfego.
  • Nós maiores tornam o escalonamento incremental mais caro. Adicionar um único nó agora custaria mais, mesmo se precisássemos apenas de um pouco de capacidade extra.

Desafio: Atravessar o limite de ponteiros de objetos comuns compactados (OOPs)

Mudar para menos nós maiores não era tão simples quanto alterar o tamanho da instância. Um gargalo que enfrentamos foi preservar o tamanho total do heap disponível (tamanho do heap em um nó x número total de nós) à medida que migrávamos.

Limitamos o tamanho do heap em nossos nós de dados para ~30,5 GB, conforme sugerido pela Elastic, para garantir que ficássemos abaixo do limite para que a JVM pudesse usar OOPs compactados. Se limitarmos o tamanho do heap para ~30,5 GB depois de passar para menos nós maiores, reduziríamos nossa capacidade geral de heap, pois trabalharíamos com menos nós.

“As instâncias para as quais estávamos migrando eram enormes e queríamos atribuir uma grande parte da RAM ao heap para que tivéssemos espaço para os ponteiros, com sobra suficiente para o cache do sistema de arquivos”

Não encontramos muitos conselhos sobre o impacto de cruzar esse limite. As instâncias para as quais estávamos migrando eram enormes e queríamos atribuir uma grande parte da RAM ao heap para que tivéssemos espaço para os ponteiros, com sobra suficiente para o cache do sistema de arquivos. Experimentamos alguns limites replicando nosso tráfego de produção para testar clusters e estabelecemos cerca de 33% a 42% da RAM como tamanho de heap para máquinas com mais de 200 GB de RAM.

A mudança no tamanho do heap afetou vários clusters de maneira diferente. Embora alguns clusters não tenham mostrado alterações em métricas como “JVM % heap em uso” ou “Young GC Collection Time”, a tendência geral foi de aumento. Independentemente disso, no geral foi uma experiência positiva e nossos clusters estão funcionando há mais de 9 meses com essa configuração – sem problemas.

Correção de longo prazo: muitos fragmentos menores

Uma solução de longo prazo seria avançar para ter um número maior de fragmentos menores em relação ao número e tamanho dos nós de dados. Podemos chegar a fragmentos menores de duas maneiras:

  • Migrar o índice para ter mais estilhaços primários: isso distribui os dados no índice entre mais estilhaços.
  • Quebrando o índice em índices menores (partições): isso distribui os dados no índice entre mais índices.

É importante observar que não queremos criar um milhão de pequenos fragmentos ou ter centenas de partições. Cada índice e shard requer alguns recursos de memória e CPU.

“Nós nos concentramos em tornar mais fácil experimentar e corrigir configurações abaixo do ideal em nosso sistema, em vez de nos fixar na configuração 'perfeita'”

Na maioria dos casos, um pequeno conjunto de fragmentos grandes usa menos recursos do que muitos fragmentos pequenos. Mas existem outras opções – a experimentação deve ajudá-lo a alcançar uma configuração mais adequada para o seu caso de uso.

Para tornar nossos sistemas mais resilientes, nos concentramos em tornar mais fácil experimentar e corrigir configurações abaixo do ideal em nosso sistema, em vez de nos fixar na configuração “perfeita”.

Índices de particionamento

Aumentar o número de shards primários às vezes pode afetar o desempenho de consultas que agregam dados, o que ocorreu durante a migração do cluster responsável pelo produto de relatórios da Intercom. Por outro lado, particionar um índice em vários índices distribui a carga em mais estilhaços sem prejudicar o desempenho da consulta.

A Intercom não exige a localização de dados para vários clientes, por isso optamos por particionar com base nos IDs exclusivos dos clientes. Isso nos ajudou a entregar valor mais rapidamente, simplificando a lógica de particionamento e reduzindo a configuração necessária.

“Para particionar os dados de uma maneira que impactasse menos os hábitos e métodos existentes de nossos engenheiros, primeiro investimos muito tempo para entender como nossos engenheiros usam o Elasticsearch”

Para particionar os dados de uma maneira que impactasse menos os hábitos e métodos existentes de nossos engenheiros, primeiro investimos muito tempo para entender como nossos engenheiros usam o Elasticsearch. Integramos profundamente nosso sistema de observabilidade na biblioteca de cliente do Elasticsearch e varremos nossa base de código para aprender sobre todas as diferentes maneiras pelas quais nossa equipe interage com as APIs do Elasticsearch.

Nosso modo de recuperação de falhas era repetir solicitações, então fizemos as alterações necessárias onde estávamos fazendo solicitações não idempotentes. Acabamos adicionando alguns linters para desencorajar o uso de APIs como `update/delete_by_query`, pois eles facilitavam fazer solicitações não idempotentes.

Criamos dois recursos que trabalharam juntos para fornecer funcionalidade completa:

  • Uma maneira de rotear solicitações de um índice para outro. Esse outro índice pode ser uma partição ou apenas um índice não particionado.
  • Uma maneira de gravação dupla de dados em vários índices. Isso nos permitiu manter as partições sincronizadas com o índice que está sendo migrado.

“Otimizamos nossos processos para minimizar o raio de explosão de qualquer incidente, sem comprometer a velocidade”

Ao todo, o processo de migração de um índice para partições se parece com isso:

  1. Criamos as novas partições e ativamos a escrita dupla para que nossas partições permaneçam atualizadas com o índice original.
  2. Acionamos um preenchimento de todos os dados. Essas solicitações de preenchimento serão gravadas duplamente nas novas partições.
  3. Quando o preenchimento for concluído, validamos que os índices antigos e novos têm os mesmos dados. Se tudo estiver bem, usamos sinalizadores de recursos para começar a usar as partições para alguns clientes e monitorar os resultados.
  4. Quando estivermos confiantes, moveremos todos os nossos clientes para as partições, enquanto gravamos duas vezes no índice antigo e nas partições.
  5. Quando temos certeza de que a migração foi bem-sucedida, interrompemos a gravação dupla e excluímos o índice antigo.

Esses passos aparentemente simples trazem muita complexidade. Otimizamos nossos processos para minimizar o raio de explosão de quaisquer incidentes, sem comprometer a velocidade.

Colhendo os benefícios

Esse trabalho nos ajudou a melhorar o balanceamento de carga em nossos clusters do Elasticsearch. Mais importante, agora podemos melhorar a distribuição de carga sempre que ela se tornar inaceitável, migrando índices para partições com menos shards primários, obtendo o melhor dos dois mundos: menos e menores shards por índice.

Aplicando esses aprendizados, conseguimos desbloquear importantes ganhos e economias de desempenho.

  • Reduzimos os custos de dois de nossos clusters em 40% e 25%, respectivamente, e também obtivemos economias de custo significativas em outros clusters.
  • Reduzimos a utilização média da CPU para um determinado cluster em 25% e melhoramos a latência de solicitação média em 100%. Conseguimos isso migrando um alto índice de tráfego para partições com menos estilhaços primários por partição em comparação com o original.
  • A capacidade geral de migrar índices também nos permite alterar o esquema de um índice, permitindo que engenheiros de produto criem melhores experiências para nossos clientes ou reindexe os dados usando uma versão mais recente do Lucene que desbloqueia nossa capacidade de atualizar para o Elasticsearch 8.

Fig. 5 Melhoria no desequilíbrio de carga e utilização da CPU

(Fig. 5) 50% de melhoria no desequilíbrio de carga e 25% de melhoria na utilização da CPU ao migrar um alto índice de tráfego para partições com menos shards primários por partição.

Fig.6 Latência de solicitação mediana

(Fig. 6) A latência de solicitação mediana melhorou em 100% em média com a migração de um alto índice de tráfego para partições com menos estilhaços primários por partição.

Qual é o próximo?

A introdução do Elasticsearch para impulsionar novos produtos e recursos deve ser simples. Nossa visão é simplificar a interação de nossos engenheiros com o Elasticsearch, assim como as estruturas da Web modernas permitem a interação com bancos de dados relacionais. Deve ser fácil para as equipes criar um índice, ler ou gravar no índice, fazer alterações em seu esquema e muito mais – sem ter que se preocupar com a forma como as solicitações são atendidas.

Você está interessado na forma como nossa equipe de Engenharia trabalha na Intercom? Saiba mais e confira nossas vagas abertas aqui.

Carreiras CTA - Engenharia (horizontal)