Mise à l'échelle de LunaDb, notre système interne de chargement de données déclaratives

Image du contributeur – Équipe AsanaArvind Vijayakumar
5 mars 2026
17 min de lecture
facebookx-twitterlinkedin
Gemini said Two female professionals collaborating at a desk in a bright, modern office. One woman sits while the other leans in, smiling and using the computer mouse, with a male colleague working in the background.

Chez Asana, nous avons créé un système de chargement de données appelé LunaDb, qui constitue l’épine dorsale de notre application Web. Malgré son nom, il ne s’agit pas d’une base de données. Il s’agit plutôt d’un système de type GraphQL permettant de récupérer des données de manière déclarative : en gros, un moyen de charger la dernière version des données et toutes les mises à jour futures.

Nous avons initialement lancé LunaDb en 2015 dans le cadre d'une refonte radicale de notre infrastructure backend¹. Le composant central de ce nouveau système était le serveur de synchronisation, un monolithe qui effectue toutes les opérations, de la synchronisation du client au chargement des données et au contrôle d'accès. Sans modifications significatives, cette architecture initiale a évolué bien au-delà des niveaux initialement prévus, jusqu'à atteindre des millions d'utilisateurs actifs chaque semaine et des milliards de requêtes quotidiennes. 

Bien que les performances soient restées solides, à mesure que le trafic et la complexité des fonctionnalités augmentaient, il est devenu de plus en plus difficile d'exploiter et d'améliorer LunaDb en raison des contraintes du serveur de synchronisation.

Présentation de notre infrastructure de chargement de données

aperçu de notre infrastructure de chargement de données

Pourquoi était-il difficile à exploiter ?

Le basculement du trafic est coûteux

Le serveur de synchronisation gérait directement les connexions WebSocket persistantes aux clients. Chaque WebSocket était soutenu par une session client avec état. Lorsqu'une connexion était interrompue, tout cet état était supprimé et le client se réabonnait à toutes les données qui l'intéressaient. Lorsque cela se produit avec de nombreuses sessions, cela devient rapidement coûteux. Nous avons donc dû faire preuve de prudence lors du transfert de ces connexions, compte tenu de l'augmentation considérable de la charge de travail liée aux reconnexions.

le basculement du trafic

le basculement du trafic

Le déploiement de serveurs de synchronisation implique le basculement du trafic

Bien entendu, il est impossible d'éviter perpétuellement le déplacement du trafic. Chaque fois que vous souhaitez déployer un nouveau code ou augmenter/diminuer la capacité, vous devez mettre hors service les serveurs de synchronisation, ce qui nécessite de déplacer tout le trafic des instances de terminaison.

mises à jour régulières

mises à jour régulières

Les serveurs de synchronisation ne sont pas performants au démarrage

Dans le même temps, les serveurs de synchronisation ne devenaient performants qu'après une période de préchauffage non négligeable du processus. La gestion de ces deux problèmes a constitué un équilibre assez fragile et a nécessité un travail d'ingénierie considérable par le passé.

Les serveurs de synchronisation sont complexes et difficiles à surveiller

Enfin, comme les serveurs de synchronisation exécutaient de nombreux éléments arbitraires du code produit (via des fonctions personnalisées côté serveur), ils étaient très vulnérables aux régressions de performances dues à des voisins bruyants, difficiles à attribuer². 

le problème du voisin bruyant

le problème du voisin bruyant


Une question raisonnable à se poser est la suivante : « Pourquoi les serveurs de synchronisation sont-ils complexes et peu performants au démarrage ? »

Une cause fréquente de ces problèmes est notre code produit côté serveur. Les principaux éléments du code du serveur de synchronisation sont écrits en Scala.  Malgré certaines complexités liées à la gestion de l'état de la session et aux différents aspects du cadre Luna, ce code de cadre/plateforme se comporte généralement comme prévu (il y a relativement peu de problèmes opérationnels et de performance). 

