Fullstack-Performance-Lektionen aus der Skalierung eines Startups
Mit Javascript, Node, MongoDB, Events, Cron-Jobs, Socket, CQRS, Kubernetes und React
Hintergrund
Ich bin Teil der Startup-Reise von Doctrins, seit sie 2016 als Fullstack-Javascript-Entwickler begann. Doctrin ist ein Tech-Gesundheitsunternehmen, das Gesundheitsdienstleister unterstützt, indem es Kommunikationskanäle, Anamnese, Switch-Boarding, Zusammenarbeit usw. bereitstellt.
Als wir 2017 unsere erste Version veröffentlichten, bearbeitete die Plattform nur eine Handvoll Patientenfälle pro Tag. Seitdem sind wir stark gewachsen und bearbeiten derzeit mehr als 2,2 Millionen Patientenfälle pro Jahr.
Unnötig zu sagen, dass wir unseren gerechten Anteil an Leistungsverbesserungen leisten mussten, um die Plattform bei einer ständig wachsenden Nutzung stabil zu halten. In der Vergangenheit gab es herausfordernde Zeiten, in denen wir nicht garantieren konnten, dass die Plattform einsatzbereit und leistungsfähig ist. Ich hoffe, dass Sie nach dem Lesen dieses Artikels die notwendigen Erkenntnisse erhalten, um dies zu vermeiden.
CQRS, Redis und Socket.io
Mehrere Leistungserbringer können denselben Patientenfall sehen und daran zusammenarbeiten, daher müssen wir die Daten für alle auf dem neuesten Stand halten. Um dies zu erreichen, haben wir uns für einen CQRS - Ansatz ( Command and Query Responsibility Segregation ) entschieden. Nachdem ein Schreibvorgang durchgeführt wurde, geben wir ein socket.io- Ereignis an betroffene Benutzer aus. Die Ereignisnutzlast ist klein und enthält nur die ID-Referenz und den Vorgangstyp. Die Ressource wird dann mit der bereitgestellten ID abgerufen.
Wenn es viele betroffene Benutzer gibt, stellen wir sicher, dass die aktualisierte Ressource in Redis zwischengespeichert wird, bevor das neue Ereignis ausgegeben wird.
Als Reaktion auf das Ereignis ist es wichtig, nur eine Ressource abzurufen. Machen Sie keine Listenabfragen oder die Dinge können schnell außer Kontrolle geraten, was wir auf die harte Tour gelernt haben. Damit dies mit unseren Listen funktioniert, mussten wir die Sortierlogik auf den Client verschieben. Wenn also eine Ressource aktualisiert wird, wird sie automatisch umsortiert, ohne dass wir die gesamte Liste erneut abrufen müssen. Gleiches gilt, wenn die Ressource hinzugefügt oder entfernt wird.
APIs und MongoDB
Fragen Sie nur ab, was tatsächlich benötigt wird. Es wird die Speichernutzung verringern und die Dinge deutlicher machen. Das Empfangen unnötiger Daten führt zu unnötiger Verwirrung und unnötigem Untersuchungszeitaufwand, wenn eines der Felder aktualisiert/migriert/entfernt werden muss. Um den Endpunkt-Countdown niedrig zu halten, entscheiden wir uns normalerweise für generische Endpunkte, bei denen der Client die Projektion in einer Abfrage angibt.
Verwenden Sie für Listenabfragen Streams von Mongo über die API bis zum Client. Andernfalls verschwendet der Dienst viel Speicher, um die Listen im Speicher zu halten.
Verwenden Sie für Service-zu-Service-Anrufe keinen Keep-Alive-Agenten. Dadurch treffen alle Anforderungen auf dieselbe Replik des aufgerufenen Dienstes, was zu einer ungleichmäßigen Auslastung führt.
Stellen Sie sicher, dass abgefragte Felder in Mongo indiziert werden. Dies ist eines der einfachsten Dinge mit der größten Wirkung. Es ist jedoch sehr einfach, ein Feld zu übersehen. Um dies zu handhaben, schlage ich vor, Abfragen zu testen, um zu überprüfen, ob die verwendeten Felder indiziert sind. Aber was ist, wenn Sie immer noch einen Index vermissen? Dann werden Sie wahrscheinlich ein Update über Mongo CLI schreiben, um es so schnell wie möglich zu beheben. Dies führt jedoch dazu, dass Produktion und Codebasis nicht synchron sind, was wir beheben, indem wir unsere Indizes bei jeder Bereitstellung synchronisieren. Es ist jedoch wichtig, dies asynchron im Hintergrund zu tun, da die synchrone Synchronisierung von Indizes auch große Auswirkungen auf die Leistung hat.
Verwenden Sie beim Abrufen einer Ressource von Mongo mit Mongoose nicht das Mongoose-Objekt. Es ist nicht nur groß, sondern führt auch mehrere Transformationen durch, die die Dinge verlangsamen. Verwenden Sie stattdessen .lean(), um nur ein einfaches js-Objekt zu erhalten.
Achten Sie beim Schreiben in Mongo darauf, $set zu verwenden. Es macht den Betrieb nicht nur kleiner und schneller, sondern auch sicherer. Ohne $set besteht die Gefahr von Datenverlust, wenn 2 Aufrufe gleichzeitig versuchen, dieselbe Ressource zu aktualisieren.
Cron-Jobs und -Ereignisse
Cron-Jobs können einen enormen Einfluss auf die Leistung haben. Sie fragen normalerweise große Listen ab und führen viele Schreibvorgänge gleichzeitig aus. Der große Burst von Schreibvorgängen ist eine Ausnahme von normalen Vorgängen und nicht etwas, für das das System zu viele zusätzliche Ressourcen benötigen sollte, um damit fertig zu werden. Bei Hochfrequenzbetrieb besteht auch das Risiko, dass Jobs miteinander kollidieren, wenn die Datenbank wächst und die Jobs langsamer werden. Wir erledigen unseren schwersten Job nachts, aber jetzt ist unsere Datenbank so stark gewachsen, dass selbst die Nacht bald nicht mehr ausreichen wird, um sie zu erledigen. Auch beim internationalen Anbau mit unterschiedlichen Zeitzonen ist auf die Nachtzeit kein Verlass.
Anstatt Cron-Jobs zu verwenden, sollten Sie nach Möglichkeit Pub/Sub-Ereignisse mit einem Nachrichtenbroker wie rabbitMQ verwenden . Veröffentlichen Sie nach einem Schreibvorgang ein Ereignis, dass die Ressource aktualisiert/hinzugefügt/gelöscht wurde. Abonnenten, die auf dieses Ereignis lauschen, können dann auf ihre betroffene Ressource reagieren. Das ist aus mehreren Gründen viel besser. Die Ereignisse werden natürlich basierend darauf verteilt, wann sie statt auf festgelegte Zeiten stattfinden. Das System reagiert schnell auf die einzelne betroffene Ressource, anstatt mit schweren Listen zu arbeiten. Sie fördert die Entkoppelung von Diensten und Erhebungen.
Wenn Sie einen Cron-Job verwenden müssen, schlage ich vor, beim Lesen der Liste einen Stream zu verwenden und dann innerhalb des Streams Aufgabenereignisse für jede Ressourcenaktualisierung zu erstellen. Auf diese Weise muss nicht die gesamte Liste im Speicher gehalten werden und die Schreiblast wird ziemlich niedrig und gleichmäßig gehalten. Die Verwendung von Aufgabenereignissen verteilt die Last auf mehrere Dienstreplikate. Dies ermöglicht auch die Fehlerbehandlung und Wiederholungslogik pro Aufgabe, ohne den Lesestrom zu beeinträchtigen.
Kubernetes
Versuchen Sie, die Pod-Ressourcennutzung so gering wie möglich zu halten, und skalieren Sie stattdessen, indem Sie die Anzahl der Replikate erhöhen. Bei kleinen Schoten ist es einfacher, jeden Knoten so weit wie möglich zu füllen. Während Sie bei großen Pods am Ende viele ungenutzte Ressourcen haben, die nicht ausreichen, um in diesen großen Pod zu passen. Dies erleichtert auch die Wartung und hält die Kosten in Testumgebungen niedrig. Die Pod-Ressourcen können gleich sein, skalieren Sie einfach die Replikate herunter.
Um zu vermeiden, dass Knoten auf OOM gehen, setzen Sie Speicherlimit und Anforderung immer auf denselben Wert. Andernfalls gibt es keine Garantie dafür, dass der Knoten den erforderlichen Speicher zur Verfügung hat, wenn der Pod mehr als angefordert verwendet. Legen Sie außerdem die Umgebungsvariable node — max-old-space-size auf ungefähr 90 % des angeforderten Speichers fest. Wenn der Pod nicht über genügend Speicher verfügt, wird er auf diese Weise nur den Knotenprozess neu starten, anstatt den Pod zum Absturz zu bringen, der viel langsamer ist, um wieder zum Laufen zu kommen.
Klient
Das vielleicht Wichtigste im Client ist, keine unnötigen Anfragen zu stellen. Achten Sie bei Seiten, zwischen denen der Benutzer hin und her wechselt, darauf, die Ressourcen in einem globalen Zustand zu speichern und nicht erneut abzurufen, wenn sie bereits festgelegt sind. Um sicherzustellen, dass die Daten auf dem neuesten Stand sind, verwenden Sie wie zuvor erwähnt Ereignisse. Verwenden Sie Debounce/Throttle, bevor Sie Anfragen stellen, um sie niedrig zu halten. Dies wird typischerweise verwendet, wenn beim Tippen Nebenwirkungen auftreten, aber es kann beispielsweise auch auf hochfrequente Ereignisse angewendet werden. Kann dieselbe Ressource von mehreren Orten abgerufen werden? Zwischenspeichern Sie das Anforderungsversprechen und geben Sie es zurück, anstatt eine weitere Anforderung auszuführen. Wenn Sie gleichzeitig viele einzelne Ressourcenanforderungen für dieselbe Sammlung haben, sollten Sie sie in einem einzigen Aufruf zusammenfassen.
Dies sollte selbstverständlich sein, aber achten Sie darauf, statische Ressourcen wie Bilder und CSS/JS-Bundles zu minimieren und zwischenzuspeichern.
Verwenden Sie einen Keep-Alive-Agenten. Dies beschleunigt Anfragen durch Verringern der Latenz und reduziert auch die Serverlast aufgrund weniger gleichzeitiger Verbindungen.
Beschleunigen Sie das Laden von Seiten, indem Sie jede Funktion auf der Plattform verzögert laden. Wenn Sie dies tun, ist es besonders wichtig, die Bundles zwischenzuspeichern, da der Server sonst 404 auf alte Bundles auslöst, wenn eine neue Version bereitgestellt wird.
Wenn Sie serverseitiges Rendering verwenden, stellen Sie sicher, dass Sie es streamen und nur das rendern, was wirklich wichtig ist. Vielleicht reicht es aus, nur das Layout zu rendern und dann diese Listen stattdessen im Client abzurufen? Serverseitiges Rendering ist sehr schwer für den Server, also versuchen Sie es minimal zu halten.
Wenn Sie React mit Redux verwenden, stellen Sie sicher, dass Container nur dann erneut gerendert werden, wenn sich ihre Requisiten tatsächlich geändert haben. Normalerweise führt eine Zustandsaktualisierung dazu, dass alle Container, die diesen Zustand verwenden, neu gerendert werden. Dies geschieht unabhängig davon, ob der Wert der tatsächlich verwendeten Zustandseigenschaft geändert wurde oder nicht. Glücklicherweise kann dies leicht gelöst werden, indem die Reselect- Memoisierung verwendet wird. Entscheiden Sie sich auch für viele kleine Container statt für wenige große, um den UI-Bereich, der neu gerendert werden muss, so klein wie möglich zu halten.
Letzte Worte
Ein Großteil der Leistung läuft darauf hinaus, nur das Notwendige zu lesen/schreiben, wenn es notwendig ist, während die Dinge klein gehalten und die Last verteilt werden.
Ich hoffe, Sie fanden diesen Artikel interessant und nützlich. Wenn Interesse besteht, schreibe ich vielleicht einen weiteren Artikel über die Skalierung von Codestruktur, Teams und Eigentümerschaft.
Vielen Dank fürs Lesen!

![Was ist überhaupt eine verknüpfte Liste? [Teil 1]](https://post.nghiatu.com/assets/images/m/max/724/1*Xokk6XOjWyIGCBujkJsCzQ.jpeg)



































