
à l'automne 2019, l'équipe iOS de Mail.ru Cloud a connu un événement tant attendu. La base de données principale pour le stockage persistant de l'état de l'application est devenue assez exotique pour le monde mobile. (LMDB). Dans cet article, nous vous proposons un aperçu détaillé en quatre parties. Tout d'abord, parlons des raisons de ce choix non trivial et difficile. Ensuite, nous examinerons les trois piliers de l'architecture LMDB : les fichiers mappés en mémoire, l'arbre B+- et l'approche copy-on-write pour implémenter la transactionalité et la multi-versioning. Enfin, pour finir en beauté, une partie pratique. Nous verrons comment concevoir et réaliser un schéma de base de données avec plusieurs tables, y compris l'indexation, sur une API key-value de bas niveau.
Contenu
3.1.
3.2.
3.3.
4.1.
4.2.
4.3.
1. Motivation de l'implémentation
Un jour, vers 2015, nous avons décidé de mesurer à quelle fréquence l'interface de notre application buguait. Nous ne l'avons pas fait par simple curiosité. Nous avions reçu de plus en plus de plaintes disant que parfois l'application ne répondait plus aux actions de l'utilisateur : les boutons ne réagissaient pas, les listes ne défilaient pas, etc. Concernant la mécanique des mesures, je travaille chez AvitoTech, donc je ne présente ici que des ordres de grandeur.

Les résultats des mesures ont été un véritable choc. Il s'est avéré qu'il y avait beaucoup plus de problÚmes causés par des gels que par d'autres raisons. Avant cette réalisation, le principal indicateur technique de qualité était l'absence de crash, mais aprÚs, l'accent vers l'absence de gel.
En construisant et en effectuant et de leurs causes, il est devenu évident que le principal ennemi était la lourde logique métier, s'exécutant dans le thread principal de l'application. La réaction naturelle à cette situation était un désir ardent de la répartir sur des threads de travail. Pour résoudre ce problÚme de maniÚre systématique, nous avons eu recours à une architecture multithread basée sur des acteurs légers. J'ai consacré dans le Twitter collectif et . Dans le cadre de ce récit, je souhaite souligner les aspects de la solution qui ont influencé le choix de la base de données.
Le modĂšle d'acteur de l'organisation du systĂšme suppose que la multi-threading devient sa seconde essence. Les objets de ce modĂšle aiment franchir les frontiĂšres des threads. Et ils le font non pas de temps en temps et ici ou lĂ , mais pratiquement en permanence et partout.

La base de donnĂ©es est l'un des composants fondamentaux du schĂ©ma prĂ©sentĂ©. Sa tĂąche principale est de mettre en Ćuvre le macro-modĂšle. . Dans le monde de l'entreprise, elle est utilisĂ©e pour organiser la synchronisation des donnĂ©es entre les services, tandis que dans le cas de l'architecture d'acteur, elle le fait entre les threads. Ainsi, nous avons besoin d'une telle base de donnĂ©es, dont le travail dans un environnement multi-thread n'entraĂźne mĂȘme pas de difficultĂ©s minimales. En particulier, cela signifie que les objets obtenus de celle-ci doivent ĂȘtre au minimum sĂ»rs pour les threads, et idĂ©alement immuables. Comme on le sait, les derniers peuvent ĂȘtre utilisĂ©s simultanĂ©ment depuis plusieurs threads sans avoir recours Ă aucune forme de verrouillage, ce qui a des impacts positifs sur les performances.
Un autre facteur significatif qui a influencé le choix de la base de données est notre API cloud. Elle a été inspirée par l'approche de synchronisation adoptée par git. Tout comme celui-ci, nous visons à , ce qui est plus que pertinent pour les clients cloud. On supposait qu'ils téléchargeraient une seule fois l'état complet du cloud, puis la synchronisation se ferait dans la grande majorité des cas par le biais de l'application des changements. Malheureusement, cette possibilité est encore à l'état théorique, et en pratique, les clients n'ont pas encore appris à travailler avec les patchs. Il y a plusieurs raisons objectives à cela, que nous laisserons de cÎté pour ne pas prolonger l'introduction. Actuellement, il est bien plus intéressant de discuter des leçons importantes de ce qui se passe quand l'API a dit « A », mais son consommateur n'a pas dit « B ».
Ainsi, si vous imaginez git, qui, lors de l'exĂ©cution de la commande pull, au lieu d'appliquer des patches Ă l'instantanĂ© local, compare son Ă©tat complet Ă celui du serveur, vous aurez une idĂ©e assez prĂ©cise de la maniĂšre dont la synchronisation se dĂ©roule dans les clients cloud. Il n'est pas difficile de deviner qu'il est nĂ©cessaire d'allouer en mĂ©moire deux arbres DOM contenant des mĂ©tadonnĂ©es sur tous les fichiers serveur et locaux pour rĂ©aliser cela. Cela signifie que si un utilisateur stocke 500 000 fichiers dans le cloud, il doit recrĂ©er et dĂ©truire deux arbres avec 1 million de nĆuds. Et chaque nĆud est un agrĂ©gat contenant un graphe de sous-objets. Dans ce contexte, les rĂ©sultats du profilage Ă©taient attendus. Il s'est avĂ©rĂ© que mĂȘme sans tenir compte de l'algorithmique de fusion, la simple procĂ©dure de crĂ©ation et de destruction subsĂ©quente d'un grand nombre de petits objets coĂ»te cher. La situation est aggravĂ©e par le fait que l'opĂ©ration de synchronisation de base est intĂ©grĂ©e dans un grand nombre de scĂ©narios utilisateurs. En consĂ©quence, nous notons un second critĂšre important dans le choix d'une base de donnĂ©es : la possibilitĂ© de rĂ©aliser des opĂ©rations CRUD sans allocation dynamique d'objets.
D'autres exigences sont plus traditionnelles et leur liste complĂšte est la suivante.
- Filtrage de la sécurité des threads.
- Multiprocessing. Cela est dictĂ© par le dĂ©sir d'utiliser la mĂȘme instance de base de donnĂ©es pour synchroniser l'Ă©tat non seulement entre les threads, mais aussi entre l'application principale et les extensions iOS.
- Possibilité de représenter les entités stockées sous la forme d'objets immuables.
- Absence d'allocations dynamiques dans le cadre des opérations CRUD.
- Support des propriétés de base des transactions : atomicité, consistance, isolation et durabilité.
- Vitesse sur les cas les plus populaires.
Un bon choix avec cet ensemble d'exigences est et reste SQLite. Cependant, dans le cadre de l'exploration d'alternatives, je suis tombĂ© sur un livre Sous sa direction, un benchmark a Ă©tĂ© créé pour comparer la vitesse de travail avec diffĂ©rentes bases de donnĂ©es dans le cadre de scĂ©narios cloud rĂ©els. Le rĂ©sultat a dĂ©passĂ© les attentes les plus optimistes. Pour les cas les plus populaires â obtenir un curseur sur une liste triĂ©e de tous les fichiers et une liste triĂ©e de tous les fichiers pour un rĂ©pertoire donnĂ© â LMDB s'est avĂ©rĂ© 10 fois plus rapide que SQLite. Le choix est devenu Ă©vident.

2. Positionnement de LMDB
LMDB est une petite bibliothĂšque (seulement 10K lignes) qui met en Ćuvre la couche fondamentale la plus basse des bases de donnĂ©es â le stockage.

