Téléporter des tonnes de données vers PostgreSQL

Aujourd'hui, je vais partager quelques solutions architecturales utiles qui ont surgi lors du développement de notre outil d'analyse de masse des performances des serveurs PostgeSQL , et qui nous aident maintenant à «intégrer» la surveillance et l'analyse complètes de plus d'un millier d'hôtes dans le même matériel, ce qui au début suffisait à peine pour cent ...





Intro



Permettez-moi de vous rappeler quelques notes d’introduction:



  • nous construisons un service qui reçoit des informations des logs des serveurs PostgreSQL
  • collecter des journaux, nous voulons faire quelque chose avec eux (analyser, analyser, demander des informations supplémentaires) en ligne
  • tout ce qui est collecté et "analysé" doit être sauvegardé quelque part


Parlons du dernier point - comment tout cela peut être livré au stockage PostgreSQL . Dans notre cas, ces données sont des multiples des statistiques de charge d'origine dans le contexte d'une application et d'un modèle de plan spécifiques, de la consommation de ressources et du calcul des problèmes dérivés précis à un nœud de plan unique, des verrous de surveillance, et bien plus encore.

Plus de détails sur les principes du service peuvent être vus dans le rapport vidéo et lisez dans l'article "Optimisation de masse des requêtes PostgreSQL" .


pousser vs tirer



Il existe deux modèles principaux pour obtenir des journaux ou d'autres métriques qui arrivent constamment:



  • push - il y a de nombreux récepteurs peer-to-peer sur le service , sur les serveurs surveillés - certains agents locaux déversent périodiquement les informations accumulées dans le service
  • pull - sur le service, chaque processus / thread / coroutine / ... traite les informations d'une seule «propre» source , dont la réception des données est initiée par elle-même


Chacun de ces modèles a des côtés positifs et négatifs.



pousser



L'interaction est initiée par le nœud observé:



... est bénéfique si:



  • vous avez beaucoup de sources (des centaines de milliers)
  • la charge sur eux ne diffère pas beaucoup entre eux et ne dépasse pas ~ 1rps
  • un traitement compliqué n'est pas nécessaire




Exemple: le récepteur de l'opérateur OFD recevant les chèques de chaque caisse client.



... cause des problèmes:



  • verrous / blocages lors de la tentative d'écriture de dictionnaires / analytiques / agrégats dans le contexte de l'objet de surveillance à partir de différents flux
  • la pire utilisation du cache de chaque processus BL / connexion à la base de données - par exemple, la même connexion à la base de données doit d'abord être écrite dans une table ou un segment d'index, et immédiatement dans un autre
  • un agent spécial doit être placé sur chaque source, ce qui augmente la charge dessus
  • surcharge élevée dans l'interaction réseau - les en-têtes doivent «lier» l'envoi de chaque paquet, et non la connexion entière à la source dans son ensemble


tirer



L'initiateur est un hôte / processus / thread spécifique du collecteur, qui "lie" le nœud à lui-même et récupère indépendamment les données de la "cible":



... est bénéfique si:



  • vous avez peu de sources (des centaines de milliers)
  • il y a presque toujours une charge de leur part, parfois elle atteint 1Krps
  • nécessite un traitement complexe avec segmentation par source




Exemple: chargeur / analyseur de trades dans le cadre de chaque plateforme de trading.



... cause des problèmes:



  • limitation des ressources pour le traitement d' une source par un processus (cœur de processeur), car il ne peut pas être "étalé" sur deux destinataires
  • un coordinateur est nécessaire pour redistribuer dynamiquement la charge des sources aux processus / threads / ressources existants


Étant donné que notre modèle de charge pour la surveillance PostgreSQL s'est clairement orienté vers l'algorithme pull, et que les ressources d'un processus et le cœur d'un processeur moderne nous suffisent pour une source, nous nous sommes décidés dessus.



Bûches pull-pull



