Come Braze sfrutta Ruby su larga scala

Pubblicato: 2022-08-18

Se sei un ingegnere che legge Hacker News, Developer Twitter o qualsiasi altra fonte di informazioni simile là fuori, ti sei quasi sicuramente imbattuto in un migliaio di articoli con titoli come "Speed ​​of Rust vs C", "What Makes Node. js Faster Than Java?" o "Perché dovresti usare Golang e come iniziare". Questi articoli generalmente affermano che esiste questo linguaggio specifico che è la scelta ovvia per scalabilità o velocità e che l'unica cosa da fare è abbracciarlo.

Mentre ero al college e durante il mio primo anno o due come ingegnere, leggevo questi articoli e avviavo immediatamente un progetto per imparare la nuova lingua o il framework du jour. Dopotutto, era garantito che funzionasse "su scala globale" e "più veloce di qualsiasi cosa tu abbia mai visto", e chi può resistergli? Alla fine ho capito che in realtà non avevo bisogno di nessuna di queste cose molto specifiche per la maggior parte dei miei progetti. E con il progredire della mia carriera, mi sono reso conto che nessuna scelta di lingua o struttura mi avrebbe effettivamente offerto queste cose gratuitamente.

Invece, ho scoperto che è l'architettura che in realtà è la leva più grande quando si cerca di scalare i sistemi, non i linguaggi o i framework.

Qui a Braze, operiamo su un'immensa scala globale. E sì, utilizziamo Ruby e Rails come due dei nostri strumenti principali per farlo. Tuttavia, non esiste un valore di configurazione "global_scale = true" che renda tutto ciò possibile: è il risultato di un'architettura ben congegnata che si estende in profondità all'interno delle applicazioni fino alle topologie di distribuzione. Gli ingegneri di Braze esaminano costantemente i colli di bottiglia della scalabilità e cercano di capire come rendere il nostro sistema più veloce, e la risposta di solito non è "allontanarsi da Ruby": quasi sicuramente sarà un cambiamento nell'architettura.

Quindi diamo un'occhiata a come Braze sfrutta un'architettura ponderata per risolvere effettivamente la velocità e un'enorme scala globale, e dove Ruby e Rails si adattano (e non lo fanno)!

Il potere dell'architettura best-in-class

Una semplice richiesta web

A causa della scala in cui operiamo, sappiamo che i dispositivi associati alla base di utenti dei nostri clienti faranno miliardi di richieste web ogni singolo giorno che dovranno essere servite da alcuni server web di Braze. E anche nel più semplice dei siti Web, avrai un flusso relativamente complesso associato a una richiesta da un client al server e viceversa:

  1. Inizia con il resolver DNS del client (di solito il suo ISP) che determina a quale indirizzo IP andare, in base al dominio nell'URL del tuo sito web.

  2. Una volta che il client ha un indirizzo IP, invierà la richiesta al proprio router gateway, che la invierà al router "next hop" (cosa che può accadere più volte), finché la richiesta non raggiunge l'indirizzo IP di destinazione.

  3. Da lì, il sistema operativo sul server che riceve la richiesta gestirà i dettagli di rete e notificherà al processo di attesa del server Web che è stata ricevuta una richiesta in entrata sul socket/porta su cui era in ascolto.

  4. Il server web scriverà la risposta (la risorsa richiesta, forse un index.html) su quel socket, che viaggerà all'indietro attraverso i router fino al client.

Roba piuttosto complicata per un semplice sito web, no? Fortunatamente, molte di queste cose vengono risolte per noi (ne parleremo più in un secondo). Ma il nostro sistema ha ancora archivi di dati, lavori in background, problemi di concorrenza e altro ancora con cui deve fare i conti! Immergiamoci in come appare.

I primi sistemi che supportano la scalabilità