Le schĂ©ma ci-dessus montre qu'il n'est pas tout Ă fait correct de comparer LMDB Ă SQLite, qui met Ă©galement en Ćuvre des niveaux plus Ă©levĂ©s, de la mĂȘme maniĂšre qu'il ne serait pas juste de comparer SQLite Ă Core Data. En tant que concurrents Ă part entiĂšre, il serait plus Ă©quitable de citer des moteurs de stockage Ă©quivalents â BerkeleyDB, LevelDB, Sophia, RocksDB, etc. Il existe mĂȘme des projets oĂč LMDB est utilisĂ© comme composant du moteur de stockage pour SQLite. Le premier de ces essais a Ă©tĂ© rĂ©alisĂ© en 2012 l'auteur de LMDB . , qui s'est avĂ©rĂ© si intrigant que son initiative a Ă©tĂ© reprise par des passionnĂ©s de l'OSS et a trouvĂ© sa continuitĂ© sous le nom de En janvier 2020, l'auteur de ce projet, Den Shearer, l'a prĂ©sentĂ© lors de LinuxConfAu.
LMDB est principalement utilisĂ© comme moteur pour les bases de donnĂ©es applicatives. La bibliothĂšque doit son existence aux dĂ©veloppeurs , qui Ă©taient trĂšs insatisfaits de BerkeleyDB comme base de leur projet. En s'appuyant sur la humble bibliothĂšque Howard Chu a pu crĂ©er l'une des alternatives les plus populaires aujourd'hui. Il a consacrĂ© son trĂšs bon discours Ă cette histoire ainsi qu'Ă la structure interne de LMDB . Un bon exemple de conquĂȘte du stockage a Ă©tĂ© partagĂ© par Leonid Yuriev (aka ) de Positive Technologies lors de son discours Ă Highload 2015 . Dans celui-ci, il parle de LMDB dans le contexte d'une tĂąche similaire de mise en Ćuvre de ReOpenLDAP, et LevelDB a fait l'objet d'une critique comparative. Ă la suite de cette mise en Ćuvre, Positive Technologies a mĂȘme créé un fork en pleine expansion , avec des fonctionnalitĂ©s, des optimisations et des .
. LMDB est souvent utilisé également comme stockage tel quel. Par exemple, le navigateur Mozilla Firefox pour un certain nombre de besoins, et, à partir de la version 9, Xcode LMDB à SQLite pour le stockage des index.
Le moteur sâest rĂ©vĂ©lĂ© dans le monde du dĂ©veloppement mobile. On peut trouver des traces de son utilisation le client iOS pour Telegram. LinkedIn est allĂ© encore plus loin en choisissant LMDB comme stockage par dĂ©faut pour son propre cadre de mise en cache de donnĂ©es, Rocket Data, comme dans son article en 2016.
LMDB lutte avec succĂšs pour une place au soleil dans le crĂ©neau laissĂ© libre par BerkeleyDB aprĂšs son passage sous le contrĂŽle dâOracle. La bibliothĂšque est apprĂ©ciĂ©e pour sa rapiditĂ© et sa fiabilitĂ©, mĂȘme par rapport Ă ses homologues. Comme on le sait, il n'y a pas de repas gratuits, et il y a lieu de souligner le compromis auquel il faudra faire face en choisissant entre LMDB et SQLite. Le schĂ©ma ci-dessus montre clairement comment la rapiditĂ© accrue est atteinte. PremiĂšrement, nous ne payons pas pour des couches d'abstraction supplĂ©mentaires au-dessus du stockage sur disque. Il va de soi que dans une bonne architecture, on ne peut pas Ă©viter de telles couches, et elles apparaĂźtront inĂ©vitablement dans le code de l'application, mais elles seront beaucoup plus lĂ©gĂšres. Elles ne comprendront pas de fonctionnalitĂ©s non nĂ©cessaires pour l'application concernĂ©e, comme le support des requĂȘtes en SQL. DeuxiĂšmement, il est possible dâoptimiser le mapping des opĂ©rations applicatives sur les requĂȘtes au stockage sur disque. Alors que SQLite part de besoins moyens dâune application ordinaire, vous, en tant que dĂ©veloppeur applicatif, ĂȘtes bien conscient des scĂ©narios de charge principaux. Une solution plus performante impliquera un coĂ»t accru tant pour le dĂ©veloppement de la solution initiale que pour son support ultĂ©rieur.
3. Les trois piliers de LMDB
En regardant LMDB dâun point de vue global, il est temps de plonger plus profondĂ©ment. Les trois sections suivantes seront consacrĂ©es Ă lâanalyse des principaux piliers sur lesquels repose lâarchitecture du stockage :
- Fichiers mappés en mémoire comme mécanisme d'accÚs au disque et de synchronisation des structures de données internes.
- Arbre B+ comme organisation de la structure des données stockées.
- Copy-on-write comme approche pour garantir les propriétés ACID des transactions et la multi-versioning.
3.1. Pilier n°1. Fichiers mappés en mémoire
Les fichiers mappĂ©s en mĂ©moire constituent un Ă©lĂ©ment architectural si important qu'ils figurent mĂȘme dans le nom du stockage. Les questions de mise en cache et de synchronisation d'accĂšs Ă l'information stockĂ©e sont entiĂšrement laissĂ©es Ă l'operating system. LMDB ne contient aucun cache interne. C'est une dĂ©cision consciente de l'auteur, car lire les donnĂ©es directement Ă partir des fichiers mappĂ©s permet de contourner de nombreux obstacles dans la mise en Ćuvre du moteur. Ci-dessous, je prĂ©sente une liste non exhaustive de certains d'entre eux.
- Le maintien de la cohérence des données dans le stockage lors de son utilisation par plusieurs processus devient la responsabilité de l'operating system. La section suivante examine cette mécanique en détail avec des illustrations.
- L'absence de caches libÚre entiÚrement LMDB des frais généraux liés aux allocations dynamiques. La lecture des données consiste en fait à positionner un pointeur à la bonne adresse dans la mémoire virtuelle et rien de plus. Cela semble fantastique, mais dans le code source du stockage, tous les appels à salloc sont concentrés dans la fonction de configuration du stockage.
- L'absence de caches signifie également qu'il n'y a pas de verrous liés à la synchronisation de leur accÚs. Les lecteurs, dont il peut exister un nombre quelconque à un moment donné, ne rencontrent aucun mutex sur leur chemin vers les données. Cela permet d'obtenir une vitesse de lecture avec une évolutivité linéaire idéale par rapport au nombre de CPU. Dans LMDB, seules les opérations de modification sont synchronisées. à un moment donné, il ne peut y avoir qu'un seul écrivain.
- Un minimum de logique de mise en cache et de synchronisation dĂ©barrasse le code des types d'erreurs trĂšs complexes liĂ©s au travail en environnement multithread. Ă la confĂ©rence Usenix OSDI 2014, deux Ă©tudes intĂ©ressantes sur les bases de donnĂ©es ont Ă©tĂ© prĂ©sentĂ©es : et . On peut y trouver des informations sur la fiabilitĂ© sans prĂ©cĂ©dent de LMDB, ainsi que sur sa mise en Ćuvre pratiquement impeccable des propriĂ©tĂ©s ACID des transactions, surpassant ce que l'on trouve dans SQLite.
- Le minimalisme de LMDB permet au code de sa reprĂ©sentation machine d'ĂȘtre entiĂšrement placĂ© dans le cache L1 du processeur, avec les caractĂ©ristiques de vitesse qui en dĂ©coulent.
Malheureusement, sur iOS, la gestion des fichiers mappĂ©s en mĂ©moire n'est pas aussi simple qu'on le souhaiterait. Pour discuter plus consciemment des inconvĂ©nients qui y sont associĂ©s, il est nĂ©cessaire de se rappeler les principes gĂ©nĂ©raux de la mise en Ćuvre de ce mĂ©canisme dans les systĂšmes d'exploitation.
Informations générales sur les fichiers mappés en mémoire
Chaque application exĂ©cutĂ©e est associĂ©e par le systĂšme d'exploitation Ă une entitĂ© appelĂ©e processus. Ă chaque processus, un intervalle d'adresses contiguĂ«s est attribuĂ©, dans lequel il place tout ce dont il a besoin pour fonctionner. Dans les adresses les plus basses se trouvent les sections contenant le code et les donnĂ©es et ressources hardcodĂ©es. Ensuite, il y a un bloc d'espace d'adresses dynamiques qui s'Ă©lĂšve, bien connu sous le nom de heap. Il contient des adresses d'entitĂ©s qui apparaissent au cours de l'exĂ©cution du programme. En haut se trouve la zone mĂ©moire utilisĂ©e par la pile de l'application. Celle-ci croĂźt et se rĂ©trĂ©cit, en d'autres termes, sa taille a Ă©galement une nature dynamique. Pour que la pile et le heap ne se gĂȘnent pas, ils sont sĂ©parĂ©s par les deux extrĂ©mitĂ©s de l'espace d'adresses. Entre les deux sections dynamiques en haut et en bas, il y a un vide. Le systĂšme d'exploitation utilise les adresses dans cette zone intermĂ©diaire pour associer au processus diverses entitĂ©s. En particulier, il peut associer un ensemble contigu d'adresses Ă un fichier sur le disque. Ce fichier est appelĂ© fichier mappĂ© en mĂ©moire.
L'espace d'adresses allouĂ© au processus est immense. ThĂ©oriquement, le nombre d'adresses est limitĂ© uniquement par la taille du pointeur, dĂ©terminĂ©e par la taille du systĂšme. Si la mĂ©moire physique Ă©tait directement mappĂ©e 1 Ă 1, alors le premier processus mangerait toute la RAM, et il ne pourrait ĂȘtre question de multitĂąche.
Cependant, d'aprÚs notre expérience, nous savons que les systÚmes d'exploitation modernes peuvent exécuter simultanément autant de processus que nécessaire. Cela est possible parce qu'ils n'attribuent aux processus qu'une grande quantité de mémoire sur le papier, et en réalité, ils ne chargent dans la mémoire physique principale que la partie qui est nécessaire ici et maintenant. C'est pourquoi la mémoire associée à un processus est appelée mémoire virtuelle.

Le systÚme d'exploitation organise la mémoire virtuelle et physique sous forme de pages de taille définie. DÚs qu'une page de mémoire virtuelle est demandée, le systÚme d'exploitation la charge en mémoire physique et établit une correspondance entre elles dans une table spéciale. Si aucun emplacement libre n'est disponible, l'une des pages précédemment chargées est copiée sur le disque, et la page demandée prend sa place. Cette procédure, à laquelle nous reviendrons bientÎt, s'appelle l'échange (swapping). L'illustration ci-dessous montre le processus décrit. La page A avec l'adresse 0 a été chargée et placée à l'adresse 4 en mémoire principale. Ce fait est reflété dans la table des correspondances à la cellule numéro 0.

L'histoire est exactement la mĂȘme avec les fichiers affichĂ©s en mĂ©moire. Logiquement, ils sont censĂ©s ĂȘtre continus et entiĂšrement placĂ©s dans l'espace d'adressage virtuel. Cependant, ils entrent en mĂ©moire physique page par page et uniquement sur demande. La modification de ces pages est synchronisĂ©e avec le fichier sur le disque. Ainsi, il est possible d'exĂ©cuter des opĂ©rations d'entrĂ©e/sortie de fichiers simplement en travaillant avec des octets en mĂ©moire : toutes les modifications seront automatiquement transfĂ©rĂ©es par le noyau du systĂšme d'exploitation vers le fichier d'origine.
â
L'image ci-dessous montre comment LMDB synchronise son Ă©tat lorsqu'il travaille avec une base de donnĂ©es depuis diffĂ©rents processus. En mappant la mĂ©moire virtuelle de diffĂ©rents processus sur le mĂȘme fichier, de facto, nous obligeons le systĂšme d'exploitation Ă synchroniser transitivement certains blocs de leurs espaces d'adresses, que LMDB consulte.
â

