paint-brush
Dites adieu aux plantages OOMpar@wydfy111
719 lectures
719 lectures

Dites adieu aux plantages OOM

par Jiafeng Zhang11m2023/06/12
Read on Terminal Reader

Trop long; Pour lire

Une solution de gestion de mémoire plus robuste et flexible avec des optimisations dans l'allocation de mémoire, le suivi de la mémoire et la limite de mémoire.
featured image - Dites adieu aux plantages OOM
Jiafeng Zhang HackerNoon profile picture

Qu'est-ce qui garantit la stabilité du système dans les tâches de requête de données volumineuses ? Il s'agit d'un mécanisme efficace d'allocation de mémoire et de surveillance. C'est ainsi que vous accélérez le calcul, évitez les points chauds de mémoire, répondez rapidement à une mémoire insuffisante et minimisez les erreurs OOM.




Du point de vue d'un utilisateur de base de données, comment souffre-t-il d'une mauvaise gestion de la mémoire ? Voici une liste de choses qui dérangeaient nos utilisateurs :


  • Les erreurs OOM provoquent le blocage des processus backend. Pour citer l'un des membres de notre communauté : Bonjour, Apache Doris, vous pouvez ralentir les choses ou échouer à quelques tâches lorsque vous manquez de mémoire, mais lancer un temps d'arrêt n'est tout simplement pas cool.


  • Les processus backend consomment trop d'espace mémoire, mais il n'y a aucun moyen de trouver la tâche exacte à blâmer ou de limiter l'utilisation de la mémoire pour une seule requête.


  • Il est difficile de définir une taille de mémoire appropriée pour chaque requête, il est donc probable qu'une requête soit annulée même lorsqu'il y a beaucoup d'espace mémoire.


  • Les requêtes à haute simultanéité sont disproportionnellement lentes et les points chauds de la mémoire sont difficiles à localiser.


  • Les données intermédiaires lors de la création de HashTable ne peuvent pas être vidées sur les disques, de sorte que les requêtes de jointure entre deux grandes tables échouent souvent en raison du MOO.


Heureusement, ces jours sombres sont derrière nous car nous avons amélioré notre mécanisme de gestion de la mémoire de bas en haut. Maintenant préparez-vous; les choses vont être intensives.

Allocation de mémoire

Dans Apache Doris, nous avons une interface unique pour l'allocation de mémoire : Allocator . Il fera les ajustements qu'il juge appropriés pour garder l'utilisation de la mémoire efficace et sous contrôle.


De plus, des MemTrackers sont en place pour suivre la taille de la mémoire allouée ou libérée, et trois structures de données différentes sont responsables de l'allocation de mémoire importante lors de l'exécution de l'opérateur (nous y reviendrons immédiatement).




Structures de données en mémoire

Étant donné que différentes requêtes ont différents modèles de points d'accès mémoire lors de leur exécution, Apache Doris fournit trois structures de données en mémoire différentes : Arena , HashTable et PODArray . Ils sont tous sous le règne de l'Allocation.



  1. Arène

L'Arena est un pool de mémoire qui maintient une liste de morceaux, qui doivent être alloués à la demande de l'allocateur. Les blocs prennent en charge l'alignement de la mémoire. Ils existent tout au long de la durée de vie de l'arène et seront libérés lors de la destruction (généralement lorsque la requête est terminée).


Les blocs sont principalement utilisés pour stocker les données sérialisées ou désérialisées pendant Shuffle, ou les clés sérialisées dans HashTables.


La taille initiale d'un morceau est de 4096 octets. Si le morceau actuel est plus petit que la mémoire demandée, un nouveau morceau sera ajouté à la liste.


Si le morceau actuel est inférieur à 128M, le nouveau morceau doublera sa taille ; s'il est supérieur à 128M, le nouveau bloc sera, au plus, 128M plus grand que ce qui est requis.


