Mes notes sur la conception du schéma Postgres de GitLab
- En étudiant le schéma Postgres de GitLab, je voulais le comparer au schéma que je conçois et apprendre de bonnes pratiques à partir des définitions de schéma de GitLab.
- GitLab est une plateforme DevOps open source, alternative à GitHub et pouvant être auto-hébergée.
Utiliser le bon type de clé primaire
- Quand la base de données est petite, on ne le remarque pas, mais à mesure qu’elle grandit, la clé primaire impacte l’espace de stockage, la vitesse d’écriture et la vitesse de lecture.
- GitLab utilise
bigserialcomme type de clé primaire pour 380 des 573 tables,serial4pour 170, et des clés primaires composites pour les 23 restantes.
Utiliser des IDs internes et externes
- Il est recommandé de ne pas exposer la clé primaire au monde extérieur.
- GitLab utilise à la fois l’ID interne (
id) et l’ID externe (iid) dans des tables commeissues,ci_pipelines,deploymentsetepics.
Utiliser le type texte text avec des contraintes CHECK
- Le schéma de GitLab utilise à la fois
character varying(n)ettext, mais emploie plus souvent le typetext. - Le type
textn’impose pas de contrainte de longueur, et GitLab utiliseCHECKpour définir la contrainte de longueur.
Conventions de nommage
- Toutes les tables sont au pluriel et utilisent un préfixe de nom de module pour fournir un espace de noms.
- Les noms des tables et des colonnes suivent la convention
snake_case.
Gestion des fuseaux horaires des timestamps
- GitLab utilise à la fois
timestamp with timezoneettimestamp without timezone. - Les opérations système utilisent
timestamp without timezone, tandis que les actions utilisateur utilisenttimestamp with timezone.
Contraintes de clé étrangère
- GitLab applique des contraintes de clé étrangère sur la plupart des tables, mais pas sur certaines, comme
audit_events,abuse_reports,web_hooks_logsetspam_logs.
Partitionnement des grandes tables
- GitLab partitionne les tables qui peuvent devenir volumineuses afin d’améliorer les performances des requêtes.
Prise en charge des cas d’usage de recherche LIKE avec les trigrammes et gin_trgm_ops
- GitLab utilise des index GIN (Generalized Inverted Index) pour effectuer des recherches efficaces.
Utiliser jsonb
- Le schéma de GitLab utilise le type de données
jsonbdans plusieurs tables.
Autres conseils
- Les tables modifiables utilisent des champs d’audit comme
updated_at, tandis que les tables de logs immuables ne l’utilisent pas. - Les enums sont stockées en
smallintau lieu decharacter varyingpour économiser de l’espace.
GN⁺ :
- La conception du schéma de GitLab apporte des enseignements utiles pour la conception de base de données, notamment des leçons clés sur l’optimisation de schéma pour les systèmes à grande échelle.
- Comme GitLab est open source, ces décisions de conception de schéma fournissent des exemples pratiques que d’autres développeurs peuvent appliquer à leurs propres projets.
- Les points importants à retenir du schéma de GitLab sont qu’il faut considérer attentivement des éléments qui affectent fortement les performances et la maintenance de la base de données, comme le choix des types de données, la stratégie d’indexation, le partitionnement et l’usage des contraintes de clés étrangères.
1 commentaires
Avis sur Hacker News
Je me demande pourquoi cette pratique consistant à ne pas exposer les clés primaires à l’extérieur est nécessaire. Si les requêtes doivent de toute façon être authentifiées, je ne vois pas bien en quoi empêcher de deviner les ID a de la valeur
Si l’on peut faire quelque chose d’utile avec un ID deviné, sans authentification ni autorisation, c’est qu’une autre partie est déjà gravement cassée, et il faudrait se concentrer là-dessus plutôt que d’ajouter une complexité inutile au schéma. Il peut y avoir une certaine valeur à cacher des informations concurrentielles, par exemple empêcher un concurrent d’estimer le nombre de clients, mais je doute que GitLab s’en soit beaucoup préoccupé. La décision de GitLab autour de
id + iidsemble plus probablement motivée par des exigences de performance des requêtes que par la prévention de la devinette d’ID internesSi vous exposez un UUID aux utilisateurs, même avec la même faille, l’attaquant doit deviner l’UUID, ce qui est beaucoup plus difficile et peut nécessiter une source secondaire. Même s’il y a fuite de données, cela laisse du temps pour réagir et permet d’en estimer l’ampleur. À l’inverse, avec des ID séquentiels, le problème peut immédiatement devenir une fuite totale, et se transformer en incident majeur à déclaration obligatoire auprès des autorités de protection des données personnelles. C’est de la défense en profondeur qui ne devrait pas servir, mais il existe réellement des logiciels terribles qui ont été compromis de cette manière
Si l’on expose la clé primaire
idde la table des issues, elle ne commence pas à 1 lorsqu’on crée une issue dans un projet, ce qui permet d’estimer facilement combien d’issues existent sur l’ensemble de GitLabBien sûr, si quelque chose est possible sans authentification ni autorisation avec un simple ID deviné, c’est bien un gros problème. Mais si, à ce moment-là, il n’y a pas d’autres couches, la partie est déjà perdue. Les bugs ne préviennent pas à l’avance, surtout les bugs subtils. Le jour où un tel bug apparaît, si vous avez au moins rendu les ID impossibles à deviner, vous serez reconnaissant d’avoir évité que tous les comptes utilisateurs du système deviennent facilement accessibles
Bien entendu, il faut d’abord se concentrer sur la garantie d’un contrôle d’accès correct, mais des ID impossibles à deviner peuvent faire la différence entre une catastrophe majeure et un incident évité de justesse. Si les UUID ne conviennent pas, on peut aussi utiliser des ID de base de données auto-incrémentés et les chiffrer ; avec une couche logicielle appropriée, les ID chiffrés fonctionnent presque automatiquement
Sur les 128 millions de dépôts publics, la grande majorité sont des forks d’autres dépôts et n’existent que pour créer des pull requests vers le dépôt principal ; ils n’auront donc probablement pas d’issues, sauf erreur
Les petits projets jouets ou rapidement abandonnés sont aussi susceptibles de ne pas avoir d’issues, ou très peu. Il y a certainement des projets avec des centaines ou des milliers d’issues, mais la moyenne sur l’ensemble des 128 millions de dépôts est probablement assez basse et pourrait rester sous la limite de 2 milliards. Cela dit, je suis d’accord pour dire qu’utiliser un type 4 octets, plus précisément 31 bits, pour cette table est une bombe à retardement pour certaines organisations, y compris github.com
https://play.clickhouse.com/play?user=play#U0VMRUNUIHVuaXEoc...
Un besoin aussi basique que
PRIMARY KEY(repo_id, issue_id)devient inutilement complexe dans ActiveRecord, et il faut contourner le problème en utilisant à la fois une clé unique et une clé primaire séparée pour satisfaire l’exigence d’ActiveRecord d’avoir une seule colonne de clé primaire. Les clés primaires UUID ressemblent aussi à un contournement, mais la contrainte d’unicité sur la paire(repo_id, issue_id)reste nécessaire, ce qui augmente la taille de la base de données et l’overhead. Plus largement, je pense que la structure MVC monolithique de Ruby on Rails, fondée sur des couches modèle, contrôleur et vue uniques, pose aussi des problèmes de scalabilité et de maintenabilité à mesure que l’application grossit ; MVC me semble mieux adapté à des architectures modulaires ou à base de composantsLes produits proposant à la fois de l’auto-hébergement et du cloud ont souvent préféré les bases de données par client. Cela simplifie beaucoup les parties partagées du code, car elles peuvent utiliser les mêmes requêtes quel que soit le type d’hébergement. Avec une base par client, on s’approche rarement de ce genre de limite d’usage, et si c’est le cas, l’auto-hébergement serait probablement plus adapté
intversbigintest possible. Cela demande un peu de préparation et de code custom, mais on peut le faire sans interruption de serviceNous suivons globalement cette procédure pour gérer de grosses migrations, avec quelques adaptations : http://zemanta.github.io/2021/08/25/column-migration-from-in...
Les clés étrangères, index et contraintes compliquent tout le processus, mais ce n’est pas impossible. Dans mon cas, la migration des données a pris quelques heures, mais elle n’avait pas besoin d’être rapide. À ma connaissance, GitLab dispose d’outils qui exécutent des tâches post-mise à niveau afin que cela fonctionne à n’importe quel moment pendant une montée de version
L’argument sur la taille de stockage d’une colonne UUID est peu convaincant. Si une table contient 5 autres colonnes, la différence entre 128 bits et 64 bits n’est pas énorme.
La préoccupation la plus importante, c’est la performance. UUIDv4 est largement pris en charge, mais comme il est totalement aléatoire, il n’est pas idéal pour les performances des index. UUIDv7[0] est plus proche de Snowflake[1] et bénéficie d’une localité temporelle, mais ses implémentations sont moins répandues. Une autre approche consiste à utiliser
bigserialet à chiffrer la clé : https://github.com/abevoelker/gfc64Mais cette méthode a deux problèmes : 1) on ne peut pas faire tourner la valeur secrète, et 2) une fois qu’elle fuite, n’importe qui peut faire une estimation de Fermi de la taille de la table. Séparer l’ID public et l’ID interne est pénible, et si l’ID public est un UUIDv4, on sacrifie aussi les performances. À mon avis, UUIDv7 est la solution qui satisfait le plus de contraintes.
[0]: https://uuid7.com/
[1]: https://en.wikipedia.org/wiki/Snowflake_ID
idest utilisé comme clé étrangère, ainsi que la taille des index nécessaires sur ces colonnes de clés étrangères.Il suffit de penser à une valeur comme un ID utilisateur, qui peut être référencée par des dizaines, voire des centaines de clés étrangères dans toute la base de données.
Il y a trois niveaux de performance pour une base de données : 1) les index et les données tiennent tous deux en mémoire ; 2) les index tiennent en mémoire, mais pas les données ; 3) ni les index ni les données ne tiennent en mémoire. Le cas 1 est idéal, mais sinon il faut s’accrocher au cas 2 coûte que coûte. Doubler la taille des index rend cela plus difficile.
typedef void*. Sa taille influence les performances globales : usage mémoire et disque, goulets d’étranglement de débit, et même temps CPU passé à comparer les clés dans les boucles les plus internes des jointures et des recherches.À l’arrivée des CPU x86-64, l’impact sur les performances du passage aux pointeurs 64 bits était si important que x32/ilp32 ont été créés, et c’est aussi pour cela que .NET garde encore aujourd’hui “prefer 32-bit” comme valeur par défaut. Utiliser des UUID 128 bits comme clés primaires de base de données est une erreur terrible.
Par exemple, on peut vouloir mettre en ligne une vidéo un mois avant sa publication, sans que le public le sache.
Il est aussi possible de chiffrer la valeur lorsqu’on l’affiche dans une URL, un e-mail, etc. : https://wiki.postgresql.org/wiki/Pseudo_encrypt
Cela permet de conserver une grande partie des avantages des index séquentiels tout en rendant possible le changement de clé. En revanche, changer la clé casse les favoris, invalide les liens envoyés dans d’anciens e-mails et a, en pratique, le même effet que renommer tous les éléments.
C’est un détail, mais je veux revenir sur la section
textcontrevarchar.L’auteur passe beaucoup de temps à essayer de prouver une différence de performances qui n’existe pas, puis conclut qu’“il n’y a pas de grande différence de performances entre les deux types”. C’est un sujet tranché depuis longtemps : ce n’est pas “pas grande”, c’est “aucune”. Le wiki PostgreSQL[1] dit explicitement d’utiliser
textsauf très bonne raison de faire autrement, et la documentation[2] indique que “pour de nombreux usages,character varyingse comporte comme un domaine au-dessus detext”, puis affirme dans un encadré Tip vert qu’“il n’y a aucune différence de performances entre ces trois types”. Le fait que GitLab utilise surtouttextdonne donc l’impression qu’ils ont lu la documentation et conçu leur schéma pour PostgreSQL, plutôt que de produire un schéma vaguement “portable”.[1] https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use...
[2] https://www.postgresql.org/docs/current/datatype-character.h...
Passer de
varchar(300)àvarchar(200)oblige à réécrire toutes les lignes, alors que mettre à jour une contrainte sur une colonnetextest essentiellement presque gratuit et ne nécessite qu’un scan complet de la table pour vérifier que les valeurs existantes respectent la nouvelle contrainte. L’article indique aussi qu’utiliser le typetextavec une contrainteCHECKfacilite l’évolution du schéma par rapport àcharacter varyingouvarchar(n)lorsqu’il y a une vérification de longueur.L’affirmation selon laquelle les clés étrangères coûtent cher est souvent répétée, mais rarement étayée par des benchmarks.
Il y a beaucoup de façons de mal les implémenter, mais de toute façon, l’intégrité est bien imposée quelque part dans la stack. S’appuyer sur la base de données plutôt que la réimplémenter demande des connaissances et des expérimentations, et évite généralement de gros incidents.
Je me demande si quelqu’un a déjà compilé ou observé les différences de performances entre GitLab et GitHub.
Ce sont tous deux des applications basées sur Rails, mais, globalement, les temps de chargement des pages de GitLab me semblent catastrophiques comparés à ceux de GitHub.
Chrome et GitHub emploieront toutes sortes d’astuces, quitte à maltraiter les utilisateurs. Par exemple, il m’est arrivé d’ouvrir un diff de merge sur le GitHub de l’entreprise, de chercher avec
Ctrl F, de ne rien trouver, puis de passer au suivant en fouillant manuellement l’historique Git, pour découvrir seulement au 100e diff que le fichier le plus important était caché tout au fond. Sans doute parce que quelqu’un avait intérêt à atteindre les métriques de chargement de page et à faciliter sa promotion.Voici un billet d’un ancien développeur qui peut aider à comprendre la culture de GitLab et son peu d’attention aux performances : https://news.ycombinator.com/item?id=39303323
Je n’utilise pas assez GitLab pour ressentir moi-même ces problèmes de performance, mais je pense que ce texte peut être utile.
Je me suis toujours demandé ce que signifiait le I supplémentaire dans les variables CI
CI_PIPELINE_IIDetCI_MERGE_REQUEST_IID.Je supposais que c’était lié à un choix de base de données, et cet article le confirme.
Quand on voit que « 1 quintillion équivaut à 1 000 000 000 billions », il est assez étrange que nous choisissions en général seulement entre
int32etint64. Il faudrait sans doute un type entier sur 5 octets prenant en charge une cardinalité d’environ 1 billion.Ne pas utiliser d’ID auto-incrémentés peut être raisonnable, mais je ne vois pas bien l’intérêt d’avoir deux ID, un interne et un externe.
Cela augmente le nombre de colonnes et d’index, oblige toujours à faire une recherche préalable, et je n’imagine pas de scénario de sécurité où l’on changerait la clé interne sans changer la clé externe. Est-ce que je rate quelque chose ?
On dit qu’utiliser le type UUID v4 natif de PostgreSQL au lieu de
bigserialaugmente la taille des tables de 25 % et fait tomber le débit d’insertion à 25 % de celui debigserial; je me demande pourquoi UUIDv4 est si mauvais.Un UUID n’est-il pas simplement un nombre sur 128 bits ? Est-ce que son coût de génération est énorme, ou que se passe-t-il ?
L’indexation d’une colonne UUIDv4 devient donc plus lente, ce qui a motivé le développement d’UUIDv6 et UUIDv7.
Le plus gros problème est le débit d’insertion. Avec les UUID, ce débit est limité par la quantité de RAM disponible. Ce n’est pas le cas avec les entiers auto-incrémentés. Les entiers sont corrélés au temps, alors que les UUID4 sont aléatoires ; à grande échelle, les caractéristiques de performance changent donc fondamentalement. Sur de petites tables, la pénalité à l’insertion est presque négligeable, mais lorsque la taille de l’index B-tree atteint la limite de la mémoire, PostgreSQL ne peut plus garder tout le B-tree des UUID en mémoire et doit s’appuyer sur le remplacement de pages disque. Avec des entiers auto-incrémentés, les lignes proches dans le temps utilisent les mêmes pages d’index, donc il n’est pas nécessaire de toucher le disque sous la même charge. Une fois cette échelle atteinte, la différence n’est pas une dégradation constante de 25 %, mais un mur de performance d’un facteur 25 ; hors migration de schéma, la seule solution est d’acheter plus de RAM.
Les UUID provoquent beaucoup d’écritures non triées, ce qui entraîne un gonflement des pages.
La localité des insertions par lots se dégrade aussi lors des lectures ultérieures, car il faut retrouver les enregistrements liés de manière aléatoire. Au final, on paie le coût à la fois au moment de l’insertion et lors des sélections suivantes.