Estrazione della fatturazione: una storia sull'ottimizzazione dell'API interna di GraphQL

Pubblicato: 2022-03-11

Una delle priorità principali per il team di ingegneri di Toptal è la migrazione verso un'architettura basata sui servizi. Un elemento cruciale dell'iniziativa è stato Billing Extraction , un progetto in cui abbiamo isolato la funzionalità di fatturazione dalla piattaforma Toptal per implementarla come servizio separato.

Negli ultimi mesi abbiamo estratto la prima parte della funzionalità. Per integrare la fatturazione con altri servizi, abbiamo utilizzato sia un'API asincrona (basata su Kafka) sia un'API sincrona (basata su HTTP).

Questo articolo è una registrazione dei nostri sforzi per ottimizzare e stabilizzare l'API sincrona.

Approccio incrementale

Questa è stata la prima fase della nostra iniziativa. Nel nostro viaggio verso l'estrazione completa della fatturazione, ci sforziamo di lavorare in modo incrementale apportando modifiche piccole e sicure alla produzione. (Guarda le diapositive di un eccellente discorso su un altro aspetto di questo progetto: l'estrazione incrementale di un motore da un'app Rails.)

Il punto di partenza è stata la piattaforma Toptal, un'applicazione monolitica Ruby on Rails. Abbiamo iniziato individuando le giunture tra la fatturazione e la piattaforma Toptal a livello di dati. Il primo approccio consisteva nel sostituire le relazioni Active Record (AR) con chiamate di metodi regolari. Successivamente, dovevamo implementare una chiamata REST al servizio di fatturazione per recuperare i dati restituiti dal metodo.

Abbiamo implementato un piccolo servizio di fatturazione accedendo allo stesso database della piattaforma. Siamo stati in grado di interrogare la fatturazione utilizzando l'API HTTP o con chiamate dirette al database. Questo approccio ci ha permesso di implementare un sicuro fallback; nel caso in cui la richiesta HTTP non fosse riuscita per qualsiasi motivo (implementazione errata, problemi di prestazioni, problemi di distribuzione), abbiamo utilizzato una chiamata diretta e restituito il risultato corretto al chiamante.

Per rendere le transizioni sicure e senza interruzioni, abbiamo utilizzato un flag di funzionalità per passare da HTTP a chiamate dirette. Sfortunatamente, il primo tentativo implementato con REST si è rivelato inaccettabilmente lento. La semplice sostituzione delle relazioni AR con richieste remote causava arresti anomali quando HTTP era abilitato. Anche se l'abbiamo abilitato solo per una percentuale relativamente piccola di chiamate, il problema persiste.

Sapevamo di aver bisogno di un approccio radicalmente diverso.

L'API interna di fatturazione (nota anche come B2B)

Abbiamo deciso di sostituire REST con GraphQL (GQL) per ottenere maggiore flessibilità sul lato client. Volevamo prendere decisioni basate sui dati durante questa transizione per essere in grado di prevedere i risultati questa volta.

Per fare ciò, abbiamo strumentato ogni richiesta dalla piattaforma Toptal (monolith) alla fatturazione e registrato informazioni dettagliate: tempo di risposta, parametri, errori e persino traccia dello stack su di essi (per capire quali parti della piattaforma utilizzano la fatturazione). Questo ci ha permesso di rilevare gli hotspot, luoghi nel codice che inviano molte richieste o quelli che causano risposte lente. Quindi, con stacktrace e parametri , potremmo riprodurre i problemi in locale e avere un breve ciclo di feedback per molte correzioni.

Per evitare brutte sorprese sulla produzione, abbiamo aggiunto un altro livello di flag di funzionalità. Avevamo un flag per metodo nell'API per passare da REST a GraphQL. Stavamo abilitando HTTP gradualmente e osservando se nei log compariva "qualcosa di brutto".

Nella maggior parte dei casi, "qualcosa di brutto" era un tempo di risposta lungo (di più secondi), 429 Too Many Requests o 502 Bad Gateway . Abbiamo utilizzato diversi modelli per risolvere questi problemi: precaricamento e memorizzazione nella cache dei dati, limitazione dei dati recuperati dal server, aggiunta di jitter e limitazione della velocità.

