Noi di Asana abbiamo creato un sistema di caricamento dei dati chiamato LunaDb, che funge da spina dorsale della nostra app web. Nonostante il nome, non è un database. Piuttosto, è un sistema simile a GraphQL per il recupero dichiarativo dei dati: in pratica, un modo per caricare la versione più recente dei dati e tutti gli aggiornamenti futuri.
Inizialmente, abbiamo lanciato LunaDb nel 2015 come una riscrittura radicale della nostra infrastruttura di back-end¹. Il componente centrale di questo nuovo sistema era il server di sincronizzazione, un monolite che esegue tutto, dalla sincronizzazione del client al caricamento dei dati e al controllo degli accessi. Senza modifiche significative, questa architettura iniziale si è ampliata ben oltre i livelli inizialmente previsti, fino a raggiungere milioni di utenti attivi settimanali e miliardi di query giornaliere.
Sebbene le prestazioni fossero rimaste elevate, con l'aumento del traffico e della complessità delle funzionalità, è diventato sempre più difficile gestire e migliorare LunaDb a causa dei limiti del server di sincronizzazione.
panoramica della nostra infrastruttura di caricamento dei dati
Perché era difficile da gestire?
Lo spostamento del traffico è costoso
Il server di sincronizzazione gestiva direttamente le connessioni WebSocket persistenti ai client. Ogni websocket era supportato da una sessione client stateful. Quando una connessione veniva interrotta, tutto questo stato veniva eliminato e il client si iscriveva nuovamente a tutti i dati che gli interessavano. Quando ciò accade con molte sessioni, i costi aumentano rapidamente. Quindi abbiamo dovuto fare attenzione quando abbiamo spostato queste connessioni, dato il grande picco di lavoro derivante dalle riconnessioni.
spostamento del traffico
Distribuire server di sincronizzazione significa spostare il traffico
Naturalmente, non è possibile evitare perennemente lo spostamento del traffico. Ogni volta che si desidera eseguire il push di nuovo codice o aumentare/diminuire la scalabilità, è necessario disattivare i server di sincronizzazione, e ciò richiede lo spostamento di tutto il traffico dalle istanze di terminazione.
aggiornamenti continui
I server di sincronizzazione non sono performanti all’avvio
Al contempo, i server di sincronizzazione diventerebbero performanti solo dopo una quantità non trascurabile di riscaldamento del processo. La gestione di entrambi questi problemi ha rappresentato un equilibrio piuttosto fragile e ha richiesto un ampio lavoro di ingegneria in passato.
I server di sincronizzazione sono complessi e difficili da monitorare
Infine, poiché i server di sincronizzazione eseguivano molti bit arbitrari di codice del prodotto (tramite funzioni personalizzate lato server), erano molto vulnerabili a regressioni delle prestazioni basate sul noisy neighbor, che erano difficili da attribuire².
il problema del vicino rumoroso
Una domanda ragionevole da porsi è: “Perché i server di sincronizzazione sono complessi e non performanti all'avvio?”
Una causa comune di questi problemi è il nostro codice prodotto lato server. I principali frammenti di codice del server di sincronizzazione sono scritti in Scala. Nonostante alcune complessità legate alla gestione dello stato della sessione e ai vari aspetti del framework Luna, questo codice di framework/piattaforma si comporta per lo più come previsto (ci sono relativamente pochi problemi operativi e di prestazioni).
D'altra parte, questi valori calcolati dal server del prodotto (li chiamiamo SCV, ma pensa a resolver personalizzati) sono scritti in Typescript. Entrambi i set di codice vengono eseguiti insieme in GraalVM, una VM poliglotta che consente l'utilizzo di più linguaggi tramite il suo Framework Truffle. Poiché sono scritti in Typescript, gli SCV vengono essenzialmente interpretati all’avvio, il che prevedibilmente si traduce in prestazioni e utilizzo della CPU inaccettabili. GraalVM cercherà di eseguire la compilazione just-in-time sugli SCV richiamati. Questo è positivo! GraalVM/Truffle possono ottimizzare notevolmente le loro prestazioni, ma farlo non è gratuito. La compilazione SCV può essere piuttosto costosa (in termini di CPU, cache del codice, ecc.).
la configurazione della nostra VM poliglotta
Perché i due linguaggi?
La nostra prima progettazione per SCV era interamente in Scala. D'altra parte, i nostri sistemi di mutazione e di attività asincrone sono scritti in Javascript/Typescript. Sebbene gli SCV basati su Scala funzionassero, la duplicazione della logica di business tra i nostri sistemi di mutazione e di job asincroni e LunaDb, insieme alla scarsa familiarità degli ingegneri di prodotto con Scala, è diventata un notevole freno alla velocità del prodotto.
Perché GraalVM?
Memorizziamo nella cache molti dati in-process per velocizzare il calcolo (e il ricalcolo) dei risultati degli abbonamenti. L'utilizzo di GraalVM ci offre un modo semplice per condividere queste cache tra i vari linguaggi senza i problemi di correttezza o prestazioni che potrebbero derivare dalla suddivisione delle parti in Scala e TypeScript in container separati.
Perché era difficile migliorare?
Poiché il server svolgeva così tante funzioni ed era relativamente fragile da gestire, tendevamo a evitare modifiche più importanti. Non solo per la complessità del codice, ma anche per l'elevato sovraccarico legato all'implementazione sicura di nuove modifiche.
Sì, mi piace fare domande
Date le difficoltà di funzionamento e miglioramento del server di sincronizzazione, abbiamo preso la difficile decisione di cambiare la nostra architettura. In sostanza, abbiamo deciso di sostituire il server di sincronizzazione monolitico con due tipi di componenti più piccoli:
un broker di sessione che gestisce le connessioni dei client e la risoluzione dello stato
un caricatore sincronizzabile responsabile del caricamento dei dati, ovvero dell'elaborazione delle query dei broker di sessione
broker di sessione e caricatori sincronizzabili
Perché questo è utile?
Immediatamente, questa nuova architettura separa il traffico websocket in movimento (ovvero l'implementazione di broker di sessione) dal riscaldamento di nuovi processi (ovvero l'implementazione di caricatori sincronizzabili). Di conseguenza, possiamo ridurre al minimo le interruzioni distribuendo i loader sincronizzabili separatamente dai session broker.
Ognuno di questi nuovi componenti è più semplice.
I session broker sono molto più leggeri, non richiedono il riscaldamento dei processi e non eseguono alcun codice di prodotto. Di conseguenza, non abbiamo bisogno di distribuirli così spesso e, quando lo facciamo, è abbastanza semplice.
I loader sincronizzabili hanno un'interfaccia più semplice (richieste di sottoscrizione stateless) che è più facile adattare all'autoscaling orizzontale standard dei pod di Kubernetes. Questo rende anche più veloce e semplice il loro warm-up: possiamo semplicemente utilizzare il mirroring del traffico sulle richieste ambient che scorrono tra i session broker e i syncable loader
La nuova architettura ci consente di semplificare notevolmente il processo di sviluppo del prodotto. Fin dall'inizio, le pianificazioni di distribuzione indipendenti del codice del prodotto lato server e la chiamata del codice del prodotto lato client sono state un freno alla velocità e una fonte di lavoro operativo (a causa dell'incompatibilità delle versioni). Poiché i loader sincronizzabili sono ora l'unico processo rimanente che esegue il codice del prodotto e la loro distribuzione non è più dirompente, possiamo ridistribuirli ogni volta che inviamo un nuovo codice del prodotto.
Questa nuova architettura ci consente di adattarci meglio alle nuove funzionalità distribuendo diversi pool di loader sincronizzabili per diversi tipi di carico di lavoro (ad esempio, funzionalità distinte come Posta in arrivo, Attività, Obiettivi, ecc.). Il session broker funziona come un gateway di servizio in grado di controllare direttamente il modo in cui le query di dati vengono instradate a diversi caricatori sincronizzabili a monte.
Fantastico! Questa nuova architettura sembra molto migliore, ma come l'abbiamo realizzata? Il server di sincronizzazione è fondamentalmente un monolite in quanto comprende più funzioni, e suddividere i monoliti è quasi sempre complicato. Nel nostro caso, abbiamo dovuto superare alcuni ostacoli chiave di progettazione.
Scomposizione di PubSub
PubSub, il nostro sistema per implementare la reattività, è stato progettato attorno a un unico processo (il server di sincronizzazione) responsabile del caricamento di nuovi dati e del loro invio al client. Abbiamo dovuto riprogettare PubSub in modo da garantire la correttezza tra questi due tipi di processi, ora indipendenti (caricatori sincronizzabili e session broker).
Vediamo brevemente come viene implementato nei server di sincronizzazione. Nota: potrebbe essere utile leggere il nostro post precedente sulla pipeline di invalidazione, ma forniremo una visione del sistema senza prerequisiti.
Sul server di sincronizzazione, monitoriamo le iscrizioni per sessione. Utilizziamo la pipeline di invalidazione per monitorare continuamente le iscrizioni per verificare la presenza di aggiornamenti. A ogni nuovo messaggio di invalidazione, il server di sincronizzazione ricaricherà tutte le iscrizioni interessate.
I server di sincronizzazione memorizzano in cache in modo massiccio i risultati di oggetti/query del database, i risultati dei resolver personalizzati e i risultati delle iscrizioni precedenti per ottimizzare i ricaricamenti delle iscrizioni (ad esempio, utilizzando un modello di lettura). Gli artefatti memorizzati nella cache vengono invalidati passivamente dalla stessa pipeline di invalidazione utilizzata per le iscrizioni. Ogni volta che cerchiamo di utilizzare i dati memorizzati nella cache, ne verificheremo la validità e, se necessario, ricorreremo al ricalcolo.
Possiamo osservare una chiara dipendenza tra il ricaricamento delle iscrizioni e l'invalidazione dei dati memorizzati nella cache. Dopo aver ricevuto un'invalidazione, se ricarichiamo una sottoscrizione prima che i dati memorizzati nella cache siano stati invalidati, potremmo calcolare un risultato obsoleto. Quando sia il caricamento dei dati che la gestione delle iscrizioni avvengono nello stesso processo, è banale garantire questa dipendenza: basta invalidare le cache prima di ricaricare.
La condizione di gara può causare dati obsoleti durante l'aggiornamento
Nella nuova architettura che proponiamo, i broker di sessione e i loader sincronizzabili sono entrambi consumer indipendenti della pipeline di invalidazione. Come possiamo quindi imporre che le cache vengano invalidate prima che le iscrizioni vengano ricaricate?
Versioning di richiesta e risposta
Avremmo potuto risolvere questo problema facendo in modo che la pipeline di invalidazione consegnasse i messaggi in modo sincronizzato. Oppure avremmo potuto creare un meccanismo per imporre la garanzia di ordinamento, in modo che nessun messaggio della pipeline di invalidazione arrivasse al session broker prima del loader sincronizzabile. Entrambe queste soluzioni presentavano però compromessi non ideali: in particolare, aumentavano l'accoppiamento tra session broker e syncable loader.
Invece, abbiamo risolto questo problema ampliando il nostro protocollo di caricamento dei dati con versioni di richiesta e risposta basate sul loro relativo avanzamento nei flussi di invalidazione. Poiché il flusso rappresenta un ordinamento totale degli aggiornamenti ai database, il suo avanzamento può essere utilizzato come un contatore di versione globale.
versioni di richiesta e risposta
Ricaricamenti di invalidazione
I server di sincronizzazione caricano una grande quantità di dati: la maggior parte di tutte le letture per il sito. La nostra nuova architettura richiede che i session broker e i syncable loader scambino molti dati sulla rete. Per i nuovi abbonamenti, questo sovraccarico di rete è relativamente trascurabile. Tuttavia, questo è particolarmente inefficiente per i ricaricamenti di invalidazione, poiché spesso non è necessario restituire la risposta completa, ma solo i dati aggiornati³. Alcuni casi sono particolarmente problematici in questo senso. Immagina un caso in cui un utente ha eseguito il paging di 10.000 attività in un progetto e un altro utente modifica costantemente le descrizioni delle attività in questo progetto: tutte le attività dovrebbero essere inviate a ogni invalidazione! Chiaramente, l’ideale è inviare solo i dati aggiornati, ma come possiamo implementarlo in modo efficiente?
ricaricamenti per invalidazione
Fingerprinting
Affinché il loader sincronizzabile calcoli il delta dei dati aggiornati, deve sapere di quali dati dispone già il richiedente. Ma passare i dati più recenti con la richiesta sarebbe costoso quanto restituire il risultato completo. Dobbiamo rappresentare i dati in modo più efficiente in termini di spazio.
Bene, l'hashing è un ottimo modo per risparmiare spazio. Ogni bit di dati granulari che ci interessa è chiamato syncable. Possiamo calcolare un hash murmur a 128 bit di ogni syncable serializzato da utilizzare come impronta digitale ⁴. In particolare, questa impronta digitale è un identificatore per quella versione del syncable.
Ovunque monitoriamo i dati syncable, possiamo invece utilizzare le loro impronte digitali. Ora, quando vogliamo monitorare una risposta completa all'iscrizione, possiamo semplicemente usare una serie di impronte digitali senza dover passare tutti i dati!
Nota a margine: cos’è un syncable e come si collega alle iscrizioni?
I syncable sono i contenuti del risultato di un abbonamento. Quando carichiamo un abbonamento, i risultati vengono restituiti come un insieme di syncable. Più specificamente, un syncable può essere un oggetto, una query o un risultato SCV.
sincronizzabile con la mappatura dell'abbonamento
Chiaramente, ogni iscrizione è mappata a più syncable. Tuttavia, gli elementi sincronizzabili possono essere condivisi da più iscrizioni (quando caricano dati sovrapposti). Pertanto, in realtà esiste una mappatura molti-a-molti tra iscrizioni e syncable.
Passiamo l'insieme di queste impronte digitali con ogni richiesta dal broker di sessione. Sul loader del syncable, calcoliamo la risposta completa, calcoliamo il suo set di fingerprint, escludiamo tutti i dati che si sovrappongono alla richiesta e restituiamo il delta.
Date le dimensioni e la criticità della modifica, abbiamo suddiviso il rollout in circa 4 fasi.
Fase 1 - Refactoring del monolite
Suddividere il nostro codice di gestione delle sessioni e di caricamento dei dati altamente accoppiato in componenti indipendenti
Fase 2 - Syncable-loader locale
Utilizzare il nuovo componente di caricamento dei dati per creare un server gRPC locale ed eseguire la migrazione del caricamento dei dati
Fase 3 - Syncable-loader remoto
Creare un nuovo binario syncable-loader e una nuova distribuzione
Migrare tutto il caricamento dei dati del sync-server alla nostra nuova distribuzione syncable-loader
Fase 4 - Binario session-broker separato
Creare un nuovo binario session-broker e una nuova distribuzione
Migrare tutto il traffico dai sync-server ai session-broker
Tantissime. Da dove cominciare?
Risposte di grandi dimensioni
Un problema in cui ci siamo imbattuti rapidamente è stato quello delle grandi dimensioni delle risposte. Poiché il caricamento dei dati sui server di sincronizzazione avveniva tutto all'interno dello stesso processo, fino ad ora questo non si era rivelato un problema enorme⁵. Tuttavia, una volta iniziato a caricare i dati oltre un confine gRPC locale, abbiamo iniziato a riscontrare molti problemi.
Avevamo sempre sospettato che alcune risposte potessero essere di grandi dimensioni, ma quando abbiamo iniziato a indagare su questo aspetto, abbiamo trovato risultati davvero sorprendenti. Avevamo migliaia di caricamenti al giorno che superavano regolarmente i 100 MiB! Non potevamo fisicamente restituire risposte così grandi con un metodo gRPC unario (si inizia a raggiungere la dimensione massima del frame http2). Cosa fare?
Abbiamo preso in considerazione alcuni modi per risolvere il problema in modo sistematico, ma alla fine abbiamo concluso che dovevamo affrontare le cause alla radice. Avremmo potuto implementare lo streaming gRPC lato server, ma gli elevati costi di serializzazione sostenuti e l'elevata contesa dei socket avrebbero avuto un effetto piuttosto regressivo su latenza e throughput. Avremmo potuto semplicemente rifiutare queste risposte, ma il tasso di occorrenza era troppo alto per rendere accettabile questa soluzione.
Abbiamo optato per un approccio in tre fasi in cui contrassegniamo tutte le risposte di grandi dimensioni, analizziamo ed eliminiamo ogni caso problematico e quindi applichiamo un limite superiore rigoroso alla dimensione della risposta.
Abbiamo contrassegnato tutti i carichi di dimensioni superiori a 1 MB e registrato eventi dettagliati relativi a origine, utilizzo e suddivisione dei dati. C'erano alcuni casi d'uso costosi, ma il più noto era probabilmente quello dei blob delle miniature degli allegati. È emerso che venivano codificati come stringhe base64 e inclusi nelle risposte serializzate. Erano tollerabili in piccole quantità, ma diventavano rapidamente enormi quando venivano caricati in massa, come quando si caricava una vista basata su griglia che rendeva le miniature degli allegati per ogni attività.
Siamo riusciti a risolvere progressivamente problemi come questi limitando le miniature per le risposte enormi, utilizzando miniature più piccole e, infine, rimuovendo i dati binari dalla risposta. Dopo semplici misure di mitigazione come queste, il numero di risposte enormi è scomparso e successivamente siamo stati in grado di applicare limitazioni alle dimensioni delle risposte a livello di framework⁶.
Collisioni di argomenti
Un altro strano problema che abbiamo riscontrato è stato quello delle collisioni di argomenti pubsub. È emerso che avevamo utilizzi non conformi del Framework che generavano lo stesso topic di sottoscrizione indipendentemente dal dominio. Quando pubsub si verificava solo su un singolo tipo di processo, gli effetti erano relativamente benigni. In genere, un singolo argomento pubsub corrisponde ai dati di un singolo dominio. Tuttavia, con pubsub ora suddiviso tra session-broker e syncable-loader, era possibile che i due tipi di processo fossero in disaccordo sul dominio di un particolare argomento. Quando ciò accadeva, assistevamo a un tasso elevato e stabile di ricaricamenti di invalidazione a causa di questa “mancata corrispondenza di dominio”. Fortunatamente, la correzione è stata abbastanza semplice, ma è interessante come questo bug sia sopravvissuto nel nostro Framework per così tanto tempo senza essere rilevato.
Ricalibrazione dei carichi di lavoro
I session broker e i syncable loader hanno carichi di lavoro notevolmente diversi da quelli dei server di sincronizzazione originali. I session broker sono responsabili solo della gestione della sessione e i syncable loader sono responsabili solo del caricamento dei dati.
Non eravamo esattamente sicuri di come questo avrebbe influito sul loro fabbisogno di risorse, quindi abbiamo iniziato entrambi con richieste di risorse (cpu/mem) e impostazioni di horizontal pod autoscaler (HPA) simili.
session-brokers
Come abbiamo osservato, è diventato chiaro che i broker di sessione erano notevolmente più leggeri. Funzionavano in modo affidabile con una CPU molto bassa (semmai erano molto più vincolati alla memoria⁷). Poche repliche sembravano sufficienti per servire il traffico di un'intera cella dell'infrastruttura. Tuttavia, quando abbiamo effettivamente ridotto le minReplicas sull'HPA, abbiamo osservato che i ricaricamenti dei dati e la latenza di ricaricamento sono aumentati. Cosa stava succedendo?
In breve, avevamo trascurato di considerare tutte le nostre impostazioni condivise relative al dimensionamento della cache e ai limitatori. Con solo poche repliche, ogni session broker gestiva molte più sessioni per pod (circa 3,5 volte) rispetto a un tipico server di sincronizzazione. Poiché ciascuno di essi vedeva molti più dati, riempiva completamente le proprie cache di topic ⇔ subscription di PubSub e ogni espulsione innescava un ricaricamento (per motivi di sicurezza). Aumentando opportunamente questa soglia di circa 6 volte, abbiamo risolto il problema dei tassi elevati di espulsione dalla cache e di ricarica. Allo stesso modo, abbiamo scoperto che i nostri limitatori di ricarica gerarchici erano configurati in modo errato per le nuove velocità di traffico in arrivo. Analogamente, il corretto dimensionamento di queste impostazioni dei limitatori ha portato a una drastica riduzione della latenza di ricarica e del ritardo di reattività end-to-end (ovvero il tempo che impiega un'app web per vedere la propria scrittura) di circa 5-10 volte.
syncable-loaders
D'altra parte, i loader sincronizzabili erano notevolmente più pesanti del previsto. Ogni server caricava più sottoscrizioni al secondo (circa 1,5 volte) rispetto a un server di sincronizzazione con risorse equivalenti. A differenza dei session broker, erano molto più vincolati alla CPU ⁸.
È interessante notare che una parte non trascurabile della CPU è stata attribuita a un aumento delle deottimizzazioni di Truffle relative al nostro codice TS SCV. Molto probabilmente, ciò è stato causato dal fatto che ogni loader syncable accedeva a una parte maggiore del nostro codice SCV. A ogni modo, ha richiesto un modesto aumento delle dimensioni della nostra cache del codice⁹.
Riscaldamento del processo
Il riscaldamento dei processi per il caricamento dei dati è stato storicamente una sfida. Fortunatamente, nella nostra nuova architettura, è un po' più semplice. La nostra interfaccia principale per i syncable-loader è costituita da query di dati stateless, quindi possiamo riscaldarli semplicemente riproducendo o eseguendo il mirroring del traffico esistente tra i session-broker e i syncable-loader.
D'altra parte, dobbiamo ancora affrontare molte delle stesse sfide che si presentano con il riscaldamento dei sync-server. Principalmente, il riscaldamento dei processi richiede molta CPU, e questo causa ogni sorta di problema di noisy neighbor (per noi, la metrica rilevante qui è il partial stall, poiché non stiamo raggiungendo i limiti di throttling di k8s) all'avvio. Un bel miglioramento che abbiamo apportato in questo caso è stato l'utilizzo del ridimensionamento in-place del pod per limitare le risorse di un syncable-loader all'avvio, ma consentirgli di aumentare dopo l'avvio.
Nonostante ciò, il warming di ogni pod richiede ancora alcuni minuti. Osservando i profili JFR, riteniamo che il principale collo di bottiglia sia la compilazione insufficiente del codice TS SCV durante il warming e crediamo che ci sia più margine di miglioramento in questo ambito. Stiamo cercando attivamente di riscaldare in modo più preciso i percorsi pertinenti con metodi più mirati e di rimodellare le nostre interfacce TS¹⁰ per una migliore compilazione.
I nostri broker di sessione non sono responsabili del caricamento dei dati e in pratica non hanno mai richiesto alcun tipo di riscaldamento.
La nostra nuova architettura ha ridotto significativamente la complessità operativa, ha accelerato la distribuzione e la velocità del codice e ha aperto la strada a future opportunità di velocità e scalabilità.
La nostra nuova architettura si adatta semplicemente in modo automatico alle variazioni del traffico di lettura totale senza richiedere una complessa gestione del traffico. Ogni tipo di operazione di spostamento del traffico viene gestita in modo ordinato semplicemente riavviando i pod. È necessario avviare tutte le sessioni? Esegui il ciclo di tutti i broker di sessione. Devi svuotare le nostre cache? Esegui il ciclo di tutti i caricatori sincronizzabili. Entrambe le operazioni sono sicure per quanto riguarda l'uptime.
In passato, la velocità di sviluppo del prodotto era principalmente limitata da colli di bottiglia dovuti al deployment di server di sincronizzazione. Nel nostro nuovo mondo, dobbiamo solo implementare i syncable-loader per implementare il codice del prodotto. Lo abbiamo fatto spostando i syncable-loader nella loro cella distribuibile, che stiamo cercando di distribuire più frequentemente (e, in ultima analisi, insieme al codice del prodotto). La distribuzione dei syncable-loader è già circa il 40% più veloce (circa 20 minuti più veloce) rispetto ai sync-server (possiamo tranquillamente aumentare la velocità molto di più) e puntiamo a incrementi maggiori in futuro.
In particolare, i miglioramenti delle prestazioni non erano un obiettivo di questo lavoro, ma vale la pena parlarne, data la misura in cui la progettazione e l'implementazione li hanno coinvolti. La latenza del calcolo dei risultati delle query è effettivamente migliorata nel nostro nuovo sistema (probabilmente grazie a un migliore bilanciamento del carico/ottimizzazione/ridimensionamento automatico). Di conseguenza, la latenza della sottoscrizione iniziale è notevolmente migliore. D'altra parte, la latenza di riflessione della mutazione end-to-end (cioè il tempo necessario per distribuire una modifica ad altre sessioni) è pressoché la stessa. Le tracce mostrano che ciò è probabilmente dovuto all'asimmetria nel consumo del flusso PubSub tra i broker di sessione e i loader sincronizzabili (i loader sincronizzabili non possono servire una richiesta finché non sono aggiornati con la versione della richiesta).
Il nostro sistema attuale è notevolmente migliorato, ma limita ancora la velocità del prodotto richiedendo considerazioni sulla compatibilità con le versioni precedenti/successive a ogni rilascio. Tuttavia, con tutti i nostri miglioramenti nella velocità di distribuzione, ora è possibile distribuire tutte le nostre modifiche al modello dati contemporaneamente, eliminando così qualsiasi necessità di ragionare sulla compatibilità con le versioni precedenti/successive. Stiamo lavorando attivamente per realizzare questo obiettivo nel prossimo futuro.
Una delle nostre principali motivazioni per questo lavoro sono state le regressioni delle prestazioni, difficili da attribuire. In particolare, questa è un’area in cui non abbiamo apportato miglioramenti significativi a seguito di questo lavoro. Il lato positivo è che ora questo è uno dei principali problemi che affronteremo in futuro. A differenza della maggior parte del lavoro qui discusso, si tratta di un problema notevolmente più interfunzionale che coinvolge il dominio del prodotto, il modello di dati, il Framework e considerazioni sull'infrastruttura.
pool di lavoratori del loader sincronizzabili per funzionalità
Siamo entusiasti di esplorare soluzioni che interessano l'infrastruttura della piattaforma (ad esempio, pool di worker per funzionalità), i Framework della piattaforma e gli strumenti della piattaforma (ad esempio, test black box/white box).
Arvind Vijayakumar è un ingegnere del team LunaDb, dove lavora per contribuire a creare e scalare la piattaforma principale di caricamento dei dati di Asana, l’infrastruttura fondamentale che garantisce agli utenti di vedere sempre aggiornamenti accurati, reattivi e velocissimi nelle nostre app web e API.
Questo lavoro per scalare LunaDb è stato un impegno di team di lunga durata che ha attraversato gli ultimi anni, coinvolgendo molti membri di LunaDb, sia presenti che passati: Brandon Zhang, Alex Matevish, Sean Wentzel, Eric Walton, Spencer Yu, Sophia Yao, George Ong, Tyler Prete, Koushik Ghosh, Natan Dubitski, Vinodh Chandra Murthy e altri
In particolare, l'abbiamo creato prima che Facebook rendesse open source GraphQL
Ci sono maggiori dettagli nel post precedente sul riscaldamento dei processi, ma in breve ci affidiamo al fatto di consentire ai pod di sincronizzazione di aumentare arbitrariamente la potenza come modo per scalare rapidamente ai picchi di traffico locale
Effettuiamo inoltre aggiornamenti con una granularità per oggetto anziché per campo (per motivi storici). Ciò aumenta la quantità di dati coinvolti nei ricaricamenti di invalidazione.
Usiamo un murmur hash a 128 bit per evitare collisioni.
In particolare, utilizziamo la compressione websocket da molto tempo, il che probabilmente mitiga gli effetti percepiti dal client.
In particolare, inizialmente abbiamo anche avuto alcuni problemi con l'overhead dell'hop di rete. Dopo alcune indagini, abbiamo scoperto che la maggior parte dell'overhead era in realtà dovuta alla nostra service mesh (Istio/Envoy) e abbiamo apportato alcune modifiche mirate per migliorare le prestazioni in questo ambito.
Un fattore significativo nella memoria conservata è stata la riscrittura del nostro archivio sincronizzabile lato server per conservare solo gli hash dei dati. Non era un problema semplice e potremmo pubblicare un post di follow-up su questo argomento! Senza questo, i dati del client memorizzati facevano sì che i broker di sessione utilizzassero una quantità di memoria notevolmente maggiore.
Prima di questo, tutti i server di sincronizzazione funzionavano su istanze ottimizzate per la memoria. I loader sincronizzabili, d'altra parte, traggono vantaggio da tipi di nodi più ricchi di CPU, come le istanze miste.
Una caratteristica degna di nota dell'esecuzione di TS su GraalVM in questo modo è che utilizza una quantità di cache del codice leggermente superiore a quella tipica di un'applicazione Scala/Java JVM più standard.
Per quanto ne sappiamo, un elevato polimorfismo rende la compilazione TS più costosa. Stiamo valutando di passare a interfacce monomorfe e di sfruttare le specializzazioni polimorfe di reporting