Partie 20: Réindexation et compaction de tables
Les articles de la série
- Partie 1 : Construire le REPL
- Partie 2 : Sérialisation des données
- Partie 3 : Database
- Partie 4 : Tables
- Partie 5 : Parser les commandes, création de la boîte à outils
- Partie 6 : Parser les commandes SQL
- Partie 7 : Tuples de données
- Partie 8 : Contraindre les données via un schéma
- Partie 9 : Découper le stockage des tables en pages
- Partie 10 : Clefs primaires
- Partie 11 : Expressions Logiques SQL
- Partie 12 : Scans et filtrage
- Partie 13 : UTF-8 et caractères échappés
- Partie 14 : Attributs nullifiables
- Partie 15 : Index secondaires
- Partie 16 : Directive EXPLAIN
- Partie 17 : Logical Plan
- Partie 18 : Exécution du plan logique
- Partie 19 : Suppression d'enregistrements
- Partie 20 : Réindexation et compactation de tables
Bonjour à toutes et tous 😃
Lors de la partie précédente, nous avons supprimé des enregistrements de la base de données. Mais en agissant ainsi, nous avons laissé des trous dans nos pages de stockage.
En effet, chaque insertion incrémente un ID interne propre à chaque page, ce qui fait que même si nous supprimons un enregistrement, son emplacement n'est pas réaffecté pour l'enregistrement suivant. Au lieu de cela, une nouvelle entrée est créée dans la page ayant le plus grand ID interne. S'il n'y a pas de place, alors nous ajoutons une nouvelle page.
Nous allons considérer que les pages possèdent au maximum 3 enregistrements.
# page 0
0 => data0
1 => data1
2 => data2
# page 1
3 => data3
4 => data4
Supprimons quelques enregistrements.
# page 0
0 => NULL
1 => data1
2 => NULL
# page 1
3 => data3
4 => NULL
Si l'on ajoute un nouvel enregistrement, il va se trouver dans la page 1. Et si l'on ajoute un autre enregistrement, il va se trouver dans la page 2 nouvellement créée.
# page 0
0 => NULL
1 => data1
2 => NULL
# page 1
3 => data3
4 => NULL
5 => data5
# page 2
6 => data6
On se retrouve avec des trous dans nos pages aux index 0, 2 et 4.
Ces emplacements vides imposent un surplus de pages. Nous voulons réduire le nombre de pages nécessaire.
# page 0
0 => data6
1 => data1
2 => data5
# page 1
3 => data3
Comme vous pouvez le voir, les ID internes 0 et 2 ont été réaffectés pour deux enregistrements déjà existants, respectivement data6
et data5
.
Cependant, les index, et plus particulièrement les index primaires, ne sont pas au courant de ces changements et restent donc dans le même état qu'avant la compaction de la table.
Il va donc falloir mettre à jour ces index pour que notre table reste cohérente.
Aujourd'hui encore, cela ne sera pas si simple, mais cela promet d'être intéressant. 😎
Compactation de la table
Nous allons commencer par nous charger de la compaction de la table.
Notre algorithme de compaction sera volontairement naïf.
Nous allons passer en revue les pages une par une et dès que l'on trouve un emplacement qui a été libéré par une suppression, nous allons le remplacer par l'enregistrement ayant le plus grand ID interne.
Et l'on répète ce processus jusqu'à ce que tous les trous aient été comblés par des les enregistrements de haut ID.
Lorsque le premier trou rencontré se trouve au-delà du nombre total d'enregistrements, le processus de compaction est terminé.
Comme tout cela est un peu compliqué, je vous ai fait une animation pour vous expliquer tout cela.
La légende est la suivante:
- les blocs roses sont les enregistrements actifs de la page
- les blocs verts sont les enregistrements supprimés de la page
- les blocs blancs sont les espaces libres de la page
Les numéros des blocs représentent l'ID de données de l'enregistrement.
L'ID interne lui est représenté par la position du bloc dans chaque rectangle.
Chacun de ces rectangles représente une page de 20 enregistrements.
Aussi bien pour les pages que pour les blocs, les ID se lisent de haut en bas et de gauche à droite.
On y voit tout le processus de déplacement des données.
D'abord on recherche le premier emplacement supprimé. Il se trouve être l'ID 1. On recherche alors le dernier enregistrement actif de la base de données. Dans notre cas c'est l'enregistrement ayant l'ID 76.
On inverse les deux enregistrements 1 et 76.
Sur l'animation, je swap les deux enregistrements, mais dans la réalité je replace l'enregistrement ayant l'ID 1 par celui ayant l'ID 76.
Comme l'ID 1 est supprimé, il n'est pas nécessaire de conserver les donnees de l'enregistrement ayant l'ID 1.
On s'économise alors une copie de données par inversion.
Et on repart pour un tour en recherchant le prochain emplacement supprimé et le dernier enregistrement actif, respectivement l'ID 16 et l'ID 75.
La recherche des emplacements vides et actifs est optimisée par notre système de header de page qui nous permet de trouver rapidement les emplacements vides.
La dernière inversion est entre l'enregistrement ayant l'ID 59 et celui ayant l'ID 63.
À partir de ce moment, le dernier enregistrement actif est l'enregistrement ayant l'ID 62 et le premier emplacement supprimé est celui ayant l'ID 63.
L'algorithme de compaction est donc terminé.
Nous nous retrouvons avec tous les enregistrements actifs de la base de données contigus et l'ensemble des enregistrements supprimés à la suite.
La dernière étape consiste à les marquer comme inutilisés en modifiant l'ID interne de la table pour qu'elle écrive le prochain enregistrement à l'ID interne 63.
Il existe un cas particulier de l'algorithme qui amène à vider entièrement une page.
Dans ce cas, la page est devenue inutile et peut être supprimée.
Supression des pages
Nous avons donc besoin d'un mécanisme pour réaliser la supression des pages.
Nous allons implémenter ce système sur le pager associé à la table.
L'API drain consomme les élément d'un itérateur et les supprime de celui-ci.
Implémentation du garbage collector
Le processus de compaction nécessite deux étapes:
- le déplacement des enregistrements supprimés à la suite des enregistrements actifs
- la suppression des pages vides
La suppression des pages a été réalisée juste au-dessus.
Il nous reste à réaliser le déplacement des enregistrements supprimés.
Pour faire cela, nous allons créer une structure que nous allons appeler garbage collector.
Et associer cette structure à la table.
Comme la compaction est une opération complexe, nous allons créer une structure que nous appellerons VacuumReport
qui nous donnera des statistiques sur la compaction.
Nous pouvons désormais implementer notre garbage collector.
Commande VACUUM TABLE
Contrairement à ce que laisse penser son nom de "garbage collector", celui-ci n'est ni une tâche de fond, ni une opération autonome.
Elle sera déclenchée par la commande VACUUM TABLE
.
VACUUM TABLE table_name;
Notre commande sera matérialisée par la structure suivante:
Et son parser:
Réindexation de la table
Comme je vous l'ai dit en introduction, nous avons besoin de réindexer notre table après compaction pour éviter une décohérence des index par rapport au contenu de la table.
Mais fort heureusement, la réindexation n'est pas un processus si complexe que cela, au vu de l'avancé actuelle de notre projet.
L'algorithme de réindexation est le suivant:
- Pour tous les index de la table, vider les entrées
- Scanner tous les enregistrements de la table
- Pour chaque enregistrement, réindexer les données
On rajoute sur le trait Indexable
une méthode clear
qui a pour responsabilité de vider un index.
Nous avons deux implémentations de Indexable
:
ValueIndex
PrimaryIndex
On implémente alors une méthode sur l'IndexRegistry
qui permet de vider tous les index de la table.
On implémente ensuite la réindexation de la table.
Tout comme lorsque l'on a réalisé la suppression dans la partie 19, nous avons besoin de lire les enregistrements de la table et de les reindexer.
Mais là encore, il n'est pas possible de le faire lors du scan de la table, on doit d'abord lire un certain nombre d'enregistrements, que l'on puisse ensuite reindexer.
On défini alors une "continuation" qui correspond au dernier enregistrement lu.
Ce qui permet de relancer le scan en commençant à partir de cette continuation.
Et l'on recommence ainsi jusqu'a ce que l'on ait lu tous les enregistrements de la table.
Implémentation de la commande VACUUM
Maintenant que nous avons tous les éléments nécessaires, nous pouvons implémenter la commande VACUUM.
On implémente la commande sur la base de données:
Puis sur la table:
On y construit le garbage collector que l'on exécute, ce qui produit un rapport de compaction.
On réindex alors la table.
Et on renvoie le rapport.
Afin de rendre plus intelligible le rapport de compaction, on définit le trait Display
:
Ce qui permet de l'afficher en retour de commande dans la méthode run
du projet:
On test !
On va maintenant tester notre commande VACUUM.
INTEGER PRIMARY KEY, value TEXT(180));
(id (value);
Puis on ajoute quelques enregistrements:
-- INSERT for 1 to 77
INSERT INTO Users (id, value) VALUES (0, 'test_0');
INSERT INTO Users (id, value) VALUES (1, 'test_1');
INSERT INTO Users (id, value) VALUES (2, 'test_2');
INSERT INTO Users (id, value) VALUES (3, 'test_3');
-- ...
INSERT INTO Users (id, value) VALUES (74, 'test_74');
INSERT INTO Users (id, value) VALUES (75, 'test_75');
INSERT INTO Users (id, value) VALUES (76, 'test_76');
La liste complète est ici.
Puis on supprime quelques enregistrements pour créer des trous dans nos pages:
-- DELETE 1, 8, 16, 19, 28, 33, 35, 37, 39, 49, 52, 55, 56, 59, 64, 65, 66, 69, 71, 72,
DELETE FROM Users WHERE id = 1;
DELETE FROM Users WHERE id = 8;
DELETE FROM Users WHERE id = 16;
DELETE FROM Users WHERE id = 19;
DELETE FROM Users WHERE id = 28;
DELETE FROM Users WHERE id = 33;
DELETE FROM Users WHERE id = 35;
DELETE FROM Users WHERE id = 37;
DELETE FROM Users WHERE id = 39;
DELETE FROM Users WHERE id = 49;
DELETE FROM Users WHERE id = 52;
DELETE FROM Users WHERE id = 55;
DELETE FROM Users WHERE id = 56;
DELETE FROM Users WHERE id = 59;
DELETE FROM Users WHERE id = 64;
DELETE FROM Users WHERE id = 65;
DELETE FROM Users WHERE id = 66;
DELETE FROM Users WHERE id = 69;
DELETE FROM Users WHERE id = 71;
DELETE FROM Users WHERE id = 72;
On ajoute un enregistrement:
INSERT INTO Users (id, value) VALUES (666, 'satan');
Si on liste les enregistrements de la table:
SELECT * FROM Users;
On obtient:
ID interne | ID | Value |
---|---|---|
0 | 0 | test_0 |
2 | 2 | test_2 |
3 | 3 | test_3 |
4 | 4 | test_4 |
5 | 5 | test_5 |
6 | 6 | test_6 |
7 | 7 | test_7 |
9 | 9 | test_9 |
10 | 10 | test_10 |
11 | 11 | test_11 |
12 | 12 | test_12 |
13 | 13 | test_13 |
14 | 14 | test_14 |
15 | 15 | test_15 |
17 | 17 | test_17 |
18 | 18 | test_18 |
20 | 20 | test_20 |
21 | 21 | test_21 |
22 | 22 | test_22 |
23 | 23 | test_23 |
24 | 24 | test_24 |
25 | 25 | test_25 |
26 | 26 | test_26 |
27 | 27 | test_27 |
63 | 63 | test_63 |
67 | 67 | test_67 |
68 | 68 | test_68 |
70 | 70 | test_70 |
73 | 73 | test_73 |
74 | 74 | test_74 |
75 | 75 | test_75 |
76 | 76 | test_76 |
77 | 77 | satan |
On remarque que:
- les enregistrements sont bien supprimés.
- le dernier enregistrement est ajouté à la fin.
- les ID internes et de l'enregistrement sont synchronisés.
On va maintenant lancer le processus de compaction.
VACUUM TABLE Users;
On obtient:
Vacuuming table Users
Clearing index PRIMARY
Clearing index idx_value
Reindexing row 0
Insert [Text("test_0")] into non unique index idx_value => row_id 0
Reindexing row 1
Insert [Text("satan")] into non unique index idx_value => row_id 1
Reindexing row 2
Insert [Text("test_2")] into non unique index idx_value => row_id 2
Reindexing row 3
// ...
Reindexing row 55
Insert [Text("test_60")] into non unique index idx_value => row_id 55
Reindexing row 56
Insert [Text("test_58")] into non unique index idx_value => row_id 56
Reindexing row 57
Insert [Text("test_57")] into non unique index idx_value => row_id 57
Vacuum Report for table: Users
Pages deleted: 1
Vacuumed records: 13
Swap records:
Swap #1 -> #77
Swap #8 -> #76
Swap #16 -> #75
Swap #19 -> #74
Swap #28 -> #73
Swap #33 -> #70
Swap #35 -> #68
Swap #37 -> #67
Swap #39 -> #63
Swap #49 -> #62
Swap #52 -> #61
Swap #55 -> #60
Swap #56 -> #58
On y voit la purge des index puis la réindaxation des enregistrements.
Et finallement le rapport de compaction.
On y remarque les opérations d'inversement de blocs dans les pages.
Le nombre de pages supprimées ainsi que les rows supprimées du stockage.
On recommence à lire la table:
SELECT * FROM Users;
ID interne | ID | Value |
---|---|---|
0 | 0 | test_0 |
1 | 666 | satan |
2 | 2 | test_2 |
3 | 3 | test_3 |
4 | 4 | test_4 |
5 | 5 | test_5 |
6 | 6 | test_6 |
7 | 7 | test_7 |
8 | 76 | test_76 |
9 | 9 | test_9 |
10 | 10 | test_10 |
11 | 11 | test_11 |
12 | 12 | test_12 |
13 | 13 | test_13 |
14 | 14 | test_14 |
15 | 15 | test_15 |
16 | 75 | test_75 |
17 | 17 | test_17 |
18 | 18 | test_18 |
19 | 74 | test_74 |
20 | 20 | test_20 |
21 | 21 | test_21 |
22 | 22 | test_22 |
23 | 23 | test_23 |
24 | 24 | test_24 |
25 | 25 | test_25 |
26 | 26 | test_26 |
27 | 27 | test_27 |
28 | 73 | test_73 |
29 | 29 | test_29 |
30 | 30 | test_30 |
31 | 31 | test_31 |
32 | 32 | test_32 |
33 | 70 | test_70 |
34 | 34 | test_34 |
35 | 68 | test_68 |
36 | 36 | test_36 |
37 | 67 | test_67 |
38 | 38 | test_38 |
39 | 63 | test_63 |
40 | 40 | test_40 |
41 | 41 | test_41 |
42 | 42 | test_42 |
43 | 43 | test_43 |
44 | 44 | test_44 |
45 | 45 | test_45 |
46 | 46 | test_46 |
47 | 47 | test_47 |
48 | 48 | test_48 |
49 | 62 | test_62 |
50 | 50 | test_50 |
51 | 51 | test_51 |
52 | 61 | test_61 |
53 | 53 | test_53 |
54 | 54 | test_54 |
55 | 60 | test_60 |
56 | 58 | test_58 |
57 | 57 | test_57 |
Cette fois-ci les ID internes et les ID ne sont plus synchronisés.
Par contre, il n'y a plus de saut sur les ID internes.
De même l'ID interne maximale qui était de 77 est passé à 57.
Dans l'opération, nous venons de supprimer du stockage sans perte de données ! 😄
Parlant de perte de données, vérifions que nos index sont toujours cohérents:
EXPLAIN SELECT * FROM Users WHERE value = "test_76";
On obtient le plan suivant:
Scan index idx_value : value = 'test_76' for table Users
Filter: value = 'test_76'
Donc le query planner ira chercher sa données depuis l'index idx_value
.
Donc si on a foiré la réindexation de l'index idx_value
, il n'y aura perte de données.
Vérifions cela:
SELECT * FROM Users WHERE value = "test_76";
ID interne | ID | Value |
---|---|---|
8 | 76 | test_76 |
Nickel, pas de perte de données ! 🎉
Conclusion
Clairement, nous sommes dans l'optimisation, mais cela me semblait intéressant de poser les bases de la réindexation de données.
Et puis, je me suis bien amusé à réaliser les petites animations. 😄
Dans la prochaine partie nous allons implementer la commande UPDATE table SET value = x WHERE id = y
.
Merci de votre lecture ❤️
Vous pouvez trouver le code la partie ici et le diff là.
Ce travail est sous licence CC BY-NC-SA 4.0.