En revanche, ces valeurs calculées par le serveur du produit (que nous appelons SCV, mais qui sont en fait des résolveurs personnalisés) sont écrites en Typescript. Les deux ensembles de code s'exécutent ensemble dans GraalVM, une machine virtuelle polyglotte qui permet l'utilisation de plusieurs langages via son cadre Truffle. Comme elles sont écrites en TypeScript, les SCV sont essentiellement interprétées au démarrage, ce qui entraîne, de manière prévisible, des performances et une utilisation du processeur inacceptables. GraalVM essaiera d'effectuer une compilation juste-à-temps sur les SCV invoquées. C'est bien ! GraalVM/Truffle peut considérablement optimiser leurs performances, mais cela a un coût. La compilation SCV peut être assez coûteuse (en CPU, cache de code, etc.). 

notre configuration de VM polyglotte

notre configuration de VM polyglotte

Pourquoi utiliser les deux langages ?

Notre première conception pour les SCV était entièrement en Scala. En revanche, nos systèmes de mutation et de tâches asynchrones sont écrits en JavaScript/TypeScript. Bien que les SCV basés sur Scala fonctionnaient, la duplication de la logique Business entre nos systèmes de mutation et de tâches asynchrones et LunaDb, ainsi que le manque de familiarité des ingénieurs produit avec Scala, ont considérablement ralenti la vélocité produit. 

Pourquoi GraalVM ?

Nous mettons en cache un grand nombre de données en cours de traitement pour accélérer le calcul (et le recalcul) des résultats d'abonnement. L'utilisation de GraalVM nous offre un moyen simple de partager ces caches entre les langages sans les problèmes d'exactitude ou de performance qui pourraient résulter de la division des parties Scala et TypeScript en conteneurs distincts.


Pourquoi était-il difficile de l'améliorer ?

Étant donné que le serveur effectuait de nombreuses tâches et qu'il était relativement fragile à utiliser, nous avions tendance à éviter les changements importants. Non seulement en raison de la complexité du code, mais aussi en raison des frais généraux élevés liés au déploiement en toute sécurité de nouvelles modifications.

Comment résolvons-nous les problèmes ?

Oui, j'aime poser des questions

Compte tenu des difficultés liées à l'exploitation et à l'amélioration du serveur de synchronisation, nous avons pris la décision difficile de modifier notre architecture. Nous avons principalement décidé de remplacer le serveur de synchronisation monolithique par deux types de composants plus petits :

  • un courtier de session qui gère les connexions client et la résolution d'état

  • un chargeur synchronisable responsable du chargement des données, c'est-à-dire de l'exécution des requêtes des brokers de session

agents de session et chargeurs synchronisables

agents de session et chargeurs synchronisables

Pourquoi cela est-il utile ?

Immédiatement, cette nouvelle architecture sépare le déplacement du trafic WebSocket (c'est-à-dire le déploiement de brokers de session) du préchauffage de nouveaux processus (c'est-à-dire le déploiement de chargeurs synchronisables). Par conséquent, nous pouvons minimiser les perturbations en déployant les chargeurs synchronisables séparément des brokers de session.