DNS e server dei nomi in genere non richiedono molta attenzione nella maggior parte dei casi. Il tuo server dei nomi di dominio di primo livello avrà probabilmente alcune voci per mappare "tuositoweb.com" ai server dei nomi per il tuo dominio e, se stai utilizzando un servizio come Amazon Route 53 o Azure DNS, gestiranno il nome server per il tuo dominio (es. gestione A, CNAME o altri tipi di record). Di solito non devi pensare al ridimensionamento di questa parte, poiché verrà gestita automaticamente dai sistemi che stai utilizzando.

Tuttavia, la parte di instradamento del flusso può diventare interessante. Esistono diversi algoritmi di routing, come Open Shortest Path First o Routing Information Protocol, tutti progettati per trovare il percorso più veloce/più breve dal client al server. Poiché Internet è effettivamente un gigantesco grafo connesso (o, in alternativa, una rete di flusso), potrebbero esserci più percorsi che possono essere sfruttati, ciascuno con un corrispondente costo maggiore o minore. Sarebbe proibitivo fare il lavoro per trovare il percorso più veloce in assoluto, quindi la maggior parte degli algoritmi utilizza un'euristica ragionevole per ottenere un percorso accettabile. I computer e le reti non sono sempre affidabili, quindi ci affidiamo a Fastly per migliorare la capacità dei nostri clienti di instradare più rapidamente i nostri server.

Funziona velocemente fornendo punti di presenza (POP) in tutto il mondo con connessioni molto veloci e affidabili tra di loro. Considerali come l'autostrada interstatale di Internet. I record A e CNAME dei nostri domini puntano a Fastly, il che fa sì che le richieste dei nostri clienti vadano direttamente all'autostrada. Da lì, Fastly può indirizzarli nel posto giusto.

La porta d'ingresso da brasare

Ok, quindi la richiesta del nostro cliente è arrivata lungo l'autostrada Fastly ed è proprio davanti alla porta d'ingresso della piattaforma Braze, cosa succede dopo?

In un caso semplice, quella porta principale sarebbe un singolo server che accetta le richieste. Come puoi immaginare, ciò non scalerebbe molto bene, quindi in realtà indichiamo Fastly a un set di bilanciatori di carico. Esistono tutti i tipi di strategie che i bilanciatori del carico possono utilizzare, ma immagina che, in questo scenario, arrotonda velocemente le richieste a un pool di bilanciatori del carico in modo uniforme. Questi sistemi di bilanciamento del carico accoderanno le richieste, quindi distribuiranno tali richieste ai server Web, che possiamo anche immaginare vengano trattate richieste dei client in modo round robin. (In pratica ci possono essere vantaggi per certi tipi di affinità, ma questo è un argomento per un'altra volta.)

Ciò ci consente di aumentare il numero di sistemi di bilanciamento del carico e il numero di server Web in base al throughput delle richieste che riceviamo e al throughput delle richieste che possiamo gestire. Finora, abbiamo costruito un'architettura in grado di gestire un gigantesco assalto di richieste senza sudare! Può persino gestire schemi di traffico frenetico tramite l'elasticità delle code di richiesta dei bilanciatori di carico, il che è fantastico!

I server web

Infine, arriviamo alla parte eccitante (Ruby): il server web. Usiamo Ruby on Rails, ma questo è solo un framework web: il server web effettivo è Unicorn. Unicorn funziona avviando una serie di processi di lavoro su una macchina, in cui ogni processo di lavoro è in ascolto su un socket del sistema operativo per il lavoro. Gestisce per noi la gestione dei processi e rinvia il bilanciamento del carico delle richieste al sistema operativo stesso. Abbiamo solo bisogno del nostro codice Ruby per elaborare le richieste il più velocemente possibile; tutto il resto è effettivamente ottimizzato al di fuori di Ruby per noi.

Poiché la maggior parte delle richieste effettuate dal nostro SDK all'interno delle applicazioni dei nostri clienti o tramite la nostra API REST sono asincrone (ovvero non è necessario attendere il completamento dell'operazione per restituire una risposta specifica ai clienti), la maggior parte delle nostre I server API sono straordinariamente semplici: convalidano la struttura della richiesta, qualsiasi vincolo della chiave API, quindi lanciano la richiesta su una coda Redis e restituiscono una risposta 200 al client se tutto è andato a buon fine.

