Cum Braze folosește Ruby la scară

Publicat: 2022-08-18

Dacă ești un inginer care citește Hacker News, Developer Twitter sau orice alte surse de informații similare, aproape sigur că ai întâlnit o mie de articole cu titluri precum „Viteza ruginii vs C”, „Ce face nodul. js mai rapid decât Java?” sau „De ce ar trebui să utilizați Golang și cum să începeți”. Aceste articole susțin în general că există acest limbaj specific care este alegerea evidentă pentru scalabilitate sau viteză - și că singurul lucru pe care trebuie să-l faci este să-l îmbrățișezi.

În timp ce eram la facultate și în primul meu an sau doi ca inginer, citeam aceste articole și puneam imediat la punct un proiect pentru a învăța noua limbă sau cadrul de zi cu zi. La urma urmei, era garantat să funcționeze „la scară globală” și „mai rapid decât orice ați văzut vreodată”, și cine poate rezista? În cele din urmă, mi-am dat seama că nu am nevoie de niciunul dintre aceste lucruri foarte specifice pentru majoritatea proiectelor mele. Și pe măsură ce cariera mea a progresat, mi-am dat seama că nicio limbă sau o alegere de cadru nu mi-ar oferi aceste lucruri gratis.

În schimb, am descoperit că arhitectura este de fapt cea mai mare pârghie atunci când căutați să scalați sisteme, nu limbaje sau cadre.

Aici, la Braze, operăm la o scară globală imensă. Și da, folosim Ruby și Rails ca două dintre instrumentele noastre principale pentru a face acest lucru. Cu toate acestea, nu există o valoare de configurare „global_scale = true” care să facă totul posibil – este rezultatul unei arhitecturi bine gândite care se întinde în adâncime în aplicații până la topologiile de implementare. Inginerii de la Braze examinează constant blocajele de scalare și descoperă cum să ne facem sistemul mai rapid, iar răspunsul nu este, de obicei, „să ne îndepărtăm de Ruby”: aproape sigur va fi o schimbare a arhitecturii.

Deci, să aruncăm o privire la modul în care Braze folosește arhitectura atentă pentru a rezolva de fapt viteza și o scară globală masivă - și unde se potrivește Ruby și Rails (și nu)!

Puterea arhitecturii de cea mai bună calitate

O simplă solicitare web

Datorită amplorii la care operăm, știm că dispozitivele asociate cu bazele de utilizatori ale clienților noștri vor face miliarde de solicitări web în fiecare zi, care vor trebui să fie deservite de un server web Braze. Și chiar și în cele mai simple site-uri web, veți avea un flux relativ complex asociat cu o solicitare de la un client la server și înapoi:

  1. Începe cu soluția DNS al clientului (de obicei, ISP-ul lor) care stabilește la ce adresă IP să meargă, în funcție de domeniul din adresa URL a site-ului tău web.

  2. Odată ce clientul are o adresă IP, va trimite cererea către routerul gateway-ului său, care o va trimite router-ului „next hop” (ceea ce se poate întâmpla de mai multe ori), până când cererea își va ajunge la adresa IP de destinație.

  3. De acolo, sistemul de operare de pe serverul care primește cererea se va ocupa de detaliile de rețea și va notifica procesul de așteptare al serverului web că a fost primită o solicitare de intrare pe socketul/portul pe care asculta.

  4. Serverul web va scrie răspunsul (resursa solicitată, poate un index.html) pe acel socket, care va călători înapoi prin routere înapoi la client.

Lucruri destul de complicate pentru un site simplu, nu? Din fericire, multe dintre aceste lucruri sunt îngrijite pentru noi (mai multe despre asta într-o secundă). Dar sistemul nostru are încă depozite de date, joburi de fundal, probleme de concurență și multe altele cu care trebuie să se ocupe! Să ne aprofundăm cum arată.

Primele sisteme care suportă scară

