Partie 9 : Découper le stockage des tables en pages
Lecture 5 min. âą
Table des matiĂšres
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 đ
Petit article (en comparaison des autres đ ).
Bien que lâon soit en train de rĂ©implĂ©menter tout et nâimporte quoi pour notre plaisir et notre apprentissage Ă la fois dans Rust et dans la crĂ©ation de base de donnĂ©es.
Notre objectif est de créer un sqlite, et qui dit sqlite, dit persistance sur disque.
Pour le moment, toutes nos opĂ©rations sont in-memory, on peut se permettre de scanner la mĂ©moire pour un coup modique. MĂȘme si on a plusieurs centaines de mĂ©gaoctets le dĂ©lais de scan de la table reste acceptable.
Par contre si on commence Ă stocker sur disque, un problĂšme de taille (et câest le cas de le dire) va intervenir: lire un fichier est LENT !!
Pour Ă©viter cette lenteur, il faut absolument le âmonter en mĂ©moireâ, mais on ne va pas monter 2Go de base de donnĂ©es!
La solution est de le découper en plusieurs unité logique que nous allons appeler des pages.
Ces pages seront bien plus petites que le fichier de la base de donnĂ©es et permettront par la suite de faire des opĂ©rations de cache trĂšs intĂ©ressantes. đ
Ces dans ces pages que nous allons stocker nos données et donc nos tuples.

La notion cruciale que nous allons devoir respecter pour espĂ©rer rĂ©cupĂ©rer quoique ce soit de page est que chaque donnĂ©es doit avoir rigoureusemet la mĂȘme taille.
Heureusement, câest dĂ©jĂ la cas grĂące Ă notre SchĂ©ma qui contraint dĂ©sormais nos tuples Ă respecter toutes les rĂšgles que lâon lui dicte avant insertion des dites donnĂ©es dans la table.
Nous pouvons alors implémenter le trait Size sur le Schéma
impl Size for Schema {
fn size(&self) -> usize {
self.fields
.values()
.map(|column| column.size())
.sum::<usize>()
}
}Cela est possible car chaque ColumnType connaĂźt trĂšs exactement la place quâil prend. On peut donc sommer ces emplacements pour obtenir la taille du tuple en mĂ©moire.
impl Size for ColumnType {
fn size(&self) -> usize {
match self {
ColumnType::Integer => size_of::<i64>(),
ColumnType::Text(size) => *size,
}
}
}Nous allons nous retrouver dans cette configuration.

Chaque tuple dâune table prend la mĂȘme place et on peut alors les sĂ©rialiser les uns aprĂšs les autres.
Ici jâai reprĂ©sentĂ© deux pages de 96 cases et des tuples de 6 cases.
En faisant la division on se retrouve avec 96 / 6 = 16 tuples stockables dans une page. Le 17Ăšme se trouvant dans la page suivante (on commence Ă 0 les index).
Mais attention! Si la taille du tuple nâest pas aussi sympathique, comme 7 cases par exemple, nous allons observer un phĂ©nomĂšne de fragmentation de la donnĂ©e qui se retrouve sur plus dâune page, on nomme ça du dĂ©salignement.
En effet 96 / 7 = 13.7.., le 14Úme élément va se retrouver coupé en deux.

Pour contrer ce phénomÚne, nous allons condamner les cases qui provoquent ce désalignement.