Un point important est que LMDB modifie par dĂ©faut le fichier de donnĂ©es via le mĂ©canisme d'appel systĂšme write, tandis que le fichier lui-mĂȘme est mappĂ© en mode lecture seule. Cette approche a deux consĂ©quences importantes.
La premiĂšre consĂ©quence est commune Ă tous les systĂšmes d'exploitation. Elle consiste Ă ajouter une protection contre la corruption involontaire de la base de donnĂ©es par un code incorrect. Comme nous le savons, les instructions exĂ©cutables d'un processus peuvent accĂ©der aux donnĂ©es depuis n'importe quel endroit de son espace d'adresses. En mĂȘme temps, comme nous venons de le rappeler, le mappage d'un fichier en mode lecture-Ă©criture signifie que n'importe quelle instruction peut Ă©galement le modifier. Si cela se produit par erreur, par exemple en essayant rĂ©ellement de réécrire un Ă©lĂ©ment de tableau Ă un index inexistant, cela peut entraĂźner une modification accidentelle du fichier mappĂ© Ă cette adresse, ce qui entraĂźnera la corruption de la base de donnĂ©es. Si le fichier est mappĂ© en mode lecture seule, alors tenter de modifier l'espace d'adresses correspondant entraĂźnera une fermeture inattendue du programme avec un signal SIGSEGV, et le fichier restera intact.
La deuxiÚme conséquence est déjà spécifique à iOS. Ni l'auteur ni aucune autre source ne la mentionnent explicitement, mais sans elle, LMDB ne serait pas utilisable dans ce systÚme d'exploitation mobile. La prochaine section lui est dédiée.
La spécificité des fichiers mappés en mémoire sous iOS
En 2018, lors de la WWDC, il y avait une excellente présentation . Elle explique qu'en iOS, toutes les pages situées dans la mémoire physique appartiennent à l'un des 3 types : dirty, compressed et clean.

La mĂ©moire propre est un ensemble de pages qui peuvent ĂȘtre sans douleur dĂ©placĂ©es de la mĂ©moire physique. Les donnĂ©es qu'elles contiennent peuvent ĂȘtre rechargĂ©es Ă partir de leurs sources initiales si nĂ©cessaire. Les fichiers mappĂ©s en lecture seule tombent prĂ©cisĂ©ment dans cette catĂ©gorie. iOS n'a pas peur de dĂ©charger Ă tout moment les pages mappĂ©es vers des fichiers de la mĂ©moire, car elles sont garantis d'ĂȘtre synchronisĂ©es avec le fichier sur le disque.
â
La mĂ©moire dirty comprend toutes les pages modifiĂ©es, peu importe oĂč elles se trouvaient Ă l'origine. En particulier, les fichiers mappĂ©s en mĂ©moire qui ont Ă©tĂ© modifiĂ©s par l'Ă©criture dans la mĂ©moire virtuelle qui leur est associĂ©e seront Ă©galement classĂ©s ainsi. En ouvrant LMDB avec le drapeau MDB_WRITEMAP, aprĂšs avoir apportĂ© des modifications, vous pourrez le constater personnellement.
Une fois que l'application commence Ă utiliser trop de mĂ©moire physique, iOS compresse ses pages dirty. La quantitĂ© de mĂ©moire occupĂ©e par les pages dirty et compressĂ©es constitue ce que l'on appelle l'empreinte mĂ©moire de l'application. Lorsqu'elle atteint un certain seuil, le dĂ©mon systĂšme OOM killer intervient et termine le processus de maniĂšre forcĂ©e. C'est ce qui distingue iOS des systĂšmes d'exploitation de bureau. Contrairement Ă ces derniers, la rĂ©duction de l'empreinte mĂ©moire par l'Ă©change de pages de la mĂ©moire physique vers le disque n'est pas prĂ©vue dans iOS. Les raisons en sont incertaines. Peut-ĂȘtre que le processus de dĂ©placement intensif des pages vers le disque et vice versa est trop Ă©nergivore pour les appareils mobiles, ou qu'iOS Ă©conomise les ressources de réécriture des cellules sur les disques SSD, ou encore que les concepteurs Ă©taient insatisfaits de la performance globale du systĂšme, oĂč tout est constamment Ă©changĂ©. Quoi qu'il en soit, le fait est lĂ .
La bonne nouvelle, déjà mentionnée précédemment, est que LMDB n'utilise pas par défaut le mécanisme mmap pour mettre à jour les fichiers. Cela signifie que les données affichées sont classées par iOS comme mémoire propre et n'augmentent pas l'empreinte mémoire. On peut s'en rendre compte grùce à un outil Xcode appelé VM Tracker. Dans la capture d'écran ci-dessous, on peut voir l'état de la mémoire virtuelle de l'application iOS Obcloud pendant son fonctionnement. Lors du démarrage, deux instances de LMDB ont été initialisées. La premiÚre a été autorisée à mapper son fichier sur 1GiB de mémoire virtuelle, la deuxiÚme sur 512MiB. Bien que les deux magasins occupent une certaine quantité de mémoire résidente, aucun d'eux ne contribue à la taille dirty.

Et maintenant, parlons des mauvaises nouvelles. GrĂące au mĂ©canisme d'Ă©change dans les systĂšmes d'exploitation de bureau 64 bits, chaque processus peut occuper autant d'espace d'adresse virtuelle que l'espace libre sur le disque dur le permet pour son potentiel d'Ă©change. Le remplacement de l'Ă©change par la compression dans iOS rĂ©duit radicalement le maximum thĂ©orique. Maintenant, tous les processus actifs doivent tenir dans la mĂ©moire principale (c'est-Ă -dire la RAM), et ceux qui ne peuvent pas sont terminĂ©s de maniĂšre forcĂ©e. Cela est mentionnĂ© dans le passage prĂ©cĂ©dent. , ainsi que dans En consĂ©quence, iOS limite strictement la taille de la mĂ©moire accessible pour l'allocation via mmap. Voici On peut examiner les limites empiriques des volumes de mĂ©moire allouĂ©s sur diffĂ©rents dispositifs Ă l'aide de cet appel systĂšme. Sur les modĂšles de smartphones iOS les plus modernes, on a accĂšs Ă 2 Go, tandis que les versions haut de gamme de l'iPad en proposent 4. En pratique, il faut se concentrer sur les modĂšles d'entrĂ©e de gamme pris en charge, oĂč la situation est assez morose. Pire encore, en examinant l'Ă©tat de la mĂ©moire de l'application dans le VM Tracker, on peut constater que LMDB n'est pas la seule Ă prĂ©tendre Ă la mĂ©moire mappĂ©e. D'importants morceaux sont pris par les allocateurs systĂšmes, les fichiers de ressources, les frameworks pour le traitement d'images et d'autres prĂ©dateurs moins gros.
Ă l'issue des expĂ©rimentations dans le Cloud, nous avons abouti aux valeurs compromettantes suivantes pour la mĂ©moire allouĂ©e Ă LMDB : 384 mĂ©gaoctets pour les dispositifs 32 bits et 768 pour les dispositifs 64 bits. AprĂšs avoir Ă©puisĂ© ce volume, toutes les opĂ©rations de modification commencent Ă se terminer avec le code MDB_MAP_FULL. Nous observons ces erreurs dans notre surveillance, mais leur frĂ©quence est suffisamment faible pour qu'Ă ce stade, elles puissent ĂȘtre nĂ©gligĂ©es.
Une raison non Ă©vidente de la consommation excessive de mĂ©moire par le stockage peut ĂȘtre liĂ©e aux transactions de longue durĂ©e. Pour comprendre comment ces deux phĂ©nomĂšnes sont liĂ©s, examinons les deux autres piliers de LMDB.
3.2. Pilier n°2. L'arbre B+
Pour émuler des tables au-dessus d'un stockage clé-valeur, il est nécessaire que son API comprenne les opérations suivantes :
- Insertion d'un nouvel élément.
- Recherche d'un élément avec une clé donnée.
- Suppression d'un élément.
- Itération sur des intervalles de clés dans l'ordre de leur tri.
La structure de donnĂ©es la plus simple, qui permet d'implĂ©menter facilement les quatre opĂ©rations, est l'arbre binaire de recherche. Chaque nĆud reprĂ©sente une clĂ©, divisant ainsi tous les sous-ensembles de clĂ©s filles en deux sous-arbres. Ă gauche se trouvent celles qui sont infĂ©rieures au parent, et Ă droite celles qui sont supĂ©rieures. L'obtention d'un ensemble de clĂ©s ordonnĂ© est rĂ©alisĂ©e par l'un des parcours classiques de l'arbre.
Les arbres binaires prĂ©sentent deux dĂ©fauts fondamentaux qui les empĂȘchent d'ĂȘtre efficaces en tant que structures de donnĂ©es sur disque. D'une part, leur degrĂ© de Ă©quilibrage est imprĂ©visible. Il existe un risque considĂ©rable de se retrouver avec des arbres oĂč la hauteur des diffĂ©rentes branches peut varier fortement, ce qui dĂ©grade nettement la complexitĂ© algorithmique de la recherche par rapport Ă ce qui est attendu. D'autre part, l'abondance de liens entre les nĆuds prive les arbres binaires de leur localitĂ© en mĂ©moire. Des nĆuds proches (en termes de liens entre eux) peuvent se retrouver sur des pages complĂštement diffĂ©rentes dans la mĂ©moire virtuelle. En consĂ©quence, mĂȘme pour une simple traversĂ©e de plusieurs nĆuds adjacents dans l'arbre, il peut ĂȘtre nĂ©cessaire de visiter un nombre comparable de pages. Cela pose problĂšme mĂȘme lorsque l'on considĂšre l'efficacitĂ© des arbres binaires en tant que structures de donnĂ©es en mĂ©moire, car la rotation constante des pages dans le cache du processeur est un luxe coĂ»teux. Lorsque l'on parle de charger frĂ©quemment des pages liĂ©es aux nĆuds depuis le disque, la situation devient carrĂ©ment .
Les arbres B, Ă©tant une Ă©volution des arbres binaires, rĂ©solvent les problĂšmes mentionnĂ©s dans le paragraphe prĂ©cĂ©dent. Tout d'abord, ils sont auto-Ă©quilibrĂ©s. DeuxiĂšmement, chaque nĆud divise plusieurs enfants clĂ©s non pas en 2, mais en M sous-ensembles ordonnĂ©s, oĂč M peut ĂȘtre assez grand, de l'ordre de plusieurs centaines, voire milliers.
GrĂące Ă cela :
- Chaque nĆud contient un grand nombre de clĂ©s dĂ©jĂ ordonnĂ©es et les arbres deviennent trĂšs plats.
- L'arbre acquiert la propriĂ©tĂ© de localitĂ© d'allocation en mĂ©moire, car des clĂ©s proches par leur valeur se retrouvent naturellement cĂŽte Ă cĂŽte sur le mĂȘme nĆud ou des nĆuds adjacents.
- Le nombre de nĆuds intermĂ©diaires diminue lors de la descente dans l'arbre lors d'une opĂ©ration de recherche.
- Le nombre de nĆuds cibles lus lors des requĂȘtes de plage diminue, car chacun d'eux contient dĂ©jĂ un grand nombre de clĂ©s ordonnĂ©es.