Serverele DNS și de nume, de obicei, nu necesită multă atenție în majoritatea cazurilor. Serverul dvs. de nume de domeniu de nivel superior va avea probabil câteva intrări pentru a mapa „site-ul dumneavoastră.com” la serverele de nume pentru domeniul dvs. și, dacă utilizați un serviciu precum Amazon Route 53 sau Azure DNS, aceștia se vor ocupa de numele. servere pentru domeniul dvs. (de exemplu, gestionarea A, CNAME sau alt tip de înregistrări). De obicei, nu trebuie să vă gândiți la scalarea acestei părți, deoarece aceasta va fi gestionată automat de sistemele pe care le utilizați.

Cu toate acestea, partea de rutare a fluxului poate deveni interesantă. Există câțiva algoritmi de rutare diferiți, cum ar fi Open Shortest Path First sau Routing Information Protocol, toți proiectați pentru a găsi cea mai rapidă/cea mai scurtă rută de la client la server. Deoarece internetul este efectiv un graf conectat gigant (sau, alternativ, o rețea de flux), pot exista mai multe căi care pot fi valorificate, fiecare cu un cost corespunzător mai mare sau mai mic. Ar fi prohibitiv să faci munca pentru a găsi cea mai rapidă rută absolută, așa că majoritatea algoritmilor folosesc euristici rezonabile pentru a obține o rută acceptabilă. Calculatoarele și rețelele nu sunt întotdeauna fiabile, așa că ne bazăm pe Fastly pentru a îmbunătăți capacitatea clientului nostru de a se ruta către serverele noastre mai rapid.

Fastly funcționează oferind puncte de prezență (POP) în întreaga lume conexiuni foarte rapide și fiabile între ele. Gândiți-vă la ele ca pe autostrada interstatală a internetului. Înregistrările A și CNAME ale domeniilor noastre indică Fastly, ceea ce face ca cererile clienților noștri să ajungă direct pe autostradă. De acolo, Fastly le poate direcționa către locul potrivit.

Ușa din față pentru lipire

Bine, deci cererea clientului nostru a mers pe autostrada Fastly și este chiar la ușa din față a platformei Braze — ce se întâmplă mai departe?

Într-un caz simplu, acea ușă din față ar fi un singur server care acceptă cereri. După cum vă puteți imagina, asta nu s-ar scala foarte bine, așa că de fapt indicăm Fastly către un set de echilibratori de încărcare. Există tot felul de strategii pe care echilibratorii de încărcare le pot folosi, dar imaginați-vă că, în acest scenariu, Fastly round-robin solicită unui grup de echilibratori de încărcare în mod egal. Acești echilibratori de încărcare vor pune în coadă cererile, apoi vor distribui acele cereri către serverele web, despre care ne putem imagina, de asemenea, că sunt tratate cererile clienților într-un mod round-robin. (În practică, pot exista avantaje pentru anumite tipuri de afinitate, dar acesta este un subiect pentru altă dată.)

Acest lucru ne permite să creștem numărul de echilibratori de încărcare și numărul de servere web, în ​​funcție de debitul de solicitări pe care le primim și de volumul de solicitări pe care le putem gestiona. Până acum, am construit o arhitectură care poate face față unui aval uriaș de solicitări fără a transpira! Poate gestiona chiar și modele de trafic intens prin elasticitatea cozilor de solicitare ale echilibratorilor de încărcare, ceea ce este minunat!

Serverele Web

În cele din urmă, ajungem la partea interesantă (Ruby): serverul web. Folosim Ruby on Rails, dar acesta este doar un cadru web - serverul web real este Unicorn. Unicorn funcționează prin pornirea unui număr de procese de lucru pe o mașină, în care fiecare proces de lucru ascultă pe un soclu de sistem de operare pentru lucru. Se ocupă de gestionarea proceselor pentru noi și amână echilibrarea sarcinilor către sistemul de operare însuși. Avem nevoie doar de codul nostru Ruby pentru a procesa cererile cât mai repede posibil; totul este optimizat eficient în afara Ruby pentru noi.