L'ancien petit morceau ne sera pas alloué pour les nouvelles requêtes. Il y a un curseur pour marquer la ligne de démarcation entre les morceaux alloués et ceux non alloués.


  1. Table de hachage

Les tables de hachage sont applicables aux jointures de hachage, aux agrégations, aux opérations d'ensemble et aux fonctions de fenêtre. La structure PartitionedHashTable ne prend pas en charge plus de 16 sous-HashTables. Il prend également en charge la fusion parallèle de HashTables et chaque sous-jointure de hachage peut être mise à l'échelle indépendamment.


Ceux-ci peuvent réduire l'utilisation globale de la mémoire et la latence causée par la mise à l'échelle.


Si le HashTable actuel est inférieur à 8M, il sera mis à l'échelle par un facteur de 4 ;

S'il est supérieur à 8M, il sera mis à l'échelle par un facteur de 2 ;

S'il est inférieur à 2G, il sera mis à l'échelle lorsqu'il sera plein à 50 % ;

et s'il est supérieur à 2G, il sera mis à l'échelle lorsqu'il sera plein à 75 %.


Les HashTables nouvellement créées seront pré-dimensionnées en fonction de la quantité de données dont elles disposeront. Nous fournissons également différents types de HashTables pour différents scénarios. Par exemple, pour les agrégations, vous pouvez appliquer PHmap.


  1. Tableau PODA

PODArray, comme son nom l'indique, est un tableau dynamique de POD. La différence entre lui et std::vector est que PODArray n'initialise pas les éléments. Il prend en charge l'alignement de la mémoire et certaines interfaces de std::vector .


Il est mis à l'échelle par un facteur de 2. En destruction, au lieu d'appeler la fonction destructeur pour chaque élément, il libère la mémoire de l'ensemble du PODArray. PODArray est principalement utilisé pour enregistrer des chaînes dans des colonnes et est applicable dans de nombreux calculs de fonctions et filtrage d'expressions.

Interface mémoire

En tant que seule interface qui coordonne Arena, PODArray et HashTable, l'allocateur exécute l'allocation de mappage de mémoire (MMAP) pour les requêtes supérieures à 64 Mo.


Ceux qui sont inférieurs à 4K seront directement alloués depuis le système via malloc/free ; et ceux entre les deux seront accélérés par un ChunkAllocator de mise en cache à usage général, qui apporte une augmentation des performances de 10 % selon nos résultats de benchmarking.


Le ChunkAllocator essaiera de récupérer un morceau de la taille spécifiée à partir de la FreeList du noyau actuel sans verrouiller ; si un tel bloc n'existe pas, il essaiera à partir d'autres cœurs d'une manière basée sur le verrouillage ; si cela échoue toujours, il demandera la taille de mémoire spécifiée au système et l'encapsulera dans un bloc.


Nous avons choisi Jemalloc plutôt que TCMalloc après avoir expérimenté les deux. Nous avons essayé TCMalloc dans nos tests à haute simultanéité et avons remarqué que Spin Lock dans CentralFreeList prenait 40 % du temps total de requête.


La désactivation de la "désactivation agressive de la mémoire" a amélioré les choses, mais cela a entraîné une utilisation beaucoup plus importante de la mémoire. Nous avons donc dû utiliser un thread individuel pour recycler régulièrement le cache. Jemalloc, en revanche, était plus performant et stable dans les requêtes à forte simultanéité.


Après un réglage fin pour d'autres scénarios, il a fourni les mêmes performances que TCMalloc mais a consommé moins de mémoire.

Réutilisation de la mémoire

La réutilisation de la mémoire est largement exécutée sur la couche d'exécution d'Apache Doris. Par exemple, des blocs de données seront réutilisés tout au long de l'exécution d'une requête. Pendant Shuffle, il y aura deux blocs à l'extrémité expéditeur et ils fonctionnent en alternance, l'un recevant des données et l'autre dans le transport RPC.


Lors de la lecture d'une tablette, Doris réutilisera la colonne de prédicat, implémentera la lecture cyclique, filtrera, copiera les données filtrées dans le bloc supérieur, puis effacera.