Precaricamento e memorizzazione nella cache

Il primo problema che abbiamo notato è stato un flusso di richieste inviate da una singola classe/vista, simile al problema N+1 in SQL.

Il precaricamento di Active Record non funzionava oltre il confine del servizio e, di conseguenza, una singola pagina inviava circa 1.000 richieste di fatturazione a ogni ricaricamento. Mille richieste da una sola pagina! La situazione in alcuni lavori in background non era molto migliore. Abbiamo preferito fare decine di richieste piuttosto che migliaia.

Uno dei lavori in background è stato il recupero dei dati del lavoro (chiameremo questo modello Product ) e il controllo se un prodotto deve essere contrassegnato come inattivo in base ai dati di fatturazione (per questo esempio, chiameremo il modello BillingRecord ). Anche se i prodotti venivano prelevati in batch, i dati di fatturazione venivano richiesti ogni volta che era necessario. Ogni prodotto necessitava di record di fatturazione, quindi l'elaborazione di ogni singolo prodotto ha causato una richiesta al servizio di fatturazione per recuperarli. Ciò significava una richiesta per prodotto e si traduceva in circa 1.000 richieste inviate da una singola esecuzione di un lavoro.

Per risolvere il problema, abbiamo aggiunto il precaricamento batch dei record di fatturazione. Per ogni lotto di prodotti prelevati dal database, abbiamo richiesto i record di fatturazione una volta e li abbiamo quindi assegnati ai rispettivi prodotti:

 # 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

Con batch di 100 e una singola richiesta al servizio di fatturazione per batch, siamo passati da ~1.000 richieste per lavoro a ~10.

Join lato client

Il batch delle richieste e la memorizzazione nella cache dei record di fatturazione hanno funzionato bene quando avevamo una raccolta di prodotti e avevamo bisogno dei loro record di fatturazione. Ma che dire del contrario: se recuperassimo i record di fatturazione e poi provassimo a utilizzare i rispettivi prodotti, prelevati dal database della piattaforma?

Come previsto, questo ha causato un altro problema N+1, questa volta sul lato della piattaforma. Quando utilizzavamo i prodotti per raccogliere N record di fatturazione, eseguivamo N query di database.

La soluzione era recuperare tutti i prodotti necessari contemporaneamente, archiviarli come hash indicizzati per ID e quindi assegnarli ai rispettivi record di fatturazione. Un'implementazione semplificata è:

 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

Se pensi che sia simile a un hash join, non sei solo.

Filtraggio lato server e underfetching

Abbiamo respinto i peggiori picchi di richieste e problemi N+1 sul lato della piattaforma. Tuttavia, abbiamo avuto ancora risposte lente. Abbiamo identificato che erano causati dal caricamento di troppi dati sulla piattaforma e dal filtraggio lì (filtro lato client). Caricare i dati in memoria, serializzarli, inviarli sulla rete e deserializzare solo per farne cadere la maggior parte è stato uno spreco colossale. È stato conveniente durante l'implementazione perché avevamo endpoint generici e riutilizzabili. Durante le operazioni si è rivelato inutilizzabile. Ci serviva qualcosa di più specifico.

Abbiamo affrontato il problema aggiungendo argomenti di filtro a GraphQL. Il nostro approccio era simile a una nota ottimizzazione che consiste nello spostare il filtro dal livello dell'app alla query DB ( find_all vs. where in Rails). Nel mondo dei database, questo approccio è ovvio e disponibile come WHERE nella query SELECT . In questo caso, ci ha richiesto di implementare la gestione delle query da soli (in Fatturazione).

Abbiamo distribuito i filtri e abbiamo aspettato di vedere un miglioramento delle prestazioni. Invece, abbiamo visto 502 errori sulla piattaforma (e anche i nostri utenti li hanno visti). Non bene. Non va per niente bene!