Notre communication avec le serveur fourni pour très nombreuses opérations de réseau et de travailler avec des chaînes de texte slaboformatirovannymi , donc en tant que JavaScript noyau de collecteur fut parfait dans son incarnation en tant que serveur Node.js .



La solution la plus simple pour obtenir des données à partir du journal du serveur s'est avérée être de "mettre en miroir" l'intégralité du fichier journal sur la console à l'aide d'une simple commande linux tail -F <current.log>. Seule notre console n'est pas simple, mais virtuelle - à l'intérieur d'une connexion sécurisée au serveur étirée via le protocole SSH .



Par conséquent, assis du deuxième côté de la connexion SSH, le collecteur reçoit une copie complète de tout le trafic du journal en entrée. Et si nécessaire, il demande au serveur des informations système étendues sur l'état actuel des choses.



Pourquoi pas syslog



Il y a deux principales raisons:



  1. syslog push-, . - «» , .



    «» / , .
  2. PostgreSQL, , «» (relation/page/tuple/...).

    Vous pouvez en savoir plus sur la résolution de ce problème dans l'article "DBA: à la recherche de verrous volants" .


Configuration de la base du récepteur



En principe, d'autres solutions pourraient être utilisées comme un SGBD pour stocker les données analysées à partir du journal, mais le volume d'informations entrantes de 150 à 200 Go / jour ne laisse pas trop de marge de manœuvre. Par conséquent, nous avons également choisi PostgreSQL comme stockage.



- PostgreSQL pour stocker les journaux? Sérieusement?

- Premièrement, il y a loin d'être seulement et pas tant des logs que diverses représentations analytiques . Deuxièmement, "vous ne savez tout simplement pas comment les cuisiner!" :)






Paramètres du serveur



Ce point est subjectif et dépend fortement de votre matériel, mais nous avons établi les principes suivants pour nous-mêmes pour configurer l'hôte PostgreSQL pour l'enregistrement actif.



Paramètres du système de fichiers