Questo ciclo di richiesta/risposta impiega circa 10 millisecondi per l'elaborazione del codice Ruby e una parte viene spesa in attesa su Memcached e Redis. Anche se dovessimo riscrivere tutto questo in un'altra lingua, non è davvero possibile ottenere molte più prestazioni da questo. E, in definitiva, è l'architettura di tutto ciò che hai letto finora che ci consente di scalare questo processo di acquisizione dei dati per soddisfare le crescenti esigenze dei nostri clienti.

Le code di lavoro

Questo è un argomento che abbiamo esplorato in passato, quindi non approfondirò questo aspetto in modo approfondito: per saperne di più sul nostro sistema di accodamento dei lavori, dai un'occhiata al mio post su Come raggiungere la resilienza con le code. Ad alto livello, ciò che facciamo è sfruttare numerose istanze Redis che fungono da code di lavoro, bufferizzando ulteriormente il lavoro che deve essere svolto. Analogamente ai nostri server Web, queste istanze sono suddivise in zone di disponibilità, per fornire una maggiore disponibilità in caso di problemi in una particolare zona di disponibilità, e vengono fornite in coppie primarie/secondarie utilizzando Redis Sentinel per la ridondanza. Possiamo anche ridimensionarli sia orizzontalmente che verticalmente per ottimizzare sia la capacità che la produttività.

I lavoratori

Questa è sicuramente la parte più interessante: come possiamo far crescere i lavoratori?

Innanzitutto, i nostri lavoratori e le nostre code sono segmentati in base a una serie di dimensioni: clienti, tipi di lavoro, archivi dati necessari, ecc. Questo ci consente di avere un'elevata disponibilità; ad esempio, se un particolare archivio dati ha delle difficoltà, altre funzioni continueranno a funzionare perfettamente. Ci consente inoltre di ridimensionare automaticamente i tipi di lavoratore in modo indipendente, a seconda di una di queste dimensioni. Finiamo per essere in grado di gestire la capacità dei lavoratori in modo scalabile orizzontalmente, ovvero, se abbiamo più di un certo tipo di lavoro, possiamo aumentare la scalabilità di più lavoratori.

Ecco il punto in cui potresti iniziare a vedere che la lingua o la scelta del quadro sono importanti. In definitiva, un lavoratore più efficiente sarà in grado di svolgere più lavoro, più rapidamente. Linguaggi compilati come C o Rust tendono ad essere molto più veloci nelle attività di calcolo rispetto a linguaggi interpretati come Ruby e ciò può portare a lavoratori più efficienti per alcuni carichi di lavoro. Tuttavia, passo molto tempo a guardare le tracce e l'elaborazione grezza della CPU è una quantità sorprendentemente piccola nel quadro generale di Braze. La maggior parte del nostro tempo di elaborazione viene dedicato all'attesa di risposte da archivi dati o richieste esterne, non a scricchiolare numeri; non abbiamo bisogno di codice C fortemente ottimizzato per questo.

Gli archivi di dati

Finora, tutto ciò che abbiamo trattato è abbastanza scalabile. Quindi prendiamoci un minuto e parliamo di dove i nostri dipendenti trascorrono la maggior parte del loro tempo: gli archivi dati.