Ainsi, le début 14Úme élément est désormais déplacé dans la deuxiÚme page, ce qui résout le désalignement.
Câest bien beau de pouvoir stocker de la donnĂ©e dans nos pages, encore faut-il pouvoir les lire aprĂšs coup.
Pour cela, nous allons utiliser une arme des plus commode : les mathĂ©matiques !! đ
DĂ©jĂ calculons la taille de la page. Prenons arbitrairement 4096 octets, câest la taille dâun bloc de donnĂ©es dans plusieurs systĂšme de fichier, ça accĂšlerera les opĂ©rations.
Nous lâappellerons en majuscules : PAGE_SIZE.
Pour éviter les désalignements en fonction de la taille du tuple row_size, nous devons tronquer cette taille.
page_size = (PAGE_SIZE // row_size) * row_sizeCela nous donne un page_size en minuscule cette fois.
Le symbole
//est la division entiĂšre, on ne peut pas simplifier âen haut et en basâ parrow_size.
Pour retrouver la position du tuple dâindex row_number, il faut dâabord trouver sa page.
page_number = (row_number * row_size) // page_size
^---------------------^
index globalCe qui revient Ă mettre Ă plat toute nos pages tronquĂ©es et Ă dĂ©finir lâindex global dans cette immense tableau, puis Ă diviser cette index par la taille dâune page tronquĂ©e.
Pour trouver la position du tuple dans cette page son offset, on va alors utiliser la mĂȘme technique, on calcul lâindex global global_index, mais cette fois ci au lieu de diviser, on soustrait les tailles de pages qui ne sont pas la page sĂ©lectionnĂ©e.
offset = global_index - page_number * page_sizeEn cumulant ces deux informations, il est possible de retrouver notre tuple dans les pages, vive les maths! đ€©
Matérialisons tout ça.
Dâabord, on se créé une structure Page qui sera notre page.
pub const PAGE_SIZE: usize = 4096;
pub struct Page {
inner: [u8; PAGE_SIZE],
}
impl Page {
pub fn new() -> Self {
Page {
inner: [0u8; PAGE_SIZE],
}
}
}Si on demande la taille dâune page, on aura trĂšs exactement
4096bytes.
On lui ajoute deux mĂ©thodes qui permettent dâaccĂ©der en lecture et en Ă©criture Ă une zone de la page.
impl Page {
pub fn get_mut(&mut self, offset: usize) -> &mut [u8] {
&mut self.inner[offset..]
}
pub fn get(&self, offset: usize) -> &[u8] {
&self.inner[offset..]
}
}On peut maintenant définir un objet Pager qui aura pour rÎle de maintenir les pages.
pub struct Pager {
// les pages sont stockées dans une map
// qui associe le page_num et la page elle mĂȘme
pages: BTreeMap<usize, Page>,
page_size: usize,
row_size: usize,
}
impl Pager {
pub fn new(row_size: usize) -> Self {
// tronque la page size pour éviter le désalignement
let page_size = (PAGE_SIZE / row_size) * row_size;
Pager {
pages: BTreeMap::default(),
row_size,
page_size,
}
}
}Lors de la création du pager, on y calcule notre fameuse taille tronquée.
On peut alors dĂ©finir notre mĂ©thode qui rĂ©alise lâopĂ©ration mathĂ©matique de rĂ©cupĂ©ration le page et de son offset.
impl Pager {
fn get_position(&self, row_number: usize) -> Position {
// calcul de l'index global
let global_index = row_number * self.row_size;
// calcul la page contenant le record
let page_number = global_index / self.page_size;
// calcul l'offset de la valeur dans cette page
let offset = global_index - page_number * self.page_size;
Position {
page_number,
offset,
}
}
}On dĂ©fini pour lâoccasion une structure de Position
struct Position {
page_number: usize,
offset: usize,
}Pour finalement définir deux méthodes qui permettent respectivement de récupérer une slice en lecture et en écriture, positionné au début du tuple demandé.
impl Pager {
pub fn write(&mut self, row_number: usize) -> &mut [u8] {
let Position {
page_number,
offset,
} = self.get_position(row_number);
// si la page n'existe pas elle est allouée à la volée
self.pages
.entry(page_number)
.or_insert_with(Page::new)
.get_mut(offset)
}
pub fn read(&self, row_number: usize) -> Option<&[u8]> {
let Position {
page_number,
offset,
} = self.get_position(row_number);
match self.pages.get(&page_number) {
None => None,
Some(page) => Some(page.get(offset)),
}
}
}On peut alors finalement modifier la table pour lui rajouter le Pager et lui retirer la slice de data.
pub struct Table {
pub(crate) schema: Schema,
row_number: usize,
pager: Pager,
}
impl Table {
pub fn new(schema: Schema) -> Self {
Self {
pager: Pager::new(schema.size()),
schema,
row_number: 0,
}
}
}Et finalement utiliser le pager dans nos commandes dâinsertions
impl Table {
pub fn insert(&mut self, row: &HashMap<String, Value>) -> Result<(), InsertionError> {
// récupération de la slice de données en écriture du tuple
let page = self.pager.write(self.row_number);
let mut writer = Cursor::new(page);
// appel à la sérialisation au travers du schéma
// la vérification est également faite à ce moment
self.schema
.serialize(&mut writer, row)
.map_err(InsertionError::Serialization)?;
// self.offset += writer.position() as usize;
self.row_number += 1;
Ok(())
}
}Et de sĂ©lection, pour le moment on dĂ©clare une erreur si la page nâexiste pas, dans lâavenir on dira que la donnĂ©e nâexiste pas.
impl Table {
pub fn select(&self) -> Result<Vec<Vec<Value>>, SelectError> {
let mut rows = Vec::with_capacity(self.row_number);
for row_number in 0..self.row_number {
let page = self.pager.read(row_number).ok_or(SelectError::PageNotExist(row_number))?;
let mut reader = Cursor::new(page);
rows.push(
// désérialisation par le schéma
self.schema
.deserialize(&mut reader)
.map_err(SelectError::Deserialization)?,
)
}
Ok(rows)
}
}Conclusion
Un article court et globalement technique, les tests unitaires nâont pas bougĂ© dâun poil car les modifications sont purement internes.
Mais le travail que nous avons faut va nous ouvrir la voie dans les prochaines parties sur plein dâoptimisations trĂšs intĂ©ressantes comme du cache LRU.
Dans la prochaine partie, nous verrons les index primaires et comment rĂ©cupĂ©rer de la donnĂ©es autrement quâen scannant toute la table !
Merci de votre lecture â€ïž