Deoarece majoritatea solicitărilor, fie făcute de SDK-ul nostru în interiorul aplicațiilor clienților noștri, fie prin intermediul API-ului nostru REST, sunt asincrone (adică nu trebuie să așteptăm finalizarea operațiunii pentru a returna un răspuns specific clienților), majoritatea cererilor noastre Serverele API sunt extraordinar de simple - validează structura cererii, orice constrângere a cheii API, apoi aruncă cererea într-o coadă Redis și returnează clientului un răspuns de 200 dacă totul se verifică.

Acest ciclu de solicitare/răspuns durează aproximativ 10 milisecunde pentru ca codul Ruby să fie procesat – iar o parte din acesta este cheltuită așteptând Memcached și Redis. Chiar dacă ar fi să rescriem toate acestea într-o altă limbă, nu este cu adevărat posibil să strângem mult mai multă performanță din asta. Și, în cele din urmă, arhitectura a tot ceea ce ați citit până acum este cea care ne permite să scalam acest proces de asimilare a datelor pentru a satisface nevoile în continuă creștere ale clienților noștri.

Cozile de locuri de muncă

Acesta este un subiect pe care l-am explorat în trecut, așa că nu voi intra în acest aspect atât de profund – pentru a afla mai multe despre sistemul nostru de așteptare a locurilor de muncă, consultați postarea mea despre Achieving Resiliency With Queues. La un nivel înalt, ceea ce facem este să valorificăm numeroase instanțe Redis care acționează ca cozi de lucru, adăugând în continuare lucrările care trebuie făcute. Similar cu serverele noastre web, aceste instanțe sunt împărțite în zone de disponibilitate - pentru a oferi o disponibilitate mai mare în cazul unei probleme într-o anumită zonă de disponibilitate - și vin în perechi primare/secundare folosind Redis Sentinel pentru redundanță. De asemenea, le putem scala atât pe orizontală, cât și pe verticală pentru a le optimiza atât pentru capacitate, cât și pentru debit.

Muncitorii

Aceasta este cu siguranță partea cea mai interesantă – cum îi facem pe muncitori să se extindă?

În primul rând, angajații și cozile noastre sunt segmentate după o serie de dimensiuni: clienți, tipuri de muncă, depozite de date necesare etc. Acest lucru ne permite să avem o disponibilitate ridicată; de exemplu, dacă un anumit depozit de date întâmpină dificultăți, alte funcții vor continua să funcționeze perfect. De asemenea, ne permite să autoscalăm tipurile de lucrători în mod independent, în funcție de oricare dintre aceste dimensiuni. Sfârșim prin a fi capabili să gestionăm capacitatea lucrătorilor într-un mod scalabil pe orizontală - adică, dacă avem mai mult de un anumit tip de muncă, putem crește mai mulți lucrători.

Aici este locul în care ați putea începe să vedeți că alegerea limbii sau a cadrului contează. În cele din urmă, un lucrător mai eficient va putea face mai multă muncă, mai rapid. Limbile compilate precum C sau Rust tind să fie mult mai rapide la sarcinile de calcul decât limbajele interpretate precum Ruby, ceea ce poate duce la lucrători mai eficienți pentru anumite sarcini de lucru. Cu toate acestea, petrec mult timp uitându-mă la urme, iar procesarea brută a CPU este o cantitate surprinzător de mică din ea în imaginea de ansamblu la Braze. Cea mai mare parte a timpului nostru de procesare este petrecut așteptând răspunsuri de la depozitele de date sau de la solicitări externe, nu strângând numere; nu avem nevoie de cod C puternic optimizat pentru asta.

Depozitele de date

Până acum, tot ceea ce am acoperit este destul de scalabil. Așa că haideți să luăm un minut și să vorbim despre locul în care își petrec lucrătorii noștri cea mai mare parte a timpului: depozitele de date.

