Extragerea facturării: o poveste despre optimizarea API-ului intern GraphQL

Publicat: 2022-03-11

Una dintre prioritățile principale pentru echipa de ingineri Toptal este migrarea către o arhitectură bazată pe servicii. Un element crucial al inițiativei a fost Billing Extraction , un proiect în care am izolat funcționalitatea de facturare de platforma Toptal pentru a o implementa ca serviciu separat.

În ultimele luni, am extras prima parte a funcționalității. Pentru a integra facturarea cu alte servicii, am folosit atât un API asincron (bazat pe Kafka), cât și un API sincron (bazat pe HTTP).

Acest articol este o înregistrare a eforturilor noastre de a optimiza și stabiliza API-ul sincron.

Abordare incrementală

Aceasta a fost prima etapă a inițiativei noastre. În călătoria noastră către extragerea completă a facturilor, ne străduim să lucrăm într-o manieră incrementală, oferind modificări mici și sigure ale producției. (Vezi diapozitive dintr-o discuție excelentă despre un alt aspect al acestui proiect: extragerea incrementală a unui motor dintr-o aplicație Rails.)

Punctul de plecare a fost platforma Toptal, o aplicație monolitică Ruby on Rails. Am început prin a identifica cusăturile dintre facturare și platforma Toptal la nivel de date. Prima abordare a fost înlocuirea relațiilor Active Record (AR) cu apeluri obișnuite de metodă. Apoi, trebuia să implementăm un apel REST către serviciul de facturare care preia datele returnate de metodă.

Am implementat un mic serviciu de facturare accesând aceeași bază de date ca și platforma. Am putut să interogăm facturarea utilizând HTTP API sau cu apeluri directe către baza de date. Această abordare ne-a permis să implementăm o rezervă sigură; în cazul în care cererea HTTP a eșuat din orice motiv (implementare incorectă, problemă de performanță, probleme de implementare), am folosit un apel direct și am returnat rezultatul corect apelantului.

Pentru a face tranzițiile sigure și fără întreruperi, am folosit un semnalizator de caracteristică pentru a comuta între apelurile HTTP și apelurile directe. Din păcate, prima încercare implementată cu REST s-a dovedit a fi inacceptabil de lentă. Pur și simplu înlocuirea relațiilor AR cu solicitări de la distanță a provocat blocări atunci când HTTP a fost activat. Chiar dacă l-am activat doar pentru un procent relativ mic de apeluri, problema a persistat.

Știam că avem nevoie de o abordare radical diferită.

API-ul Billing Internal (alias B2B)

Am decis să înlocuim REST cu GraphQL (GQL) pentru a obține mai multă flexibilitate din partea clientului. Am vrut să luăm decizii bazate pe date în timpul acestei tranziții pentru a putea prezice rezultatele de data aceasta.

Pentru a face acest lucru, am instrumentat fiecare solicitare de la platforma Toptal (monolit) la facturare și am înregistrat informații detaliate: timpul de răspuns, parametrii, erorile și chiar urmărirea stivei pe acestea (pentru a înțelege ce părți ale platformei utilizează facturarea). Acest lucru ne-a permis să detectăm hotspot-uri — locuri din cod care trimit multe solicitări sau cele care provoacă răspunsuri lente. Apoi, cu stacktrace și parametri , am putea reproduce problemele la nivel local și am putea avea o buclă scurtă de feedback pentru multe remedieri.

Pentru a evita surprizele urâte la producție, am adăugat un alt nivel de semnalizatoare de caracteristici. Am avut un indicator pentru fiecare metodă în API pentru a trece de la REST la GraphQL. Am activat HTTP treptat și am urmărit dacă „ceva rău” apărea în jurnale.

În cele mai multe cazuri, „ceva rău” a fost fie un timp de răspuns lung (de mai multe secunde), 429 Too Many Requests sau 502 Bad Gateway . Am folosit mai multe modele pentru a remedia aceste probleme: preîncărcarea și stocarea în cache a datelor, limitarea datelor preluate de pe server, adăugarea de fluctuații și limitarea ratei.