Dans LMDB, une des variantes de l'arbre B appelĂ©e arbre B+ est utilisĂ©e pour stocker les donnĂ©es. Le schĂ©ma ci-dessus illustre trois types de nĆuds qui y figurent :
- Ă la pointe se trouve la racine (root). Elle matĂ©rialise elle-mĂȘme rien d'autre que le concept de la base de donnĂ©es dans le stockage. Ă l'intĂ©rieur d'une instance LMDB, il est possible de crĂ©er plusieurs bases de donnĂ©es, partageant entre elles un espace d'adresses virtuel mappĂ©. Chacune d'elles commence par sa propre racine.
- Au niveau le plus bas se trouvent les feuilles (leaf). Ce sont elles qui contiennent exclusivement les paires clĂ©-valeur stockĂ©es dans la base de donnĂ©es. Ă propos, c'est lĂ que rĂ©side la particularitĂ© des arbres B+. Contrairement Ă un arbre B classique qui stocke les parties value dans les nĆuds de tous les niveaux, la variation B+ ne les stocke qu'au niveau le plus bas. En prenant en compte ce fait, nous appellerons dĂ©sormais le sous-type de l'arbre utilisĂ© dans LMDB simplement un arbre B.
- Entre la racine et les feuilles se situent 0 niveaux techniques ou plus avec des nĆuds de navigation (branch). Leur tĂąche est de diviser l'ensemble triĂ© des clĂ©s entre les feuilles.
Physiquement, les nĆuds sont des blocs de mĂ©moire de longueur prĂ©dĂ©finie. Leur taille est un multiple de la taille des pages mĂ©moire dans le systĂšme d'exploitation, dont nous avons discutĂ© plus haut. Ci-dessous est affichĂ©e la structure d'un nĆud. Dans l'en-tĂȘte se trouve des mĂ©tadonnĂ©es, dont la plus Ă©vidente pour l'exemple est le contrĂŽle de la somme. Ensuite, viennent les informations sur les offsets oĂč se trouvent les cellules de donnĂ©es. Les donnĂ©es peuvent ĂȘtre soit des clĂ©s, dans le cas des nĆuds de navigation, soit des paires clĂ©-valeur entiĂšres dans le cas des feuilles. Vous pouvez en savoir plus sur la structure des pages dans le document .

Une fois que nous avons compris le contenu interne des nĆuds de page, nous allons simplifier la reprĂ©sentation de l'arbre B de LMDB comme suit.

Les pages avec des nĆuds sont disposĂ©es consĂ©cutivement sur le disque. Les pages avec des numĂ©ros plus Ă©levĂ©s sont situĂ©es plus prĂšs de la fin du fichier. La soi-disant page mĂ©ta (meta page) contient des informations sur les offsets par lesquels il est possible de trouver les racines de tous les arbres. Lors de l'ouverture d'un fichier, LMDB scanne le fichier page par page de la fin vers le dĂ©but Ă la recherche d'une page mĂ©ta valide, et c'est par son intermĂ©diaire qu'il trouve les bases de donnĂ©es existantes.

Maintenant que nous avons une compréhension de la structure logique et physique de l'organisation des données, nous pouvons passer à l'examen du troisiÚme pilier de LMDB. C'est grùce à lui que toutes les modifications du stockage se produisent de maniÚre transactionnelle et isolée les unes des autres, donnant à la base de données dans son ensemble la propriété de la multi-version.
3.3. Pilier n°3. Copy-on-write
Certaines opĂ©rations avec les B-arbres impliquent une sĂ©rie de modifications dans ses nĆuds. Un exemple est l'ajout d'une nouvelle clĂ© dans un nĆud qui a dĂ©jĂ atteint sa capacitĂ© maximale. Dans ce cas, il est nĂ©cessaire, tout d'abord, de diviser le nĆud en deux, et ensuite d'ajouter un lien vers le nouveau nĆud fils dĂ©tachĂ© dans son parent. Cette procĂ©dure est potentiellement trĂšs dangereuse. Si, pour une raison quelconque (plantage, coupure de courant, etc.), seule une partie des changements de la sĂ©rie est effectuĂ©e, l'arbre restera dans un Ă©tat incohĂ©rent.
Une des solutions traditionnelles pour garantir la rĂ©silience d'une base de donnĂ©es aux pannes consiste Ă ajouter Ă cĂŽtĂ© du B-arbre une structure de donnĂ©es supplĂ©mentaire sur disque : le journal des transactions, Ă©galement connu sous le nom de write-ahead log (WAL). C'est un fichier dans lequel l'opĂ©ration supposĂ©e est Ă©crite strictement avant la modification du B-arbre lui-mĂȘme. Ainsi, si une corruption des donnĂ©es est dĂ©tectĂ©e lors de l'autodiagnostic, la base de donnĂ©es consulte le journal pour se remettre en ordre.
LMDB, en tant que mécanisme de résilience aux pannes, a choisi une autre méthode appelée copy-on-write. Son essence est que, au lieu de mettre à jour les données sur une page existante, elle la copie entiÚrement et toutes les modifications sont ensuite effectuées sur la copie.

Ensuite, pour que les donnĂ©es mises Ă jour soient accessibles, il est nĂ©cessaire de modifier le lien vers le nĆud devenu actuel dans le parent par rapport Ă lui. Ătant donnĂ© que cela nĂ©cessite Ă©galement de modifier le parent, celui-ci est Ă©galement copiĂ© au prĂ©alable. Le processus se poursuit rĂ©cursivement jusqu'Ă la racine. En dernier, les donnĂ©es sur la page de mĂ©ta sont modifiĂ©es.

Si, par malheur, pendant la procédure de mise à jour, il y a une terminaison imprévue du processus, soit une nouvelle méta-page ne sera pas créée, soit elle ne sera pas enregistrée sur le disque jusqu'à la fin, et sa somme de contrÎle sera incorrecte. Dans l'un de ces deux cas, les nouvelles pages seront inaccessibles et les anciennes ne seront pas affectées. Cela libÚre LMDB de la nécessité de maintenir un journal d'écriture en avant pour assurer la cohérence des données. De facto, la structure de stockage des données sur le disque décrite ci-dessus assume également cette fonction. L'absence explicite d'un journal de transactions est l'une des caractéristiques de LMDB, garantissant une haute vitesse de lecture des données.

La structure rĂ©sultante appelĂ©e B-tree append-only garantit naturellement l'isolation des transactions et la multi-version. Dans LMDB, chaque transaction ouverte est associĂ©e Ă la racine de l'arbre actuelle. Tant que la transaction n'est pas terminĂ©e, les pages de l'arbre qui lui est liĂ© ne seront jamais modifiĂ©es ou rĂ©utilisĂ©es pour de nouvelles versions des donnĂ©es. Ainsi, il est possible de travailler indĂ©finiment avec le mĂȘme ensemble de donnĂ©es qui Ă©tait valide au moment de l'ouverture de la transaction, mĂȘme si le stockage continue Ă ĂȘtre activement mis Ă jour. C'est l'essence de la multi-version, qui fait de LMDB une source de donnĂ©es idĂ©ale pour tous nos bien-aimĂ©s UICollectionView. En ouvrant une transaction, il n'est pas nĂ©cessaire d'augmenter la mĂ©moire utilisĂ©e par l'application en extrayant rapidement les donnĂ©es actuelles dans une structure in-memory, craignant de se retrouver Ă court. Cette particularitĂ© distingue positivement LMDB de SQLite, qui ne peut pas se vanter d'une isolation aussi totale. En ouvrant deux transactions dans ce dernier et en supprimant une certaine entrĂ©e dans le cadre de l'une d'elles, cette mĂȘme entrĂ©e ne pourra plus ĂȘtre rĂ©cupĂ©rĂ©e non plus dans le cadre de la seconde transaction restante.
L'envers du dĂ©cor est une consommation potentiellement beaucoup plus importante de la mĂ©moire virtuelle. La diapositive montre Ă quoi ressemblera la structure de la base de donnĂ©es si elle est modifiĂ©e simultanĂ©ment avec 3 transactions de lecture ouvertes, regardant diffĂ©rentes versions de la base de donnĂ©es. Comme LMDB ne peut pas rĂ©utiliser les nĆuds accessibles depuis les racines liĂ©es aux transactions en cours, le stockage n'a d'autre choix que de placer en mĂ©moire une quatriĂšme racine et de cloner Ă nouveau les pages modifiables sous elle.

