Dans cet article, nous examinerons en détail comment l'arborescence B + est créée dans une base de données Apache Ignite distribuée.

Il y a déjà quelques articles sur les arbres H sur les arbres B ( un , deux ), mais ils sont plus probablement théoriques et même s'ils contiennent une implémentation, ils ne sont pas prêts pour la production . De cela, il y a un intérêt à regarder la mise en œuvre qui est utilisée dans la vie.
Avant de lire l'article plus loin, je vous conseille de regarder une conférence de Maxim Babenko , si vous ne savez toujours pas ce qu'est l'arbre B en théorie. Mais je n'ai pas besoin de connaître Java en profondeur ou le projet Apache Ignite - j'écrirai les détails explicitement ou le cacherai sous les spoilers.
Lors de la lecture des sources Ignite, je vous conseille de sauter les arguments des méthodes dans votre esprit, dont la signification n'est pas très claire et de lire les noms des fonctions - il est beaucoup plus facile de lire le corps de la fonction si vous savez à l'avance ce qu'elle fait.
Veuillez noter que je ne suis pas le développeur principal d'Apache Ignite et que j'ai peut-être mal compris quelque chose du code. J'ai donc mis la phrase «pour autant que je comprends», qui devrait être ajoutée mentalement avant chaque phrase affirmative.
Pourquoi l'arbre B + dans Apache Ignite
Apache Ignite est une base de données en mémoire, mais depuis la version 2.1, elle possède un magasin de données persistantes - une fonction pour enregistrer les données sur le disque (n'a rien à voir avec la structure des données persistantes ) . Par conséquent, il est clair pourquoi une structure est nécessaire pour la mémoire externe, il reste à comprendre pourquoi ils n'en ont pas choisi une autre.
Pour commencer, l'arbre B + est une optimisation de l'arbre B, où les valeurs sont stockées uniquement dans les feuilles. Dans cette optimisation, davantage de clés tiennent dans un nœud, ce qui augmente le degré de ramification. Par conséquent, il n'y a pas beaucoup de raisons d'utiliser l'arbre B classique.
B * et B + * - sont plus denses sur le disque, mais leurs performances sont moins bonnes, car nous stockons les données de la RAM, les performances sont plus importantes pour nous.
À en juger par les repères, un arbre LSM est plus rapide à écrire, mais plus lent à lire. De plus, la perte en lecture est supérieure au gain en écriture, donc pour un cas général hypothétique, je prendrais aussi l'arbre B +.
Il existe également d'étranges arbres fractals , cependant, apparemment, ils sont brevetés et mis en œuvre uniquement dans TokuDB .
Personnellement, je suis plus intéressé par la raison pour laquelle il était impossible de prendre un backend de disque prêt à l'emploi, comme LevelDB ? Une réponse partielle est que PDS prend en charge le stockage tiers.
Mise en œuvre de grandes cellules
Au départ, GridGain a développé Apache Ignite, mais avant de partir pour l'open source, il portait le nom de la société, donc certains noms de composants commencent par Grid
et d'autres par Ignite
. Par conséquent GridCursor
, mais IgniteTree
. Il n'y a pas d'autre logique ici.
Le code Apache Ignite est écrit dans les modèles de meilleures pratiques Java, et chaque classe hérite d'une interface, sinon une. Depuis l'interface IgniteTree
et lancez la danse. Je donne le code sans javadoc, pour faire court.
public interface IgniteTree<L, T> { public T put(T val) throws IgniteCheckedException; public void invoke(L key, Object x, InvokeClosure<T> c) throws IgniteCheckedException; public T findOne(L key) throws IgniteCheckedException; public GridCursor<T> find(L lower, L upper) throws IgniteCheckedException; public GridCursor<T> find(L lower, L upper, Object x) throws IgniteCheckedException; public T findFirst() throws IgniteCheckedException; public T findLast() throws IgniteCheckedException; public T remove(L key) throws IgniteCheckedException; public long size() throws IgniteCheckedException; interface InvokeClosure<T> { void call(@Nullable T row) throws IgniteCheckedException; T newRow(); OperationType operationType(); } enum OperationType { NOOP, REMOVE, PUT } }
L'interface IgniteTree
décrit un ensemble standard d'opérations. Veuillez noter qu'avoir une recherche de gamme vous oblige à tricoter des feuilles dans une liste. Le bonus prend en charge une opération d'enregistrement arbitraire - invoke
.
L'opération put
ne prend qu'un seul argument de type T
sans clé. Vous ne trouverez pas d'explication à cela dans IgniteTree
, mais la réponse est masquée dans l'en-tête BPlusTree
.
public abstract class BPlusTree<L, T extends L> extends DataStructure implements IgniteTree<L, T>
Comme vous pouvez le voir, la valeur hérite de la clé, par conséquent, dans l'opération put, la clé est calculée à partir de la valeur. Cela ne limite pas la fonctionnalité de l'arborescence. Mon hypothèse est qu'il est plus compact de stocker des ensembles.
Habituellement, ils font un ensemble de carte en attachant une constante de mémoire à la valeur. Cependant, dans l'arborescence B +, seules les clés sont stockées dans les nœuds; si la valeur stocke également la clé, dans les feuilles, il suffit de stocker uniquement les valeurs . Et si l'arbre est un ensemble, il se trouve automatiquement que dans les feuilles, il n'y aura que des clés sans valeurs de déchets.

Examinons maintenant le code de recherche d'élément.
private void doFind(Get g) throws IgniteCheckedException { for (;;) {
La base de l'algorithme de traversée de l'arbre B est restée inchangée: ils descendent l'arbre récursivement jusqu'à la feuille souhaitée: si la valeur est présente, alors ils retournent le résultat, et sinon, alors null
. La récursivité a apparemment été laissée pour plus de commodité, de toute façon, les arbres B ne sont pas profonds.

J'ai été surpris car il y avait une installation claire dans ma tête: la récursivité est toujours supprimée dans un projet réel ( il n'y a pas d'optimisation de récursivité de queue en Java , la récursivité est acceptable dans les projets dans d'autres langages). Mais vraiment, la hauteur de l'arbre B est mesurée en unités, car la taille du bloc de commande , et le nombre de données de la commande et la hauteur sera .
Apache Ignite aime la simultanéité . Par conséquent, l'arborescence prend en charge la modification compétitive. Au moment de l'opération, une page est bloquée, mais un autre thread peut modifier le reste de l'arborescence afin qu'une deuxième lecture soit nécessaire. Et donc la procédure peut atteindre la racine.
Au début, je ne comprenais pas la signification d'une telle fonctionnalité, car le disque est lent et un thread traitera calmement toutes les opérations d'E / S. Il est clair que la recherche dans le nœud chargé à partir du disque coûte un peu et pendant ce temps, vous pouvez charger le disque avec une autre opération, mais des tentatives répétées grugeront le gain. Cependant, il m'est apparu que dans cette implémentation, les nœuds n'étaient pas immédiatement vidés sur le disque après la modification, mais ils étaient suspendus dans la mémoire pendant un certain temps, afin d'appliquer ensuite de nombreuses modifications à la fois. Aucune donnée n'est perdue grâce à Write Ahead Log. Plus d'informations à ce sujet à la fin de l'article.
Voyons maintenant le code pour ajouter un élément.
private T doPut(T row, boolean needOld) throws IgniteCheckedException { checkDestroyed(); Put p = new Put(row, needOld); try { for (;;) {
La seule différence est qu'après la détection de la position, le code se transforme en replace
et insert
. Vous ne pouvez plus regarder le code de remove
. Le mécanisme de base est celui des tentatives répétées de parcourir récursivement l'arborescence avec un objet spécial en fonction de l'opération: Get
, Put
ou Remove
.
Invoke
fonctionne de la même manière, seule l'opération a lieu avec une copie de l'enregistrement, puis son type est déterminé: NOOP
pour la lecture, REMOVE
pour la suppression et PUT
pour la mise à jour ou l'ajout, puis l'objet Put
ou Remove
correspondant est généré, qui est appliqué à l'enregistrement dans l'arborescence.
Utiliser
Ci-dessous, j'examinerai de plus près deux BPlusTree
CacheDataTree
: CacheDataTree
et PendingEntriesTree
. Overboard est la mise en œuvre d'index - c'est un sujet pour une discussion séparée, pour lequel je ne suis pas encore prêt.
Avant de poursuivre, je précise que la carte distribuée locale a une fonctionnalité IgniteCache
et s'appelle IgniteCache
- ci-après simplement un cache.
CacheDataTree
CacheDataTree
est un mappage de plusieurs IgniteCache
sur le disque. Le tri est à plusieurs niveaux: triez d'abord par identifiant de cache pour regrouper les clés dans un cache, puis par hachage.
CacheDataTree
pas sur la plage, car les index sont implémentés dans les héritiers de H2Tree extends BPlusTree
, donc le type spécifique de tri n'est pas important: tout est suffisant pour les opérations put
et get
. La comparaison des hachages est plus rapide que les objets. Mais plus important encore, un hachage uniforme remplira l'arbre plus densément.
Les arbres s'équilibrent pour ne pas dégénérer en liste. Mais si vous ajoutez des clés uniformément réparties à l'arborescence de recherche, elle sera automatiquement équilibrée. Étant donné que les arbres B commencent à s'équilibrer lorsque des problèmes surviennent et que les hachages mélangent les clés de manière égale, le tri par hachage réduit la fréquence de l'équilibrage.
L'utilisation de hachages dans l'arbre de recherche n'est pas une idée aussi étrange qu'il y paraît, son développement logique conduira à un tri mappé de tableau Hash .
PendingEntriesTree
PendingEntriesTree
stocke les clés des données au fil du temps et est utilisé comme défini. Le tri est à plusieurs niveaux: d'abord trié par identifiant de cache pour regrouper les clés dans un cache, puis par durée de vie. Vient ensuite un autre tour de comparaison - apparemment purement technique, pour distinguer les éléments. À partir du tri, il est facile de deviner que cette classe est utilisée pour rechercher des plages. Cette arborescence duplique les clés d'entrée du cache pour la préemption.
Comprenez comment fonctionne cette aventure séparée.
AventureDans IgniteCacheOffheapManagerImpl.expire()
prenez le curseur et supprimez les entrées de PendingEntriesTree
. Les entrées dans CacheDataTree
sont supprimées dans la fermeture c
, qui est passée dans les paramètres.
@Override public boolean expire( GridCacheContext cctx, IgniteInClosure2X<GridCacheEntryEx, GridCacheVersion> c, int amount )
Apache Ignite a récemment cessé de prendre en charge Java 7, la fermeture est donc créée via une classe anonyme.
private final IgniteInClosure2X<GridCacheEntryEx, GridCacheVersion> expireC = new IgniteInClosure2X<GridCacheEntryEx, GridCacheVersion>() { @Override public void applyx(GridCacheEntryEx entry, GridCacheVersion obsoleteVer) { boolean touch = !entry.isNear(); while (true) { try { if (log.isTraceEnabled()) log.trace("Trying to remove expired entry from cache: " + entry); if (entry.onTtlExpired(obsoleteVer)) touch = false; break; } catch (GridCacheEntryRemovedException ignore) { entry = entry.context().cache().entryEx(entry.key()); touch = true; } } if (touch) entry.context().evicts().touch(entry, null); } };
Ce que nous recherchons est dans la méthode GridCacheMapEntry.onTtlExpired()
, où la ligne précieuse se trouve dans le bloc finally
.
cctx.cache().removeEntry(this);
Détails d'implémentation
Travailler avec des pages dans Offheap
Offheap est une technique pour optimiser la gestion manuelle de la mémoire dans une langue avec un garbage collector.
C'est un mythe qu'en raison du ramasse-miettes tout ralentit, les collecteurs coûtent généralement un pourcentage misérable de performances . Même les gros tas en eux-mêmes ne sont pas un problème, car les collecteurs fonctionnent de manière compétitive (par exemple, CMS et G1 en Java), et les serveurs ont des dizaines de cœurs . Bien sûr, le collecteur ajoute des pauses imprévisibles à l'application, ce qui est important pour les jeux, mais tolérable pour la base de données.
Mais ce qui sera vraiment le problème, c'est une violation de l'hypothèse générationnelle sur un grand tas.
Hypothèses de générationLes optimisations GC utilisent l'hypothèse générationnelle. Cette hypothèse existe en deux versions: forte et faible.
Hypothèse générationnelle faible: la plupart des objets meurent jeunes.
Une hypothèse forte sur les générations: plus l'objet est âgé, plus il vivra longtemps.
Une hypothèse forte implique une faiblesse, mais pas l'inverse. Idéalement, le GC devrait se contenter de remplir l'hypothèse faible, mais en pratique ce n'est pas le cas.
Consultez la présentation d'Alexey Shipilev sur le nouveau GC en Java, si vous voulez mieux comprendre le sujet: un et deux .
Et ici, c'est une chose telle qu'avant l'avènement de PDS, Apache Ignite était positionné principalement comme un cache entre les services et une base de données de disques (par exemple, Hadoop ). Par conséquent, les cartes dans Ignite sont appelées IgniteCache
et ont la fonctionnalité d'extrusion correspondante. Et les caches violent simplement l'hypothèse générationnelle - en eux, la probabilité qu'un objet soit supprimé augmente avec le temps. Par conséquent, dans ce cas, Offheap pour le stockage des données utilisateur améliore les performances.
Plus de bonus:
- Offheap facilite l'implémentation de structures contenant plus de éléments
Integer.MAX_VALUE
. - Si vous conservez un groupe de moins de 32 Go, les liens pèseront 4 octets , ce qui économise quelques gigaoctets.
- Puisque le collecteur construit un graphe d'objets, il consomme de la mémoire proportionnellement à leur nombre. Et le nombre d'objets est proportionnel au tas. Le collecteur consomme également un processeur, qui est mieux dépensé pour la compression de données, par exemple.
- Sur de très grands tas, même si l'hypothèse générationnelle n'est pas violée dans son ensemble, il y aura encore pas mal d'objets anciens en valeur absolue qui la violeront.
Étant donné que les données sont ensuite envoyées sur le disque, les objets sont sérialisés en mémoire directement via unsafe
, puis cette zone de mémoire est utilisée pour le tampon d'E / S.
Écrire le journal à l'avance
Write Ahead Log est un journal des opérations avec une structure linéaire, ce qui lui coûte une constante, contrairement à un arbre. L'arborescence est mise à jour moins fréquemment et si des données sont perdues en raison d'une chute, elles sont restaurées à partir du journal en appliquant des opérations à partir du dernier état enregistré. Le résultat est une amélioration des performances sans compromettre la fiabilité. Je vous conseille de jeter un œil à l'interface IgniteWriteAheadLogManager
- il y a une documentation détaillée.
Contournement de noeud
Comme les nœuds dans les arbres B ne sont pas petits, ils sont contournés par la recherche binaire. Pour cela, les descendants de la classe BPlusTree.GetPageHandler
sont BPlusTree.GetPageHandler
, et pour différentes opérations ils sont différents.
Implémentation de la recherche binaire private int findInsertionPoint(int lvl, BPlusIO<L> io, long buf, int low, int cnt, L row, int shift) throws IgniteCheckedException { assert row != null; int high = cnt - 1; while (low <= high) { int mid = (low + high) >>> 1; int cmp = compare(lvl, io, buf, mid, row); if (cmp == 0) cmp = -shift;
La méthode de compare
est différente pour différents descendants de BPlusTree
. Un indice négatif code l'absence d'un élément avec la position où il pourrait être. Collections.binarySearch
de la bibliothèque standard fait de même.
Faites attention aux lignes suivantes.
if (cmp == 0) cmp = -shift;
Pour l'opération findOne
, ce code ne fait rien, car shift
est mis à zéro, c'est-à-dire si les mêmes clés se trouvent dans l'arborescence, elles en trouveront une arbitraire.
Cependant, si vous recherchez la plage de cette manière, les éléments seront perdus, donc le shift
est défini sur 1
. Par conséquent, la recherche ne trouve pas l'élément même s'il est présent , mais il n'est pas important de rechercher la plage.
Liste des fiches
Pour contourner efficacement la gamme, les feuilles sont liées à une liste. BPlusTree.ForwardCursor
, qui va de feuille en feuille, est renvoyé comme résultat de recherche. Apparemment, le passage du curseur n'est pas isolé des autres opérations de l'arborescence, car lors du passage, le verrou n'est pris que sur une seule page. Je n'ai pas trouvé de mécanisme de synchronisation qui protège l'accès aux méthodes de curseur.
Conclusion
Étant donné que l'arborescence B + dans Apache Ignite est jeune par rapport à d'autres bases de données relationnelles, nous obtenons l'ensemble nécessaire pour une arborescence B + prête pour la production:
- WAL offre une sécurité bon marché, en conséquence, l'arborescence se met rarement à jour sur le disque.
- Offheap avec des données sous forme sérialisée.
- Concurrence - pour des parties de l'arborescence chargées en mémoire.