Naviguer jusqu'au contenu principal

La quête de rapidité dans Figma

Notre enquête de plusieurs mois sur la lenteur des temps de recherche a débouché sur une solution qui a permis non seulement d'améliorer les performances, mais aussi de jeter les bases d'une évolution future.

Partager La quête de rapidité dans Figma

Illustration et animation de Chou Chia Yu

En début d'année, nous avons entrepris d'améliorer le fonctionnement de la recherche chez Figma. Bien que l'infrastructure de recherche ait toujours été intégrée à Figma, notre croissance a rendu de plus en plus difficile pour notre système de recherche de localiser de manière fiable le contenu que les utilisateurs recherchent. Pour les mois et années à venir, nous souhaitions établir une base solide pour les recherches.

ElasticSearch est un moteur de recherche qui peut être personnalisé pour rechercher parmi des milliards de documents.

Jusqu'à la fin de l'année 2023, nous nous appuyions sur une ancienne version d'ElasticSearch pour effectuer des recherches dans Figma. Nous avons ensuite lancé la mise à niveau vers OpenSearch, exécuté dans le cadre du service OpenSearch géré par AWS. OpenSearch est un dérivé d'ElasticSearch, créé lorsque la licence d'ElasticSearch a changé en 2021. Bien que les deux soient en grande partie compatibles, de petites différences se sont accumulées au cours des trois dernières années, rendant la migration plus difficile que prévu.

Recherche à l'échelle

J'ai rejoint l'équipe de recherche de Figma avec très peu d'expérience de production avec ElasticSearch ou OpenSearch, mais une grande expérience dans la mise à l'échelle de grands services Web. Comme toujours lorsque je m'attaque à des problèmes avec les services Web, je cherche à comprendre ce qui se passe en arrière-plan avec OpenSearch.

Dans un monde idéal, le temps de recherche indiqué par OpenSearch et le temps indiqué par notre application devaient être très proches, mais dans ce cas, l'un était 120 fois plus long que l'autre. Notre service de recherche n'est pas « si » compliqué. Alors, qu'est-ce qui a pris du temps ?

Nous avons appris grâce à l'intégration native OpenSearch de DataDog que notre « recherche moyenne » prenait environ huit millisecondes (ms). Un délai aussi court semblait incroyable (et peu probable) pour une recherche à travers de nombreux téraoctets de données, même si ces données étaient fragmentées en centaines de fragments d'index consultables en parallèle. Dans le même temps, nous savions que l'API de recherche de notre service avait une latence du 99e centile de près d'une seconde. Quelque chose ne collait pas.

Pour approfondir la question, nous avons ajouté une instrumentation supplémentaire à notre code afin de voir ce qui avait pris du temps. Nous avons ajouté des métriques et des traces autour de la plupart des grands blocs de code interne derrière la recherche. Cela nous a permis de comprendre plusieurs idées clés :

  1. OpenSearch aurait pu prétendre qu'il avait une latence moyenne de huit millisecondes, mais nos appels à leur bibliothèque d'API ont enregistré une latence moyenne de 150 ms et une latence du 99e centile de 200 à 400 ms. Notre temps de latence minimum était supérieur à 40 ms, ce qui était bien plus élevé que le temps de latence maximum indiqué.
  2. Nous avons passé beaucoup de temps à élaborer nos requêtes avant même de les transmettre à OpenSearch.
  3. Nous avons consacré encore plus de temps à vérifier les autorisations sur les résultats des requêtes après leur réception, ce que nous appelons le post-traitement, pour nous assurer de ne jamais renvoyer des résultats de fichiers à des utilisateurs qui n'ont pas l'autorisation de les consulter.
  4. Nos performances n'étaient pas très stables et variaient considérablement d'une heure et d'un jour à l'autre. Aux heures de pointe, nous étions des centaines de millisecondes plus lents que pendant le week-end.

À ce stade, nous tentions de réconcilier deux informations contradictoires : OpenSearch indiquait que les recherches prenaient environ huit millisecondes alors que notre code indiquait qu'elles prenaient environ 150 ms. Cela n'aurait de sens que si les deux parties se trouvaient à l'autre bout du monde, mais en réalité, elles fonctionnent dans la même zone de disponibilité AWS, à seulement quelques millisecondes l'une de l'autre. Nous avons donc étudié en profondeur la documentation et avons découvert que la mesure de huit millisecondes que nous avions examinée était en fait le temps de requête moyen sur un seul fragment, et non le temps de requête global.