Chiunque abbia mai scalato server Web o lavoratori asincroni che utilizzano un database SQL si è probabilmente imbattuto in un problema di scalabilità specifico: le transazioni. Potresti avere un endpoint che si occupa del completamento di un ordine, che crea due FulfillmentRequests e una PaymentReceipt. Se questo non accade tutto in una transazione, potresti ritrovarti con dati incoerenti. L'esecuzione simultanea di numerose transazioni su un singolo database può comportare molto tempo speso in blocchi o addirittura deadlock. In Braze, affrontiamo il problema del ridimensionamento direttamente con i modelli di dati stessi, attraverso l'indipendenza dagli oggetti e l'eventuale coerenza. Con questi principi, possiamo spremere molte prestazioni dai nostri archivi di dati.

Oggetti dati indipendenti

Sfruttiamo molto MongoDB in Braze, per ottime ragioni: in particolare, ci consente di ridimensionare in modo sostanzialmente orizzontale gli shard di MongoDB e ottenere aumenti quasi lineari in termini di storage e prestazioni. Questo funziona molto bene per i nostri profili utente a causa della loro indipendenza l'uno dall'altro: non ci sono istruzioni JOIN o relazioni di vincolo da mantenere tra i profili utente. Man mano che ciascuno dei nostri clienti cresce o aggiungiamo nuovi clienti (o entrambi), possiamo semplicemente aggiungere nuovi database e nuovi shard ai database esistenti per aumentare la nostra capacità. Evitiamo esplicitamente funzionalità come le transazioni multi-documento per mantenere questo livello di scalabilità.

A parte MongoDB, utilizziamo spesso Redis come archivio dati temporaneo per cose come il buffering delle informazioni analitiche. Poiché la fonte della verità per molte di queste analisi esiste in MongoDB come documenti indipendenti per un periodo di tempo, manteniamo un pool scalabile orizzontalmente di istanze Redis che fungano da buffer; in questo approccio, l'ID documento con hash viene utilizzato in uno schema di partizionamento orizzontale basato su chiavi, distribuendo uniformemente il carico grazie all'indipendenza. I lavori periodici svuotano quei buffer da un datastore con scalabilità orizzontale a un altro datastore con scalabilità orizzontale. Scala raggiunta!

Inoltre, utilizziamo Redis Sentinel per queste istanze proprio come facciamo per le code di lavoro menzionate sopra. Distribuiamo inoltre numerosi "tipi" di questi cluster Redis per scopi diversi, fornendoci un flusso di errori controllato (ad esempio, se un particolare tipo di cluster Redis presenta problemi, non vediamo che funzionalità non correlate iniziano a non funzionare contemporaneamente).

Eventuale coerenza

Braze sfrutta anche l'eventuale coerenza come principio per la maggior parte delle operazioni di lettura. Ciò ci consente di sfruttare la lettura dei membri primari e secondari dei set di repliche MongoDB nella maggior parte dei casi, rendendo la nostra architettura più efficiente. Questo principio nel nostro modello di dati ci consente di utilizzare pesantemente la memorizzazione nella cache in tutto il nostro stack.

Utilizziamo un approccio multilivello utilizzando Memcached: in pratica, quando si richiede un documento dal database, controlliamo prima un processo Memcached locale della macchina con un tempo di vita molto basso (TTL), quindi controlliamo un'istanza Memcached remota (con un TTL più alto), prima ancora di chiedere direttamente al database. Questo ci aiuta a ridurre drasticamente le letture del database per i documenti comuni, come le impostazioni dei clienti o i dettagli della campagna. "Eventuale" può sembrare spaventoso, ma, in realtà, sono solo pochi secondi e l'adozione di questo approccio riduce un'enorme quantità di traffico dalla fonte della verità. Se hai mai frequentato un corso di architettura del computer, potresti riconoscere quanto questo approccio sia simile al funzionamento di un sistema di cache CPU L1, L2 e L3!

Con questi trucchi, possiamo spremere molte prestazioni dalla parte probabilmente più lenta della nostra architettura e quindi ridimensionarla orizzontalmente come appropriato quando il nostro throughput o la nostra capacità devono aumentare.

Dove si inseriscono Ruby e Rails