Il est utile de se remémorer la section sur les fichiers mappés en mémoire. Bien que le coût supplémentaire de la mémoire virtuelle ne devrait pas trop nous inquiéter, car il n'affecte pas l'empreinte mémoire de l'application. Cependant, il a été noté qu'iOS est trÚs avare dans son allocation, et nous ne pouvons pas, comme sur un serveur ou un bureau, offir à LMDB une région de 1 To sans nous soucier de cette particularité. Il est donc préférable de rendre la durée de vie des transactions aussi courte que possible.
4. Conception du schéma de données au-dessus de l'API clé-valeur
Nous commencerons l'analyse de l'API par un examen des abstractions de base fournies par LMDB : environnement et bases de données, clés et valeurs, transactions et curseurs.
Remarque sur les extraits de code
Toutes les fonctions de l'API publique de LMDB renvoient le rĂ©sultat de leur opĂ©ration sous forme de code d'erreur, mais dans tous les extraits suivants, la vĂ©rification de celui-ci a Ă©tĂ© omise au profit de la concision. Dans la pratique, nous avons utilisĂ© notre propre wrapper C++ , oĂč les erreurs se matĂ©rialisent sous forme d'exceptions C++.
Comme moyen le plus rapide de connecter LMDB au projet pour iOS ou macOS, je propose ma CocoaPod .
4.1. Abstractions de base
Environnement (environment)
Structure MDB_env est le réceptacle de l'état interne de LMDB. La famille de fonctions avec le préfixe mdb_env permet de configurer certaines de ses propriétés. Dans le cas le plus simple, l'initialisation du moteur se déroule comme suit.
mdb_env_create(env);â
mdb_env_set_map_size(*env, 1024 * 1024 * 512)â
mdb_env_open(*env, path.UTF8String, MDB_NOTLS, 0664);Dans l'application Cloud Mail.ru, nous n'avons modifié les valeurs par défaut que pour deux paramÚtres.
Le premier d'entre eux est la taille de l'espace d'adressage virtuel, sur lequel le fichier de stockage est mappĂ©. Malheureusement, mĂȘme sur le mĂȘme appareil, la valeur spĂ©cifique peut varier considĂ©rablement d'un lancement Ă l'autre. Pour tenir compte de cette particularitĂ© d'iOS, la taille maximale du stockage est dĂ©terminĂ©e dynamiquement. En commençant par une certaine valeur, elle est ensuite rĂ©duite de moitiĂ© jusqu'Ă ce que la fonction mdb_env_open ne retourne un rĂ©sultat diffĂ©rent de ENOMEM. En thĂ©orie, il existe aussi l'approche inverse : d'abord allouer un minimum de mĂ©moire au moteur, puis, en cas d'erreurs MDB_MAP_FULL, l'augmenter. Cependant, cela est beaucoup plus compliquĂ©. La raison en est que la procĂ©dure de rĂ©allocation de mĂ©moire (remap) via la fonction mdb_env_set_map_size invalide toutes les entitĂ©s (curseurs, transactions, clĂ©s et valeurs) obtenues prĂ©cĂ©demment du moteur. Tenir compte de ce tournant des Ă©vĂ©nements dans le code compliquera considĂ©rablement sa structure. Si, nĂ©anmoins, la mĂ©moire virtuelle est trĂšs prĂ©cieuse pour vous, cela pourrait ĂȘtre une raison d'explorer un fork qui a pris beaucoup d'avance, , oĂč parmi les caractĂ©ristiques annoncĂ©es figure "ajustement automatique de la taille de la base de donnĂ©es Ă la volĂ©e."
Le second paramÚtre, dont la valeur par défaut ne nous a pas convenu, régule la mécanique de la sécurité des threads. Malheureusement, au moins dans iOS 10, il y a des problÚmes de prise en charge du stockage local des threads. Pour cette raison, dans l'exemple ci-dessus, le stockage est ouvert avec le drapeau MDB_NOTLS. De plus, il était également nécessaire le wrapper C++ , afin de supprimer les variables avec cet attribut.
Bases de données
La base de données est une instance distincte d'un arbre B, dont nous avons parlé ci-dessus. Son ouverture se fait dans le cadre d'une transaction, ce qui peut sembler un peu étrange au départ.
MDB_txn *txn;â
MDB_dbi dbi;â
mdb_txn_begin(env, NULL, MDB_RDONLY, &txn);â
mdb_dbi_open(txn, NULL, MDB_CREATE, &dbi);â
mdb_txn_abort(txn);En effet, une transaction dans LMDB est une entité de stockage, et non d'une base de données particuliÚre. Ce concept permet des opérations atomiques sur les entités présentes dans différentes bases de données. En théorie, cela ouvre la possibilité de modéliser des tables sous forme de bases différentes, mais pour ma part, j'ai choisi une autre voie, décrite en détail ci-dessous.
Clés et valeurs
Structure MDB_val modélise à la fois la clé et la valeur. Le stockage n'a aucune idée de leur sémantique. Pour lui, quelque chose ou autre chose n'est qu'un tableau d'octets d'une taille donnée. La taille maximale de la clé est de 512 octets.
typedef struct MDB_val {â
size_t mv_size;â
void *mv_data;â
} MDB_val;ââĂ l'aide du comparateur, le stockage ordonne les clĂ©s par ordre croissant. Si vous ne remplacez pas le vĂŽtre, le par dĂ©faut sera utilisĂ©, qui les trie octet par octet dans l'ordre lexicographique.
Transactions
Le mécanisme des transactions est décrit en détail dans , donc ici je vais briÚvement répéter leurs principales propriétés :
- Support de toutes les propriĂ©tĂ©s de base : atomicitĂ©, cohĂ©rence, isolation et durabilitĂ©. Je ne peux pas m'empĂȘcher de noter qu'il existe un bug concernant la durabilitĂ© sur macOS et iOS, corrigĂ© dans MDBX. Vous pouvez en lire plus dans leurs .
- L'approche multi-thread est décrite par le schéma « écrivain unique / lecteurs multiples ». Les écrivains se bloquent mutuellement, mais ne bloquent pas les lecteurs. Les lecteurs ne bloquent ni les écrivains ni entre eux.
- Prise en charge des transactions imbriquées.
- Support de la multi-version.
La multi-version dans LMDB est si bonne que je veux la démontrer en action. Le code ci-dessous montre que chaque transaction fonctionne avec la version de la base de données qui était actuelle au moment de son ouverture, étant complÚtement isolée de tous les changements ultérieurs. L'initialisation du stockage et l'ajout d'un enregistrement de test ne présentent rien d'intéressant, donc ces rituels sont laissés sous spoiler.
Ajout d'un enregistrement de test
MDB_env *env;
MDB_dbi dbi;
MDB_txn *txn;
mdb_env_create(&env);
mdb_env_open(env, ".\/testdb", MDB_NOTLS, 0664);
mdb_txn_begin(env, NULL, 0, &txn);
mdb_dbi_open(txn, NULL, 0, &dbi);
mdb_txn_abort(txn);
char k = 'k';
MDB_val key;
key.mv_size = sizeof(k);
key.mv_data = (void *)&k;
int v = 997;
MDB_val value;
value.mv_size = sizeof(v);
value.mv_data = (void *)&v;
mdb_txn_begin(env, NULL, 0, &txn);
mdb_put(txn, dbi, &key, &value, MDB_NOOVERWRITE);
mdb_txn_commit(txn);MDB_txn *txn1, *txn2, *txn3;
MDB_val val;
// Ouvrant 2 transactions, chacune d'elles regardant
// la version de la base de données avec une seule entrée.
mdb_txn_begin(env, NULL, 0, &txn1); // lecture-écriture
mdb_txn_begin(env, NULL, MDB_RDONLY, &txn2); // lecture seule
// Dans le cadre de la premiÚre transaction, supprimons l'entrée existante de la base de données.
mdb_del(txn1, dbi, &key, NULL);
// Validons la suppression.
mdb_txn_commit(txn1);
// Ouvrons une troisiĂšme transaction, qui regarde
// la version actuelle de la base de donnĂ©es, oĂč l'entrĂ©e n'existe dĂ©jĂ plus.
mdb_txn_begin(env, NULL, MDB_RDONLY, &txn3);
// Assurons-nous que l'entrée correspondant à la clé recherchée n'existe déjà plus.
assert(mdb_get(txn3, dbi, &key, &val) == MDB_NOTFOUND);
// Terminons la transaction.
mdb_txn_abort(txn3);
// Vérifions que dans le cadre de la deuxiÚme transaction, ouverte au moment
// de l'existence de l'entrée dans la base de données, elle est toujours accessible par la clé.
assert(mdb_get(txn2, dbi, &key, &val) == MDB_SUCCESS);
// Vérifions que pour la clé, nous avons reçu des données valides et non pas des déchets.
assert(*(int *)val.mv_data == 997);
// Terminons la transaction, travaillant malgré tout avec une base de données obsolÚte mais cohérente.
mdb_txn_abort(txn2);Je recommande en option d'essayer de faire le mĂȘme tour avec SQLite et de voir ce qui en rĂ©sulte.
La multi-versionnalité apporte des avantages trÚs agréables dans la vie des développeurs iOS. Grùce à cette propriété, il est facile et sans contrainte de réguler la vitesse de mise à jour de la source de données pour les formulaires d'écran, en fonction de l'expérience utilisateur. Par exemple, prenons une fonctionnalité de l'application Mail.ru appelée chargement automatique de contenu depuis la galerie multimédia systÚme. Avec une bonne connexion, le client peut ajouter plusieurs photos sur le serveur par seconde. Si aprÚs chaque chargement, nous actualisons UICollectionView le contenu multimédia dans le nuage de l'utilisateur, on peut oublier les 60 fps et le défilement fluide pendant ce processus. Pour prévenir des mises à jour fréquentes de l'écran, il faut d'une maniÚre ou d'une autre limiter la vitesse de changement des données sous-jacentes. UICollectionViewDataSource.
Si une base de données ne prend pas en charge la version multiple et ne permet de travailler qu'avec l'état actuel, pour créer un instantané stable des données, il est nécessaire de le copier soit dans une structure de données en mémoire, soit dans une table temporaire. Chacune de ces approches est trÚs coûteuse. Dans le cas d'un stockage en mémoire, nous avons des coûts en mémoire liés au stockage des objets construits, et des coûts en temps associés aux transformations ORM excessives. Quant à la table temporaire, c'est un plaisir encore plus coûteux, qui n'a de sens que dans des cas non triviaux.
La multi-versionnalité de LMDB résout la tùche de maintien d'une source de données stable de maniÚre trÚs élégante. Il suffit d'ouvrir une transaction et voilà - tant que nous ne la terminons pas, l'ensemble de données est garanti fixé. La logique de sa vitesse de mise à jour est désormais entiÚrement entre les mains de la couche de présentation, avec l'absence totale de frais généraux significatifs.
Curseurs
Les curseurs fournissent un mécanisme pour l'itération ordonnée sur les paires clé-valeur en parcourant l'arbre B. Sans eux, il serait impossible de modéliser efficacement des tables dans la base de données, que nous allons examiner.
4.2. Modélisation des tables
La propriété d'ordre des clés permet de construire des abstractions de haut niveau comme une table au-dessus des abstractions de base. Illustrons ce processus avec un exemple de la table principale d'un client cloud, qui cache des informations sur tous les fichiers et dossiers de l'utilisateur.
Schéma de la table
Un des scĂ©narios frĂ©quents pour lesquels la structure de la table avec un arbre de dossiers doit ĂȘtre conçue est la sĂ©lection de tous les Ă©lĂ©ments situĂ©s Ă l'intĂ©rieur d'un rĂ©pertoire donnĂ©. Un bon modĂšle d'organisation des donnĂ©es pour des requĂȘtes efficaces de ce type est . Pour le mettre en Ćuvre au-dessus d'un stockage clĂ©-valeur, il est nĂ©cessaire de trier les clĂ©s des fichiers et des dossiers de maniĂšre Ă les regrouper en fonction de leur appartenance au rĂ©pertoire parent. De plus, pour afficher le contenu du rĂ©pertoire d'une maniĂšre familiĂšre pour l'utilisateur de Windows (d'abord les dossiers, puis les fichiers, et les deux triĂ©s par ordre alphabĂ©tique), il est nĂ©cessaire d'inclure dans la clĂ© les champs supplĂ©mentaires correspondants.
L'image ci-dessous montre comment, en fonction de la tĂąche donnĂ©e, la reprĂ©sentation des clĂ©s peut apparaĂźtre sous la forme d'un tableau d'octets. Au dĂ©but, les octets avec l'identifiant du rĂ©pertoire parent (rouges) sont placĂ©s, puis ceux avec le type (verts), et enfin, ceux avec le nom (bleus). En Ă©tant triĂ©s par le comparateur par dĂ©faut de LMDB dans l'ordre lexicographique, ils sont organisĂ©s de la maniĂšre requise. L'itĂ©ration sĂ©quentielle des clĂ©s avec le mĂȘme prĂ©fixe rouge nous donne les valeurs qui leur sont associĂ©es dans l'ordre dans lequel elles doivent ĂȘtre affichĂ©es dans l'interface utilisateur (Ă droite), sans nĂ©cessiter de traitement supplĂ©mentaire.

Sérialisation des clés et des valeurs
Dans le monde, de nombreux mĂ©thodes de sĂ©rialisation d'objets ont Ă©tĂ© inventĂ©es. Ătant donnĂ© que nous n'avions d'autre exigence que la vitesse, nous avons choisi celle qui Ă©tait la plus rapide â le dump de la mĂ©moire occupĂ©e par une instance de la structure de langage C. Ainsi, la clĂ© d'un Ă©lĂ©ment de rĂ©pertoire peut ĂȘtre modĂ©lisĂ©e par la structure suivante NodeKey.
typedef struct NodeKey {â
EntityId parentId;â
uint8_t type;â
uint8_t nameBuffer[256];â
} NodeKey;Pour sauvegarder NodeKey dans le stockage, il faut positionner le pointeur vers les donnĂ©es Ă l'adresse de dĂ©but de la structure, et leur taille peut ĂȘtre calculĂ©e avec la fonction MDB_val sizeof MDB_val serialize(NodeKey * const key) { return MDB_val { .mv_size = sizeof(NodeKey), .mv_data = (void *)key }; }.
Dans le premier chapitre sur les critĂšres de sĂ©lection d'une base de donnĂ©es, j'ai mentionnĂ© la minimisation des allocations dynamiques dans le cadre des opĂ©rations CRUD comme un facteur de choix important. Le code de la fonctionserialize montre comment, dans le cas de LMDB, on peut complĂštement Ă©viter celles-ci lors de l'insertion de nouvelles entrĂ©es dans la base de donnĂ©es. Le tableau d'octets en provenance du serveur est d'abord transformĂ© en structures de pile, puis elles sont trivialement dumpĂ©es dans le stockage. Ătant donnĂ© qu'il n'y a Ă©galement pas d'allocations dynamiques Ă l'intĂ©rieur de LMDB, on peut obtenir une situation incroyable selon les normes iOS â n'utiliser que de la mĂ©moire de pile pour travailler avec des donnĂ©es tout au long de leur parcours depuis le rĂ©seau jusqu'au disque ! Ordonnancement des clĂ©s par un comparateur binaire
Ordonnancement des clés par un comparateur binaire
L'ordre des clés est défini par une fonction spéciale appelée comparateur. Comme le moteur ne connaßt pas la sémantique des octets qu'il contient, le comparateur par défaut n'a d'autre choix que d'ordonner les clés par ordre lexicographique en procédant à une comparaison octet par octet. L'utiliser pour trier des structures est semblable à se raser avec une hache. Néanmoins, dans des cas simples, je trouve cette méthode acceptable. L'alternative est décrite un peu plus bas, et je noterai ici quelques piÚges éparpillés sur ce chemin.
La premiÚre chose à garder à l'esprit est la représentation en mémoire des types de données primitifs. Ainsi, sur tous les appareils Apple, les variables entiers sont stockées au format . Cela signifie que l'octet le moins significatif se trouve à gauche, et il n'est pas possible de trier les entiers en utilisant leur comparaison octet par octet. Par exemple, tenter de le faire avec un ensemble de nombres de 0 à 511 produira le résultat suivant.
// value (hex dump)
000 (0000)
256 (0001)
001 (0100)
257 (0101)
...
254 (fe00)
510 (fe01)
255 (ff00)
511 (ff01)Pour rĂ©soudre ce problĂšme, les entiers doivent ĂȘtre stockĂ©s dans la clĂ© dans un format adaptĂ© au comparateur par octets. Les fonctions de la famille hton* (notamment htons pour les nombres Ă deux octets de l'exemple).
Le format de reprĂ©sentation des chaĂźnes en programmation est, comme on le sait, une vĂ©ritable . Si la sĂ©mantique des chaĂźnes et le codage utilisĂ© pour leur reprĂ©sentation en mĂ©moire supposent qu'un symbole peut occuper plus d'un octet, alors l'idĂ©e d'utiliser le comparateur par dĂ©faut doit ĂȘtre abandonnĂ©e immĂ©diatement.
DeuxiÚme chose à garder à l'esprit : des champs de la structure par le compilateur. En raison de cela, des octets avec des valeurs indésirables peuvent se former en mémoire entre les champs, ce qui casse bien sûr le tri par octets. Pour éliminer les valeurs indésirables, il faut soit déclarer les champs dans un ordre strictement défini, en gardant à l'esprit les rÚgles d'alignement, soit utiliser dans la déclaration de la structure l'attribut packed.
Ordonnancement des clés par un comparateur externe
La logique de comparaison des clés peut se révéler trop complexe pour un comparateur binaire. L'une des nombreuses raisons en est la présence de champs techniques à l'intérieur des structures. Je vais illustrer leur apparition par l'exemple de la clé que nous connaissons déjà pour un élément de répertoire.
typedef struct NodeKey {â
EntityId parentId;â
uint8_t type;â
uint8_t nameBuffer[256];â
} NodeKey;Malgré sa simplicité, il consomme dans la grande majorité des cas trop de mémoire. Le tampon pour le nom occupe 256 octets, bien que les noms de fichiers et de dossiers dépassent rarement 20 à 30 caractÚres en moyenne.
Une des techniques standard pour optimiser la taille d'une entrée consiste à la ``couper'' à sa taille réelle. L'idée est que le contenu de tous les champs de longueur variable est stocké dans le tampon à la fin de la structure, et leur longueur est conservée dans des variables séparées. Conformément à cette approche, la clé NodeKey se transforme comme suit.
typedef struct NodeKey {â
EntityId parentId;â
uint8_t type;â
uint8_t nameLength;â
uint8_t nameBuffer[256];â
} NodeKey;Ensuite, lors de la sérialisation, la taille des données spécifiée n'est pas celle de la structure entiÚre, mais la taille de tous les champs de longueur fixe plus la taille de la partie réellement utilisée du tampon. MDB_val serialize(NodeKey * const key) { return MDB_val { .mv_size = sizeof(NodeKey), .mv_data = (void *)key }; } MDB_val serialize(NodeKey * const key) { return MDB_val { .mv_size = offsetof(NodeKey, nameBuffer) + key->nameLength, .mv_data = (void *)key }; }
En consĂ©quence du refactoring effectuĂ©, nous avons rĂ©alisĂ© une Ă©conomie significative de l'espace occupĂ© par les clĂ©s. Cependant, en raison du champ techniquenameLength , le compareur binaire par dĂ©faut n'est plus adaptĂ© pour comparer les clĂ©s. Si nous ne le remplaçons pas par notre propre, la longueur du nom sera un facteur plus prioritaire lors du tri que le nom lui-mĂȘme.LMDB permet d'assigner Ă chaque base de donnĂ©es sa propre fonction de comparaison des clĂ©s. Cela se fait Ă l'aide de la fonction
mdb_set_compare strictement avant l'ouverture. Pour des raisons Ă©videntes, il ne peut pas ĂȘtre changĂ© durant toute la vie de la base de donnĂ©es. En entrĂ©e, le compareur reçoit deux clĂ©s au format binaire, et en sortie, il renvoie le rĂ©sultat de la comparaison : moins (-1), plus (1) ou Ă©gales (0). Le pseudocode pour ressemble Ă ceci. NodeKey int compare(MDB_val * const a, MDB_val * const b) {â NodeKey * const aKey = (NodeKey * const)a->mv_data;â NodeKey * const bKey = (NodeKey * const)b->mv_data;â return // ... }â
Tant que toutes les clĂ©s dans la base de donnĂ©es sont du mĂȘme type, le miisement inconditionnel de leur reprĂ©sentation binaire au type de la structure de clĂ© appliquĂ©e est lĂ©gal. Il y a un petit dĂ©tail, mais il sera discutĂ© un peu plus bas dans la section "Lecture des entrĂ©es".SĂ©rialisation des valeurs
Sérialisation des valeurs
Les clĂ©s des enregistrements stockĂ©s dans LMDB fonctionnent de maniĂšre extrĂȘmement intensive. Leur comparaison se fait dans le cadre de toute opĂ©ration applicative, et la vitesse du comparateur influe sur les performances de l'ensemble de la solution. Dans un monde idĂ©al, un comparateur binaire par dĂ©faut suffirait pour comparer les clĂ©s, mais si l'on doit utiliser son propre comparateur, alors la procĂ©dure de dĂ©sĂ©rialisation des clĂ©s doit ĂȘtre aussi rapide que possible.
La valeur partie d'un enregistrement (la valeur) n'intĂ©resse pas particuliĂšrement la base de donnĂ©es. Sa transformation de la reprĂ©sentation binaire en objet se produit seulement lorsque le code applicatif en a besoin, par exemple, pour l'afficher Ă l'Ă©cran. Comme cela arrive relativement rarement, les exigences de vitesse pour cette procĂ©dure ne sont pas si critiques, et dans sa mise en Ćuvre, nous sommes beaucoup plus libres de nous concentrer sur la commoditĂ©. Par exemple, pour la sĂ©rialisation des mĂ©tadonnĂ©es sur les fichiers non chargĂ©s, nous utilisons NSKeyedArchiver.
NSData *data = serialize(object);â
MDB_val value = {â
.mv_size = data.length,â
.mv_data = (void *)data.bytesâ
};Cependant, il y a des cas oĂč la performance a tout de mĂȘme de l'importance. Par exemple, pour la sauvegarde des mĂ©tadonnĂ©es de la structure de fichiers d'un cloud utilisateur, nous utilisons toujours le mĂȘme dump mĂ©moire des objets. La particularitĂ© de la tĂąche de formation de leur reprĂ©sentation sĂ©rialisĂ©e est le fait que les Ă©lĂ©ments du rĂ©pertoire sont modĂ©lisĂ©s par une hiĂ©rarchie de classes.

Pour sa mise en Ćuvre en langage C, les champs spĂ©cifiques des hĂ©ritiers sont placĂ©s dans des structures sĂ©parĂ©es, et leur lien avec la base est Ă©tabli par un champ de type union. Le contenu actuel de l'union est dĂ©fini par un attribut technique type.
typedef struct NodeValue {â
EntityId localId;â
EntityType type;â
union {â
FileInfo file;â
DirectoryInfo directory;â
} info;â
uint8_t nameLength;â
uint8_t nameBuffer[256];â
} NodeValue;âAjout et mise Ă jour des enregistrements
Les clĂ©s et valeurs sĂ©rialisĂ©es peuvent ĂȘtre ajoutĂ©es au stockage. Pour cela, la fonction est utilisĂ©e mdb_put.
// key Đž value ĐžĐŒĐ”ŃŃ ŃОп MDB_valâ
mdb_put(..., &key, &value, MDB_NOOVERWRITE);Lors de la configuration de l'entrepĂŽt, il est possible de permettre ou d'interdire le stockage de plusieurs enregistrements avec la mĂȘme clĂ©. Si la duplication des clĂ©s est interdite, lors de l'insertion d'un enregistrement, il est possible de dĂ©terminer si la mise Ă jour d'un enregistrement existant est autorisĂ©e ou non. Si le remplacement ne peut se produire qu'en raison d'une erreur dans le code, il est possible de se prĂ©munir contre cela en spĂ©cifiant un indicateur. NOOVERWRITE.
Lecture des enregistrements
La fonction conçue pour lire les enregistrements dans LMDB est mdb_get. Si la paire clé-valeur a déjà été présentée avec des structures dumppées, cette procédure se présente comme suit.
NodeValue * const readNode(..., NodeKey * const key) {â
MDB_val rawKey = serialize(key);â
MDB_val rawValue;â
mdb_get(..., &rawKey, &rawValue);â
return (NodeValue * const)rawValue.mv_data;â
}Le listing prĂ©sentĂ© montre comment la sĂ©rialisation via le dump de structures permet de se dĂ©barrasser des allocations dynamiques non seulement lors de l'Ă©criture, mais aussi lors de la lecture des donnĂ©es. Le pointeur obtenu de la fonction mdb_get regarde prĂ©cisĂ©ment Ă l'adresse de la mĂ©moire virtuelle oĂč la base de donnĂ©es stocke la reprĂ©sentation binaire de l'objet. En rĂ©alitĂ©, nous obtenons une sorte d'ORM, fournissant pratiquement gratuitement une vitesse de lecture des donnĂ©es trĂšs Ă©levĂ©e. MalgrĂ© toute la beautĂ© de cette approche, il est important de se rappeler plusieurs caractĂ©ristiques associĂ©es.
- Pour une transaction en lecture seule, le pointeur vers la structure-valeur restera garanti valide tant que la transaction n'est pas terminĂ©e. Comme mentionnĂ© prĂ©cĂ©demment, les pages de l'arbre B oĂč l'objet est situĂ©, grĂące au principe de copy-on-write, restent inchangĂ©es tant qu'elles sont rĂ©fĂ©rencĂ©es par au moins une transaction. D'un autre cĂŽtĂ©, dĂšs que la derniĂšre transaction qui leur est associĂ©e se termine, les pages peuvent ĂȘtre rĂ©utilisĂ©es pour de nouvelles donnĂ©es. Si les objets doivent survivre Ă la transaction qui les a engendrĂ©s, ils devront nĂ©anmoins ĂȘtre copiĂ©s.
- Pour une transaction en lecture-écriture, le pointeur vers la structure-valeur obtenue ne sera valide que jusqu'à la premiÚre opération de modification (écriture ou suppression de données).
- Bien que la structure
NodeValuene soit pas complĂšte, mais tronquĂ©e (voir la section « Ordonnancement des clĂ©s par un comparateur externe »), on peut facilement accĂ©der Ă ses champs via le pointeur. L'essentiel est de ne pas le dĂ©rĂ©fĂ©rencer ! - En aucun cas, il ne faut modifier la structure via le pointeur obtenu. Tous les changements doivent ĂȘtre effectuĂ©s uniquement par la mĂ©thode
mdb_put. Cependant, mĂȘme avec tout le dĂ©sir de le faire, cela ne sera pas possible, car la zone mĂ©moire oĂč cette structure rĂ©side est mappĂ©e en mode lecture seule. - Remapper le fichier dans l'espace d'adressage du processus dans le but, par exemple, d'augmenter la taille maximale de stockage Ă l'aide de la fonction
mdb_env_set_map_sizeannule complÚtement toutes les transactions ainsi que les entités associées en général et les pointeurs vers les objets lus en particulier.
Enfin, une autre caractĂ©ristique est si sournoise que sa rĂ©vĂ©lation ne peut tout simplement pas se rĂ©sumer Ă un autre point. Dans le chapitre sur l'arbre B, j'ai prĂ©sentĂ© un schĂ©ma de la disposition de ses pages en mĂ©moire. Cela implique que l'adresse du dĂ©but du tampon contenant les donnĂ©es sĂ©rialisĂ©es peut ĂȘtre complĂštement arbitraire. En raison de cela, le pointeur vers celles-ci, obtenu dans la structure MDB_val et converti en pointeur vers la structure, se rĂ©vĂšle en gĂ©nĂ©ral non alignĂ©. En mĂȘme temps, les architectures de certaines puces (dans le cas d'iOS, c'est armv7) exigent que l'adresse de toute donnĂ©e soit un multiple de la taille d'un mot machine ou, en d'autres termes, du bit des systĂšmes (pour armv7, c'est 32 bits). En d'autres termes, une opĂ©ration comme *(int *foo)0x800002 est Ă©quivalente Ă une fuite et entraĂźne une exĂ©cution avec le verdict EXC_ARM_DA_ALIGN. On peut Ă©viter un sort aussi malheureux de deux maniĂšres.
La premiÚre consiste à copier les données à l'avance dans une structure manifestement alignée. Par exemple, avec un comparateur personnalisé, cela se traduira comme suit.
int compare(MDB_val * const a, MDB_val * const b) {
NodeKey aKey, bKey;
memcpy(&aKey, a->mv_data, a->mv_size);
memcpy(&bKey, b->mv_data, b->mv_size);
return // ...
}Un chemin alternatif consiste Ă informer Ă l'avance le compilateur que les structures contenant la clĂ© et la valeur peuvent ne pas ĂȘtre alignĂ©es grĂące Ă l'attribut aligned(1). Sur ARM, un effet similaire peut ĂȘtre obtenu Ă©galement avec l'attribut packed. Ătant donnĂ© qu'il favorise Ă©galement l'optimisation de l'espace occupĂ© par la structure, je trouve cette mĂ©thode prĂ©fĂ©rable, bien que cela ait pour consĂ©quence d'augmenter le coĂ»t des opĂ©rations d'accĂšs aux donnĂ©es.
typedef struct __attribute__((packed)) NodeKey {
uint8_t parentId;
uint8_t type;
uint8_t nameLength;
uint8_t nameBuffer[256];
} NodeKey;RequĂȘtes de plage
Pour itérer sur un groupe d'enregistrements dans LMDB, une abstraction de curseur est prévue. Nous allons voir comment travailler avec cela en prenant l'exemple de la table des métadonnées du cloud utilisateur que nous connaissons déjà .
Dans le cadre de l'affichage de la liste des fichiers dans un répertoire, il est nécessaire de trouver toutes les clés associées à ses fichiers et dossiers enfants. Dans les sections précédentes, nous avons trié les clés NodeKey de telle sorte qu'elles soient d'abord ordonnées par l'identifiant du répertoire parent. Ainsi, la tùche technique d'obtenir le contenu d'un dossier se résume à positionner le curseur à la limite supérieure du groupe de clés avec un préfixe donné, puis à itérer jusqu'à la limite inférieure.

La limite supĂ©rieure peut ĂȘtre trouvĂ©e "de maniĂšre brute" par une recherche sĂ©quentielle. Pour cela, le curseur est placĂ© au dĂ©but de toute la liste des clĂ©s dans la base de donnĂ©es et s'incrĂ©mente jusqu'Ă ce qu'il rencontre une clĂ© avec l'identifiant du rĂ©pertoire parent. Cette approche prĂ©sente deux inconvĂ©nients Ă©vidents :
- La complexitĂ© linĂ©aire de la recherche, alors qu'il est connu que dans les arbres en gĂ©nĂ©ral et dans l'arbre B en particulier, cela peut ĂȘtre effectuĂ© en logarithmique.
- On ramĂšne inutilement en mĂ©moire principale toutes les pages prĂ©cĂ©dant celle recherchĂ©e, ce qui est extrĂȘmement coĂ»teux.
Heureusement, l'API LMDB prĂ©voit un moyen efficace de positionnement initial du curseur. Pour cela, il faut former une clĂ© dont la valeur sera nĂ©cessairement infĂ©rieure ou Ă©gale Ă la clĂ© situĂ©e Ă la limite supĂ©rieure de l'intervalle. Par exemple, concernant la liste de l'image ci-dessus, nous pouvons crĂ©er une telle clĂ©, oĂč le champ parentId sera Ă©gal Ă 2, tandis que tous les autres seront remplis de zĂ©ros. Cette clĂ© partiellement remplie est entrĂ©e dans la fonction mdb_cursor_get avec l'opĂ©ration MDB_SET_RANGE.
NodeKey upperBoundSearchKey = {â
.parentId = 2,â
.type = 0,â
.nameLength = 0â
};â
MDB_val value, key = serialize(upperBoundSearchKey);â
MDB_cursor *cursor;â
mdb_cursor_open(..., &cursor);â
mdb_cursor_get(cursor, &key, &value, MDB_SET_RANGE);Si la limite supérieure du groupe de clés est trouvée, nous itérons ensuite sur celle-ci jusqu'à ce que nous rencontrions soit une clé différente parentId, soit que les clés s'épuisent complÚtement.
do {â
rc = mdb_cursor_get(cursor, &key, &value, MDB_NEXT);â
// traitement...â
} while (MDB_NOTFOUND != rc && // vĂ©rifier la fin de la tableâ
IsTargetKey(key)); // vĂ©rifier la fin du groupe de clĂ©sâCe qui est agrĂ©able, c'est que lors de l'itĂ©ration avec mdb_cursor_get, nous obtenons non seulement la clĂ©, mais aussi la valeur. Si, pour remplir les conditions de sĂ©lection, il est nĂ©cessaire de vĂ©rifier Ă©galement les champs dans la partie valeur de l'enregistrement, ils sont tout Ă fait accessibles sans dĂ©marches supplĂ©mentaires.
4.3. Modélisation des relations entre les tables
à ce stade, nous avons réussi à examiner tous les aspects de la conception et du travail avec une base de données à table unique. On peut dire qu'une table est un ensemble d'enregistrements triés, composés de paires clé-valeur similaires. Si l'on représente la clé sous la forme d'un rectangle et la valeur qui lui est associée sous la forme d'un parallélépipÚde, cela donne un schéma visuel de la base de données.
â
![]()
Cependant, dans la vie réelle, il est rare de pouvoir se contenter d'un cadre aussi restreint. Souvent, une base de données doit, premiÚrement, comporter plusieurs tables et, deuxiÚmement, effectuer des sélections dans un ordre différent de celui de la clé primaire. Les questions de leur création et de leur liaison entre elles sont consacrées à cette derniÚre section.
Tables d'index
Dans l'application cloud, il existe une section « Galerie ». Elle affiche le contenu multimĂ©dia de tout le cloud, triĂ© par date. Pour une mise en Ćuvre optimale de cette sĂ©lection, il est nĂ©cessaire de crĂ©er une nouvelle table Ă cĂŽtĂ© de la table principale, avec un nouveau type de clĂ©s. Celle-ci contiendra un champ avec la date de crĂ©ation du fichier, qui servira de critĂšre principal de tri. Comme les nouvelles clĂ©s se rĂ©fĂšrent aux mĂȘmes donnĂ©es que les clĂ©s de la table principale, elles sont appelĂ©es des clĂ©s d'index. Dans l'image ci-dessous, elles sont mises en Ă©vidence en orange.

Pour sĂ©parer les clĂ©s des diffĂ©rentes tables au sein d'une mĂȘme base de donnĂ©es, un champ technique supplĂ©mentaire tableId a Ă©tĂ© ajoutĂ© Ă toutes. En faisant de ce champ le plus prioritaire pour le tri, nous obtiendrons un groupement des clĂ©s d'abord par tables, puis Ă l'intĂ©rieur des tables, selon nos propres rĂšgles.
La clĂ© d'index pointe vers les mĂȘmes donnĂ©es que la clĂ© primaire. ImplĂ©menter directement cette propriĂ©tĂ© via une association avec une copie de la partie valeur de la clĂ© primaire est inefficace sous plusieurs angles :
- En termes d'espace occupĂ©, Ă©tant donnĂ© que les mĂ©tadonnĂ©es peuvent ĂȘtre assez riches.
- En termes de performance, car lors de la mise à jour des métadonnées, il faudra réécrire sur deux clés.
- En termes de support de code, dÚs que nous oublions de mettre à jour les données pour l'une des clés, nous obtenons un bogue difficile à cerner d'incohérence des données dans le stockage.
Nous allons maintenant examiner comment éliminer ces lacunes.
Organisation des relations entre les tables
Pour relier une table d'index Ă la table principale, le modĂšle suivant est bien adaptĂ© « clĂ© comme valeur ». Comme son nom l'indique, dans ce modĂšle, une copie de la valeur de la clĂ© primaire sert de partie valeur de l'enregistrement indexĂ©. Cette approche annule tous les inconvĂ©nients mentionnĂ©s ci-dessus liĂ©s au stockage d'une copie de la partie valeur de l'enregistrement primaire. Le seul inconvĂ©nient est qu'il faut effectuer 2 requĂȘtes dans la base de donnĂ©es au lieu d'une pour obtenir la valeur par la clĂ© d'index. Le schĂ©ma rĂ©sultant de la base de donnĂ©es apparaĂźt comme suit.

Un autre modÚle d'organisation des relations entre les tables est « clé redondante ». Son principe réside dans l'ajout d'attributs supplémentaires à la clé, qui ne sont pas nécessaires pour le tri, mais pour recréer la clé associée. Dans l'application Cloud de Mail.ru, il existe de réels exemples de son utilisation, cependant, pour éviter une immersion profonde dans le contexte de frameworks iOS spécifiques, je vais donner un exemple fictif, mais plus compréhensible.
Dans les clients mobiles cloud, il existe une page qui affiche tous les fichiers et dossiers auxquels l'utilisateur a donnĂ© accĂšs Ă d'autres personnes. Ătant donnĂ© qu'il y a relativement peu de ces fichiers, et qu'il y a beaucoup d'informations spĂ©cifiques concernant la publicitĂ© (qui a accĂšs, avec quels droits, etc.), il ne serait pas rationnel d'alourdir la partie valeur de l'enregistrement dans la table principale. Cependant, si l'on souhaite afficher ces fichiers hors ligne, il est nĂ©cessaire de les stocker quelque part. Une solution naturelle consiste Ă crĂ©er une table sĂ©parĂ©e Ă cet effet. Dans le schĂ©ma ci-dessous, sa clĂ© a le prĂ©fixe « P », et l'espace rĂ©servĂ© « propname » peut ĂȘtre remplacĂ© par une valeur plus spĂ©cifique « information publique ».

Toutes les mĂ©tadonnĂ©es uniques, pour lesquelles une nouvelle table a Ă©tĂ© créée, sont transfĂ©rĂ©es dans la partie valeur de l'enregistrement. En mĂȘme temps, il n'est pas souhaitable de dupliquer les donnĂ©es sur les fichiers et les dossiers qui sont dĂ©jĂ stockĂ©es dans la table principale. Au lieu de cela, des donnĂ©es redondantes sous forme de champs « node ID » et « timestamp » sont ajoutĂ©es Ă la clĂ© « P ». GrĂące Ă elles, il est possible de construire une clĂ© d'index, permettant d'obtenir la clĂ© primaire, et enfin d'accĂ©der aux mĂ©tadonnĂ©es du nĆud.
Conclusion
Nous évaluons positivement les résultats de l'implémentation de LMDB. AprÚs cela, le nombre de plantages de l'application a diminué de 30%.

Les résultats du travail effectué ont trouvé un écho au-delà de l'équipe iOS. Actuellement, l'un des principaux sections « Fichiers » de l'application Android a également commencé à utiliser LMDB, et d'autres parties sont en cours. Le langage C, dans lequel le stockage clé-valeur est implémenté, a été un bon soutien pour créer initialement un cadre d'application multiplateforme en C++. Un générateur de code a été utilisé pour une connexion transparente de la bibliothÚque C++ résultante avec le code natif en Objective-C et Kotlin. de Dropbox, mais c'est une histoire complÚtement différente.
Source : habr.com
