Les risques des clés primaires UUID dans SQLite
(andersmurphy.com)- L’implémentation des clés primaires dans SQLite produit un ordre de stockage physique différent entre les tables
rowidclassiques et les tablesWITHOUT ROWID, et l’utilisation de UUID4 aléatoires comme index clusterisé entraîne un rééquilibrage du B-tree et des coûts de pagination supplémentaires - La référence avec un
rowidentier atteint environ 1 million d’insertions par seconde par lots de 1 million de lignes, tandis que UUID4 WITHOUT ROWID enregistre des temps d’insertion 14 à 16 fois plus lents - Le caractère non ordonné d’UUID4 provoque des insertions de lignes aléatoires dans le B-tree, et le profilage montre davantage de temps passé à l’équilibrage de l’arbre ainsi qu’aux lectures et écritures
- UUID7 WITHOUT ROWID réduit les problèmes de tri d’UUID4 grâce à son ordre temporel et affiche des temps d’insertion plus raisonnables, mais reste plus lent qu’une clé entière de 8 octets car la clé BLOB fait 16 octets
- UUID4 WITH ROWID profite du caractère séquentiel du
rowidcaché, mais l’amplification d’écriture due aux deux index et le coût des insertions aléatoires dans l’index demeurent, ce qui le rend moins performant que UUID7 WITHOUT ROWID
Qu’est-ce qu’un index clusterisé ?
- Un index clusterisé détermine l’ordre de stockage physique des lignes d’une table
- Comme les lignes ne peuvent être physiquement triées que d’une seule manière, chaque table ne peut avoir qu’un seul index clusterisé
- L’index clusterisé est la table elle-même, tandis qu’un index non clusterisé ne stocke que les colonnes indexées et un pointeur vers l’emplacement des données réelles
Rowid
- Toutes les tables SQLite classiques possèdent une clé primaire entière implicite sur 64 bits appelée
rowid - Les données de la table sont stockées dans une structure B-tree avec une entrée par ligne, en utilisant la valeur
rowidcomme clé - En pratique,
rowidest l’index clusterisé de SQLite, et l’ordre de stockage physique des lignes suit l’ordre desrowid
WITHOUT ROWID
- SQLite prend en charge les tables
WITHOUT ROWID, qui n’ont pas derowidimplicite - Dans une table
WITHOUT ROWID, la clé primaire déclarée joue le rôle d’index clusterisé - Les tables SQLite
rowidsont implémentées sous forme de B*-tree où tout le contenu est stocké dans les feuilles, tandis que les tablesWITHOUT ROWIDutilisent un B-tree classique qui stocke du contenu à la fois dans les feuilles et dans les nœuds intermédiaires
Référence : clé primaire entière rowid
- La référence mesure le temps d’insertion par lots de 1 million de lignes dans une table
rowidclassique de structureid INTEGER PRIMARY KEY, data BLOB - Le tableau de résultats couvre un total de 10 millions à 100 millions de lignes, avec des temps mesurés allant de 692 ms à 838 ms
- La performance de référence se situe autour d’environ 1 million d’insertions par seconde
UUID4 WITHOUT ROWID
- Le test UUID4 insère des valeurs
random-uuid4-bytescomme clé primaire dans une tableWITHOUT ROWIDde structureid BLOB PRIMARY KEY, data BLOB - Dans le tableau de résultats, le temps mesuré est de 2649 ms à 10 millions de lignes et de 12586 ms à 100 millions de lignes
- Les performances d’insertion sont 14 à 16 fois plus lentes que la référence avec
rowidentier
Profilage
- Le diffgraph normalisé compare les instantanés de profilage de INTEGER et UUID4, et affiche les différences sous forme de flamegraph
- La vue normalisée ajuste le nombre total d’échantillons des deux profils pour visualiser les écarts relatifs en pourcentage
- Les cadres bleus indiquent que, dans le second profil UUID4, le temps passé dans la fonction correspondante a diminué par rapport à INTEGER, tandis que les cadres rouges indiquent qu’il a augmenté dans UUID4
- L’intensité de la couleur représente la variation relative du nombre d’échantillons du cadre lui-même, c’est-à-dire la variation du self time
- Le diffgraph montre davantage de temps consacré au rééquilibrage de l’arbre ainsi qu’aux lectures et écritures
- En raison du caractère non ordonné d’UUID4, les clés sont disposées dans un ordre aléatoire, ce qui oblige SQLite à rééquilibrer en permanence le B-tree
UUID7 WITHOUT ROWID
- UUID7 est un UUID ordonné dans le temps, ce qui permet d’éliminer les problèmes de tri d’UUID4
- Le test UUID7 est lui aussi exécuté sur une table
WITHOUT ROWIDde structureid BLOB PRIMARY KEY, data BLOB - Dans le tableau de résultats, le temps mesuré est de 1372 ms à 10 millions de lignes et de 1258 ms à 100 millions de lignes
- UUID7 WITHOUT ROWID revient à des valeurs plus raisonnables qu’UUID4 WITHOUT ROWID, mais reste malgré tout plus lent que la référence
- Une clé primaire UUID en BLOB fait 16 octets, contre 8 octets pour une clé primaire entière
UUID4 WITH ROWID
- Le test UUID4 WITH ROWID utilise une table
id BLOB PRIMARY KEY, data BLOBsansWITHOUT ROWID - Dans cette configuration, le
rowidcaché est l’index clusterisé, et son avantage est d’être séquentiel - L’inconvénient est que la table se retrouve avec deux index, d’où une amplification d’écriture
- Dans le tableau de résultats, le temps mesuré est de 2003 ms à 10 millions de lignes et de 7119 ms à 100 millions de lignes
- UUID4 WITH ROWID n’est pas aussi performant que UUID7 WITHOUT ROWID, car même sans être l’index clusterisé, l’index doit toujours être construit via des insertions aléatoires
Conclusion
- Dans SQLite, les clés primaires UUID sont un choix dont l’impact sur les performances d’insertion peut fortement varier selon l’index clusterisé et l’ordonnancement des clés
- Le problème des UUID aléatoires ne se limite pas à SQLite et s’étend aussi à d’autres bases de données utilisant des index clusterisés
- L’ensemble du code du benchmark est disponible dans le dépôt GitHub
1 commentaires
Avis sur Lobste.rs
Bien. Il serait aussi intéressant de voir les chiffres quand, dans une table rowid, la clé entière n'est pas monotone croissante mais aléatoire
Un point important manquant dans l'article est que l'index primaire d'une table rowid est un arbre B+, alors qu'une table
without rowidutilise un arbre BDonc, en général, dès que la taille moyenne des enregistrements dépasse un certain seuil, la seconde option n'est pas idéale. En effet, les nœuds internes de l'index stockent l'enregistrement complet et, si je me souviens bien, le manuel de SQLite donne comme règle empirique 1/20 de la taille de page
Ils ont mis tant d'efforts à mesurer les performances des UUID, mais ils n'ont pas pris en compte les clés naturelles
Qu'il s'agisse d'entiers, d'UUID ou d'une autre forme, les clés de substitution ajoutent de la complexité, n'apportent aucune information et masquent des erreurs de normalisation
L'auteur dit qu'il utilise des UUID pour « éviter les doublons », mais ce n'est pas une raison. Dans toute base de données, chaque ligne de chaque table doit avoir au moins une clé, c'est-à-dire un ensemble de colonnes qui identifie cette ligne de manière unique
Une base de données sans une telle clé contient des informations dupliquées et est vulnérable aux incohérences logiques. Si cette clé existe déjà, il n'y a pas besoin de clé de substitution. Si une clé naturelle existe déjà et est bien imposée, l'argument « il en faut pour les performances » implique un coût supplémentaire et une complexité inutile, donc il doit être démontré
Une valeur qui semble unique à première vue ne l'est souvent pas autant qu'on l'espérait, ou bien une valeur qu'on pensait immuable doit finalement changer
À l'inverse, avec une clé de substitution, je peux définir l'identité à l'intérieur de mon système sans dépendre de la manière dont quelqu'un d'autre a défini l'identifiabilité, ou plus souvent ne l'a pas vraiment définie
Il y a des exceptions. Si je définis moi-même tout le modèle et que les données ne viennent pas de ce qu'on appelle le monde réel, une clé naturelle a plus de sens. Mais quand on conçoit un schéma pour des données réelles qui n'ont probablement jamais été totalement normalisées, les clés de substitution sont souvent utiles
Le simple fait de pouvoir référencer n'importe quelle ligne par un entier simplifie énormément l'accès, et c'est plus facile à mémoriser pour les humains comme à utiliser dans les requêtes
Si elle est monotone croissante, elle contient aussi une information en elle-même. C'est une information redondante, mais c'est un fait
Les performances en lecture sont également optimisées. C'est presque le cas idéal pour l'indexation avec des arbres B, des bitmaps, etc.
À mon avis, si les gens utilisent surtout des UUID, c'est en grande partie par confusion. En général, l'idée est d'obfusquer la clé et de la rendre imprévisible, mais j'ai renoncé à comprendre pourquoi ils pensent qu'il faut imposer cela jusqu'à la clé primaire, au lieu d'utiliser un identifiant séparé pour cet objectif
UUID version 7 contient un horodatage de 48 bits au début, donc il ne présente pas ce type de distribution aléatoire. Cela devrait donc aussi réduire la pagination excessive et les rééquilibrages
Les gens utilisent vraiment des UUID comme clés primaires ? Quand un UUID est nécessaire, quel est l'avantage à faire cela plutôt que d'en faire une clé secondaire ?
Les GUID et les UUID résolvent structurellement ce problème
Les v1 et v6 encodent un identifiant de machine et un horodatage, donc ils se rapprochent d'entiers auto-incrémentés avec un espace de noms par machine
Une grande partie de la confusion vient du fait que beaucoup supposent qu'un UUID est aléatoire. Ce n'est vrai que pour la v4 et, malheureusement, choisir la v4 a un coût
On a souvent besoin d'un certain degré de déterminisme, avec des versions comme v3, v5 ou v7, pour aider à organiser les données ou pour des garanties de performance et de collision