OpenSearch chez Figma

Pour comprendre comment nous en sommes arrivés là, il faut comprendre le fonctionnement d'OpenSearch (et d'ElasticSearch) et leur utilisation au sein de Figma. Comme la plupart des services Web, nous avons un calque API qui traduit les actions de l'utilisateur en divers services back-end. Lorsque OpenSearch reçoit une requête, il atteint un « nœud coordinateur » qui envoie ensuite une copie de la requête aux « nœuds de travail », une requête par fragment pour l'index interrogé. En général, tous les nœuds se relaient pour assurer la fonction de coordinateur. C'est ce qu'on appelle la phase de « requête » d'une recherche OpenSearch. Le coordinateur recueille ensuite les résultats, les trie et demande généralement des informations complémentaires aux fragments qui ont fourni les meilleurs résultats. C'est ce qu'on appelle la phase de « récupération » d'une recherche OpenSearch. Enfin, il renvoie les résultats au client.

Le délai de huit millisecondes ne couvrait que chacune des requêtes individuelles par fragment entre le nœud coordinateur et les nœuds de travail. Dans notre configuration initiale, il y avait potentiellement 500 requêtes par segment et par utilisateur. Beaucoup de ces processus se déroulent en parallèle, mais pas tous. C'est là que l'écart est apparu : nous n'utilisions pas la bonne métrique. Nous avions besoin de la métrique de latence qui couvre la vue du nœud coordinateur de la requête. Après avoir consulté la documentation et fait des recherches approfondies sur Google et Stack Overflow, nous nous sommes tournés vers notre gestionnaire de compte chez AWS pour obtenir de l'aide. Il s'avère qu'aucune des métriques et aucun des journaux d'OpenSearch ne suit en fait le temps de requête global, mais seulement le temps par segment.

En fait, le seul endroit où OpenSearch fait état des performances globales de la requête est dans la réponse de l'API de la requête, qui comprend un champ (« took ») indiquant le nombre de millisecondes nécessaires à OpenSearch pour répondre à la requête. Nous avons donc extrait cette valeur de chaque réponse à une recherche et l'avons intégrée à notre système de suivi. Cela nous a permis d'obtenir un temps de latence qui correspondait en grande partie au wrapper de temps que nous avions placé autour des appels de l'API OpenSearch. Après avoir regroupé plusieurs ensembles de données, nous avons pu mieux comprendre le récit que nous présentait notre système de surveillance.

Malheureusement, le récit nous a principalement indiqué que nous avons passé moins de 30 % de notre temps total d'API de requête à attendre OpenSearch, et en réalité, le prétraitement et le post-traitement ont pris plus de temps que la recherche. La phase de prétraitement récupère un ensemble d'informations à propos des fichiers auxquels l'utilisateur peut accéder et crée une clause de filtrage OpenSearch qui exclut principalement les fichiers auxquels il ne peut pas accéder. La phase de post-traitement vérifie ensuite que l'utilisateur a effectivement accès à chaque fichier renvoyé.

Les deux étapes étaient lentes, en particulier le post-traitement. Nous avons travaillé avec notre équipe d'autorisations pour comprendre et améliorer les performances de post-traitement. L'analyse statistique a révélé que l'évaluation des différentes parties du système d'autorisation dans un ordre différent pourrait produire les mêmes résultats en beaucoup moins de temps. Nous avons également découvert que nous consacrions énormément de temps à vérifier la sécurité des types au moment de l'exécution en Ruby dans le système d'autorisations et la désactivation des parties les plus intrusives de ce système a permis de réduire les délais de façon substantielle.