Perché è successo? Tale modifica dovrebbe migliorare i tempi di risposta, non interrompere il servizio. Avevamo introdotto un bug sottile inavvertitamente. Abbiamo mantenuto entrambe le versioni dell'API (GQL e REST) ​​sul lato client. Siamo passati gradualmente con un flag di funzionalità. La prima, sfortunata versione che abbiamo distribuito ha introdotto una regressione nel ramo REST legacy. Abbiamo concentrato i nostri test sul ramo GQL, quindi abbiamo perso il problema delle prestazioni in REST. Lezione appresa: se mancano i parametri di ricerca, restituisci una raccolta vuota, non tutto ciò che hai nel database.

Dai un'occhiata ai dati di NewRelic per la fatturazione. Abbiamo implementato le modifiche con il filtro lato server durante una pausa del traffico (abbiamo disattivato il traffico di fatturazione dopo aver riscontrato problemi con la piattaforma). Puoi vedere che le risposte sono più veloci e più prevedibili dopo la distribuzione.

Immagine: dati NewRelic per il servizio di fatturazione. Le risposte sono più veloci dopo la distribuzione.

Non è stato troppo difficile aggiungere filtri a uno schema GQL. Le situazioni in cui GraphQL ha davvero brillato sono stati i casi in cui abbiamo recuperato troppi campi, non troppi oggetti. Con REST, stavamo inviando tutti i dati eventualmente necessari. La creazione di un endpoint generico ci ha obbligato a comprimerlo con tutti i dati e le associazioni utilizzate sulla piattaforma.

Con GQL siamo stati in grado di selezionare i campi. Invece di recuperare oltre 20 campi che richiedevano il caricamento di diverse tabelle di database, abbiamo selezionato solo da tre a cinque campi necessari. Ciò ci ha consentito di rimuovere i picchi improvvisi di utilizzo della fatturazione durante le distribuzioni della piattaforma perché alcune di queste query sono state utilizzate da processi di reindicizzazione della ricerca elastica eseguiti durante la distribuzione. Come effetto collaterale positivo, ha reso le implementazioni più veloci e affidabili.

La richiesta più veloce è quella che non fai

Abbiamo limitato il numero di oggetti recuperati e la quantità di dati impacchettati in ogni oggetto. cos'altro potremmo fare? Forse non recuperare affatto i dati?

Abbiamo notato un'altra area con margini di miglioramento: utilizzavamo frequentemente una data di creazione dell'ultimo record di fatturazione nella piattaforma e ogni volta chiamavamo la fatturazione per recuperarla. Abbiamo deciso che invece di recuperarlo in modo sincrono ogni volta che era necessario, potevamo memorizzarlo nella cache in base agli eventi inviati dalla fatturazione.

Abbiamo pianificato in anticipo, preparato i compiti (da quattro a cinque) e abbiamo iniziato a lavorare per portarli a termine il prima possibile, poiché quelle richieste stavano generando un carico significativo. Avevamo due settimane di lavoro davanti a noi.

Fortunatamente, non molto tempo dopo l'inizio, abbiamo dato una seconda occhiata al problema e ci siamo resi conto che potevamo utilizzare i dati che erano già sulla piattaforma ma in una forma diversa. Invece di aggiungere nuove tabelle per memorizzare nella cache i dati di Kafka, abbiamo trascorso un paio di giorni a confrontare i dati della fatturazione e della piattaforma. Abbiamo anche consultato esperti di dominio sulla possibilità di utilizzare i dati della piattaforma.

Infine, abbiamo sostituito la chiamata remota con una query DB. È stata un'enorme vittoria sia dal punto di vista delle prestazioni che del carico di lavoro. Abbiamo anche risparmiato più di una settimana di tempo di sviluppo.

Immagine: prestazioni e carico di lavoro con una query DB anziché una chiamata remota.

Distribuire il carico

Stavamo implementando e distribuendo queste ottimizzazioni una per una, ma c'erano ancora casi in cui la fatturazione rispondeva con 429 Too Many Requests . Avremmo potuto aumentare il limite di richieste su Nginx ma volevamo capire meglio il problema, in quanto era un suggerimento che la comunicazione non si sta comportando come previsto. Come ricorderete, potremmo permetterci di avere quegli errori in produzione, poiché non erano visibili agli utenti finali (a causa del fallback a una chiamata diretta).