Preîncărcare și stocare în cache

Prima problemă pe care am observat-o a fost un val de solicitări trimise dintr-o singură clasă/vedere, similară cu problema N+1 din SQL.

Preîncărcarea înregistrărilor active nu a funcționat peste granița serviciului și, în consecință, am avut o singură pagină care trimitea aproximativ 1.000 de solicitări către facturare la fiecare reîncărcare. O mie de cereri de pe o singură pagină! Situația în unele locuri de muncă de fundal nu era cu mult mai bună. Am preferat să facem zeci de solicitări decât mii.

Una dintre lucrările de fundal a fost preluarea datelor despre job (să numim acest model Product ) și verificarea dacă un produs ar trebui să fie marcat ca inactiv pe baza datelor de facturare (de exemplu, vom numi modelul BillingRecord ). Chiar dacă produsele au fost preluate în loturi, datele de facturare au fost solicitate de fiecare dată când a fost nevoie. Fiecare produs avea nevoie de înregistrări de facturare, așa că procesarea fiecărui produs a determinat o solicitare către serviciul de facturare pentru a le prelua. Aceasta a însemnat o solicitare per produs și a dus la aproximativ 1.000 de solicitări trimise dintr-o singură execuție a lucrării.

Pentru a remedia acest lucru, am adăugat preîncărcarea în lot a înregistrărilor de facturare. Pentru fiecare lot de produse preluat din baza de date, am solicitat o singură dată înregistrările de facturare și apoi le-am atribuit produselor respective:

 # fetch all required billing records and assign them to respective products def cache_billing_records(products) # array of billing records billing_records = Billing::QueryService .billing_records_for_products(*products) indexed_records = billing_records.group_by(&:product_gid) products.each do |p| e.cache_billing_records!(indexed_records[p.gid].to_a) } end end

Cu loturi de 100 și o singură solicitare către serviciul de facturare per lot, am trecut de la ~1.000 de solicitări per job la ~10.

Asocieri la nivelul clientului

Solicitările în loturi și memorarea în cache a înregistrărilor de facturare au funcționat bine atunci când aveam o colecție de produse și aveam nevoie de înregistrările lor de facturare. Dar ce se întâmplă cu invers: dacă am preluat înregistrările de facturare și apoi am încercat să folosim produsele respective, preluate din baza de date a platformei?

După cum era de așteptat, acest lucru a cauzat o altă problemă N+1, de data aceasta pe partea platformei. Când folosim produse pentru a colecta N înregistrări de facturare, efectuam N interogări la baza de date.

Soluția a fost să preluați toate produsele necesare simultan, să le stocați ca un hash indexat după ID și apoi să le atribuiți înregistrărilor de facturare respective. O implementare simplificată este:

 def product_billing_records(products) products_by_gid = products.index_by(&:gid) product_gids = products_by_gid.keys.compact return [] if product_gids.blank? billing_records = fetch_billing_records(product_gids: product_gids) billing_records.each do |billing_record| billing_record.preload_product!( products_by_gid[billing_record.product_gid] ) end end

Dacă crezi că seamănă cu un hash join, nu ești singur.

Filtrarea și preluarea insuficientă pe partea serverului

Am luptat împotriva celor mai grave vârfuri de solicitări și a problemelor N+1 din partea platformei. Totuși, am avut răspunsuri lente. Am identificat că acestea au fost cauzate de încărcarea prea multor date pe platformă și filtrarea acestora acolo (filtrare la nivelul clientului). Încărcarea datelor în memorie, serializarea lor, trimiterea lor prin rețea și deserializarea doar pentru a scăpa cea mai mare parte a fost o risipă colosală. A fost convenabil în timpul implementării, deoarece aveam puncte finale generice și reutilizabile. În timpul operațiunilor, s-a dovedit inutilizabil. Aveam nevoie de ceva mai concret.

Am rezolvat problema adăugând argumente de filtrare la GraphQL. Abordarea noastră a fost similară cu o optimizare binecunoscută care constă în mutarea filtrarii de la nivelul aplicației la interogarea DB ( find_all vs. where in Rails). În lumea bazelor de date, această abordare este evidentă și disponibilă ca WHERE în interogarea SELECT . În acest caz, ne-a cerut să implementăm singuri gestionarea interogărilor (în Facturare).