Oricine a extins vreodată serverele web sau lucrătorii asincroni care utilizează o bază de date SQL s-a confruntat probabil cu o problemă de scară specifică: Tranzacțiile. Este posibil să aveți un punct final care se ocupă de finalizarea unei Comenzi, care creează două Solicitări de îndeplinire și o chitanță de plată. Dacă toate acestea nu se întâmplă într-o tranzacție, puteți ajunge la date inconsistente. Executarea simultană a numeroase tranzacții pe o singură bază de date poate duce la o mulțime de timp petrecut pe blocaje sau chiar blocare. La Braze, luăm acea problemă de scalare direct cu modelele de date în sine, prin independența obiectului și eventuala consistență. Cu aceste principii, putem extrage multă performanță din depozitele noastre de date.

Obiecte de date independente

Folosim MongoDB foarte mult la Braze, din motive foarte întemeiate: și anume, ne face posibil să scalam substanțial pe orizontală fragmentele MongoDB și să obținem creșteri aproape liniare ale stocării și performanței. Acest lucru funcționează foarte bine pentru profilurile noastre de utilizator datorită independenței lor unul față de celălalt - nu există declarații JOIN sau relații de constrângere de menținut între profilurile de utilizator. Pe măsură ce fiecare dintre clienții noștri crește sau pe măsură ce adăugăm clienți noi (sau ambele), putem pur și simplu să adăugăm noi baze de date și noi fragmente la bazele de date existente pentru a ne crește capacitatea. Evităm în mod explicit funcții precum tranzacțiile cu mai multe documente pentru a menține acest nivel de scalabilitate.

În afară de MongoDB, folosim adesea Redis ca depozit de date temporare pentru lucruri precum stocarea în tampon a informațiilor de analiză. Deoarece sursa adevărului pentru multe dintre aceste analize există în MongoDB ca documente independente pentru o perioadă de timp, menținem un grup scalabil orizontal de instanțe Redis pentru a acționa ca tampon; în cadrul acestei abordări, ID-ul documentului hashing este utilizat într-o schemă de fragmentare bazată pe chei, repartizând uniform încărcarea datorită independenței. Lucrările periodice elimină acele buffer-uri dintr-un depozit de date la scară orizontală într-un alt depozit de date la scară orizontală. Amploare atinsă!

În plus, folosim Redis Sentinel pentru aceste situații, la fel ca și pentru cozile de locuri de muncă menționate mai sus. De asemenea, implementăm numeroase „tipuri” de aceste clustere Redis în scopuri diferite, oferindu-ne un flux de eșec controlat (adică, dacă un anumit tip de cluster Redis are probleme, nu vedem că caracteristicile necorelate încep să eșueze concomitent).

Consecvență eventuală

Braze folosește, de asemenea, eventuala consistență ca principiu pentru majoritatea operațiunilor de citire. Acest lucru ne permite să valorificăm citirea atât de la membrii primari, cât și de la membrii secundari ai setului de replici MongoDB în majoritatea cazurilor, făcând arhitectura noastră mai eficientă. Acest principiu din modelul nostru de date ne permite să utilizăm în mod intens stocarea în cache în toată stiva noastră.

Folosim o abordare multistrat folosind Memcached — practic, atunci când solicităm un document din baza de date, vom verifica mai întâi un proces Memcached local de mașină cu un timp de viață foarte scăzut (TTL), apoi vom verifica o instanță Memcached la distanță (cu un TTL mai mare), înainte de a solicita direct baza de date. Acest lucru ne ajută să reducem drastic citirile bazei de date pentru documente obișnuite, cum ar fi setările clienților sau detaliile campaniei. „Eventual” poate suna înfricoșător, dar, în realitate, durează doar câteva secunde, iar adoptarea acestei abordări reduce o cantitate enormă de trafic de la sursa adevărului. Dacă ați luat vreodată o clasă de arhitectură computerizată, s-ar putea să recunoașteți cât de asemănătoare este această abordare cu modul în care funcționează un sistem de cache L1, L2 și L3 al procesoarelor!

Cu aceste trucuri, putem extrage o mulțime de performanțe din partea cea mai lentă a arhitecturii noastre și apoi o putem scala pe orizontală, după caz, atunci când nevoile noastre de debit sau de capacitate cresc.

Unde se potrivesc Ruby și Rails

