Como o Braze aproveita o Ruby em escala

Publicados: 2022-08-18

Se você é um engenheiro que lê Hacker News, Developer Twitter ou qualquer outra fonte de informação similar, você quase certamente já se deparou com milhares de artigos com títulos como “Speed ​​of Rust vs C”, “What Makes Node. js mais rápido que Java?”, ou “Por que você deve usar o Golang e como começar”. Esses artigos geralmente argumentam que existe uma linguagem específica que é a escolha óbvia para escalabilidade ou velocidade - e que a única coisa que você pode fazer é adotá-la.

Enquanto eu estava na faculdade e meu primeiro ano ou dois como engenheiro, eu lia esses artigos e imediatamente criava um projeto de estimação para aprender a nova linguagem ou estrutura do dia. Afinal, era garantido que funcionaria “em escala global” e “mais rápido do que qualquer coisa que você já viu”, e quem pode resistir a isso? Eventualmente, descobri que na verdade não precisava de nenhuma dessas coisas muito específicas para a maioria dos meus projetos. E à medida que minha carreira progredia, percebi que nenhuma escolha de linguagem ou estrutura realmente me daria essas coisas de graça.

Em vez disso, descobri que é a arquitetura que é, na verdade, a maior alavanca quando você procura dimensionar sistemas, não linguagens ou estruturas.

Aqui na Braze, operamos em uma imensa escala global. E sim, usamos Ruby e Rails como duas de nossas principais ferramentas para fazer isso. No entanto, não existe um valor de configuração “global_scale = true” que torne tudo isso possível — é o resultado de uma arquitetura bem pensada que abrange profundamente os aplicativos até as topologias de implantação. Os engenheiros da Braze estão constantemente examinando os gargalos de escala e descobrindo como tornar nosso sistema mais rápido, e a resposta geralmente não é “afastar-se do Ruby”: quase certamente será uma mudança na arquitetura.

Então, vamos dar uma olhada em como o Braze aproveita a arquitetura pensada para realmente resolver a velocidade e uma escala global massiva - e onde Ruby e Rails se encaixam (e não)!

O poder da melhor arquitetura da categoria

Uma simples solicitação da Web

Devido à escala em que operamos, sabemos que os dispositivos associados às bases de usuários de nossos clientes farão bilhões de requisições web todos os dias que terão que ser atendidas por algum servidor web Braze. E mesmo nos sites mais simples, você terá um fluxo relativamente complexo associado a uma solicitação de um cliente para o servidor e vice-versa:

  1. Começa com o resolvedor de DNS do cliente (geralmente seu ISP) descobrindo para qual endereço IP ir, com base no domínio na URL do seu site.

  2. Assim que o cliente tiver um endereço IP, ele enviará a solicitação para o roteador do gateway, que a enviará para o roteador de “próximo salto” (o que pode acontecer várias vezes), até que a solicitação chegue ao endereço IP de destino.

  3. A partir daí, o sistema operacional no servidor que recebe a solicitação tratará dos detalhes da rede e notificará o processo de espera do servidor da Web que uma solicitação de entrada foi recebida no soquete/porta em que estava escutando.

  4. O servidor da web escreverá a resposta (o recurso solicitado, talvez um index.html) nesse soquete, que viajará de volta pelos roteadores de volta ao cliente.

Coisas bem complicadas para um site simples, não? Felizmente, muitas dessas coisas são resolvidas para nós (mais sobre isso em um segundo). Mas nosso sistema ainda tem armazenamentos de dados, trabalhos em segundo plano, preocupações de simultaneidade e muito mais com o que ele precisa lidar! Vamos mergulhar no que isso parece.

Os primeiros sistemas que suportam escala

DNS e servidores de nomes normalmente não requerem muita atenção na maioria dos casos. Seu servidor de nomes de domínio de nível superior provavelmente terá algumas entradas para mapear “yourwebsite.com” para os servidores de nomes do seu domínio e, se você estiver usando um serviço como Amazon Route 53 ou DNS do Azure, eles manipularão o nome servidores para o seu domínio (por exemplo, gerenciamento de A, CNAME ou outro tipo de registro). Normalmente, você não precisa pensar em dimensionar essa parte, pois isso será tratado automaticamente pelos sistemas que você está usando.