Lorsque nous avons commencé à examiner les traces de recherche lente, nous avons remarqué un certain nombre de requêtes de base de données étrangement lentes. La base de données elle-même était rapide, de même que le proxy d'équilibrage de charge entre nous et la base de données, mais l'émission de requêtes prenait parfois des dizaines de millisecondes. Après des heures passées à étudier le code source et les traces, un membre de l'équipe a identifié un problème dans la manière dont nous établissons de nouvelles connexions à la base de données dans de nouveaux threads. Notre pool de connexions à la base de données n'était pas assez important, ce qui nous obligeait à effectuer des opérations coûteuses de configuration et de démontage qui n'étaient jamais nécessaires dans chaque thread. Le problème principal a été résolu et nous avons constaté des accélérations substantielles, non seulement dans la recherche, mais aussi pour l'ensemble de Figma. Avec cette vision en tête, plusieurs expériences de threading que nous avions tentées auparavant ont été réévaluées, alors que des lectures parallèles de bases de données dans les threads avaient que rarement amélioré les performances. Grâce au nouveau code d'initialisation, la majorité des occasions d'exécuter des requêtes en parallèle ont permis une accélération de Figma.

Évaluation et amélioration des performances

Une fois que nous nous sommes alignés sur des indicateurs de performance raisonnables et que nous avons amélioré quelques-uns des problèmes majeurs, il était temps d'aller plus loin. Jusque-là, nous n'avions pas suffisamment de signaux pour répondre à certaines questions clés, mais à ce stade, nous pouvions laisser les données nous guider :

  • Quelle est la qualité de nos requêtes OpenSearch ?
  • Nos index contiennent-ils les bonnes données ?
  • Avons-nous la bonne configuration OpenSearch ?

Quelle est la qualité de nos requêtes OpenSearch ?

Avec un peu de travail, vous pouvez faire en sorte que le profileur de requêtes d'OpenSearch vous indique la quantité de travail qu'il effectue et où il passe du temps. Nous avons découvert que la plupart de nos requêtes contournaient la plupart des millions de documents que nous avons par fragment d'index. Elles ne prenaient en compte que quelques centaines de documents par fragment et par requête. Cela est dû au fait que le filtrage que nous avons construit dans notre étape de prétraitement parvient à éliminer la majorité des fichiers auxquels les utilisateurs ne peuvent pas accéder et l'optimisation des requêtes d'OpenSearch en tire pleinement parti. Cela signifie que le problème n'était pas lié à nos requêtes.

Disposons-nous des bonnes données ?

La question reste ouverte, car il s'agit, au moins en partie, d'une question de pertinence de la recherche, et pas seulement de performance. Avons-nous suffisamment de données pour trouver ce que l'utilisateur recherche ? Le trouvons-nous réellement ? Ce n'est pas un problème simple, et nous menons constamment des expériences pour essayer de l'améliorer. Cependant, nous avons découvert que la grande majorité des données que nous avions introduites dans OpenSearch n'étaient pas très utiles. Nous avons pu réduire la taille de notre index à plusieurs reprises, d'abord de 50 %, puis de 90 % supplémentaires, sans impact mesurable sur la pertinence. Ainsi, tout est plus rapide, plus simple et moins coûteux.

Avons-nous la bonne configuration OpenSearch ?

OpenSearch offre une grande flexibilité, qui ne va pas sans une certaine complexité. Par exemple, Amazon répertorie 139 types d'instances de serveur OpenSearch différents avec une large gamme d'options de processeur et de mémoire, avec des tarifs allant de 0,02 $ à 17 $ de l'heure. Chaque index doit être divisé en un certain nombre de fragments, mais le nombre optimal de fragments dans une situation donnée est loin d'être évident. OpenSearch prend en charge un certain nombre de fonctionnalités qui vous permettent de faire des compromis entre l'utilisation du processeur, de la mémoire et du disque, comme les types de compression et la simultanéité de la recherche. Toutes sont ajustables et aucune n'a de valeurs « spécifiques ». Amazon fournit des conseils sur le dimensionnement, notamment en ce qui concerne la quantité de données par nœud OpenSearch et le rapport entre le nombre de fragments et le nombre de nœuds, mais nous avons découvert que la plupart de leurs recommandations étaient basées sur des charges de travail intensives en débit, comme l'interrogation de journaux, et non sur la recherche de documents sensible à la latence.

