1 points par GN⁺ 2024-02-18 | 1 commentaires | Partager sur WhatsApp

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 bigserial comme type de clé primaire pour 380 des 573 tables, serial4 pour 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 comme issues, ci_pipelines, deployments et epics.

Utiliser le type texte text avec des contraintes CHECK

  • Le schéma de GitLab utilise à la fois character varying(n) et text, mais emploie plus souvent le type text.
  • Le type text n’impose pas de contrainte de longueur, et GitLab utilise CHECK pour 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 timezone et timestamp without timezone.
  • Les opérations système utilisent timestamp without timezone, tandis que les actions utilisateur utilisent timestamp 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_logs et spam_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 jsonb dans 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 smallint au lieu de character varying pour é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

 
GN⁺ 2024-02-18
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 + iid semble plus probablement motivée par des exigences de performance des requêtes que par la prévention de la devinette d’ID internes

    • C’est vrai, mais le fait que les ID puissent être devinés peut rendre une faille de sécurité horrible, ou l’aggraver considérablement
      Si 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
    • Comme l’article le dit aussi, c’est plus proche de l’information concurrentielle que de la sécurité. Un simple ID auto-incrémenté révèle le nombre total d’enregistrements d’une table, ou son taux de croissance
      Si l’on expose la clé primaire id de 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 GitLab
    • L’expression théâtre de la sécurité est beaucoup trop utilisée. La sécurité peut et doit être composée de plusieurs couches, et le fait qu’une couche comme l’authentification soit cassée ne devrait pas rendre le reste facilement accessible
      Bien 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
    • Le contrôle d’accès peut lui aussi contenir des bugs. Des ID impossibles à deviner rendent l’exploitation de certains de ces bugs beaucoup plus difficile
      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
    • C’est une nuance terminologique mineure, mais il me semble qu’il vaut mieux appeler cela information concurrentielle, plutôt que “business intelligence”, qui désigne généralement l’exploitation des données internes d’une entreprise. https://en.wikipedia.org/wiki/Competitive_intelligence
  • 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

    • Même aujourd’hui, on en est encore sous la limite, avec 362 107 148 dépôts et 818 516 506 issues et pull requests uniques
      https://play.clickhouse.com/play?user=play#U0VMRUNUIHVuaXEoc...
    • Je pense que la décision de GitHub de s’éloigner de Rails a aussi été en partie influencée par une grosse lacune d’ActiveRecord : l’absence de prise en charge des clés primaires composites
      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 composants
    • Je me demande si l’on sait avec certitude si GitLab Cloud utilise une base de données multi-tenant, ou des bases de données par utilisateur, client ou organisation
      Les 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é
    • Cette bombe à retardement est une bombe que l’on désamorce avec une migration de 11 secondes
    • Migrer une clé primaire de int vers bigint est possible. Cela demande un peu de préparation et de code custom, mais on peut le faire sans interruption de service
      Nous 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 bigserial et à chiffrer la clé : https://github.com/abevoelker/gfc64
    Mais 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

    • Le problème n’est pas seulement la taille de cette seule colonne, mais aussi tous les endroits où cet id est 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.
    • Le problème, c’est que les 5 autres colonnes ne sont pas indexé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.
    • On peut voir la clé primaire d’une base de données comme un pointeur primitif du genre 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.
    • UUIDv7 n’est pas non plus une solution miracle. Dans beaucoup de cas, on ne veut pas divulguer l’heure de création d’une ressource.
      Par exemple, on peut vouloir mettre en ligne une vidéo un mois avant sa publication, sans que le public le sache.
    • Il existe d’autres variantes de cette approche : https://pgxn.org/dist/permuteseq/
      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 text contre varchar.
    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 text sauf très bonne raison de faire autrement, et la documentation[2] indique que “pour de nombreux usages, character varying se comporte comme un domaine au-dessus de text”, 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 surtout text donne 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...

    • En pratique, lorsqu’il faut migrer le schéma pour suivre un changement de longueur des chaînes stockées, il y a une différence de performance notable.
      Passer de varchar(300) à varchar(200) oblige à réécrire toutes les lignes, alors que mettre à jour une contrainte sur une colonne text est 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 type text avec une contrainte CHECK facilite l’évolution du schéma par rapport à character varying ou varchar(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.

    • Quand j’utilisais GitLab il y a quelques années, les grosses pull requests souffraient de sérieux problèmes de performance côté client. GitHub n’est pas idéal non plus, mais il s’en sort à un niveau acceptable.
    • Comparer GitHub à GitLab, c’est un peu comme comparer Chrome à d’autres navigateurs, même à ceux basés sur Chromium.
      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.
    • GitHub est globalement stable et généralement assez rapide, à l’exception de deux pannes l’an dernier. Sinon, je n’utiliserais pas ses raccourcis clavier.
      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_IID et CI_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 int32 et int64. Il faudrait sans doute un type entier sur 5 octets prenant en charge une cardinalité d’environ 1 billion.

    • À moins d’emballer les valeurs au plus serré, choisir une taille qui n’est pas une puissance de deux n’a pas de sens.
  • 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 ?

    • Quand on fait quelque chose au niveau d’un projet, on dispose déjà de toute façon des informations nécessaires. Et il est plus convivial pour l’utilisateur que les tickets de chaque projet commencent à 1, plutôt qu’à un nombre du genre 2 700 300 571 325.
  • On dit qu’utiliser le type UUID v4 natif de PostgreSQL au lieu de bigserial augmente la taille des tables de 25 % et fait tomber le débit d’insertion à 25 % de celui de bigserial ; 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 ?

    • UUIDv4 est totalement aléatoire, et les index B-tree s’attendent à des valeurs « orientées vers la droite » avec un ordre raisonnable.
      L’indexation d’une colonne UUIDv4 devient donc plus lente, ce qui a motivé le développement d’UUIDv6 et UUIDv7.
    • L’augmentation de 25 % de la taille est réelle, mais c’est une petite hausse linéaire prévisible de 8 octets par ligne. Comparée au reste des données de la ligne, ce n’est pas si préoccupant.
      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.
    • Je pense que c’est à cause du B-tree. Les B-tree et les pages fonctionnent mieux quand seule la dernière page est fortement sollicitée.
      Les UUID provoquent beaucoup d’écritures non triées, ce qui entraîne un gonflement des pages.
    • Quand l’ordre de tri est réparti aléatoirement, la localité de cache du B-tree se dégrade. Les insertions ne vont pas vers la dernière page, mais se dispersent un peu partout.
      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.