L'errore si verificava ogni domenica, quando la piattaforma programmava promemoria per i membri della rete di talenti in merito a schede attività scadute. Per inviare i promemoria, un lavoro recupera i dati di fatturazione per i prodotti pertinenti, che includono migliaia di record. La prima cosa che abbiamo fatto per ottimizzarlo è stato il batching e il precaricamento dei dati di fatturazione e il recupero dei soli campi obbligatori. Entrambi sono trucchi ben noti, quindi non entreremo nei dettagli qui.

Ci schierammo e aspettammo la domenica successiva. Eravamo fiduciosi di aver risolto il problema. Tuttavia, domenica, l'errore è riemerso.

Il servizio di fatturazione è stato chiamato non solo durante la pianificazione, ma anche quando è stato inviato un promemoria a un membro della rete. I promemoria vengono inviati in processi in background separati (usando Sidekiq), quindi il precaricamento era fuori questione. Inizialmente, pensavamo che non sarebbe stato un problema perché non tutti i prodotti avevano bisogno di un promemoria e perché i promemoria vengono inviati tutti in una volta. I promemoria sono previsti per le 17:00 nel fuso orario del membro della rete. Tuttavia, abbiamo perso un dettaglio importante: i nostri membri non sono distribuiti in modo uniforme tra i fusi orari.

Stavamo programmando promemoria per migliaia di membri della rete, circa il 25% dei quali vive in un fuso orario. Circa il 15% vive nel secondo fuso orario più popoloso. Poiché l'orologio segnava le 17:00 in quei fusi orari, dovevamo inviare centinaia di promemoria contemporaneamente. Ciò significava un'esplosione di centinaia di richieste al servizio di fatturazione, che era più di quanto il servizio potesse gestire.

Non è stato possibile precaricare i dati di fatturazione perché i promemoria sono pianificati in lavori indipendenti. Non siamo riusciti a recuperare meno campi dalla fatturazione, poiché avevamo già ottimizzato quel numero. Anche lo spostamento dei membri della rete in fusi orari meno popolosi era fuori questione. Allora cosa abbiamo fatto? Abbiamo spostato i promemoria, solo un po'.

Abbiamo aggiunto il jitter al momento in cui erano programmati i promemoria per evitare una situazione in cui tutti i promemoria sarebbero stati inviati esattamente allo stesso tempo. Invece di programmare alle 17 in punto, li abbiamo programmati entro un intervallo di due minuti, tra le 17:59 e le 18:01.

Abbiamo implementato il servizio e abbiamo aspettato la domenica successiva, fiduciosi di aver finalmente risolto il problema. Sfortunatamente, domenica, l'errore è apparso di nuovo.

Eravamo perplessi. Secondo i nostri calcoli, le richieste avrebbero dovuto essere distribuite su un periodo di due minuti, il che significava che avremmo avuto al massimo due richieste al secondo. Non era qualcosa che il servizio non poteva gestire. Abbiamo analizzato i registri e le tempistiche delle richieste di fatturazione e ci siamo resi conto che la nostra implementazione del jitter non funzionava, quindi le richieste apparivano ancora in un gruppo ristretto.

Immagine: numero elevato di richieste causato da un'implementazione inadeguata del jitter.

Cosa ha causato quel comportamento? Era il modo in cui Sidekiq implementava la pianificazione. Esegue il polling di redis ogni 10-15 secondi e, per questo motivo, non può fornire una risoluzione di un secondo. Per ottenere una distribuzione uniforme delle richieste, abbiamo utilizzato Sidekiq::Limiter , una classe fornita da Sidekiq Enterprise. Abbiamo impiegato il window limiter che consentiva otto richieste per una finestra mobile di un secondo. Abbiamo scelto quel valore perché avevamo un limite Nginx di 10 richieste al secondo per la fatturazione. Abbiamo mantenuto il codice jitter perché forniva una dispersione delle richieste a grana grossa: distribuiva i lavori Sidekiq in un periodo di due minuti. Quindi è stato utilizzato Sidekiq Limiter per garantire che ogni gruppo di lavori fosse elaborato senza superare la soglia definita.