Finalement, la seule manière de comprendre comment toutes ces options affectaient nos performances de recherche était de réaliser des tests et d'en mesurer l'impact. Nous avons donc commencé à mettre en place un système de test de charge. Nous avons créé de nouveaux clusters OpenSearch non productifs, y avons chargé des données, exécuté des requêtes, mesuré les résultats, puis apporté des modifications et recommencé les tests. OpenSearch dispose de son propre outil de benchmarking, (qui porte le nom très original d'opensearch-benchmark), mais nous n'avons jamais vraiment été en mesure d'en tirer des résultats cohérents. Il est conçu pour effectuer des tests de régression des performances pour le développement d'OpenSearch, et n'est pas aussi performant pour envoyer un grand nombre de requêtes aléatoires aux instances OpenSearch existantes. De plus, curieusement, il n'aime pas vraiment utiliser le nombre de latences « pris » côté serveur, ce qui signifie que toutes les mesures de latence sont basées sur les performances côté client. Il est donc difficile d'obtenir des exécutions répétables dans notre environnement. Finalement, nous avons créé notre propre outil de benchmarking en Go en une après-midi.

Cela nous a conduits à quelques conclusions clés :

  • Nous avions trop de fragments dans nos index. Nous avons commencé avec 450 fragments et avons fini avec 180 fragments, soit une réduction de 60 %. Cela nous a permis d'augmenter de plus de 50 % notre taux maximal de requêtes avant que la latence excessive ne s'installe. Étonnamment, en réduisant le nombre de fragments à partir desquels le coordinateur devait collecter des données, notre latence P50, c'est-à-dire la latence médiane, a également diminué.
  • La réduction de la quantité de données dans nos index a représenté une amélioration significative. C'est en partie ce qui nous a convaincus que nous pouvions réduire notre nombre de fragments en toute sécurité. En réduisant la quantité de données, nous avons amélioré notre taux de réussite du cache disque, ce qui a rendu toutes les performances plus prévisibles. L'optimisation initiale qui a réduit la taille de notre index de 50 % a permis de réduire la latence de nos requêtes et de la rendre plus cohérente ; une réduction supplémentaire de 90 %, en supprimant les données inutilisées, a permis à l'ensemble de notre ensemble de données de s'insérer dans le cache disque du système d'exploitation et de le rendre beaucoup plus rapide.
  • Les recommandations d'Amazon n'étaient pas adaptées à notre utilisation. En examinant les recommandations de dimensionnement d'AWS, nous avons constaté que nos mesures ne correspondaient pas du tout à leurs recommandations. Ils nous ont suggéré de limiter les fragments à 50 Go et de prévoir environ un fragment d'index pour 1,5 processeur du cluster. Bien que cela puisse avoir du sens pour les recherches de type journal, dans le cas de recherches de type document où la latence est importante, cela signifie que la tâche de vos coordinateurs n'est pas gérable pour des milliers de fragments à chaque requête. Étant donné que nos filtres étaient très efficaces, nous avons constaté de meilleures performances avec moins de fragments conséquents.
  • Nous avions configuré les nœuds OpenSearch avec trop de processeurs (coûteux) et pas assez de RAM (beaucoup moins cher). Nous avons pu passer à des nœuds avec un tiers de processeur et 25 % de RAM en plus pour environ la moitié du prix par nœud et obtenir des performances légèrement meilleures dans l'ensemble, même avant de réduire la taille de nos index.
  • La compression Zstandard (zstd) n'a pas été un succès majeur, mais elle n'a pas causé de problème non plus.
  • La recherche par segments simultanés n'a jamais été une réussite pour notre cas d'utilisation. Tout a commencé avec quelques millisecondes de latence supplémentaire à des taux de requête faibles et la latence a augmenté beaucoup plus rapidement à mesure que la charge de requête augmentait. Cela nous a surpris, car nous avions encore beaucoup de processeurs libres et nous nous attendions à ce que l'augmentation du parallélisme réduise la latence, mais cela ne semblait pas être le cas.

Dans l'ensemble, l'équipe de recherche de Figma a réussi à réduire la latence de l'API d'environ 60 %, à augmenter le nombre maximal de requêtes par seconde d'au moins 50 % et à réduire le coût total de plus de 50 %. Ce résultat a été obtenu grâce à un travail approfondi dans de nombreux domaines, allant de la surveillance et de la correction des bugs à la réduction de la taille de l'index et à l'optimisation des configurations. Bien qu'il n'existe pas de solution miracle, notre effort collaboratif a considérablement amélioré les performances de recherche de Figma et nous a préparés à une évolutivité future. La recherche de vitesse n'est jamais vraiment terminée, mais nous avons fait un grand bond en avant et nous sommes prêts à relever tous les défis qui se présenteront.

Créez et collaborez avec Figma

Lancez-vous gratuitement