Ecco il punto: si scopre che quando si dedicano molti sforzi alla costruzione di un'architettura olistica in cui ogni livello si ridimensiona bene orizzontalmente, la velocità del linguaggio o del runtime è molto meno importante di quanto si possa pensare. Ciò significa che le scelte di linguaggi, framework e runtime vengono effettuate con un insieme completamente diverso di requisiti e vincoli.

Ruby e Rails avevano una comprovata esperienza nell'aiutare i team a scorrere velocemente quando Braze è stato avviato nel 2011 e sono ancora utilizzati da GitHub, Shopify e altri marchi leader perché continua a renderlo possibile. Continuano ad essere attivamente sviluppati dalle comunità Ruby e Rails, rispettivamente, ed entrambi hanno ancora un'ampia serie di librerie open source disponibili per una varietà di esigenze. La coppia è un'ottima scelta per l'iterazione veloce, poiché hanno un'immensa flessibilità e mantengono una notevole semplicità per i casi d'uso comuni. Troviamo che sia estremamente vero ogni giorno che lo usiamo.

Ora, questo non vuol dire che Ruby on Rails sia una soluzione perfetta che funzionerà bene per tutti. Ma in Braze, abbiamo scoperto che funziona molto bene per alimentare gran parte della nostra pipeline di acquisizione dei dati, pipeline di invio di messaggi e dashboard rivolta ai clienti, che richiedono tutti una rapida iterazione e sono fondamentali per il successo di Braze piattaforma nel suo insieme.

Quando non usiamo Ruby

Ma aspetta! Non tutto ciò che facciamo in Braze è in Ruby. Ci sono alcuni posti nel corso degli anni in cui abbiamo chiesto di orientare le cose verso altri linguaggi e tecnologie per una serie di motivi. Diamo un'occhiata a tre di loro, solo per fornire alcune informazioni aggiuntive su quando lo facciamo e non ci appoggiamo a Ruby.

1. Servizi del mittente

A quanto pare, Ruby non è eccezionale nel gestire un grado molto elevato di richieste di rete simultanee in un unico processo. Questo è un problema perché quando Braze invia messaggi per conto dei nostri clienti, alcuni fornitori di servizi end-of-the-line potrebbero richiedere una richiesta per utente. Quando abbiamo una pila di 100 messaggi pronti per l'invio, non vogliamo aspettare che ciascuno di essi finisca prima di passare al successivo. Preferiremmo di gran lunga fare tutto questo lavoro in parallelo.

Inserisci i nostri "Sender Services", ovvero i microservizi senza stato scritti in Golang. Il nostro codice Ruby nell'esempio sopra può inviare tutti i 100 messaggi a uno di questi servizi, che eseguirà tutte le richieste in parallelo, attenderà che finiscano, quindi restituirà una risposta in blocco a Ruby. Questi servizi sono sostanzialmente più efficienti di quello che potremmo fare con Ruby quando si tratta di networking simultaneo.

2. Connettori di corrente

La nostra funzione di esportazione di dati ad alto volume di Braze Currents consente ai clienti di Braze di trasmettere continuamente i dati a uno o più dei nostri numerosi partner di dati. La piattaforma è alimentata da Apache Kafka e lo streaming avviene tramite Kafka Connectors. Puoi tecnicamente scriverli in Ruby, ma il modo ufficialmente supportato è con Java. E a causa dell'alto grado di supporto Java, scrivere questi connettori è molto più facile da fare in Java che in Ruby.

3. Apprendimento automatico

Se hai mai lavorato nell'apprendimento automatico, sai che il linguaggio preferito è Python. I numerosi pacchetti e strumenti per carichi di lavoro di machine learning in Python eclissano il supporto equivalente di Ruby: cose come i notebook TensorFlow e Jupyter sono fondamentali per il nostro team e questi tipi di strumenti semplicemente non esistono o non sono ben consolidati nel mondo Ruby. Di conseguenza, ci siamo rivolti a Python quando si tratta di creare elementi del nostro prodotto che sfruttano l'apprendimento automatico.