Am implementat filtrele și am așteptat să vedem o îmbunătățire a performanței. În schimb, am văzut 502 erori pe platformă (și utilizatorii noștri le-au văzut și ei). Nu e bun. Nu este bine deloc!

De ce sa întâmplat asta? Această modificare ar trebui să îmbunătățească timpul de răspuns, nu să întrerupă serviciul. Am introdus un bug subtil din neatenție. Am păstrat ambele versiuni ale API-ului (GQL și REST) ​​pe partea clientului. Am schimbat treptat cu un steag de caracteristică. Prima versiune nefericită pe care am implementat-o ​​a introdus o regresie în ramura REST moștenită. Ne-am concentrat testarea pe ramura GQL, așa că am ratat problema de performanță în REST. Lecție învățată: dacă lipsesc parametrii de căutare, returnați o colecție goală, nu tot ce aveți în baza de date.

Aruncă o privire la datele NewRelic pentru facturare. Am implementat modificările cu filtrarea pe partea serverului în timpul unei pauze în trafic (am oprit traficul de facturare după ce am întâlnit probleme cu platforma). Puteți vedea că răspunsurile sunt mai rapide și mai previzibile după implementare.

Imagine: Date NewRelic pentru serviciul de facturare. Răspunsurile sunt mai rapide după implementare.

Nu a fost prea greu să adăugați filtre la o schemă GQL. Situațiile în care GraphQL a strălucit cu adevărat au fost cazurile în care am preluat prea multe câmpuri, nu prea multe obiecte. Cu REST, trimiteam toate datele de care era posibil nevoie. Crearea unui punct final generic ne-a forțat să-l împachetăm cu toate datele și asocierile utilizate pe platformă.

Cu GQL, am putut alege câmpurile. În loc să preluăm peste 20 de câmpuri care necesitau încărcarea mai multor tabele de baze de date, am selectat doar trei până la cinci câmpuri necesare. Acest lucru ne-a permis să eliminăm vârfurile bruște de utilizare a facturării în timpul implementării platformei, deoarece unele dintre aceste interogări au fost folosite de joburile de reindexare a căutării elastice executate în timpul implementării. Ca efect secundar pozitiv, a făcut implementările mai rapide și mai fiabile.

Cea mai rapidă cerere este cea pe care nu o faci

Am limitat numărul de obiecte preluate și cantitatea de date ambalate în fiecare obiect. Ce altceva am putea face? Poate nu aduc deloc datele?

Am observat un alt domeniu cu loc de îmbunătățire: folosim frecvent o dată de creare a ultimei înregistrări de facturare în platformă și de fiecare dată sunam la facturare pentru a o prelua. Am decis că, în loc să-l preluăm sincron de fiecare dată când este nevoie, să-l putem stoca în cache pe baza evenimentelor trimise de la facturare.

Ne-am planificat din timp, am pregătit sarcini (patru până la cinci dintre ele) și am început să lucrăm pentru a le face cât mai curând posibil, deoarece acele solicitări generau o încărcare semnificativă. Aveam două săptămâni de muncă înaintea noastră.

Din fericire, la scurt timp după ce am început, am aruncat o a doua privire asupra problemei și am realizat că putem folosi date care erau deja pe platformă, dar într-o formă diferită. În loc să adăugăm noi tabele la datele în cache de la Kafka, am petrecut câteva zile comparând datele de la facturare și platformă. De asemenea, am consultat experți în domeniu pentru a stabili dacă am putea folosi datele platformei.

În cele din urmă, am înlocuit apelul de la distanță cu o interogare DB. A fost o victorie masivă atât din punct de vedere al performanței, cât și al volumului de muncă. De asemenea, am economisit mai mult de o săptămână de timp de dezvoltare.

Imagine: Performanță și volum de lucru cu o interogare DB în loc de un apel de la distanță.

Distribuirea încărcăturii