Iată chestia: se dovedește că, atunci când depui mult efort pentru a construi o arhitectură holistică în care fiecare strat se scalează bine pe orizontală, viteza limbajului sau timpul de rulare este mult mai puțin important decât ai putea crede. Aceasta înseamnă că alegerile de limbaje, cadre și timpi de execuție sunt făcute cu un set complet diferit de cerințe și constrângeri.

Ruby și Rails au avut o experiență dovedită în a ajuta echipele să repete rapid când Braze a fost lansat în 2011 – și sunt încă folosite de GitHub, Shopify și alte mărci de top, deoarece continuă să facă acest lucru posibil. Acestea continuă să fie dezvoltate în mod activ de comunitățile Ruby și, respectiv, Rails, și ambele au încă un set grozav de biblioteci open-source disponibile pentru o varietate de nevoi. Perechea este o alegere excelentă pentru o iterație rapidă, deoarece au o cantitate imensă de flexibilitate și mențin o cantitate semnificativă de simplitate pentru cazurile de utilizare obișnuite. Găsim că este copleșitor de adevărat în fiecare zi în care îl folosim.

Acum, acest lucru nu înseamnă că Ruby on Rails este o soluție perfectă care va funcționa bine pentru toată lumea. Dar la Braze, am constatat că funcționează foarte bine pentru a alimenta o mare parte a conductei noastre de absorbție de date, a conductei de trimitere a mesajelor și a tabloului de bord orientat către clienți, toate acestea necesită o iterare rapidă și sunt esențiale pentru succesul Braze. platformă în ansamblu.

Când nu folosim Ruby

Dar asteapta! Nu tot ce facem la Braze este în Ruby. Există câteva locuri de-a lungul anilor în care am făcut apelul pentru a orienta lucrurile către alte limbi și tehnologii din mai multe motive. Să aruncăm o privire la trei dintre ele, doar pentru a oferi o perspectivă suplimentară despre când ne bazăm pe Ruby și când nu.

1. Servicii de expeditor

După cum se dovedește, Ruby nu este grozav în a gestiona un grad foarte ridicat de solicitări de rețea concurente într-un singur proces. Aceasta este o problemă deoarece, atunci când Braze trimite mesaje în numele clienților noștri, unii furnizori de servicii de sfârșit de linie ar putea solicita o solicitare pentru fiecare utilizator. Când avem un teanc de 100 de mesaje gata de trimis, nu vrem să așteptăm ca fiecare dintre ele să se termine înainte de a trece la următorul. Preferăm să facem toată această muncă în paralel.

Introduceți „Serviciile expeditorului” – adică microservicii fără stat scrise în Golang. Codul nostru Ruby din exemplul de mai sus poate trimite toate cele 100 de mesaje către unul dintre aceste servicii, care va executa toate solicitările în paralel, va aștepta ca acestea să se termine, apoi va returna un răspuns în bloc lui Ruby. Aceste servicii sunt substanțial mai eficiente decât ceea ce am putea face cu Ruby atunci când vine vorba de rețele simultane.

2. Conectori de curent

Caracteristica noastră de export de date în volum mare Braze Currents permite clienților Braze să transmită în mod continuu date către unul sau mai mulți dintre numeroșii noștri parteneri de date. Platforma este alimentată de Apache Kafka, iar streamingul se face prin Kafka Connectors. Puteți scrie acestea din punct de vedere tehnic în Ruby, dar modalitatea acceptată oficial este cu Java. Și datorită gradului ridicat de suport Java, scrierea acestor conectori este mult mai ușor de făcut în Java decât în ​​Ruby.

3. Învățare automată

Dacă ați lucrat vreodată în învățarea automată, știți că limbajul ales este Python. Numeroasele pachete și instrumente pentru sarcinile de lucru de învățare automată din Python eclipsează suportul echivalent Ruby - lucruri precum TensorFlow și notebook-urile Jupyter sunt esențiale pentru echipa noastră, iar aceste tipuri de instrumente pur și simplu nu există sau nu sunt bine stabilite în lumea Ruby. În consecință, ne-am orientat spre Python atunci când este vorba de a construi elemente ale produsului nostru care să folosească învățarea automată.

