INT, UUID ou ULID : quel identifiant choisir en base de données ?
Par Louis-Arnaud Catoire
Le choix d'un identifiant primaire semble anodin. Pourtant, cette décision prise en début de projet irrigue l'ensemble de l'architecture : performances d'écriture, stratégie de sharding, sécurité des API, capacité à générer des identifiants sans coordination centralisée. Cet article pose les bases avec INT, UUID et ULID, puis approfondit les implications sur les index B-tree, les stratégies Doctrine, et enfin les contraintes architecturales des systèmes distribués à grande échelle.
INT : la simplicité séquentielle
Un entier auto-incrémenté reste le choix par défaut de la majorité des projets. Un INT occupe 4 octets, un BIGINT 8. Cette compacité a un impact direct sur les index B-tree : plus les clés sont petites, plus il y a d'entrées par page mémoire (typiquement 16 Ko sur InnoDB), et moins l'arbre est profond. Le résultat : des lectures en 2 à 3 niveaux de profondeur même sur des tables de plusieurs millions de lignes.
L'auto-incrémentation garantit des insertions séquentielles. Chaque nouvelle ligne s'ajoute à la fin de la dernière page de l'index clusterisé. Il n'y a ni page split, ni réorganisation de l'arbre. Sur MySQL InnoDB, où la clé primaire est aussi l'index clusterisé, c'est le scénario optimal pour les écritures.
Les limites apparaissent quand le projet grandit. Un INT non signé plafonne à 4,3 milliards de lignes, et la migration vers BIGINT sur une table volumineuse verrouille la table pendant de longues minutes. L'identifiant séquentiel expose aussi le volume de données : un GET /api/users/42 révèle qu'il y a au moins 42 utilisateurs, et un attaquant peut énumérer les ressources par simple incrémentation.
Le point le plus structurant concerne l'architecture : l'identifiant ne peut être généré qu'au moment de l'insertion, par le SGBD. Impossible de créer un identifiant côté applicatif avant le flush, ce qui pose problème dans les architectures événementielles où un agrégat doit connaître son identité dès sa construction.
INT dans Doctrine
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
La valeur reste null tant que l'entité n'a pas été persistée et flushée. C'est une contrainte forte dans un design DDD où l'identité de l'agrégat doit exister dès l'instanciation.
UUID : l'unicité universelle
Un UUID (Universal Unique Identifier, RFC 4122) est un identifiant de 128 bits représenté en 32 chiffres hexadécimaux. Plusieurs versions coexistent, mais deux dominent les usages actuels.
UUIDv4 est entièrement aléatoire. Sa force : l'unicité sans coordination. Sa faiblesse : la fragmentation des index B-tree. Chaque insertion atterrit à une position aléatoire dans l'arbre, provoquant des page splits fréquents. Sur InnoDB, où l'index clusterisé dicte l'organisation physique des données, cela signifie des écritures aléatoires sur disque, un taux de remplissage des pages qui tombe à 50-70 % au lieu de 90 %+, et une dégradation progressive du buffer pool.
UUIDv7 (RFC 9562) intègre un horodatage Unix en millisecondes dans les 48 premiers bits. Les insertions redeviennent quasi-séquentielles, éliminant le problème de fragmentation. UUIDv7 conserve la compatibilité avec l'écosystème UUID (format, parsing, stockage) tout en offrant un tri chronologique natif. Pour tout nouveau projet, c'est la version à privilégier.
40e6215d-b5c6-4896-987c-f30f3678f608
6ecd8c99-4036-403d-bf84-cf8400f67836
3f333df6-90a4-4fda-8dd3-9485d27cee36
Le coût structurel reste constant quelle que soit la version : 16 octets par clé, soit 4 fois plus qu'un INT. Cela se répercute sur chaque clé étrangère, chaque index secondaire, chaque jointure. Sur une table avec 5 index secondaires et 10 millions de lignes, la différence de taille d'index entre INT et UUID dépasse le gigaoctet.
UUID dans Doctrine
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
private ?Uuid $id;
Le composant Symfony Uid fournit le type Doctrine uuid et le générateur associé. Le stockage se fait en binaire (16 octets) via BINARY(16), jamais en VARCHAR(36) qui doublerait l'espace et dégraderait les comparaisons.
ULID : le compromis monotone
Un ULID (Universally Unique Lexicographically Sortable Identifier) encode 128 bits en 26 caractères Crockford Base32. Les 48 premiers bits portent un horodatage en millisecondes, les 80 suivants sont aléatoires. Cette structure garantit un tri lexicographique qui correspond à l'ordre chronologique.
01AN4Z07BY 79KA1307SR9X4MV3
|-------------| |----------------|
Timestamp Randomness
48bits 80bits
La monotonie des ULID résout le problème fondamental des UUIDv4. Les insertions suivent l'ordre chronologique, les pages de l'index B-tree se remplissent séquentiellement. Les benchmarks sur InnoDB montrent des performances d'insertion proches de INT : environ 6 500 lignes/seconde contre 8 000 pour INT et 3 000 pour UUIDv4 sur une table de 5 millions de lignes.
Le ULID partage avec UUIDv7 la même idée (horodatage + aléatoire), mais avec un encodage différent. UUIDv7 s'appuie sur un standard IETF (RFC 9562) et bénéficie du support natif des types uuid dans PostgreSQL et des outils existants. ULID offre un encodage plus compact en représentation textuelle (26 caractères contre 36 pour UUID) et une interopérabilité plus large hors de l'écosystème SQL.
ULID dans Doctrine
#[ORM\Id]
#[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.ulid_generator')]
private ?Ulid $id;
public function getId(): ?Ulid {
return $this->id;
}
Fragmentation B-tree : comprendre le mécanisme
Pour saisir la différence de performance entre identifiants aléatoires et séquentiels, il faut comprendre le fonctionnement d'un index B-tree. Un index B-tree organise les clés dans des pages de taille fixe (16 Ko sur InnoDB, 8 Ko sur PostgreSQL). Quand une page est pleine et qu'une nouvelle clé doit y être insérée, le moteur effectue un page split : la page est coupée en deux, et la moitié des clés est déplacée vers une nouvelle page.
Avec des clés séquentielles (INT, ULID, UUIDv7), les insertions se font toujours en fin d'arbre. Le page split ne se produit que lorsque la dernière page est pleine, et la nouvelle page commence vide. Le taux de remplissage moyen avoisine 90 %.
Avec des clés aléatoires (UUIDv4), chaque insertion peut cibler n'importe quelle page. Les page splits sont fréquents et produisent des pages à moitié vides. Le taux de remplissage tombe à 60-70 %, l'arbre devient plus profond, et le buffer pool du SGBD est moins efficace car il doit maintenir davantage de pages en mémoire pour couvrir le même volume de données.
Sur PostgreSQL, le mécanisme est atténué par le fill factor configurable et le TOAST pour les gros champs, mais le principe reste identique.
Génération d'identifiants en architecture distribuée
Dans un monolithe connecté à une seule base, l'auto-incrémentation suffit. Dès qu'on introduit plusieurs instances d'écriture, plusieurs bases, ou un bus de messages, la question de la génération d'identifiants devient architecturale.
L'auto-incrémentation ne fonctionne plus sans coordination. Les solutions historiques (séquences avec pas de N, tables de séquences centralisées) ajoutent un point de contention. Les identifiants UUID/ULID, générables sans état partagé, éliminent ce goulot.
Dans un contexte de sharding, l'identifiant influence directement la stratégie de répartition. Un INT auto-incrémenté global nécessite un coordinateur central (Snowflake ID chez Twitter, ou un service dédié). Un ULID ou UUIDv7, généré localement, permet à chaque noeud d'écrire indépendamment. L'horodatage intégré offre en prime un critère naturel de partitionnement temporel.
Pour les architectures CQRS/Event Sourcing, l'identifiant doit exister avant la persistance. L'agrégat reçoit son identité dès sa création, et les événements le référencent immédiatement. Seuls les identifiants générés côté applicatif (UUID, ULID) rendent ce pattern possible sans contorsion.
Migrer un système existant
La migration d'INT vers UUID ou ULID sur un système en production exige une approche progressive. Le principe : faire coexister temporairement les deux systèmes d'identifiants.
Pour une relation Parent/Enfant avec parent_id en INT, le processus se déroule en étapes distinctes, chacune correspondant à une migration Doctrine séparée :
#[ORM\Id]
#[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.ulid_generator')]
private ?Ulid $id;
public function getId(): ?Ulid {
return $this->id;
}
- Ajouter une colonne
new_id(ULID) sur la table Parent etnew_parent_idsur la table Enfant. - Générer les ULID par lots de 5 000 à 10 000 lignes via
new Ulid(), et propager la valeur du parent versnew_parent_idchez les enfants. - Créer la contrainte de clé étrangère entre
new_parent_idetnew_id. - Supprimer l'ancienne clé étrangère, l'ancienne clé primaire, puis les anciennes colonnes.
- Renommer
new_idenidetnew_parent_idenparent_id. Recréer la clé primaire.
Pièges de migration à anticiper
Sur des tables dépassant le million de lignes, chaque ALTER TABLE peut verrouiller la table. MySQL 8 supporte les DDL instantanés pour l'ajout de colonnes, mais pas pour la suppression ou le renommage. Des outils comme pt-online-schema-change ou gh-ost permettent des migrations sans verrouillage.
Les références externes constituent l'angle mort classique : files de messages en cours de traitement, caches Redis, identifiants stockés dans des systèmes tiers, URLs bookmarkées par les utilisateurs. Prévoyez une période de double-résolution où l'ancien INT et le nouveau ULID coexistent et restent résolvables.
Conclusion
Le choix d'un identifiant primaire n'est pas qu'une question de performance brute. C'est une décision architecturale qui engage la capacité du système à évoluer.
Pour un monolithe à volume modéré, INT reste imbattable en simplicité et en performance. Pour une API publique ou une architecture distribuée, ULID et UUIDv7 offrent le meilleur compromis : unicité sans coordination, tri chronologique natif, performances d'insertion proches du séquentiel. Le ULID brille par son encodage compact, UUIDv7 par son standard IETF et le support natif PostgreSQL.
La migration reste toujours possible. L'essentiel est de choisir en connaissance de cause, en mesurant les contraintes actuelles et en anticipant celles qui viendront avec la croissance du système.
Pour aller plus loin
- Arrivés au max des ID INT (2 147 483 647) — comment migrer vers BIGINT quand on atteint la limite
- Doctrine ORM 3.0 — les nouveautés de Doctrine pour la gestion des bases de données
- Symfony pour les moldus — découvrir les fondamentaux du framework Symfony
- Symfony Uid Component — composant Symfony pour générer et manipuler UUID et ULID
- RFC 4122 — UUID — spécification officielle des UUID
- GitHub — Symfony Uid — dépôt du composant Uid de Symfony
- ULID Specification — spécification officielle du format ULID