Big Appetites for Little Buffers sur Node.js

J'ai déjà parlé du service de surveillance des requêtes à PostgreSQL , pour lequel nous avons implémenté un collecteur de journaux de serveur en ligne, dont la tâche principale est de recevoir simultanément les flux de journaux d'un grand nombre d'hôtes à la fois, de les analyser rapidement en lignes , de les regrouper en paquets selon certaines règles, de traiter et d' écrire entraîner le stockage PostgreSQL .



Dans notre cas, nous parlons de plusieurs centaines de serveurs et de millions de demandes et de plans qui génèrent plus de 100 Go de journaux par jour . Par conséquent, il n'était pas du tout surprenant lorsque nous avons découvert que la part du lion des ressources est dépensée précisément pour ces deux opérations: l'analyse en lignes et l'écriture dans la base de données.

Nous avons plongé dans les entrailles du profileur et trouvé quelques fonctionnalités de travail avec BufferNode.js, dont la connaissance peut considérablement économiser votre temps et les ressources de votre serveur.

Charge CPU




La plupart du temps du processeur a été consacré au traitement du flux de journaux entrant, ce qui est compréhensible. Mais ce qui n'était pas clair, c'était l'intensité en ressources du «découpage» primitif du flux entrant de blocs binaires en lignes par \r\n: Le



développeur attentif remarquera immédiatement ici un cycle d'octets pas si efficace à travers le tampon entrant. Eh bien, puisque la ligne peut être «déchirée» entre les blocs voisins, il reste également une fonctionnalité «d'attache de queue» du bloc traité précédent.

Essayer readline


Un examen rapide des solutions disponibles nous a amenés au module de lecture régulière avec exactement les fonctionnalités nécessaires pour le découpage en lignes:



Après sa mise en œuvre, le «découpage» à partir du haut du profileur est allé plus loin:



Mais, il s'est avéré que readline force la chaîne en UTF-8 en interne , ce qui est impossible faire si l'entrée de journal (demande, plan, texte d'erreur) a un codage source différent.

En effet, même sur un serveur PostgreSQL, plusieurs bases de données peuvent être actives simultanément, chacune générant une sortie vers un journal de serveur commun exactement dans son encodage d'origine. En conséquence, les propriétaires de bases de données sur win-1251 (il est parfois pratique de l'utiliser pour économiser de l'espace disque si un UNICODE multibyte «honnête» n'est pas nécessaire) ont pu observer leurs plans avec approximativement les mêmes noms «russes» de tables et d'index:



Modification du vélo


C'est un problème ... Il faut encore faire le découpage vous-même, mais avec des optimisations du type Buffer.indexOf()au lieu de "byte-scan":



Il semble que tout va bien, la charge dans le circuit de test n'a pas augmenté, les noms de win1251 ont été réparés, nous nous sommes lancés dans la bataille ... Ta-dam! L'utilisation du CPU franchit périodiquement le plafond à 100% :



Comment est-ce? .. Il s'avère que c'est notre faute Buffer.concatavec laquelle nous «collons la queue» restante du bloc précédent:



Mais nous n'avons qu'un collage lorsque nous passons une ligne à travers le bloc , mais ils ne devraient pas être nombreux - vraiment, vraiment? .. Enfin, presque. Ce n'est que maintenant que parfois des «chaînes» de plusieurs centaines de segments de 16 Ko viennent :



Merci aux autres développeurs qui ont pris soin de générer cela. Cela arrive "rarement, mais avec précision", il n'était donc pas possible de voir à l'avance dans le circuit de test.

Il est clair que coller plusieurs centaines de fois au tampon de plusieurs mégaoctets de petits morceaux est un chemin direct vers l'abîme des réallocations de mémoire avec la consommation de ressources CPU, que nous avons observé. Donc, ne le collons pas jusqu'à ce que la ligne se termine complètement. Nous allons simplement mettre les «pièces» dans un tableau jusqu'à ce qu'il soit temps de «sortir» la ligne entière:



la charge est maintenant revenue aux indicateurs de la ligne de lecture.

Consommation de mémoire


De nombreuses personnes qui ont écrit dans des langues avec allocation de mémoire dynamique savent que l'un des "tueurs de performances" les plus désagréables est l' activité d'arrière - plan du Garbage Collector (GC), qui analyse les objets créés en mémoire et supprime ceux qui sont plus grands. personne n'est requis. Ce problème nous a également dépassés - à un moment donné, nous avons commencé à remarquer que l'activité du GC était en quelque sorte trop et hors de propos.



Les «rebondissements» traditionnels n’ont pas vraiment aidé… «Si tout le reste échoue, vider!» Et la sagesse populaire n'a pas déçu - nous avons vu un nuage de tampon de 8360 octets avec une taille totale de 520 Mo ...



Et ils ont été générés à l'intérieur de CopyBinaryStream - la situation a commencé à s'éclaircir ...

COPIE ... DE STDIN AVEC BINARY


Pour réduire la quantité de trafic transmis à la base de données, nous utilisons le format binaire COPY . En fait, pour chaque enregistrement, vous devez envoyer un tampon au flux, composé de «morceaux» - le nombre de champs dans l'enregistrement (2 octets) puis la représentation binaire des valeurs de chaque colonne (4 octets par type ID + données).

Étant donné qu'une telle ligne du tableau a presque toujours une longueur variable «résumée», l'allocation immédiate d'un tampon d'une longueur fixe n'est pas une option ; la réallocation en cas de manque de taille «grignotera» facilement les performances et est déjà allée plus haut. Donc, il vaut également la peine de «coller à partir de morceaux» en utilisant Buffer.concat().

note


Eh bien, puisque nous avons de nombreuses pièces répétées encore et encore (par exemple, le nombre de champs dans les enregistrements de la même table) - rappelons-nous simplement d' eux , puis prenons ceux prêts à l'emploi , générés une fois au premier appel. Basé sur le format COPY des options, il y a pas mal d'options - les pièces typiques viennent en 1, 2 ou 4 octets:



Et ... bam, le râteau est arrivé!



Autrement dit, chaque fois que vous créez un tampon, un morceau de mémoire de 8 Ko est alloué par défaut, de sorte que les petits tampons créés en ligne peuvent être empilés «côte à côte» dans la mémoire déjà allouée. Et notre allocation a fonctionné «à la demande», et il s'est avéré qu'elle n'était pas du tout «proche» - c'est pourquoi chacun de nos tampons de 1 à 4 octets occupait physiquement 8 Ko + en-tête - les voici, nos 520 Mo!

mémo intelligent


Hmm ... Pourquoi faut-il même attendre que tel ou tel tampon 1/2 octet soit nécessaire? Avec 4 octets est un problème distinct, mais certaines de ces différentes options pour un total de 256 + 65536. Alors laissez nagenerim leur ligne tout d'un coup ! Dans le même temps, nous supprimons la condition d'existence de chaque contrôle - cela fonctionnera également plus rapidement, car l'initialisation n'est effectuée qu'au début du processus.



Autrement dit, en plus des tampons 1/2 octet, nous initialisons immédiatement les valeurs les plus courantes (2 octets inférieurs et -1) pour celles de 4 octets. Et - cela a aidé, seulement 10 Mo au lieu de 520 Mo!


All Articles