Großer Appetit auf kleine Puffer bei Node.js.

Ich habe bereits über den Dienst zur Überwachung von Abfragen an PostgreSQL gesprochen , für den wir einen Online-Server-Protokollkollektor implementiert haben, dessen Hauptaufgabe darin besteht, gleichzeitig Protokolldatenströme von einer großen Anzahl von Hosts zu empfangen, sie schnell in Zeilen zu analysieren , sie nach bestimmten Regeln in Paketen zu gruppieren, zu verarbeiten und zu schreiben führen zu PostgreSQL- Speicher.



In unserem Fall handelt es sich um mehrere hundert Server und Millionen von Anfragen und Plänen, die mehr als 100 GB Protokolle pro Tag generieren . Daher war es keineswegs überraschend, als wir entdeckten, dass der Löwenanteil der Ressourcen genau für diese beiden Operationen aufgewendet wird: Parsen in Zeilen und Schreiben in die Datenbank.

Wir haben uns in den Darm des Profilers gestürzt und einige Funktionen für die Arbeit mit BufferNode.js gefunden, deren Kenntnis Ihre Zeit und Serverressourcen erheblich sparen kann.

CPU-Auslastung




Der größte Teil der Prozessorzeit wurde für die Verarbeitung des eingehenden Protokolldatenstroms aufgewendet, was verständlich ist. Was jedoch nicht klar war, war die Ressourcenintensität des primitiven "Aufteilens" des eingehenden Stroms von Binärblöcken in Zeilen durch \r\n: Der



aufmerksame Entwickler wird hier sofort einen nicht so effizienten Bytezyklus durch den eingehenden Puffer bemerken . Nun, da die Linie zwischen benachbarten Blöcken "gerissen" werden kann, bleibt auch eine funktionale "Schwanzbefestigung" vom vorherigen verarbeiteten Block übrig.

Readline versuchen


Ein kurzer Überblick über die verfügbaren Lösungen brachte uns zum regulären Readline-Modul mit genau der erforderlichen Funktionalität zum Schneiden in Zeilen:



Nach der Implementierung wurde das „Schneiden“ von oben im Profiler vertieft:



Wie sich herausstellte, zwingt Readline den String intern zu UTF-8 , was unmöglich ist tun, wenn der Protokolleintrag (Anfrage, Plan, Fehlertext) eine andere Quellcodierung hat.

Selbst auf einem PostgreSQL-Server können mehrere Datenbanken gleichzeitig aktiv sein, von denen jede eine Ausgabe in ein gemeinsames Serverprotokoll genau in ihrer ursprünglichen Codierung generiert. Infolgedessen konnten Besitzer von Datenbanken auf win-1251 (manchmal ist es praktisch, damit Speicherplatz zu sparen, wenn kein "ehrlicher" Multibyte-UNICODE benötigt wird) ihre Pläne mit ungefähr denselben "russischen" Namen von Tabellen und Indizes beobachten:



Das Fahrrad modifizieren


Es ist ein Problem ... Es ist immer noch notwendig, das Schneiden selbst durchzuführen, aber mit Optimierungen des Typs Buffer.indexOf()anstelle von "Byte-Scan":



Es scheint, dass alles in Ordnung ist, die Last in der Testschaltung hat nicht zugenommen, win1251-Namen wurden repariert, wir sind in die Schlacht ausgerollt ... Ta-dam! Die CPU-Auslastung durchbricht regelmäßig die Obergrenze zu 100% :



Wie ist es? .. Es stellt sich heraus, dass es unsere Schuld ist, Buffer.concatmit der wir den vom vorherigen Block übrig gebliebenen Schwanz „kleben“:



Aber wir haben nur einen Kleber, wenn wir eine Linie durch den Block führen , aber sie sollten nicht viele sein - wirklich, wirklich? .. Nun, fast. Erst jetzt kommen manchmal "Strings" von mehreren hundert 16-KB-Segmenten :



Vielen Dank an die Entwickler, die sich darum gekümmert haben, dies zu generieren. Es passiert "selten, aber genau", so dass es nicht möglich war, im Voraus in der Testschaltung zu sehen.