A parte de roteamento do fluxo pode ficar interessante, no entanto. Existem alguns algoritmos de roteamento diferentes, como Open Shortest Path First ou Routing Information Protocol, todos eles projetados para encontrar a rota mais rápida/mais curta do cliente para o servidor. Como a internet é efetivamente um grafo conectado gigante (ou, alternativamente, uma rede de fluxo), pode haver vários caminhos que podem ser aproveitados, cada um com um custo maior ou menor correspondente. Seria proibitivo fazer o trabalho para encontrar a rota mais rápida absoluta, então a maioria dos algoritmos usa heurísticas razoáveis ​​para obter uma rota aceitável. Computadores e redes nem sempre são confiáveis, por isso contamos com a Fastly para aprimorar a capacidade de nossos clientes de rotear para nossos servidores mais rapidamente.

O Fastly funciona fornecendo pontos de presença (POPs) em todo o mundo com conexões muito rápidas e confiáveis ​​entre eles. Pense neles como a rodovia interestadual da Internet. Os registros A e CNAME de nossos domínios apontam para Fastly, o que faz com que as solicitações de nossos clientes sejam direcionadas diretamente para a rodovia. A partir daí, o Fastly pode encaminhá-los para o lugar certo.

A porta da frente para brasagem

Ok, então o pedido do nosso cliente foi para a estrada Fastly e está bem na porta da frente da plataforma Braze - o que acontece a seguir?

Em um caso simples, essa porta da frente seria um único servidor aceitando solicitações. Como você pode imaginar, isso não seria muito bem dimensionado, então, na verdade, apontamos Fastly para um conjunto de balanceadores de carga. Existem todos os tipos de estratégias que os balanceadores de carga podem usar, mas imagine que, nesse cenário, as solicitações de round-robin rápidos para um pool de balanceadores de carga sejam uniformes. Esses balanceadores de carga enfileirarão solicitações e, em seguida, distribuirão essas solicitações para servidores da Web, que também podemos imaginar que estão recebendo solicitações de clientes de forma round-robin. (Na prática pode haver vantagens para certos tipos de afinidade, mas isso é assunto para outro momento.)

Isso nos permite aumentar o número de balanceadores de carga e o número de servidores Web, dependendo da taxa de transferência de solicitações que estamos recebendo e da taxa de transferência de solicitações que podemos manipular. Até agora, construímos uma arquitetura que pode lidar com um ataque gigante de solicitações sem suar a camisa! Ele pode até mesmo lidar com padrões de tráfego em rajadas por meio da elasticidade das filas de solicitação dos balanceadores de carga, o que é incrível!

Os Servidores Web

Finalmente, chegamos à parte empolgante (Ruby): O servidor web. Usamos Ruby on Rails, mas isso é apenas uma estrutura da Web - o servidor da Web real é o Unicorn. O Unicorn funciona iniciando vários processos de trabalho em uma máquina, onde cada processo de trabalho escuta em um soquete do SO para trabalhar. Ele lida com o gerenciamento de processos para nós e adia o balanceamento de carga de solicitações para o próprio sistema operacional. Só precisamos que nosso código Ruby processe as requisições o mais rápido possível; todo o resto é efetivamente otimizado fora do Ruby para nós.

Como a maioria das solicitações feitas por nosso SDK dentro dos aplicativos de nossos clientes ou por meio de nossa API REST são assíncronas (ou seja, não precisamos esperar a conclusão da operação para retornar uma resposta específica aos clientes), a maioria de nossos Os servidores de API são extraordinariamente simples - eles validam a estrutura da solicitação, quaisquer restrições de chave de API, depois lançam a solicitação em uma fila do Redis e retornam uma resposta 200 ao cliente se tudo der certo.

Esse ciclo de solicitação/resposta leva cerca de 10 milissegundos para o código Ruby ser processado — e uma parte disso é gasta aguardando o Memcached e o Redis. Mesmo que reescrevêssemos tudo isso em outra linguagem, não seria possível extrair muito mais desempenho disso. E, em última análise, é a arquitetura de tudo o que você leu até agora que nos permite dimensionar esse processo de ingestão de dados para atender às necessidades cada vez maiores de nossos clientes.

As filas de trabalho

Este é um tópico que exploramos no passado, então não vou entrar nesse aspecto tão profundamente – para saber mais sobre nosso sistema de filas de trabalhos, confira meu post sobre Alcançar resiliência com filas. Em alto nível, o que fazemos é aproveitar várias instâncias do Redis que atuam como filas de tarefas, armazenando ainda mais o trabalho que precisa ser feito. Semelhante aos nossos servidores da Web, essas instâncias são divididas em zonas de disponibilidade — para fornecer maior disponibilidade no caso de um problema em uma zona de disponibilidade específica — e vêm em pares primário/secundário usando o Redis Sentinel para redundância. Também podemos dimensioná-los horizontalmente e verticalmente para otimizar a capacidade e a taxa de transferência.

