Die Suche nach Geschwindigkeit in Figma


Unsere monatelange Untersuchung zu langsamen Suchgeschwindigkeiten führte zu einer Lösung, die nicht nur die Leistung verbesserte, sondern auch die Grundlage für eine zukünftige Skalierung legte.
Die Suche nach Geschwindigkeit in Figma teilen
Illustration und Animation von Chou Chia Yu
Anfang dieses Jahres haben wir uns das Ziel gesetzt, die Funktionsweise der Suche bei Figma zu optimieren. Obwohl die Suchinfrastruktur seit dem ersten Tag zu Figma gehört, stellt unser Wachstum eine zunehmende Herausforderung für unser Suchsystem dar, die von den Nutzer*innen gesuchten Inhalte zuverlässig zu finden. Wir wollten eine solide Grundlage für die Suche schaffen, auf der wir in den kommenden Monaten und Jahren weiter aufbauen können.
ElasticSearch ist eine Suchmaschine, die für die Suche durch Milliarden von Dokumenten angepasst werden kann.
Bis Ende 2023 haben wir uns bei der Suche in Figma auf eine ältere Version von ElasticSearch verlassen. Dann begannen wir mit dem Upgrade auf OpenSearch, das als Bestandteil des verwalteten OpenSearch-Dienstes von AWS ausgeführt wird. OpenSearch ist eine Erweiterung von ElasticSearch, die nach der Änderung der ElasticSearch-Lizenz im Jahr 2021 entstanden ist. Obwohl die beiden Versionen größtenteils kompatibel sind, haben sich im Laufe der letzten drei Jahre kleine Unterschiede ergeben, sodass die Migration schwieriger als erwartet ist.
Skalierbare Suche
Ich kam zum Suchteam von Figma mit sehr wenig Produktionserfahrung mit ElasticSearch oder OpenSearch, jedoch mit umfangreicher Erfahrung in der Skalierung großer Webdienste. Wie immer habe ich bei der Bewältigung von Problemen mit Webdiensten versucht zu verstehen, was im Hintergrund von OpenSearch vor sich geht.
In einer idealen Welt wären die von OpenSearch gemeldete Suchzeit und die von unserer Anwendung gemeldete Zeit nahezu identisch. In diesem Fall ist eine Zeit jedoch 120 Mal so lang wie die andere. Unser Suchdienst ist nicht *so* kompliziert. Wo war die Zeit also geblieben?
Wir erfuhren von der nativen OpenSearch-Integration von DataDog, dass unsere „durchschnittliche Suche“ etwa acht Millisekunden (ms) dauerte. Das erschien beeindruckend (und unwahrscheinlich) schnell für die Suche durch viele Terabytes an Daten, selbst wenn diese Daten in Hunderte von parallel durchsuchbaren Index-Shards aufgeteilt waren. Gleichzeitig wussten wir, dass die Such-API unseres Dienstes eine Latenz im 99. Perzentil von fast einer Sekunde hatte. Etwas stimmte nicht.
Zur weiteren Untersuchung fügten wir unserem Code zusätzliche Instrumente hinzu, damit wir feststellen konnten, wo die Zeit verbraucht wurde. Wir haben Metriken und Protokolle um die meisten großen internen Codeblöcke hinter der Suche hinzugefügt. So konnten wir mehrere wichtige Erkenntnisse gewinnen:
- OpenSearch hat zwar behauptet, eine durchschnittliche Latenz von acht ms zu besitzen, doch bei unseren Aufrufen ihrer API-Bibliothek wurde eine durchschnittliche Latenz von 150 ms und eine 99. Perzentil-Latenz von 200–400 ms festgestellt. Unsere minimale Latenz betrug über 40 ms, was weit über ihrer gemeldeten maximalen Latenz lag.
- Wir haben viel Zeit mit der Erstellung unserer Suchanfragen verbracht, bevor wir sie an OpenSearch gesendet haben.
- Noch mehr Zeit haben wir mit der Überprüfung von Berechtigungen für Abfrageergebnisse verbracht, nachdem sie zurückkamen – wir nennen das Nachbearbeitung – um sicherzustellen, dass wir niemals Dateiergebnisse an Nutzer*innen zurückgeben, die keine Berechtigung dafür haben.
- Unsere Leistung war nicht sehr stabil und schwankte stark von Stunde zu Stunde und von Tag zu Tag. Zu Stoßzeiten waren wir um Hunderte von Millisekunden langsamer als an Wochenenden.
Zu diesem Zeitpunkt versuchten wir, zwei widersprüchliche Informationen in Einklang zu bringen: Laut OpenSearch dauerte die Suche rund acht ms, während unser Code rund 150 ms benötigte. Das würde nur Sinn ergeben, wenn beide auf der gegenüberliegenden Seite der Welt wären. In Wirklichkeit laufen sie jedoch in derselben AWS-Verfügbarkeitszone und liegen nur ein paar Millisekunden voneinander entfernt. Daher haben wir uns eingehend mit der Dokumentation befasst und festgestellt, dass es sich bei der bisher von uns berücksichtigten Metrik von 8 ms tatsächlich um die durchschnittliche Abfragezeit für einen einzelnen Shard und nicht die Gesamtabfragezeit handelte.
OpenSearch bei Figma
Um zu verstehen, wie es dazu kam, müssen wir die Funktionsweise von OpenSearch (und ElasticSearch) verstehen und wie wir sie bei Figma einsetzen. Wie die meisten Webdienste haben wir eine API-Ebene, die zwischen den Aktionen der Nutzer*innen und verschiedenen Backend-Diensten vermittelt. Wenn OpenSearch eine Abfrage erhält, wird sie an einen „Koordinator-Knoten“ weitergeleitet, der dann eine Kopie der Abfrage an „Arbeitsknoten“ sendet, eine Abfrage pro Shard für den abgefragten Index. Im Allgemeinen übernehmen alle Knoten abwechselnd die Koordinatorrolle. Dies wird als die „Abfragephase“ einer OpenSearch-Suche bezeichnet. Der Koordinator sammelt dann die Ergebnisse, sortiert sie und fordert in der Regel weitere Informationen von den Shards an, die die besten Ergebnisse geliefert haben. Dies wird als die „Abrufphase“ einer OpenSearch-Suche bezeichnet. Schließlich werden die Ergebnisse an den/die Kund*in zurückgesendet.
Der Zeitrahmen von acht ms deckte nur die einzelnen Abfragen pro Shard zwischen dem Koordinatorknoten und den Arbeitsknoten ab. Für unsere anfängliche Konfiguration gab es möglicherweise 500 Abfragen pro Shard und pro Nutzeranfrage. Viele dieser Vorgänge laufen parallel ab, aber nicht alle. Genau hier liegt die Diskrepanz: Wir haben die falsche Metrik verwendet. Anstelle der bisherigen Messung benötigten wir eine Latenzmetrik, die die Sicht des Koordinatorknotens auf die Abfrage abdeckt. Nachdem wir die Dokumentation durchgesehen und bei Google und Stack Overflow gründlich recherchiert hatten, wandten wir uns an unseren Account-Manager bei AWS, um Hilfe zu erhalten. Es stellte sich heraus, dass keine der Metriken oder Protokolle von OpenSearch die gesamte Abfragezeit erfasst, sondern nur die Zeit pro Shard.
Die einzige Stelle, an der OpenSearch die Gesamtleistung der Abfrage angibt, ist die API-Antwort auf die Abfrage, die ein Feld („Took“) enthält, das die Anzahl der Millisekunden angibt, die OpenSearch zur Beantwortung der Abfrage benötigt hat. Also haben wir diesen Wert aus jeder Suchantwort extrahiert und zu unserer Überwachung hinzugefügt. Dadurch erhielten wir eine Backend-Latenz, die größtenteils mit dem Timing-Wrapper übereinstimmte, den wir um die OpenSearch-API-Aufrufe gelegt hatten. Nachdem wir mehrere Datenpunkte abgeglichen hatten, konnten wir die Geschichte, die unser Überwachungssystem uns erzählte, deutlicher erkennen.
Leider stellte sich heraus, dass wir weniger als 30 % unserer gesamten Abfrage-API-Zeit mit OpenSearch verbrachten, und die Vor- und Nachbearbeitung mehr Zeit in Anspruch nahm als die Suche. Die Vorverarbeitungsphase holt eine Reihe von Daten über die Dateien ein, auf die Nutzer*innen zugreifen können, und erstellt eine OpenSearch-Filterklausel, die Dateien, auf die nicht zugegriffen werden kann, größtenteils ausschließt. In der Nachverarbeitungsphase wird dann überprüft, ob der/die Nutzer*in tatsächlich Zugriff auf jede einzelne der zurückgegebenen Dateien hat.
Beide Schritte waren langsam, insbesondere die Nachbearbeitung. Wir haben mit unserem Berechtigungsteam zusammengearbeitet, um die Leistung der Nachbearbeitung zu verstehen und zu optimieren. Laut statistischer Analyse könnte die Bewertung verschiedener Bestandteile des Genehmigungssystems in einer anderen Reihenfolge in wesentlich kürzerer Zeit zu denselben Ergebnissen führen. Wir stellten außerdem fest, dass wir eine erschreckend große Menge an Zeit mit der Laufzeit-Typsicherheitsprüfung in Ruby innerhalb des Berechtigungssystems aufwandten. Somit führte die Deaktivierung der störendsten Elemente zu erheblichen Geschwindigkeitssteigerungen.
Als wir mit der Analyse der langsamen Suchspuren begannen, bemerkten wir eine Reihe seltsam langsamer Datenbankabfragen. Die Datenbank selbst war schnell. Auch der Lastausgleichs-Proxy zwischen uns und der DB war schnell. Aber das Ausführen von Abfragen dauerte manchmal mehrere zehn Millisekunden. Nach stundenlangem Betrachten des Quellcodes und der Traces identifizierte ein Mitglied des Teams ein Problem bei der Einrichtung neuer Datenbankverbindungen in neuen Threads. Unser Datenbankverbindungspool war nicht groß genug, weshalb wir in jedem Thread kostspielige Setup- und Teardown-Operationen durchführten, die nie erforderlich waren. Das grundlegende Problem konnte behoben werden. Dadurch konnten wir nicht nur bei der Suche, sondern in ganz Figma erhebliche Geschwindigkeitssteigerungen erzielen. Mit diesen Erkenntnissen im Hinterkopf haben sie eine Reihe von Threading-Experimenten, die wir in der Vergangenheit durchgeführt hatten, neu bewertet, wobei sich herausstellte, dass parallele Datenbank-Lesevorgänge in Threads nur selten zu einer Leistungssteigerung führten. Mit dem neuen Initialisierungscode wurde Figma bei fast jeder Möglichkeit zur parallelen Ausführung von Abfragen schneller.
Bewertung und Optimierung der Leistung
Nachdem wir uns auf angemessene Leistungsmetriken geeinigt und einige der großen Probleme behoben hatten, war es an der Zeit, tiefer in die Materie einzutauchen. Zuvor hatten wir nicht genügend Signale zur Beantwortung einiger wichtiger Fragen, doch jetzt konnten wir uns von den Daten leiten lassen:
- Wie gut sind unsere OpenSearch-Abfragen?
- Verfügen wir über die korrekten Daten in unseren Indizes?
- Verfügen wir über die korrekte OpenSearch-Konfiguration?
Wie gut sind unsere OpenSearch-Abfragen?
Mit ein bisschen Aufwand kannst du den Query Profiler von OpenSearch dazu bringen, dir zu sagen, wie viele Aufgaben er erledigt und wofür er seine Zeit aufwendet. Wir haben festgestellt, dass die meisten unserer Abfragen die meisten der Millionen von Dokumenten pro Index-Shard umgangen haben. Sie haben nur ein paar hundert Dokumente pro Shard und Abfrage untersucht. Das liegt an der Filterung, die wir in unserem Vorverarbeitungsschritt zur Eliminierung der meisten Dateien, auf die Nutzer*innen nicht zugreifen können, vorgenommen haben, und der Abfrageoptimierer von OpenSearch nutzt diese Tatsache voll aus. Unsere Anfragen waren also nicht das Problem.
Verfügen wir über die korrekten Daten?
Diese Frage ist noch offen, da es sich dabei zumindest teilweise um eine Frage der Suchrelevanz und nicht nur der Leistung handelt. Verfügen wir über genügend Daten, um das zu finden, wonach Nutzer*innen suchen? Finden wir es tatsächlich? Da dieses Problem nicht einfach zu lösen ist, führen wir ständig Experimente zur Verbesserung durch. Wir haben jedoch festgestellt, dass der Großteil der von uns in OpenSearch eingespeisten Daten nicht sehr nützlich war. Wir konnten die Größe unseres Indexes wiederholt reduzieren, zunächst um 50 % und dann um weitere 90 %, ohne dass die Relevanz messbar beeinträchtigt wurde. Dadurch wurde alles schneller, einfacher und kostengünstiger.
Verfügen wir über die korrekte OpenSearch-Konfiguration?
OpenSearch bietet viel Flexibilität, aber mit dieser Flexibilität geht auch Komplexität einher. Zum Beispiel listet Amazon 139 verschiedene OpenSearch-Serverinstanztypen mit einer breiten Palette an CPU- und Speicheroptionen auf, die von 0,02 bis 17 USD pro Stunde reichen. Jeder Index muss in eine bestimmte Anzahl von Shards aufgeteilt werden, aber die optimale Anzahl von Shards in jeder Situation ist alles andere als offensichtlich. Und OpenSearch unterstützt eine Reihe von Funktionen, mit denen Kompromisse zwischen CPU-, Speicher- und Festplattenauslastung, z. B. Komprimierungstypen und Parallelität der Suche, eingegangen werden können. Diese Funktionen sind alle anpassbar, wobei es für keine von ihnen einen eindeutigen „Bestwert“ gibt. Amazon bietet eine Anleitung zur Größenbestimmung, insbesondere zur Datenmenge pro OpenSearch-Knoten und zum Verhältnis von Shards zu Knoten, aber wir haben festgestellt, dass die meisten Empfehlungen auf durchsatzintensiven Workloads wie der Abfrage von Protokollen und nicht auf der latenzempfindlichen Dokumentensuche beruhen.
Letztendlich konnten wir die Auswirkungen all dieser Optionen auf unsere Suchleistung nur durch die Durchführung von Tests und die Messung ihrer Auswirkungen verstehen. Also begannen wir mit dem Aufbau eines Lasttestsystems. Wir haben neue, nicht-produktive OpenSearch-Cluster erstellt, Daten darauf geladen, Abfragen durchgeführt, die Ergebnisse gemessen und dann Änderungen vorgenommen, um die Tests von vorne zu beginnen. OpenSearch verfügt über ein eigenes Benchmarking-Tool mit dem einfallsreichen Namen opensearch-benchmark, aber wir konnten damit nie wirklich einheitliche Ergebnisse erzielen. Es wurde für Leistungsregressionstests für die OpenSearch-Entwicklung konzipiert und ist nicht so gut für das Senden einer großen Anzahl von zufälligen Abfragen an bestehende OpenSearch-Instanzen geeignet. Seltsamerweise wird die serverseitige „Took“-Latenzzahl nicht gerne verwendet, was bedeutet, dass alle Latenzmetriken auf der clientseitigen Leistung basieren. Daher ist es schwierig, in unserer Umgebung wiederholbare Läufe zu erzielen. Schließlich haben wir an einem Nachmittag unser eigenes Benchmark-Tool in Go geschrieben.
So konnten wir einige wichtige Erkenntnisse gewinnen:
- Wir hatten zu viele Shards in unseren Indizes. Wir begannen mit 450 Shards und reduzierten uns schließlich auf 180 Shards, was einer Verringerung von 60 % entspricht. Dadurch konnten wir unsere maximale Abfragerate vor dem Auftreten übermäßiger Latenz um über 50 % steigern. Überraschenderweise reduzierte sich durch die Verringerung der Anzahl der Shards, von denen der Koordinator Daten erfassen musste, auch unsere P50-Latenz – im Grunde die mittlere Latenz.
- Die Verringerung der Datenmenge in unseren Indizes stellte eine bedeutende Verbesserung dar. Auch deshalb waren wir zuversichtlich, die Anzahl unseres Shards sicher reduzieren zu können. Durch die Verringerung der Datenmenge konnten wir die Trefferquote unseres Festplattencaches verbessern, sodass die gesamte Leistung besser vorhersagbar wurde. Die anfängliche Optimierung, die unsere Indexgröße um 50 % reduzierte, verringerte unsere Abfragelatenz und machte die Latenz einheitlicher. Mit einer weiteren Reduzierung um 90 % durch das Entfernen ungenutzter Daten passte unser gesamter Datensatz in den Festplattencache des Betriebssystems und machte das System deutlich schneller.
- Die Empfehlungen von Amazon waren für unsere Nutzung nicht korrekt. Während wir die Größenempfehlungen von AWS überprüften, stellten wir fest, dass unsere Messungen stark von ihren Empfehlungen abwichen. Sie empfahlen, die Shards unter 50 GB zu halten und ungefähr einen Index-Shard für jeweils 1,5 CPUs im Cluster bereitzustellen. Das mag zwar für protokollähnliche Suchvorgänge sinnvoll sein, aber für dokumentähnliche Suchvorgänge, bei denen die Latenzzeit eine Rolle spielt, bedeutet dies, dass die Koordinatoren zu viel Arbeit haben, um potenziell Tausende von Shards für jede Anfrage zu verwalten. Da unsere Filter sehr effektiv waren, konnten wir tatsächlich eine bessere Leistung mit weniger, aber größeren Shards erzielen.
- Wir hatten OpenSearch-Knoten mit zu viel CPU (teuer) und nicht genug RAM (viel günstiger) bereitgestellt. Wir konnten auf Knoten mit einem Drittel der CPU-Leistung und 25 % mehr RAM umsteigen, und das für etwa die Hälfte des Preises pro Knoten. Dadurch erzielten wir insgesamt eine leicht bessere Leistung, selbst bevor wir die Größe unserer Indizes verringerten.
- Die Zstandard-Kompression (zstd) war kein großer Erfolg, aber sie hat auch nicht geschadet.
- Die gleichzeitige Segmentsuche war für unseren Anwendungsfall nie von Vorteil. Zu Beginn betrug die zusätzliche Latenz bei niedrigen Abfrageraten nur wenige Millisekunden. Auch nahm die Latenz mit steigender Abfragelast deutlich schneller zu. Das hat uns überrascht, denn wir hatten immer noch viele freie CPU-Kapazitäten und erwarteten, dass eine Erhöhung der Parallelität die Latenz verringern würde, was jedoch nicht der Fall zu sein schien.
Insgesamt konnte das Figma-Suchteam die API-Latenz um etwa 60 % senken, die maximale Anzahl von Abfragen pro Sekunde um mindestens 50 % erhöhen und die Gesamtkosten um mehr als 50 % senken. Erreicht werden konnte dies durch detaillierte Arbeit in mehreren Bereichen, von der Überwachung und Fehlerbehebung bis hin zur Reduzierung der Indexgröße und optimierten Konfigurationen. Auch wenn es kein Patentrezept gab, konnten wir durch unsere gemeinsame Arbeit die Suchleistung von Figma deutlich verbessern und uns auf eine künftige Skalierbarkeit einstellen. Die Suche nach Geschwindigkeit ist nie wirklich vorbei. Doch wir haben einen großen Schritt nach vorne gemacht und sind bereit für alle kommenden Herausforderungen.