Ancora una volta, lo abbiamo schierato e abbiamo aspettato domenica. Eravamo fiduciosi di aver finalmente risolto il problema e l'abbiamo fatto. L'errore è svanito.

Ottimizzazione API: Nihil Novi Sub Sole

Credo che tu non sia rimasto sorpreso dalle soluzioni che abbiamo impiegato. Il batch, il filtraggio lato server, l'invio dei soli campi obbligatori e la limitazione della velocità non sono tecniche nuove. Indubbiamente, ingegneri del software esperti li hanno utilizzati in contesti diversi.

Precaricamento per evitare N+1? Lo abbiamo in ogni ORM. Hash si unisce? Anche MySQL li ha ora. Underfetching? Il campo SELECT * vs. SELECT field è un trucco noto. Distribuire il carico? Non è nemmeno un concetto nuovo.

Allora perché ho scritto questo articolo? Perché non l'abbiamo fatto bene dall'inizio ? Come al solito, il contesto è fondamentale. Molte di queste tecniche sembravano familiari solo dopo che le abbiamo implementate o solo quando abbiamo notato un problema di produzione che doveva essere risolto, non quando abbiamo fissato il codice.

C'erano diverse possibili spiegazioni per questo. La maggior parte delle volte, stavamo cercando di fare la cosa più semplice che potesse funzionare per evitare un'eccessiva ingegnerizzazione. Abbiamo iniziato con una noiosa soluzione REST e solo dopo siamo passati a GQL. Abbiamo implementato le modifiche dietro un flag di funzionalità, monitorato il comportamento di tutto con una frazione del traffico e applicato miglioramenti basati sui dati del mondo reale.

Una delle nostre scoperte è stata che il degrado delle prestazioni è facile da trascurare durante il refactoring (e l'estrazione può essere trattata come un refactoring significativo). L'aggiunta di un limite rigoroso significava tagliare i legami aggiunti per ottimizzare il codice. Non era evidente, però, finché non abbiamo misurato le prestazioni. Infine, in alcuni casi, non siamo riusciti a riprodurre il traffico di produzione nell'ambiente di sviluppo.

Abbiamo cercato di avere una piccola superficie di un'API HTTP universale del servizio di fatturazione. Di conseguenza, abbiamo ottenuto una serie di endpoint/query universali che trasportavano i dati necessari in diversi casi d'uso. E ciò significava che in molti casi d'uso, la maggior parte dei dati era inutile. È un po' un compromesso tra DRY e YAGNI: con DRY, abbiamo solo un endpoint/query che restituisce i record di fatturazione mentre con YAGNI finiamo con dati inutilizzati nell'endpoint che danneggiano solo le prestazioni.

Abbiamo anche notato un altro compromesso quando abbiamo discusso del jitter con il team di fatturazione. Dal punto di vista del cliente (piattaforma), ogni richiesta dovrebbe ricevere una risposta quando la piattaforma ne ha bisogno. I problemi di prestazioni e il sovraccarico del server dovrebbero essere nascosti dietro l'astrazione del servizio di fatturazione. Dal punto di vista del servizio di fatturazione, dobbiamo trovare il modo di rendere i clienti consapevoli delle caratteristiche delle prestazioni del server per resistere al carico.

Ancora una volta, niente qui è nuovo o rivoluzionario. Si tratta di identificare modelli noti in diversi contesti e comprendere i compromessi introdotti dai cambiamenti. L'abbiamo imparato a nostre spese e speriamo di averti risparmiato dal ripetere i nostri errori. Invece di ripetere i nostri errori, senza dubbio farai i tuoi errori e imparerai da essi.

Un ringraziamento speciale ai miei colleghi e compagni di squadra che hanno partecipato ai nostri sforzi:

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