Le facteur le plus important affectant les performances d'écriture est le montage [incorrect] de la partition de données. Nous avons choisi les règles suivantes:



  • le répertoire PGDATA est monté (dans le cas d'ext4) avec des paramètresnoatime,nodiratime,barrier=0,errors=remount-ro,data=writeback,nobh
  • le répertoire PGDATA / pg_stat_tmp est déplacé verstmpfs
  • le répertoire PGDATA / pg_wal est déplacé vers un autre support, si cela est raisonnable


voir Réglage du système de fichiers PostgreSQL



Choisir le planificateur d'E / S optimal

Par défaut, de nombreuses distributions ont sélectionné le planificateur d'E / Scfq , affiné pour une utilisation "bureau", dans RedHat et CentOS - noop. Mais cela s'est avéré plus utile pour nous deadline.



voir PostgreSQL vs. Planificateurs d'E / S (cfq, noop et date limite)



Réduction de la taille du cache "sale"

Ce paramètre vm.dirty_background_bytesdéfinit la taille du cache en octets, une fois atteint, le système démarre le processus d'arrière-plan pour le vider sur disque. Il existe un paramètre similaire, mais mutuellement exclusifvm.dirty_background_ratio - il définit la même valeur en tant que pourcentage de la taille totale de la mémoire - par défaut, il est défini, et non "... octets".



Sur la plupart des distributions, il est de 10%, sur CentOS, il est de 5%. Cela signifie qu'avec une mémoire serveur totale de 16 Go, le système peut essayer d' écrire plus de 850 Mo sur le disque une fois - par conséquent, une charge IOps maximale se produit.



Nous le diminuons expérimentalement jusqu'à ce que les pics d'enregistrement commencent à se lisser. Par expérience, pour éviter les pics, la taille doit être inférieure au débit maximal du support (en IOps) multiplié par la taille de la page mémoire. C'est, par exemple, pour 7K IOps (~ 7000 x 4096) - environ 28 Mo.



voir Réglage des options du noyau Linux pour les paramètres d' optimisation PostgreSQL



dans postgresql.conf

Quels paramètres doivent être vus, tordus pour accélérer l'enregistrement. Tout ici est purement individuel, je ne donnerai donc que quelques réflexions sur le sujet:



  • shared_buffers - il devrait être réduit, car avec l'enregistrement ciblé de données «communes» qui se chevauchent particulièrement, les processus ne se produisent pas
  • synchronous_commit = off - vous pouvez toujours désactiver l'attente d'écriture de validation si vous faites confiance à la batterie de votre contrôleur RAID
  • fsync- si les données ne sont pas du tout critiques, vous pouvez essayer de les désactiver - "dans la limite", vous pouvez même obtenir une base de données en mémoire


Structure de la table de base de données



J'ai déjà publié quelques articles sur l'optimisation du stockage physique des données:





Mais à propos de différentes clés dans les données - il n'y en avait pas encore. Je vais vous en parler.



Les clés étrangères sont mauvaises pour les systèmes gourmands en écriture. En fait, ce sont des "béquilles" qui ne permettent pas à un programmeur imprudent d'écrire dans la base de données ce qui ne devrait pas être là.



De nombreux développeurs sont habitués au fait que les entités commerciales liées logiquement au niveau de la description des tables de base de données doivent être liées via FK. Mais ce n'est pas le cas!



Bien entendu, ce point dépend beaucoup des objectifs que vous définissez lors de l'écriture des données dans la base de données. Si vous n'êtes pas une banque (et si vous êtes également une banque, alors pas de traitement!), Alors le besoin de FK dans une base de données à écriture lourde est une grande question.



"Techniquement" chaque FK fait un SELECT séparé lors de l'insertion d'un enregistrementà partir du tableau référencé. Maintenant, regardez le tableau dans lequel vous écrivez activement, où vous avez 2-3 FK en suspens, et évaluez s'il vaut la peine pour votre tâche spécifique de fournir une baisse d'intégrité des performances de 3 à 4 fois ... Ou une connexion logique par valeur suffit-elle? Nous avons supprimé tous les FK ici.



Les clés UUID sont bonnes . Étant donné que la probabilité d'une collision d'UUID générés à différents points non liés est extrêmement faible, cette charge (en générant certains ID de substitution) peut être supprimée en toute sécurité de la base de données vers le «consommateur». L'utilisation des UUID est une bonne pratique dans les systèmes distribués connectés et non synchronisés.

Vous pouvez lire d'autres variantes d'identificateurs uniques dans PostgreSQL dans l'article "Antipatterns PostgreSQL: Identificateurs uniques ".


Les clés naturelles sont également bonnes , même si elles se composent de plusieurs champs. Il ne faut pas avoir peur des clés composites, mais d'un champ PK de substitution supplémentaire et d'un index dessus dans une table chargée, dont vous pouvez facilement vous passer.



En même temps, personne n'interdit de combiner les approches. Par exemple, nous avons un UUID de substitution attribué à un "lot" d'enregistrements séquentiels de journaux liés à une transaction d'origine (car il n'y a tout simplement pas de clé naturelle), mais une paire est utilisée comme PK (pack::uuid, recno::int2), où recnoest le numéro de séquence "naturel" de l' enregistrement dans le lot.



Flux de copie "sans fin"



PostgreSQL, comme OC, "n'aime pas" quand les données y sont écrites par lots énormes ( INSERTcomme 1000 lignes ). Mais il COPYest beaucoup plus tolérant avec les flux d'écriture équilibrés (à travers ). Mais ils doivent être capables de cuisiner très soigneusement.



  1. Étant donné qu'à l'étape précédente, nous avons supprimé tous les FK , nous pouvons maintenant écrire des informations sur lui pack- même et un ensemble de clés connexes reorddans un ordre arbitraire, de manière asynchrone . Dans ce cas, il est plus efficace de conserver un canal constamment actifCOPY pour chaque table cible .
  2. , , «», ( — COPY-) . , — 100, .
  3. , , . . .



    , , «» , . , .
  4. , node-pg, PostgreSQL Node.js, API — stream.write(data) COPY- true, , false, .





    , , « », COPY .
  5. COPY- LRU «». .