Es ist klar, dass das mehrmalige Einfügen mehrerer Megabyte kleiner Teile in den Puffer ein direkter Weg zum Abgrund der Speicherumverteilung mit dem Verbrauch von CPU-Ressourcen ist, den wir beobachtet haben. Kleben wir es also erst, wenn die Linie vollständig endet. Wir werden nur die "Teile" in ein Array einfügen, bis es Zeit ist, die gesamte Zeile "out" zu geben:



Jetzt ist die Last zu den Readline-Indikatoren zurückgekehrt.

Speicherverbrauch


Viele Leute, die in Sprachen mit dynamischer Speicherzuordnung geschrieben haben, sind sich bewusst, dass einer der unangenehmsten "Leistungskiller" die Hintergrundaktivität des Garbage Collector (GC) ist, der im Speicher erstellte Objekte scannt und größere löscht niemand ist erforderlich. Dieses Problem überholte uns auch - irgendwann bemerkten wir, dass die GC-Aktivität irgendwie zu viel und fehl am Platz war.



Traditionelle "Wendungen" haben nicht wirklich geholfen ... "Wenn alles andere fehlschlägt, entleeren Sie!" Und die Volksweisheit hat uns nicht enttäuscht - wir haben eine Pufferwolke von 8360 Bytes mit einer Gesamtgröße von 520 MB gesehen ...



Und sie wurden in CopyBinaryStream generiert - die Situation begann sich zu klären ...

KOPIEREN ... VON STDIN MIT BINARY


Um den an die Datenbank übertragenen Datenverkehr zu reduzieren, verwenden wir das Binärformat COPY . Tatsächlich müssen Sie für jeden Datensatz einen Puffer an den Stream senden, der aus „Stücken“ besteht - der Anzahl der Felder im Datensatz (2 Byte) und dann der binären Darstellung der Werte jeder Spalte (4 Byte pro Typ ID + Daten).

Da eine solche Zeile der Tabelle fast immer eine variable Länge "summiert" hat, ist die sofortige Zuweisung eines Puffers mit fester Länge keine Option . Eine Neuzuweisung bei fehlender Größe kann die Leistung leicht "verschlingen" und ist bereits höher. Es lohnt sich also auch, mit "aus Stücken zu kleben" Buffer.concat().

Memo


Nun, da wir viele Teile immer wieder wiederholen lassen (zum Beispiel die Anzahl der Felder in den Datensätzen derselben Tabelle) - erinnern wir uns einfach an sie und nehmen dann fertige , die beim ersten Aufruf einmal generiert wurden. Basierend auf dem COPY-Format gibt es nur wenige Optionen - typische Teile sind 1, 2 oder 4 Bytes lang:



Und ... bam, ein Rechen ist angekommen!



Das heißt, ja, jedes Mal, wenn Sie einen Puffer erstellen, wird standardmäßig ein 8-KB-Speicher zugewiesen, sodass kleine Puffer , die in einer Reihe erstellt werden, "neben" im bereits zugewiesenen Speicher gestapelt werden können. Und unsere Zuweisung funktionierte "on demand", und es stellte sich heraus, dass sie überhaupt nicht "nahe" war - deshalb hat jeder unserer 1-2-4-Byte-Puffer physisch 8 KB + Header belegt - hier sind sie, unsere 520 MB!

intelligentes Memo


Hmm ... Warum müssen wir überhaupt warten, bis dieser oder jener 1/2-Byte-Puffer benötigt wird? Mit 4-Byte ist ein separates Problem, aber einige dieser verschiedenen Optionen für insgesamt 256 + 65536. Lassen Sie also nagenerim ihre Zeile auf einmal ! Gleichzeitig haben wir die Bedingung für das Vorhandensein jeder Prüfung gekürzt - dies funktioniert auch schneller, da die Initialisierung erst zu Beginn des Prozesses erfolgt.



Das heißt, zusätzlich zu 1/2-Byte-Puffern initialisieren wir sofort die am häufigsten ausgeführten Werte (niedrigere 2 Bytes und -1) für 4-Byte-Puffer. Und - es hat geholfen, nur 10 MB statt 520 MB!


All Articles