Os trabalhadores

Esta é certamente a parte mais interessante – como conseguimos escalar os trabalhadores?

Em primeiro lugar, nossos trabalhadores e filas são segmentados por várias dimensões: Clientes, tipos de trabalho, armazenamentos de dados necessários, etc. Isso nos permite ter alta disponibilidade; por exemplo, se um armazenamento de dados específico estiver com dificuldades, outras funções continuarão funcionando perfeitamente. Ele também nos permite dimensionar automaticamente os tipos de trabalhador de forma independente, dependendo de qualquer uma dessas dimensões. Acabamos conseguindo gerenciar a capacidade dos trabalhadores de forma horizontalmente escalável – ou seja, se tivermos mais de um determinado tipo de trabalho, podemos escalar mais trabalhadores.

Aqui é o lugar onde você pode começar a ver a importância da escolha do idioma ou da estrutura. Em última análise, um trabalhador mais eficiente será capaz de fazer mais trabalho, mais rapidamente. Linguagens compiladas como C ou Rust tendem a ser muito mais rápidas em tarefas computacionais do que linguagens interpretadas como Ruby, e isso pode levar a trabalhadores mais eficientes para algumas cargas de trabalho. No entanto, passo muito tempo olhando para os rastros, e o processamento bruto da CPU é uma quantidade surpreendentemente pequena no quadro geral do Braze. A maior parte do nosso tempo de processamento é gasto esperando por respostas de armazenamentos de dados ou de solicitações externas, não processando números; não precisamos de código C altamente otimizado para isso.

Os armazenamentos de dados

Até agora, tudo o que cobrimos é bastante escalável. Então, vamos falar um pouco sobre onde nossos funcionários passam a maior parte do tempo: armazenamentos de dados.

Qualquer pessoa que já escalou servidores web ou trabalhadores assíncronos que usam um banco de dados SQL provavelmente se deparou com um problema de escala específico: transações. Você pode ter um endpoint que cuida da conclusão de um pedido, que cria dois FulfillmentRequests e um PaymentReceipt. Se isso não acontecer em uma transação, você pode acabar com dados inconsistentes. A execução simultânea de várias transações em um único banco de dados pode resultar em muito tempo gasto em bloqueios ou até mesmo em deadlock. Na Braze, enfrentamos esse problema de dimensionamento de frente com os próprios modelos de dados, por meio de independência de objeto e consistência eventual. Com esses princípios, podemos extrair muito desempenho de nossos armazenamentos de dados.

Objetos de dados independentes

Aproveitamos muito o MongoDB no Braze, por razões muito boas: ou seja, nos possibilita dimensionar substancialmente os fragmentos do MongoDB horizontalmente e obter aumentos quase lineares no armazenamento e no desempenho. Isso funciona muito bem para nossos perfis de usuário por causa de sua independência um do outro - não há instruções JOIN ou relacionamentos de restrição a serem mantidos entre os perfis de usuário. À medida que cada um de nossos clientes cresce ou adicionamos novos clientes (ou ambos), podemos simplesmente adicionar novos bancos de dados e novos fragmentos aos bancos de dados existentes para aumentar nossa capacidade. Evitamos explicitamente recursos como transações de vários documentos para manter esse nível de escalabilidade.

Além do MongoDB, geralmente utilizamos o Redis como um armazenamento de dados temporário para coisas como buffer de informações analíticas. Como a fonte da verdade para muitas dessas análises existe no MongoDB como documentos independentes por um período de tempo, mantemos um pool horizontalmente escalável de instâncias do Redis para atuar como buffers; sob essa abordagem, o ID do documento com hash é usado em um esquema de fragmentação baseado em chave, distribuindo uniformemente a carga devido à independência. Os trabalhos periódicos liberam esses buffers de um armazenamento de dados dimensionado horizontalmente para outro armazenamento de dados dimensionado horizontalmente. Escala alcançada!

Além disso, utilizamos o Redis Sentinel para essas instâncias, assim como fazemos para as filas de tarefas mencionadas acima. Também implantamos vários “tipos” desses clusters Redis para diferentes propósitos, fornecendo um fluxo de falhas controlado (ou seja, se um tipo específico de cluster Redis tiver problemas, não veremos recursos não relacionados começarem a falhar simultaneamente).