Ici, il convient de noter le principal avantage que nous avons obtenu avec ce schéma de lecture et d'écriture de journaux - dans notre base de données, les «faits» deviennent disponibles pour une analyse presque en ligne , après quelques secondes.



Raffinement avec un fichier



Tout semble être bon. Où est le «râteau» dans le schéma précédent? Commençons simplement ...



Synchronisation excessive



L'un des gros problèmes des systèmes chargés est la sur-synchronisation de certaines opérations qui n'en ont pas besoin. Parfois «parce qu'ils n'ont pas remarqué», parfois «c'était plus facile ainsi», mais tôt ou tard il faut s'en débarrasser.



C'est facile à réaliser. Nous avons déjà mis en place près de 1000 serveurs pour la surveillance, chacun est traité par un thread logique séparé, et chaque thread vide les informations accumulées à envoyer à la base de données avec une certaine fréquence, comme ceci:



setInterval(writeDB, interval)


Le problème ici réside précisément dans le fait que tous les flux démarrent à peu près au même moment, de sorte que les moments d'envoi coïncident presque toujours "au point".





Heureusement, cela est assez facile à résoudre - en ajoutant un intervalle de temps "aléatoire" à la fois pour le moment de début et pour l'intervalle:



setInterval(writeDB, interval * (1 + 0.1 * (Math.random() - 0.5)))






Cette méthode vous permet de "répartir" statistiquement la charge sur l'enregistrement, en la transformant en presque uniforme.



Mise à l'échelle par les cœurs de processeur



Un cœur de processeur n'est clairement pas suffisant pour l'ensemble de notre charge, mais le module de cluster nous aidera ici , ce qui nous permet de gérer facilement la création de processus enfants et de communiquer avec eux via IPC.



Nous avons maintenant 16 processus enfants pour 16 cœurs de processeur - et c'est bien, nous pouvons utiliser tout le processeur! Mais dans chaque processus, nous écrivons dans 16 plaques cibles , et lorsque la charge de pointe arrive, nous ouvrons également des canaux COPY supplémentaires. Autrement dit, basé sur plus de 256 fils d'écriture active en permanence ... oh! Un tel chaos n'a aucun effet positif sur les performances du disque et la base a commencé à brûler.



C'était particulièrement triste en essayant d'écrire certains dictionnaires courants - par exemple, le même texte de requête provenant de différents nœuds - verrous inutiles, attente ...





«Retournons» la situation - c'est-à-dire laissons les processus enfants collecter et traiter les informations de leurs sources, mais n'écrivons pas dans la base de données! Au lieu de cela, laissez-les envoyer un message via IPC au maître, et il écrit déjà quelque chose là où il doit être:





Quiconque a immédiatement vu le problème dans le schéma du paragraphe précédent - bravo. Cela réside exactement dans le moment où le master est également un processus aux ressources limitées. Par conséquent, à un moment donné, nous avons découvert qu'il commençait déjà à brûler - il a simplement cessé de faire face au déplacement de tous les threads vers la base, car il est également limité par les ressources d'un cœur de processeur . En conséquence, nous avons laissé la plupart des flux de "dictionnaire" les moins chargés à écrire via master, et les plus chargés, mais ne nécessitant pas de traitement supplémentaire, sont retournés aux nœuds de calcul:





Multicollecteur



Mais même un nœud ne suffit pas pour desservir toute la charge disponible - il est temps de penser à la mise à l'échelle linéaire. La solution était un multi- collecteur, auto-équilibré en fonction de la charge, avec un coordinateur en tête.





Chaque maître lui décharge la charge actuelle de tous ses ouvriers et reçoit en réponse des recommandations sur le noeud de surveillance qui doit être transféré à un autre ouvrier ou même à un autre collecteur. Il y aura un article séparé sur ces algorithmes d'équilibrage.



Mise en commun et limitation de la file d'attente