Chacun de ces nouveaux composants est plus simple.

  • Les brokers de session sont beaucoup plus légers, ne nécessitent aucun préchauffage de processus et n'exécutent aucun code produit. Par conséquent, nous n’avons pas besoin de les déployer aussi souvent, et lorsque nous le faisons, c’est assez simple.

  • Les chargeurs synchronisables ont une interface plus simple (demandes d'abonnement sans état) qui est plus facile à adapter à la mise à l'échelle automatique horizontale standard des pods Kubernetes. Cela rend également leur préchauffage plus rapide et plus simple : il suffit d'utiliser la mise en miroir du trafic sur les requêtes ambiantes qui circulent entre les brokers de session et les chargeurs synchronisables

La nouvelle architecture nous permet de simplifier considérablement le processus de développement produit. Depuis le début, les calendriers de mise en œuvre indépendants du code produit côté serveur et de l'appel du code produit côté client ont freiné la vitesse d'exécution et ont été source de travail opérationnel (en raison de l'incompatibilité des versions). Étant donné que les chargeurs synchronisables sont désormais le seul processus restant qui exécute le code produit et que leur déploiement n'est plus perturbateur, nous pouvons les redéployer chaque fois que nous publions un nouveau code produit.

Cette nouvelle architecture nous permet de mieux nous adapter aux nouvelles fonctionnalités en déployant différents pools de chargeurs synchronisables pour différents types de charges de travail (par exemple, différentes fonctionnalités distinctes telles que la boîte de réception, les tâches, les objectifs, etc.). Le broker de session fonctionne comme une passerelle de service qui peut contrôler directement la façon dont les requêtes de données sont acheminées vers différents chargeurs synchronisables en amont.

Quels sont les principaux défis de conception ?

Parfait ! Cette nouvelle architecture semble bien meilleure, mais comment l'avons-nous concrétisée ? Le serveur de synchronisation est essentiellement un monolithe en ce sens qu'il englobe plusieurs fonctions, et il est presque toujours délicat de décomposer les monolithes. Dans notre cas, nous avons dû surmonter certains obstacles majeurs en matière de conception. 

Diviser PubSub

PubSub, notre système d'implémentation de la réactivité, a été conçu autour d'un processus unique (le serveur de synchronisation) chargé de charger les nouvelles données et de les envoyer au client. Nous avons dû repenser PubSub de manière à garantir l'exactitude de ces deux types de processus désormais indépendants (chargeurs synchronisables et brokers de session). 

Penchons-nous brièvement sur la façon dont il est mis en œuvre dans les serveurs de synchronisation. Remarque : il peut être utile de lire notre article précédent sur le pipeline d’invalidation, mais nous fournirons une vue du système sans prérequis.

Sur le serveur de synchronisation, nous suivons les abonnements par session. Nous utilisons le pipeline d'invalidation pour surveiller en permanence les mises à jour des abonnements. À chaque nouveau message d'invalidation, le serveur de synchronisation rechargera tous les abonnements concernés. 

Les serveurs de synchronisation mettent en cache de manière intensive les résultats des objets/requêtes de la base de données, les résultats des résolveurs personnalisés et les résultats des abonnements précédents afin d'optimiser les rechargements d'abonnements (c'est-à-dire en utilisant un modèle de lecture directe). Les artefacts mis en cache sont invalidés passivement par le même pipeline d'invalidation que celui utilisé pour les abonnements. Chaque fois que nous essayons d'utiliser des données mises en cache, nous vérifions leur validité et nous revenons à un nouveau calcul si nécessaire.

Nous pouvons observer une dépendance claire entre le rechargement des abonnements et l'invalidation des données mises en cache. À la réception d'une invalidation, si nous rechargeons un abonnement avant que les données mises en cache n'aient été invalidées, nous risquons de calculer un résultat obsolète. Lorsque le chargement des données et la gestion des abonnements ont lieu sur le même processus, il est très simple de garantir cette dépendance : il suffit d'invalider les caches avant le rechargement. 

Une condition de concurrence peut entraîner des données obsolètes lors de la mise à jour

Une condition de concurrence peut entraîner des données obsolètes lors de la mise à jour

Dans la nouvelle architecture que nous proposons, les brokers de session et les chargeurs synchronisables sont tous deux des consommateurs indépendants du pipeline d'invalidation. Comment pouvons-nous alors faire en sorte que les caches soient invalidés avant que les abonnements ne soient rechargés ?

Gestion des versions des demandes et des réponses

Nous aurions pu résoudre ce problème en faisant en sorte que le pipeline d’invalidation transmette les messages de manière synchronisée. Ou nous aurions pu créer un mécanisme pour appliquer la garantie d’ordre selon laquelle aucun message du pipeline d’invalidation n’arrive au broker de session avant le chargeur synchronisable. Ces deux solutions présentaient toutefois des compromis peu idéaux : elles augmentaient de manière critique le couplage des brokers de session et des chargeurs synchronisables.

Au lieu de cela, nous avons résolu ce problème en complétant notre protocole de chargement de données par des versions de requête et de réponse basées sur leur progression relative dans les flux d'invalidation. Étant donné que le flux représente un ordre total des mises à jour apportées aux bases de données, la progression de notre flux peut être utilisée comme un compteur de version global.

versions de demande et de réponse

versions de demande et de réponse

Rechargements d'invalidation

Les serveurs de synchronisation chargent une grande quantité de données, la majorité de toutes les lectures pour le site. Notre nouvelle architecture nécessite que les brokers de session et le chargeur synchronisable échangent beaucoup de données sur le réseau. Pour les nouveaux abonnements, cette surcharge réseau est relativement négligeable. Cependant, cela est particulièrement inefficace pour les rechargements d’invalidation, car nous n’avons souvent pas besoin de renvoyer la réponse complète, mais uniquement les données mises à jour³. Certains cas sont particulièrement problématiques à cet égard. Imaginez un cas où un utilisateur a paginé 10 000 tâches dans un projet, et qu'un autre utilisateur modifie constamment les descriptions des tâches dans ce projet : toutes les tâches devraient être envoyées à chaque invalidation ! Il est évidemment préférable de ne renvoyer que les données mises à jour, mais comment mettre cela en œuvre efficacement ?

rechargements d'invalidation

rechargements d'invalidation

Empreinte numérique

Pour que le chargeur synchronisable calcule le delta des données mises à jour, il doit savoir quelles données le demandeur possède déjà. Mais transmettre les dernières données avec la demande serait aussi coûteux que de renvoyer le résultat complet.  Nous devons représenter les données de manière plus compacte.

Eh bien, le hachage est un excellent moyen d'économiser de l'espace. Chaque bit de données granulaires qui nous intéresse est appelé un syncable. Nous pouvons calculer un hachage murmur de 128 bits de chaque syncable sérialisé à utiliser comme empreinte ⁴. Plus précisément, cette empreinte est un identifiant pour cette version du syncable.

Partout où nous suivons des données syncables, nous pouvons utiliser leurs empreintes digitales à la place. Désormais, lorsque nous souhaitons suivre une réponse d'abonnement complète, nous pouvons simplement utiliser un ensemble d'empreintes digitales sans avoir à transmettre l'intégralité des données !


Remarque : qu’est-ce qu’un élément synchronisable et quel est son lien avec les abonnements ?

Les éléments synchronisables sont le contenu du résultat d’un abonnement. Lorsque nous chargeons un abonnement, les résultats sont renvoyés sous la forme d'un ensemble de syncables. Plus précisément, un élément synchronisable peut être un objet, une requête ou un résultat SCV.

synchronisable avec le mappage d'abonnement

synchronisable avec le mappage d'abonnement

De toute évidence, chaque abonnement est lié à plusieurs éléments synchronisables. Cependant, les éléments synchronisables peuvent être partagés par plusieurs abonnements (lorsqu'ils chargent des données qui se chevauchent). Par conséquent, il existe en fait un mappage plusieurs-à-plusieurs entre les abonnements et les éléments synchronisables.


Nous transmettons l'ensemble de ces empreintes avec chaque demande du courtier de session. Sur le chargeur d'éléments synchronisables, nous calculons la réponse complète, calculons son ensemble d'empreintes, excluons toutes les données qui chevauchent la demande et renvoyons le delta.

Comment y parvenir ?

Compte tenu de l'ampleur et de la criticité du changement, nous avons divisé le déploiement en 4 étapes environ. 

Étape 1 - Refactorisation du monolithe

  • Décomposer notre code de gestion de session et de chargement de données hautement couplé en composants indépendants

Étape 2 - Chargeur synchronisable local

  • Utiliser le nouveau composant de chargement de données pour créer un serveur gRPC local et migrer le chargement de données

étapes 1 et 2

Étape 3 - Chargeur syncable distant

  • Créer un nouveau binaire syncable-loader et sa mise en œuvre

  • Migrer tout le chargement de données du serveur de synchronisation vers notre nouvelle mise en œuvre de syncable-loader

Étape 4 - Binaire session-broker séparé

  • Créer un nouveau binaire session-broker et sa mise en œuvre

  • Migrer tout le trafic des serveurs de synchronisation vers les brokers de session

étapes 3 - 4

Quelles difficultés avons-nous rencontrées ?

Tellement. Par où commencer ?

Réponses volumineuses

L'un des problèmes que nous avons rapidement rencontrés était la taille importante des réponses. Étant donné que le chargement des données sur les serveurs de synchronisation se faisait dans le cadre du même processus, cela ne s’était pas révélé être un problème majeur jusqu’à présent⁵. Cependant, une fois que nous avons commencé à charger des données au-delà d'une limite gRPC locale, nous avons commencé à rencontrer de nombreux problèmes. 

Nous avions toujours soupçonné que certaines réponses pouvaient être volumineuses, mais lorsque nous avons commencé à enquêter sur ce point, nous avons obtenu des résultats vraiment surprenants. Nous avions des milliers de chargements par jour dépassant régulièrement 100 MiB ! Nous ne pouvions physiquement pas renvoyer des réponses aussi volumineuses via une méthode gRPC unaire (on commence à atteindre la taille maximale de trame http2). Que faire ?

Nous avons envisagé plusieurs façons de résoudre ce problème de manière systématique, mais nous avons finalement conclu que nous devions nous attaquer aux causes racines. Nous aurions pu mettre en œuvre le streaming gRPC côté serveur, mais les coûts de sérialisation élevés et la contention accrue des sockets auraient eu un effet plutôt régressif sur la latence et le débit. Nous aurions pu simplement rejeter ces réponses, mais le taux d'occurrence était trop élevé pour que cela soit acceptable. 

Nous avons opté pour une approche en trois phases : nous marquons toutes les réponses volumineuses, analysons et éliminons chaque cas problématique, puis imposons une limite supérieure stricte à la taille des réponses.

Nous avons marqué tous les chargements supérieurs à 1 Mo et enregistré des événements détaillés concernant la source, l'utilisation et la répartition des données. Il y avait plusieurs cas d'utilisation coûteux, mais le plus notoire était probablement celui des objets blob de vignettes de pièces jointes. Il s'avère qu'ils étaient encodés en tant que chaînes base64 et inclus dans les réponses sérialisées. Ils étaient tolérables en petit nombre, mais devenaient rapidement énormes lorsqu'ils étaient chargés en masse, par exemple lors du chargement d'une vue en grille qui affichait des vignettes de pièces jointes pour chaque tâche.

Nous avons pu progressivement résoudre ce type de problèmes en limitant les vignettes pour les réponses volumineuses, en utilisant des vignettes plus petites et, finalement, en supprimant les données binaires de la réponse. Après de simples mesures d'atténuation comme celles-ci, le nombre de réponses volumineuses a disparu et nous avons ensuite pu appliquer des limites de taille de réponse au niveau du framework⁶.

Collisions de sujets

Un autre problème étrange que nous avons rencontré était celui des collisions de sujets PubSub. Il s'avère que nous avions des utilisations de cadre non conformes qui généraient le même sujet d'abonnement quel que soit le domaine. Lorsque Pubsub ne se produisait que sur un seul type de processus, les effets étaient relativement bénins. En règle générale, un seul sujet pubsub correspond aux données d'un seul domaine. Cependant, Pubsub étant désormais réparti entre les brokers de session et les chargeurs synchronisables, il était possible que les deux types de processus soient en désaccord sur le domaine d’un sujet particulier. Lorsque cela se produisait, nous observions un taux élevé et stable de rechargements d’invalidation en raison de cette « incompatibilité de domaine ». Heureusement, le correctif était assez simple, mais il est intéressant de constater que ce bug a survécu dans notre cadre pendant si longtemps sans être détecté. 

Réajustement des charges de travail

Les brokers de session et les chargeurs synchronisables ont des charges de travail qui diffèrent considérablement de celles des serveurs de synchronisation d'origine. Les brokers de session sont uniquement responsables de la gestion des sessions et les chargeurs synchronisables sont uniquement responsables du chargement des données. 

Nous ne savions pas exactement comment cela affecterait leurs besoins en ressources. Nous avons donc commencé avec des demandes de ressources (CPU/mémoire) et des paramètres de mise à l’échelle automatique horizontale des pods (HPA) similaires pour les deux. 

session-brokers

Au fil de nos observations, il est devenu évident que les brokers de session étaient considérablement plus légers. Ils fonctionnaient de manière fiable avec un CPU très faible (au contraire, ils étaient beaucoup plus limités par la mémoire⁷). Quelques répliques semblaient suffisantes pour gérer le trafic de toute une cellule d’infrastructure. Cependant, lorsque nous avons réellement réduit les minReplicas sur le HPA, nous avons observé que les rechargements de données et la latence de rechargement augmentaient considérablement. Que se passait-il ?

En résumé, nous avions négligé de prendre en compte tous nos paramètres partagés concernant la taille du cache et les limiteurs. Avec seulement quelques répliques, chaque broker de session gérait beaucoup plus de sessions par pod (environ 3,5 fois plus) qu'un serveur de synchronisation classique. Comme chacun d'entre eux voyait beaucoup plus de données, ils remplissaient complètement leurs caches de rubrique pubsub ⇔ abonnement et chaque éviction déclenchait un rechargement (par mesure de sécurité). L'augmentation appropriée de ce seuil d'environ 6 fois a permis de corriger les taux élevés d'éviction du cache et de rechargement. De même, nous avons constaté que nos limiteurs de recharge hiérarchiques étaient mal configurés pour les nouveaux taux de trafic entrant. De même, l'ajustement de ces paramètres de limiteur a entraîné une réduction spectaculaire de la latence de rechargement et du délai de réactivité de bout en bout (c'est-à-dire le temps qu'il faut à une application Web pour voir sa propre écriture) d'environ 5 à 10 fois.

chargeurs-synchronisables

En revanche, les chargeurs synchronisables étaient considérablement plus lourds que prévu. Chaque serveur chargeait plus d'abonnements par seconde (environ 1,5 fois plus) qu'un serveur de synchronisation doté de ressources équivalentes. Contrairement aux brokers de session, ils étaient beaucoup plus gourmands en CPU ⁸.

Il est intéressant de noter qu'une part non négligeable du processeur a été attribuée à une augmentation des déoptimisations Truffle liées à notre code TS SCV. Cela était très probablement dû au fait que chaque chargeur syncable accédait à une plus grande partie de notre code SCV. Quoi qu'il en soit, cela a nécessité une légère augmentation de la taille de notre cache de code⁹. 

Préchauffage des processus

Le préchauffage des processus pour le chargement des données a toujours constitué un défi. Heureusement, dans notre nouvelle architecture, c’est un peu plus simple. Notre interface principale pour les chargeurs synchronisables est constituée de requêtes de données sans état. Nous pouvons donc les préchauffer simplement en rejouant ou en mettant en miroir le trafic existant entre les brokers de session et les chargeurs synchronisables.

En revanche, nous sommes toujours confrontés à de nombreux défis similaires à ceux rencontrés lors du préchauffage des serveurs de synchronisation. Principalement, le préchauffage des processus consomme beaucoup de ressources CPU, ce qui provoque toutes sortes de problèmes de voisinage bruyant (pour nous, l’indicateur pertinent ici est le blocage partiel, car nous n’atteignons pas les limites de limitation de k8s) au démarrage. Une belle amélioration que nous avons apportée ici a été d'utiliser le redimensionnement de pod sur place pour plafonner les ressources d'un syncable-loader au démarrage, mais lui permettre de dépasser cette limite après le démarrage. 

Malgré cela, le préchauffage de chaque pod prend encore de l'ordre de quelques minutes. En examinant les profils JFR, nous pensons que le principal frein est la compilation insuffisante du code TS SCV pendant le préchauffage, et nous pensons qu’il y a là une marge de progression. Nous cherchons activement à préchauffer plus précisément les chemins pertinents avec des méthodes plus ciblées et à remodeler nos interfaces TS¹⁰ pour une meilleure compilation.

Nos brokers de session ne sont responsables d’aucun chargement de données et, en pratique, n’ont jamais nécessité de préchauffage.

En quoi cela a-t-il été utile ?

Notre nouvelle architecture a considérablement réduit notre complexité opérationnelle, accéléré les mises en œuvre et la vélocité du code, et ouvert la voie à de futures opportunités en matière de vélocité et d'évolutivité. 

Notre nouvelle architecture s'adapte simplement automatiquement aux changements du trafic de lecture total sans nécessiter de gestion complexe du trafic. Chaque type d'opération de transfert de trafic est géré de manière ordonnée par un simple redémarrage des pods. Besoin de démarrer toutes les sessions ? Faites tourner tous les brokers de session. Besoin de vider nos caches ? Faites tourner tous les chargeurs synchronisables. Les deux opérations sont sûres en ce qui concerne la disponibilité.

Par le passé, la vitesse de développement des produits était principalement freinée par le déploiement de serveurs de synchronisation. Dans notre nouveau monde, il nous suffit de déployer des chargeurs synchronisables pour déployer le code produit. Pour ce faire, nous avons déplacé les chargeurs synchronisables dans leur propre cellule déployable, que nous nous efforçons de déployer plus fréquemment (et éventuellement en même temps que le code produit). Le déploiement des chargeurs synchronisables est déjà ~40 % plus rapide (~20 minutes de moins) que celui des serveurs de synchronisation (nous pouvons sans risque augmenter considérablement la cadence), et nous visons des augmentations encore plus importantes à l’avenir.

Il est à noter que l’amélioration des performances n’était pas un objectif de ce travail, mais elle mérite d’être mentionnée compte tenu de l’ampleur de la conception et de la mise en œuvre qu’elle a impliquée. La latence du calcul des résultats des requêtes s'est en fait améliorée dans notre nouveau système (probablement en raison d'un meilleur équilibrage de la charge, d'un meilleur réglage et d'une meilleure mise à l'échelle automatique). En conséquence, la latence de l'abonnement initial est sensiblement meilleure. En revanche, la latence de réflexion de mutation de bout en bout (c'est-à-dire le temps nécessaire pour qu'une modification soit distribuée à d'autres sessions) est à peu près la même. Les traces montrent que cela est probablement dû au déséquilibre dans la consommation du flux PubSub entre les brokers de session et les chargeurs synchronisables (les chargeurs synchronisables ne peuvent pas répondre à une demande tant qu’ils ne sont pas à jour avec la version de la demande).

Et maintenant ?

Notre système actuel est bien meilleur, mais il limite toujours la vélocité du produit en nécessitant des considérations relatives à la rétrocompatibilité et à la compatibilité ascendante à chaque version. Cependant, grâce à toutes nos améliorations en matière de vitesse de mise en œuvre, il est désormais possible de mettre en œuvre toutes nos modifications de modèle de données ensemble, ce qui élimine tout besoin de réfléchir à la rétrocompatibilité ou à la compatibilité ascendante. Nous cherchons activement à mettre en place cette fonctionnalité dans un avenir proche.

L'une de nos principales motivations pour ce travail était les régressions de performances difficiles à attribuer. Il s’agit notamment d’un domaine dans lequel nous n’avons pas apporté d’améliorations significatives à la suite de ce travail. Le point positif, c’est qu’il s’agit désormais de l’un des principaux problèmes que nous allons résoudre à l’avenir. Contrairement à l’essentiel du travail abordé ici, il s’agit d’un problème beaucoup plus interfonctionnel qui implique des considérations relatives au domaine du produit, au modèle de données, au cadre et à l’infrastructure. 

pools de travailleurs de chargeur synchronisables par fonctionnalité

pools de travailleurs de chargeur synchronisables par fonctionnalité

Nous sommes impatients d’explorer des solutions à travers l’infrastructure de la plateforme (ex. : pools de travailleurs par fonctionnalité), les cadres de la plateforme et les outils de la plateforme (ex. : tests en boîte noire/boîte blanche).


Biographie de l'auteur

Arvind Vijayakumar est ingénieur au sein de l’équipe LunaDb, où il contribue à la création et au développement de la plateforme principale de chargement de données d’Asana, l’infrastructure essentielle qui garantit aux utilisateurs de toujours voir des mises à jour précises, réactives et ultra-rapides sur nos applications Web et nos API.

Remerciements à l'équipe

Ce travail de mise à l'échelle de LunaDb a été un effort d'équipe de longue haleine qui s'est étendu sur les dernières années et a impliqué de nombreux membres de LunaDb, présents et passés : Brandon Zhang, Alex Matevish, Sean Wentzel, Eric Walton, Spencer Yu, Sophia Yao, George Ong, Tyler Prete, Koushik Ghosh, Natan Dubitski, Vinodh Chandra Murthy et bien d'autres


Notes de bas de page

  1. Nous l'avons notamment conçu avant que Facebook ne rende GraphQL open source

  2. Vous trouverez plus de détails dans l'article précédent sur le réchauffement des processus, mais en résumé, nous nous appuyons sur la possibilité pour les pods de synchronisation d'augmenter arbitrairement leur capacité afin de s'adapter rapidement aux pics de trafic local

  3. Nous effectuons également des mises à jour à une granularité par objet plutôt que par champ (pour des raisons historiques). Cela augmente la quantité de données impliquées dans les rechargements d'invalidation.

  4. Nous utilisons un hachage Murmur 128 bits pour éviter les collisions.

  5. Notamment, nous utilisons la compression WebSocket depuis très longtemps, ce qui atténue probablement les effets perçus par les clients.

  6. Il est à noter que nous avons également rencontré au départ des problèmes liés à la surcharge du saut de réseau. Après quelques recherches, nous avons constaté que la majeure partie de la surcharge était en fait due à notre maillage de services (Istio/Envoy) et nous avons apporté quelques ajustements ciblés pour améliorer les performances à ce niveau.

  7. Un facteur important dans la mémoire conservée a été la réécriture de notre magasin synchronisable côté serveur pour ne conserver que les hachages de données. Ce n’était pas un problème simple et nous publierons peut-être un article de suivi à ce sujet ! Sans cela, les données client stockées faisaient que les brokers de session utilisaient beaucoup plus de mémoire.

  8. Auparavant, tous les serveurs de synchronisation fonctionnaient sur des instances optimisées pour la mémoire. Les chargeurs synchronisables, quant à eux, bénéficient de types de nœuds plus riches en CPU, comme les instances mixtes.

  9. Une caractéristique notable de l'exécution de TS sur GraalVM de cette manière est qu'elle utilise beaucoup plus de cache de code que ce qui est habituel dans une application Scala/Java JVM plus standard.

  10. Pour autant que nous puissions en juger, un polymorphisme élevé rend la compilation TS plus coûteuse. Nous envisageons de passer à des interfaces monomorphes et d'exploiter les spécialisations polymorphes de reporting