Lors de l'ingestion de données dans une table de clés agrégées, une fois que la MemTable qui met en cache les données atteint une certaine taille, elle sera pré-agrégée, puis davantage de données seront écrites.


La réutilisation de la mémoire est également exécutée lors de l'analyse des données. Avant le début de l'analyse, un certain nombre de blocs libres (en fonction du nombre d'analyseurs et de threads) seront alloués à la tâche d'analyse.


Lors de chaque planification de scanner, l'un des blocs libres sera transmis à la couche de stockage pour la lecture des données.


Après la lecture des données, le bloc sera placé dans la file d'attente du producteur pour la consommation des opérateurs supérieurs lors du calcul ultérieur. Une fois qu'un opérateur supérieur a copié les données de calcul du bloc, le bloc retournera dans les blocs libres pour la prochaine planification du scanner.


Le thread qui pré-alloue les blocs libres sera également chargé de les libérer après l'analyse des données, il n'y aura donc pas de surcharge supplémentaire. Le nombre de blocs libres détermine en quelque sorte la simultanéité de l'analyse des données.

Suivi de la mémoire

Apache Doris utilise MemTrackers pour suivre l'allocation et la libération de mémoire tout en analysant les points chauds de mémoire. Les MemTrackers conservent des enregistrements de chaque requête de données, de l'ingestion de données, de la tâche de compactage des données et de la taille de la mémoire de chaque objet global, tel que Cache et TabletMeta.


Il prend en charge le comptage manuel et le suivi automatique MemHook. Les utilisateurs peuvent afficher l'utilisation de la mémoire en temps réel dans le backend Doris sur une page Web.

Structure des MemTrackers

Le système MemTracker avant Apache Doris 1.2.0 était dans une structure arborescente hiérarchique, composée de process_mem_tracker, query_pool_mem_tracker, query_mem_tracker, instance_mem_tracker, ExecNode_mem_tracker, etc.


Les MemTrackers de deux couches voisines sont d'une relation parent-enfant. Par conséquent, toute erreur de calcul dans un MemTracker enfant sera accumulée tout le long et se traduira par une plus grande échelle d'incrédibilité.



Dans Apache Doris 1.2.0 et versions ultérieures, nous avons beaucoup simplifié la structure de MemTrackers. Les MemTrackers ne sont divisés qu'en deux types en fonction de leurs rôles : MemTracker Limiter et les autres.


MemTracker Limiter, qui surveille l'utilisation de la mémoire, est unique dans chaque tâche de requête/ingestion/compactage et objet global ; tandis que les autres MemTrackers tracent les points chauds de la mémoire dans l'exécution des requêtes, tels que les HashTables dans les fonctions Join/Aggregation/Sort/Window et les données intermédiaires dans la sérialisation, pour donner une image de la façon dont la mémoire est utilisée dans différents opérateurs ou fournir une référence pour le contrôle de la mémoire dans vidage des données.


La relation parent-enfant entre MemTracker Limiter et les autres MemTrackers ne se manifeste que dans l'impression d'instantanés. Vous pouvez considérer une telle relation comme un lien symbolique. Ils ne sont pas consommés en même temps, et le cycle de vie de l'un n'affecte pas celui de l'autre.


Cela permet aux développeurs de les comprendre et de les utiliser beaucoup plus facilement.


Les MemTrackers (y compris MemTracker Limiter et les autres) sont placés dans un groupe de Maps. Ils permettent aux utilisateurs d'imprimer des instantanés globaux de type MemTracker, des instantanés de tâches de requête/chargement/compactage et de découvrir la requête/chargement avec le plus d'utilisation de la mémoire ou le plus de surutilisation de la mémoire.



Comment fonctionne MemTracker

Pour calculer l'utilisation de la mémoire d'une certaine exécution, un MemTracker est ajouté à une pile dans Thread Local du thread actuel. En rechargeant le malloc/free/realloc dans Jemalloc ou TCMalloc, MemHook obtient la taille réelle de la mémoire allouée ou libérée et l'enregistre dans le Thread Local du thread courant.


Lorsqu'une exécution est terminée, le MemTracker concerné sera supprimé de la pile. Au bas de la pile se trouve le MemTracker qui enregistre l'utilisation de la mémoire pendant tout le processus d'exécution de requête/chargement.


Maintenant, laissez-moi vous expliquer avec un processus d'exécution de requête simplifié.


  • Après le démarrage d'un nœud backend Doris, l'utilisation de la mémoire de tous les threads sera enregistrée dans Process MemTracker.


  • Lorsqu'une requête est soumise, un Query MemTracker sera ajouté à la pile Thread Local Storage (TLS) dans le thread d'exécution de fragment.


  • Une fois qu'un ScanNode est programmé, un ScanNode MemTracker sera ajouté à la pile Thread Local Storage (TLS) dans le thread d'exécution de fragment. Ensuite, toute mémoire allouée ou libérée dans ce thread sera enregistrée à la fois dans le Query MemTracker et le ScanNode MemTracker.


  • Après la planification d'un scanner, un Query MemTracker et un Scanner MemTracker seront ajoutés à la pile TLS du thread du scanner.


  • Une fois l'analyse terminée, tous les MemTrackers de la pile TLS du thread de scanner seront supprimés. Lorsque la planification de ScanNode est terminée, le ScanNode MemTracker sera supprimé du fil d'exécution du fragment. Ensuite, de la même manière, lorsqu'un nœud d'agrégation est planifié, un AggregationNode MemTracker sera ajouté au thread d'exécution de fragment TLS Stack et sera supprimé une fois la planification terminée.


  • Si la requête est terminée, le Query MemTracker sera supprimé du thread d'exécution de fragment TLS Stack. À ce stade, cette pile devrait être vide. Ensuite, à partir du QueryProfile, vous pouvez visualiser le pic d'utilisation de la mémoire pendant toute l'exécution de la requête ainsi que chaque phase (scanning, agrégation, etc.).



Comment utiliser MemTracker

La page Web du backend Doris illustre l'utilisation de la mémoire en temps réel, qui est divisée en types : Query/Load/Compaction/Global. La consommation de mémoire actuelle et la consommation maximale sont affichées.



Les types globaux incluent MemTrackers de Cache et TabletMeta.



À partir des types de requête, vous pouvez voir la consommation de mémoire actuelle et la consommation de pointe de la requête actuelle et les opérateurs qu'elle implique (vous pouvez dire comment ils sont liés à partir des étiquettes). Pour les statistiques de mémoire des requêtes historiques, vous pouvez consulter les journaux d'audit Doris FE ou les journaux BE INFO.



Limite de mémoire

Grâce au suivi de la mémoire largement implémenté dans les backends Doris, nous nous rapprochons de l'élimination de l'OOM, cause des temps d'arrêt du backend et des échecs de requêtes à grande échelle. L'étape suivante consiste à optimiser la limite de mémoire sur les requêtes et les processus pour contrôler l'utilisation de la mémoire.

Limite de mémoire sur la requête

Les utilisateurs peuvent mettre une limite de mémoire sur chaque requête. Si cette limite est dépassée lors de l'exécution, la requête sera annulée. Mais depuis la version 1.2, nous avons autorisé la surcharge mémoire, qui est un contrôle de limite de mémoire plus flexible.


S'il y a suffisamment de ressources mémoire, une requête peut consommer plus de mémoire que la limite sans être annulée, de sorte que les utilisateurs n'ont pas à prêter une attention particulière à l'utilisation de la mémoire ; s'il n'y en a pas, la requête attendra qu'un nouvel espace mémoire soit alloué uniquement lorsque la mémoire nouvellement libérée n'est pas suffisante pour la requête, la requête sera annulée.


Dans Apache Doris 2.0, nous avons réalisé la sécurité des exceptions pour les requêtes. Cela signifie que toute allocation de mémoire insuffisante entraînera immédiatement l'annulation de la requête, ce qui évite d'avoir à vérifier l'état "Annuler" dans les étapes suivantes.

Limite de mémoire sur le processus

Régulièrement, le backend Doris récupère la mémoire physique des processus et la taille de mémoire actuellement disponible du système. Pendant ce temps, il collecte des instantanés MemTracker de toutes les tâches de requête/chargement/compactage.


Si un processus backend dépasse sa limite de mémoire ou si la mémoire est insuffisante, Doris libère de l'espace mémoire en effaçant le cache et en annulant un certain nombre de requêtes ou de tâches d'ingestion de données. Celles-ci seront exécutées régulièrement par un thread GC individuel.



Si la mémoire de processus consommée dépasse la SoftMemLimit (81 % de la mémoire système totale, par défaut), ou si la mémoire système disponible tombe en dessous du seuil d'avertissement (moins de 3,2 Go), le GC mineur sera déclenché.


À ce moment, l'exécution de la requête sera interrompue à l'étape d'allocation de mémoire, les données mises en cache dans les tâches d'ingestion de données seront vidées de force, et une partie du cache de page de données et du cache de segment obsolète sera libérée.


Si la mémoire nouvellement libérée ne couvre pas 10 % de la mémoire de processus, avec l'option Memory Overcommit activée, Doris commencera à annuler les requêtes les plus "overcommitters" jusqu'à ce que l'objectif de 10 % soit atteint ou que toutes les requêtes soient annulées.


Ensuite, Doris raccourcira l'intervalle de vérification de la mémoire système et l'intervalle GC. Les requêtes se poursuivront une fois que plus de mémoire sera disponible.


Si la mémoire de processus consommée dépasse la MemLimit (90 % de la mémoire système totale, par défaut), ou si la mémoire système disponible tombe en dessous du Low Water Mark (moins de 1,6 Go), Full GC sera déclenché.


À ce stade, les tâches d'ingestion de données seront arrêtées et tout le cache de page de données et la plupart des autres caches seront libérés.


Si, après toutes ces étapes, la mémoire nouvellement libérée ne couvre pas 20 % de la mémoire du processus, Doris examinera tous les MemTrackers et trouvera les requêtes et les tâches d'ingestion les plus consommatrices de mémoire, et les annulera une par une.


Ce n'est qu'une fois l'objectif de 20 % atteint que l'intervalle de vérification de la mémoire système et l'intervalle GC seront prolongés, et que les requêtes et les tâches d'ingestion se poursuivront. (Une opération de récupération de place prend généralement des centaines de μs à des dizaines de ms.)

Influences et résultats

Après des optimisations de l'allocation de mémoire, du suivi de la mémoire et de la limite de mémoire, nous avons considérablement augmenté la stabilité et les performances à haute simultanéité d'Apache Doris en tant que plate-forme d'entrepôt de données analytique en temps réel. Le crash d'OOM dans le backend est une scène rare maintenant.


Même s'il existe un MOO, les utilisateurs peuvent localiser la racine du problème en fonction des journaux, puis le résoudre. De plus, avec des limites de mémoire plus flexibles sur les requêtes et l'ingestion de données, les utilisateurs n'ont pas à consacrer d'efforts supplémentaires à la gestion de la mémoire lorsque l'espace mémoire est suffisant.


Dans la phase suivante, nous prévoyons d'assurer l'achèvement des requêtes en surengagement de mémoire, ce qui signifie que moins de requêtes devront être annulées en raison d'un manque de mémoire.


Nous avons divisé cet objectif en directions de travail spécifiques : la sécurité des exceptions, l'isolation de la mémoire entre les groupes de ressources et le mécanisme de vidage des données intermédiaires.


Si vous souhaitez rencontrer nos développeurs, c'est ici que vous nous trouverez .