La prochaine bonne question est: que faire des flux d'écriture en cas de charge de pointe soudaine .



Après tout, nous ne pouvons pas ouvrir de plus en plus de nouvelles connexions à la base à l'infini - c'est inefficace et cela n'aidera pas. Une solution triviale - limitons-la pour ne pas avoir plus de 16 threads actifs simultanément pour chacune des tables cibles. Mais que faire des données que nous n'avons toujours "pas eu le temps" d'écrire? ..



Si ce "pic" de charge est exactement de pointe, c'est-à-dire à court terme , alors nous pouvons temporairement sauvegarder les données dans la file d'attente dans la mémoire du collecteur lui-même. Dès qu'un canal vers la base est libéré, nous récupérons l'enregistrement de la file d'attente et l'envoyons au flux.



Oui, cela nécessite que le collecteur ait un tampon pour stocker les files d'attente, mais il est plutôt petit et est rapidement libéré:





Priorités de la file d'attente



Le lecteur attentif, après avoir regardé la photo précédente, était à nouveau perplexe, «que se passera-t-il lorsque la mémoire sera complètement épuisée ? ..» Il y a déjà peu d'options - quelqu'un devra être sacrifié.



Mais tous les enregistrements que nous voulons fournir à la base de données ne sont pas «également utiles». Il est dans notre intérêt de les noter autant que possible, quantitativement. La "priorisation exponentielle" primitive par la taille de la chaîne écrite nous aidera avec ceci:



let priority = Math.trunc(Math.log2(line.length));
queue[priority].push(line);


Par conséquent, lors de l'écriture sur un canal, nous commençons toujours à récupérer les files d'attente "inférieures" - c'est juste que chaque ligne distincte y est plus courte, et nous pouvons les envoyer quantitativement plus:



let qkeys = Object.keys(queue);
qkeys.sort((x, y) => x.valueOf() - y.valueOf()); // - - !


Vaincre les blocages



Revenons maintenant à deux étapes. Au moment où nous avons décidé de laisser un maximum de 16 threads à l'adresse d'une table. Si la table cible est en "streaming", c'est-à-dire que les enregistrements ne sont pas corrélés les uns aux autres, tout va bien. Maximum - nous aurons des verrous «physiques» au niveau du disque.



Mais s'il s'agit d'une table d'agrégats ou même d'un "dictionnaire", alors en essayant d'écrire à partir de différents flux de lignes avec le même PK, nous recevrons une attente sur le verrou, voire un blocage. C'est triste ...



Mais après tout, quoi écrire - on se définit! Le point clé n'est pas d'essayer d'écrire un PK à partir d'endroits différents .



Autrement dit, lors du passage de la file d'attente, nous cherchons immédiatement si un thread écrit déjà dans la même table (nous nous souvenons qu'ils sont tous dans l'espace d'adressage commun d'un processus) avec un tel PK. Sinon, nous le prenons pour nous et l'écrivons dans le dictionnaire en mémoire «pour nous-mêmes»;



À la fin de la transaction, nous «nettoyons» simplement la pièce jointe «à nous-mêmes» du dictionnaire.



Une petite preuve



Premièrement, avec LRU, les «premières» connexions et les processus PostgreSQL qui les servent fonctionnent presque toujours tout le temps. Cela signifie que le système d'exploitation les bascule entre les cœurs du processeur beaucoup moins souvent , ce qui minimise les temps d'arrêt.





Deuxièmement, si vous travaillez avec les mêmes processus côté serveur presque tout le temps, les chances que deux processus soient actifs en même temps sont fortement réduites - en conséquence, la charge maximale sur le processeur dans son ensemble diminue (zone grise dans le deuxième graphique à partir de la gauche ) et LA tombe parce que moins de processus attendent leur tour.





C'est tout pour aujourd'hui.



Et permettez- moi de vous rappeler qu'avec l'aide de explic.tensor.ru, vous pouvez voir diverses options pour visualiser le plan d' exécution de la requête, ce qui vous aidera à voir clairement les zones à problèmes.



All Articles