Consistência eventual

O Braze também aproveita a consistência eventual como princípio para a maioria das operações de leitura. Isso nos permite aproveitar a leitura de membros primários e secundários dos conjuntos de réplicas do MongoDB na maioria dos casos, tornando nossa arquitetura mais eficiente. Esse princípio em nosso modelo de dados nos permite utilizar fortemente o cache em toda a nossa pilha.

Usamos uma abordagem de várias camadas usando o Memcached - basicamente, ao solicitar um documento do banco de dados, primeiro verificamos um processo Memcached local da máquina com um tempo de vida muito baixo (TTL) e, em seguida, verificamos uma instância remota do Memcached (com um TTL mais alto), antes de perguntar diretamente ao banco de dados. Isso nos ajuda a reduzir drasticamente as leituras de banco de dados de documentos comuns, como configurações de clientes ou detalhes de campanhas. “Eventual” pode parecer assustador, mas, na realidade, são apenas alguns segundos, e essa abordagem reduz uma enorme quantidade de tráfego da fonte da verdade. Se você já fez uma aula de arquitetura de computador, deve reconhecer como essa abordagem é semelhante ao funcionamento de um sistema de cache de CPUs L1, L2 e L3!

Com esses truques, podemos extrair muito desempenho da parte mais lenta de nossa arquitetura e, em seguida, dimensioná-la horizontalmente conforme apropriado quando nossa taxa de transferência ou capacidade aumentar.

Onde Ruby e Rails se encaixam

Aqui está o problema: Acontece que, quando você gasta muito esforço construindo uma arquitetura holística onde cada camada é bem dimensionada horizontalmente, a velocidade da linguagem ou do tempo de execução é muito menos importante do que você imagina. Isso significa que as escolhas de linguagens, estruturas e tempos de execução são feitas com um conjunto totalmente diferente de requisitos e restrições.

Ruby e Rails tinham um histórico comprovado de ajudar as equipes a iterar rapidamente quando o Braze foi iniciado em 2011 – e ainda são usados ​​pelo GitHub, Shopify e outras marcas líderes porque continua tornando isso possível. Eles continuam sendo ativamente desenvolvidos pelas comunidades Ruby e Rails, respectivamente, e ambos ainda têm um grande conjunto de bibliotecas de código aberto disponíveis para uma variedade de necessidades. O par é uma ótima opção para iteração rápida, pois possui uma imensa flexibilidade e mantém uma quantidade significativa de simplicidade para casos de uso comuns. Achamos isso esmagadoramente verdade todos os dias em que o usamos.

Agora, isso não quer dizer que Ruby on Rails seja uma solução perfeita que funcionará bem para todos. Mas na Braze, descobrimos que funciona muito bem para alimentar uma grande parte de nosso pipeline de ingestão de dados, pipeline de envio de mensagens e nosso painel voltado para o cliente, todos os quais exigem iteração rápida e são fundamentais para o sucesso do Braze plataforma como um todo.

Quando não usamos Ruby

Mas espere! Nem tudo que fazemos no Braze está em Ruby. Há alguns lugares ao longo dos anos em que fizemos a chamada para direcionar as coisas para outras linguagens e tecnologias por vários motivos. Vamos dar uma olhada em três deles, apenas para fornecer algumas informações adicionais sobre quando fazemos e não dependemos do Ruby.

1. Serviços do remetente

Como se vê, Ruby não é bom em lidar com um grau muito alto de solicitações de rede simultâneas em um único processo. Isso é um problema porque quando o Braze está enviando mensagens em nome de nossos clientes, alguns provedores de serviços de fim de linha podem exigir uma solicitação por usuário. Quando temos uma pilha de 100 mensagens prontas para enviar, não queremos esperar que cada uma delas termine antes de passar para a próxima. Preferimos fazer todo esse trabalho em paralelo.

Entre em nossos "Serviços de Remetente" - ou seja, microsserviços sem estado escritos em Golang. Nosso código Ruby no exemplo acima pode enviar todas as 100 mensagens para um desses serviços, que executará todas as solicitações em paralelo, aguardará sua conclusão e retornará uma resposta em massa para Ruby. Esses serviços são substancialmente mais eficientes do que poderíamos fazer com Ruby quando se trata de rede simultânea.

2. Conectores de Correntes

