Partie 20: Réindexation et compaction de tables
Lecture 10 min. âą
Table des matiĂšres
- Compactation de la table
- Commande VACUUM TABLE
- Réindexation de la table
- Implémentation de la commande VACUUM
- On test !
- Conclusion
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.
Note
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Ă©e 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 â€ïž