Când limba contează

Evident, avem câteva exemple grozave mai sus în care Ruby nu a fost alegerea ideală. Există multe motive pentru care ați putea alege o altă limbă - iată câteva despre care credem că sunt deosebit de utile să le luați în considerare.

Construiți lucruri noi fără a schimba costurile

Dacă intenționați să construiți un sistem complet nou, cu un model de domeniu nou și fără o integrare strâns cuplată cu funcționalitatea existentă, este posibil să aveți ocazia să utilizați o altă limbă dacă doriți. Mai ales în cazurile în care organizația dvs. evaluează diferite oportunități, un proiect mai mic, izolat, de tip greenfield, ar putea fi un experiment grozav în lumea reală pentru a încerca un nou limbaj sau cadru.

Ecosistem limbaj specific sarcinilor și ergonomie

Unele sarcini sunt mult mai ușoare cu un anumit limbaj sau cadru - ne plac în special Rails și Grape pentru dezvoltarea funcționalității tabloului de bord, dar codul de învățare automată ar fi un coșmar absolut pentru a scrie în Ruby, deoarece instrumentele open-source pur și simplu nu există. Este posibil să doriți să utilizați un cadru sau o bibliotecă specifică pentru a implementa un fel de funcționalitate sau integrare și, uneori, alegerea dvs. de limbă va fi influențată de aceasta, deoarece aproape sigur va avea ca rezultat o experiență de dezvoltare mai ușoară sau mai rapidă.

Viteza de execuție

Ocazional, trebuie să optimizați viteza de execuție brută, iar limbajul folosit va influența puternic acest lucru. Există un motiv întemeiat pentru care multe platforme de tranzacționare de înaltă frecvență și sisteme de conducere autonomă sunt scrise în C++; codul compilat nativ poate fi nebun de rapid! Serviciile noastre de expeditor exploatează primitivele de paralelism/concurență ale lui Golang, care pur și simplu nu sunt disponibile în Ruby tocmai din acest motiv.

Familiaritatea dezvoltatorului

Pe de altă parte, este posibil să construiți ceva izolat sau să aveți în minte o bibliotecă pe care doriți să o utilizați, dar alegerea dvs. de limbă este complet necunoscută pentru restul echipei. Introducerea unui nou proiect în Scala cu o înclinație puternică către programarea funcțională ar putea introduce o barieră de familiaritate pentru ceilalți dezvoltatori din echipa dvs., ceea ce ar duce în cele din urmă la izolarea cunoștințelor sau la scăderea vitezei nete. Considerăm că acest lucru este deosebit de important la Braze, deoarece punem un accent intens pe iterația rapidă, așa că avem tendința de a încuraja utilizarea instrumentelor, bibliotecilor, cadrelor și limbajelor care sunt deja utilizate pe scară largă în organizație.

Gânduri finale

Dacă aș putea să mă întorc în timp și să-mi spun un lucru despre ingineria software în sistemele gigantice, ar fi următorul: pentru majoritatea sarcinilor de lucru, alegerile dvs. generale de arhitectură vor defini limitele de scalare și vor accelera mai mult decât o alege vreodată o limbă. Această perspectivă este dovedită în fiecare zi aici, la Braze.

Ruby și Rails sunt instrumente incredibile care, atunci când fac parte dintr-un sistem care este proiectat corect, se scalează incredibil de bine. Rails este, de asemenea, un cadru foarte matur și susține cultura noastră la Braze de a repeta și produce rapid valoare reală pentru clienți. Acestea fac din Ruby și Rails instrumentele ideale pentru noi, instrumente pe care intenționăm să le folosim în continuare în anii următori.

Te interesează să lucrezi la Braze? Facem angajări pentru o varietate de roluri în cadrul echipelor noastre de inginerie, management de produs și experiență utilizator. Consultați pagina noastră de cariere pentru a afla mai multe despre rolurile noastre deschise și despre cultura noastră.