Nosso recurso de exportação de dados de alto volume Braze Currents permite que os clientes da Braze transmitam dados continuamente para um ou mais de nossos muitos parceiros de dados. A plataforma é alimentada pelo Apache Kafka e o streaming é feito via Kafka Connectors. Você pode tecnicamente escrevê-los em Ruby, mas a maneira oficialmente suportada é com Java. E devido ao alto grau de suporte a Java, escrever esses conectores é muito mais fácil de fazer em Java do que em Ruby.

3. Aprendizado de máquina

Se você já fez algum trabalho em aprendizado de máquina, sabe que a linguagem escolhida é o Python. Os vários pacotes e ferramentas para cargas de trabalho de aprendizado de máquina em Python eclipsam o suporte equivalente a Ruby – coisas como notebooks TensorFlow e Jupyter são fundamentais para nossa equipe, e esses tipos de ferramentas simplesmente não existem ou não estão bem estabelecidos no mundo Ruby. Da mesma forma, nos apoiamos no Python quando se trata de criar elementos de nosso produto que aproveitam o aprendizado de máquina.

Quando o idioma importa

Obviamente, temos alguns ótimos exemplos acima onde Ruby não era a escolha ideal. Há muitas razões pelas quais você pode escolher um idioma diferente - aqui estão algumas que achamos particularmente úteis a serem consideradas.

Construindo coisas novas sem custos de mudança

Se você for construir um sistema totalmente novo, com um novo modelo de domínio e nenhuma integração fortemente acoplada com a funcionalidade existente, poderá ter a oportunidade de usar uma linguagem diferente, se assim desejar. Especialmente nos casos em que sua organização está avaliando diferentes oportunidades, um projeto greenfield menor e isolado pode ser um ótimo experimento do mundo real para experimentar uma nova linguagem ou estrutura.

Ecossistema linguístico específico para tarefas e ergonomia

Algumas tarefas são muito mais fáceis com uma linguagem ou estrutura específica - gostamos particularmente de Rails e Grape para desenvolvimento de funcionalidade de painel, mas código de aprendizado de máquina seria um pesadelo absoluto para escrever em Ruby, já que as ferramentas de código aberto simplesmente não existem. Você pode querer usar uma estrutura ou biblioteca específica para implementar algum tipo de funcionalidade ou integração e, às vezes, sua escolha de idioma será influenciada por isso, pois quase certamente resultará em uma experiência de desenvolvimento mais fácil ou rápida.

Velocidade de execução

Ocasionalmente, você precisa otimizar a velocidade de execução bruta, e a linguagem usada influenciará fortemente isso. Há uma boa razão para que muitas plataformas de negociação de alta frequência e sistemas de direção autônomos sejam escritos em C++; código compilado nativamente pode ser muito rápido! Nossos Sender Services exploram as primitivas de paralelismo/simultaneidade de Golang que simplesmente não estão disponíveis em Ruby por esse motivo.

Familiaridade do desenvolvedor

Por outro lado, você pode estar construindo algo isolado ou ter uma biblioteca em mente que deseja usar, mas sua escolha de idioma é completamente desconhecida para o resto de sua equipe. A introdução de um novo projeto em Scala com uma forte inclinação para programação funcional pode introduzir uma barreira de familiaridade para os outros desenvolvedores de sua equipe, o que resultaria em isolamento de conhecimento ou diminuição da velocidade da rede. Achamos isso particularmente importante na Braze, pois damos muita ênfase à iteração rápida, por isso tendemos a incentivar o uso de ferramentas, bibliotecas, frameworks e linguagens que já são amplamente utilizadas na organização.

Pensamentos finais

Se eu pudesse voltar no tempo e me dizer uma coisa sobre engenharia de software em sistemas gigantes, seria isso: para a maioria das cargas de trabalho, suas escolhas gerais de arquitetura definirão seus limites de escala e velocidade mais do que uma escolha de linguagem jamais definirá. Essa percepção é comprovada todos os dias aqui na Braze.

Ruby e Rails são ferramentas incríveis que, quando parte de um sistema arquitetado corretamente, escala incrivelmente bem. Rails também é uma estrutura altamente madura e suporta nossa cultura na Braze de iterar e produzir valor real para o cliente rapidamente. Isso torna Ruby e Rails ferramentas ideais para nós, ferramentas que planejamos continuar usando nos próximos anos.

Interessado em trabalhar na Braze? Estamos contratando para diversas funções em nossas equipes de Engenharia, Gerenciamento de Produtos e Experiência do Usuário. Confira nossa página de carreiras para saber mais sobre nossas vagas abertas e nossa cultura.