Quando la lingua conta

Ovviamente, abbiamo alcuni ottimi esempi sopra in cui Ruby non era la scelta ideale. Ci sono molte ragioni per cui potresti scegliere una lingua diversa: eccone alcune che riteniamo particolarmente utili da considerare.

Costruire cose nuove senza cambiare i costi

Se hai intenzione di creare un sistema completamente nuovo, con un nuovo modello di dominio e nessuna integrazione strettamente accoppiata con le funzionalità esistenti, potresti avere l'opportunità di utilizzare una lingua diversa, se lo desideri. Soprattutto nei casi in cui la tua organizzazione sta valutando diverse opportunità, un progetto greenfield più piccolo e isolato potrebbe essere un ottimo esperimento nel mondo reale per provare un nuovo linguaggio o struttura.

Ecosistema linguistico specifico per attività ed ergonomia

Alcune attività sono molto più semplici con un linguaggio o un framework specifico: ci piace particolarmente Rails e Grape per lo sviluppo della funzionalità del dashboard, ma il codice di apprendimento automatico sarebbe un vero incubo da scrivere in Ruby, dal momento che gli strumenti open source semplicemente non esistono. Potresti voler utilizzare un framework o una libreria specifica per implementare un qualche tipo di funzionalità o integrazione, e talvolta la tua scelta del linguaggio sarà influenzata da ciò, poiché quasi sicuramente risulterà in un'esperienza di sviluppo più semplice o veloce.

Velocità di esecuzione

Occasionalmente, è necessario ottimizzare la velocità di esecuzione grezza e il linguaggio utilizzato influirà pesantemente su questo. C'è una buona ragione per cui molte piattaforme di trading ad alta frequenza e sistemi di guida autonoma sono scritti in C++; il codice compilato in modo nativo può essere incredibilmente veloce! I nostri servizi mittente sfruttano le primitive di parallelismo/concorrenza di Golang che semplicemente non sono disponibili in Ruby proprio per questo motivo.

Familiarità con gli sviluppatori

D'altra parte, potresti costruire qualcosa di isolato o avere in mente una libreria che desideri utilizzare, ma la tua scelta della lingua è completamente sconosciuta al resto del tuo team. L'introduzione di un nuovo progetto in Scala con una forte propensione alla programmazione funzionale potrebbe introdurre una barriera di familiarità per gli altri sviluppatori del team, che alla fine si tradurrebbe in isolamento della conoscenza o in una diminuzione della velocità di rete. Riteniamo che questo sia particolarmente importante in Braze, poiché poniamo un'enfasi intensa sull'iterazione veloce, quindi tendiamo a incoraggiare l'uso di strumenti, librerie, framework e linguaggi che sono già ampiamente utilizzati nell'organizzazione.

Pensieri finali

Se potessi tornare indietro nel tempo e dirmi una cosa sull'ingegneria del software nei sistemi giganti, sarebbe questa: per la maggior parte dei carichi di lavoro, le tue scelte architettoniche complessive definiranno i tuoi limiti di scalabilità e velocità più di quanto non farà mai una scelta linguistica. Questa intuizione è dimostrata ogni giorno qui a Braze.

Ruby e Rails sono strumenti incredibili che, quando fanno parte di un sistema progettato correttamente, si adattano incredibilmente bene. Rails è anche un framework altamente maturo e supporta la nostra cultura in Braze di iterare e produrre rapidamente valore reale per il cliente. Questi rendono Ruby e Rails strumenti ideali per noi, strumenti che prevediamo di continuare a utilizzare negli anni a venire.

Interessato a lavorare presso Braze? Stiamo assumendo per una varietà di ruoli nei nostri team di progettazione, gestione dei prodotti ed esperienza utente. Dai un'occhiata alla nostra pagina delle carriere per saperne di più sui nostri ruoli aperti e sulla nostra cultura.