Implementam și implementam acele optimizări una câte una, dar au existat încă cazuri în care facturarea a răspuns cu 429 Too Many Requests . Am fi putut crește limita de solicitare pe Nginx, dar am vrut să înțelegem mai bine problema, deoarece era un indiciu că comunicarea nu se comportă așa cum era de așteptat. După cum vă amintiți, ne-am putea permite să avem acele erori în producție, deoarece nu erau vizibile pentru utilizatorii finali (din cauza recurgerii la un apel direct).

Eroarea a apărut în fiecare duminică, când platforma programează mementouri pentru membrii rețelei de talente cu privire la foile de pontaj restante. Pentru a trimite mementourile, un job preia date de facturare pentru produsele relevante, care include mii de înregistrări. Primul lucru pe care l-am făcut pentru a-l optimiza a fost gruparea și preîncărcarea datelor de facturare și preluarea numai a câmpurilor necesare. Ambele sunt trucuri binecunoscute, așa că nu vom intra în detalii aici.

Ne-am desfășurat și am așteptat duminica următoare. Eram încrezători că am rezolvat problema. Totuși, duminică, eroarea a reapărut.

Serviciul de facturare a fost apelat nu numai în timpul programării, ci și atunci când un memento a fost trimis unui membru al rețelei. Mementourile sunt trimise în joburi de fundal separate (folosind Sidekiq), deci preîncărcarea nu era discutată. Inițial, am presupus că nu va fi o problemă, deoarece nu fiecare produs avea nevoie de un memento și pentru că mementourile sunt trimise toate odată. Mementourile sunt programate pentru ora 17:00 în fusul orar al membrului rețelei. Am omis un detaliu important, totuși: membrii noștri nu sunt distribuiți uniform pe fusurile orare.

Programăm mementouri pentru mii de membri ai rețelei, dintre care aproximativ 25% trăiesc într-un singur fus orar. Aproximativ 15% trăiesc în al doilea cel mai populat fus orar. Pe măsură ce ceasul a bifat ora 17:00 în acele fusuri orare, a trebuit să trimitem sute de mementouri deodată. Asta a însemnat o explozie de sute de solicitări către serviciul de facturare, ceea ce a fost mai mult decât putea face față serviciul.

Nu a fost posibilă preîncărcarea datelor de facturare, deoarece mementourile sunt programate în joburi independente. Nu am putut obține mai puține câmpuri de la facturare, deoarece optimizasem deja acel număr. De asemenea, mutarea membrilor rețelei în fusuri orare mai puțin populate a fost exclusă. Deci ce am făcut? Am mutat mementourile, doar puțin.

Am adăugat agitație la momentul în care au fost programate mementourile pentru a evita o situație în care toate mementourile ar fi trimise exact în același timp. În loc să ne programăm la ora 17:00, le-am programat într-un interval de două minute, între 17:59 și 18:01.

Am implementat serviciul și am așteptat duminica următoare, încrezători că în sfârșit am remediat problema. Din păcate, duminică, eroarea a apărut din nou.

Eram nedumeriți. Conform calculelor noastre, cererile ar fi trebuit să fie repartizate pe o perioadă de două minute, ceea ce însemna că am avea, cel mult, două solicitări pe secundă. Asta nu a fost ceva ce serviciul nu s-ar putea descurca. Am analizat jurnalele și momentele cererilor de facturare și ne-am dat seama că implementarea noastră de jitter nu a funcționat, așa că solicitările încă apăreau într-un grup strâns.

Imagine: Număr mare de solicitări cauzate de implementarea inadecvată a fluctuațiilor.

Ce a cauzat acest comportament? A fost modul în care Sidekiq implementează programarea. Sondează redis la fiecare 10-15 secunde și, din această cauză, nu poate oferi o rezoluție de o secundă. Pentru a realiza o distribuție uniformă a cererilor, am folosit Sidekiq::Limiter – o clasă oferită de Sidekiq Enterprise. Am folosit limitatorul de fereastră care a permis opt solicitări pentru o fereastră în mișcare de o secundă. Am ales această valoare deoarece aveam o limită Nginx de 10 solicitări pe secundă la facturare. Am păstrat codul de fluctuație deoarece a furnizat o dispersie grosieră a cererilor: a distribuit joburile Sidekiq pe o perioadă de două minute. Apoi a fost folosit Sidekiq Limiter pentru a se asigura că fiecare grup de joburi a fost procesat fără a depăși pragul definit.

Încă o dată, l-am desfășurat și am așteptat duminică. Eram încrezători că în sfârșit am remediat problema - și am făcut-o. Eroarea a dispărut.

Optimizare API: Nihil Novi Sub Sole

Cred că nu ați fost surprins de soluțiile pe care le-am folosit. Gruparea, filtrarea pe server, trimiterea numai a câmpurilor obligatorii și limitarea ratei nu sunt tehnici noi. Inginerii de software cu experiență le-au folosit fără îndoială în contexte diferite.

Preîncărcare pentru a evita N+1? Îl avem în fiecare ORM. Se alătură hash? Chiar și MySQL le are acum. Preluare insuficientă? Câmpul SELECT * vs. SELECT field este un truc cunoscut. Împărțirea sarcinii? Nu este nici un concept nou.

Deci de ce am scris acest articol? De ce nu am făcut-o chiar de la început ? Ca de obicei, contextul este cheia. Multe dintre aceste tehnici păreau familiare doar după ce le-am implementat sau doar când am observat o problemă de producție care trebuia rezolvată, nu când ne-am uitat la cod.

Au existat mai multe explicații posibile pentru asta. De cele mai multe ori, am încercat să facem cel mai simplu lucru care ar putea funcționa pentru a evita suprainginerirea. Am început cu o soluție REST plictisitoare și abia apoi ne-am mutat la GQL. Am implementat modificări în spatele unui semnalizator de caracteristică, am monitorizat modul în care totul s-a comportat cu o fracțiune din trafic și am aplicat îmbunătățiri bazate pe date din lumea reală.

Una dintre descoperirile noastre a fost că degradarea performanței este ușor de trecut cu vederea atunci când se refactorizează (și extracția poate fi tratată ca o refactorizare semnificativă). Adăugarea unei limite stricte a însemnat că am tăiat legăturile care au fost adăugate pentru a optimiza codul. Totuși, nu a fost evident până când am măsurat performanța. În sfârșit, în unele cazuri, nu am putut reproduce traficul de producție în mediul de dezvoltare.

Ne-am străduit să avem o suprafață mică a unui API HTTP universal al serviciului de facturare. Ca rezultat, am primit o mulțime de puncte finale/interogări universale care transportau date necesare în diferite cazuri de utilizare. Și asta însemna că, în multe cazuri de utilizare, majoritatea datelor au fost inutile. Este un pic un compromis între DRY și YAGNI: cu DRY, avem un singur punct final/interogare care returnează înregistrări de facturare, în timp ce cu YAGNI, ajungem cu date neutilizate în punctul final care dăunează doar performanței.

De asemenea, am observat un alt compromis când am discutat despre agitație cu echipa de facturare. Din punctul de vedere al clientului (platformei), fiecare cerere ar trebui să primească un răspuns atunci când platforma are nevoie de el. Problemele de performanță și supraîncărcarea serverului ar trebui să fie ascunse în spatele abstracției serviciului de facturare. Din punct de vedere al serviciului de facturare, trebuie să găsim modalități de a face clienții conștienți de caracteristicile de performanță ale serverului pentru a rezista la sarcină.

Din nou, nimic aici nu este nou sau inovator. Este vorba despre identificarea tiparelor cunoscute în diferite contexte și înțelegerea compromisurilor introduse de schimbări. Am învățat asta pe calea grea și sperăm că te-am ferit de la repetarea greșelilor noastre. În loc să ne repeți greșelile, fără îndoială vei face greșeli de la tine și vei învăța din ele.

Mulțumiri speciale colegilor și colegilor mei care au participat la eforturile noastre:

  • Makar Ermokhin
  • Gabriele Renzi
  • Samuel Vega Caballero
  • Luca Guidi