écrits/blog/2026/07
Blog13 juil. 2026·6 min

PostgreSQL 18 en production : E/S async et UUIDv7

PostgreSQL 18 apporte les E/S asynchrones, UUIDv7, le skip scan B-tree et les colonnes virtuelles. Guide pratique de ce qui accélère vraiment votre base.

La plupart des versions de Postgres sont une liste polie d'améliorations incrémentales. PostgreSQL 18 n'est pas de celles-là. Elle change la façon dont le moteur dialogue avec votre disque, vous donne un type de clé primaire qui cesse de fragmenter vos index, et supprime discrètement toute une catégorie d'index que vous n'avez plus besoin de créer.

Si vous exploitez une base Postgres derrière une application Next.js, un backend Laravel ou un agent IA qui la martèle de recherches vectorielles, trois de ces changements apparaîtront dans vos graphiques de latence. Voici lesquels, comment les activer, et lesquels ignorer.

Les E/S asynchrones : le changement majeur

Depuis toujours, PostgreSQL lisait les données du disque un appel bloquant à la fois. Il demandait une page au système d'exploitation, attendait, la recevait, puis demandait la suivante. L'OS essayait d'aider en anticipant — le readahead — mais l'OS ne sait pas à quoi ressemble un parcours de B-tree, et ses suppositions restaient médiocres.

PostgreSQL 18 introduit de véritables E/S asynchrones en lecture. Le backend peut désormais émettre plusieurs requêtes de lecture simultanément et continuer à travailler pendant que le noyau les remplit. Sur les parcours séquentiels et les bitmap heap scans, les gains rapportés vont de 2x à 3x, et l'effet est maximal là où ça fait le plus mal aujourd'hui : stockage réseau, volumes block cloud, et tout contexte où une lecture unique coûte une vraie latence.

Le contrôle passe par io_method :

-- Vérifier la configuration actuelle
SHOW io_method;
 
-- Trois options :
--   sync    : l'ancien comportement bloquant
--   worker  : processus d'E/S dédiés (la valeur par défaut en 18)
--   io_uring: ring buffers noyau directs (Linux 5.1+, option de compilation)

worker est la valeur par défaut et fonctionne partout. Elle confie les requêtes de lecture à un pool de processus d'arrière-plan dimensionné par io_workers. Sur Linux avec un noyau récent, et si Postgres a été compilé avec le support io_uring, io_method = io_uring contourne entièrement les processus workers et parle au noyau via des ring buffers partagés : moins de changements de contexte, moins de surcoût par lecture.

# postgresql.conf — un point de départ raisonnable sur du NVMe cloud
io_method = worker
io_workers = 3
effective_io_concurrency = 16   # valait 1 par défaut dans les versions antérieures

Le paramètre que tout le monde oublie, c'est effective_io_concurrency. Il indique au planificateur combien de lectures concurrentes le stockage peut absorber. Historiquement il valait 1, ce qui, sur un SSD, est un mensonge qui vous coûte du débit réel. Maintenant que les E/S asynchrones sont réellement implémentées, ce réglage signifie enfin ce que son nom promet. Augmentez-le, mesurez, et observez vos parcours séquentiels.

Une mise en garde avant de vous précipiter : les E/S asynchrones de la version 18 couvrent les lectures, pas les écritures. Si votre charge est dominée par les écritures — insertions massives, file de jobs saturée — cette version n'y changera rien, et les gains décrits dans les articles n'apparaîtront pas sur votre tableau de bord.

UUIDv7 : la clé primaire que vous auriez dû utiliser

Si vous générez vos clés primaires avec uuid_generate_v4() ou côté application avec crypto.randomUUID(), vous payez une taxe que vous n'avez peut-être jamais mesurée.

Les UUID aléatoires sont, par conception, répartis uniformément dans l'espace des clés. Chaque insertion atterrit dans une zone différente du B-tree. Cela signifie des écritures de pages aléatoires, une mauvaise localité de cache, des éclatements de pages, et un index qui grossit plus vite qu'il ne le devrait. C'est la raison classique pour laquelle les équipes finissent par abandonner les UUID et revenir au bigserial.

UUIDv7 corrige cela sans renoncer à l'unicité globale. Les 48 premiers bits sont un horodatage en millisecondes ; le reste est aléatoire. Les lignes insérées au même moment se retrouvent côte à côte dans l'index — la même localité qu'un entier auto-incrémenté — tout en restant sûres à générer côté client, dans un worker de file, ou sur plusieurs shards sans coordination.

PostgreSQL 18 l'intègre nativement, sans extension :

CREATE TABLE commandes (
  id          uuid PRIMARY KEY DEFAULT uuidv7(),
  client_id   uuid NOT NULL,
  total_cents bigint NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now()
);
 
INSERT INTO commandes (client_id, total_cents)
VALUES (uuidv7(), 4500)
RETURNING id;

Deux bonus à connaître. Vous pouvez extraire l'horodatage de la clé, ce qui fait de la clé elle-même un created_at gratuit pour vos requêtes par plage :

SELECT id, uuid_extract_timestamp(id) AS creation
FROM commandes
ORDER BY id DESC
LIMIT 10;

Et uuidv7() accepte un décalage par intervalle, réellement utile pour les backfills et les jeux de tests où il faut des identifiants qui se trient dans le passé :

SELECT uuidv7(INTERVAL '-1 day');

Le compromis n'est pas gratuit : un UUIDv7 divulgue la date de création approximative à quiconque le voit. Pour un identifiant de commande dans un back-office interne, c'est sans conséquence. Pour un jeton de réinitialisation de mot de passe ou un lien de partage public, non — gardez-les en v4.

Skip scan B-tree : un index dont vous n'avez plus besoin

Imaginons cet index, construit pour une requête qui filtre d'abord sur le locataire :

CREATE INDEX idx_events ON events (tenant_id, event_type, created_at);

Puis arrive une nouvelle requête qui filtre uniquement sur event_type. Sous PostgreSQL 17 et antérieur, cet index composite était inutile au planificateur, car la colonne de tête n'était pas dans le prédicat. Il ne restait qu'un parcours séquentiel ou un second index — plus de disque, écritures plus lentes, plus de maintenance.

PostgreSQL 18 ajoute le skip scan aux index B-tree. Quand la colonne de tête a une faible cardinalité — une liste de locataires, un statut, un code pays — le planificateur peut désormais parcourir chaque valeur distincte de tête et sauter directement aux sections correspondantes en dessous :

-- Peut désormais utiliser idx_events, même sans tenant_id dans le WHERE
EXPLAIN ANALYZE
SELECT * FROM events
WHERE event_type = 'checkout_completed'
  AND created_at > now() - INTERVAL '7 days';

Conséquence pratique : allez auditer vos index redondants. Beaucoup d'équipes traînent deux ou trois index composites qui se chevauchent, uniquement pour couvrir différentes colonnes de tête. Certains peuvent maintenant être supprimés, ce qui accélère chaque écriture sur la table.

La réserve concerne la cardinalité. Le skip scan gagne quand la colonne de tête a peu de valeurs distinctes. Si votre colonne de tête est user_id avec deux millions de valeurs distinctes, les parcourir toutes n'est pas moins cher qu'un parcours séquentiel, et le planificateur refusera à juste titre.

Colonnes générées virtuelles, et un changement de défaut qui va vous piéger

Les colonnes générées existaient avant la 18, mais elles étaient toujours STORED : calculées à l'écriture et physiquement stockées. PostgreSQL 18 ajoute les colonnes générées VIRTUAL, calculées à la lecture et n'occupant aucun espace disque.

CREATE TABLE utilisateurs (
  prenom    text NOT NULL,
  nom       text NOT NULL,
  nom_complet text GENERATED ALWAYS AS (prenom || ' ' || nom) VIRTUAL
);

Voici le piège : dans PostgreSQL 18, VIRTUAL est la valeur par défaut si vous omettez le mot-clé. Si vos fichiers de migration contiennent GENERATED ALWAYS AS (...) sans préciser STORED, leur comportement change à la montée de version : vous perdez la colonne physique, et vous perdez la possibilité de l'indexer. Passez vos migrations au peigne fin avant la mise à niveau, et soyez explicite :

-- Soyez explicite. Toujours.
prix_ttc numeric GENERATED ALWAYS AS (prix * 1.19) STORED;

Utilisez STORED quand vous devez indexer la colonne ou quand l'expression est coûteuse. Utilisez VIRTUAL quand la valeur est peu coûteuse à calculer et que vous voulez simplement la voir dans votre SELECT *.

Les améliorations de confort

Quelques changements plus modestes qui compteront au quotidien :

RETURNING voit les deux versions d'une ligne modifiée. Auparavant vous obteniez la nouvelle ligne. Maintenant vous obtenez les deux, ce qui élimine toute une catégorie d'aller-retours lecture-modification-écriture :

UPDATE comptes SET solde = solde - 100
WHERE id = $1
RETURNING old.solde AS avant, new.solde AS apres;

pg_upgrade conserve vos statistiques de planification. À chaque montée de version majeure précédente, on terminait la migration puis on restait assis dans une base lente et mal planifiée jusqu'à ce qu'ANALYZE ait fini de parcourir chaque table. PostgreSQL 18 transporte les statistiques à travers la mise à niveau. Sur une grosse base, cela transforme à soi seul une fenêtre de maintenance angoissante en fenêtre ennuyeuse.

NOT NULL ... NOT VALID. Vous pouvez ajouter une contrainte NOT NULL sans parcours complet de la table, puis la valider plus tard, hors des heures de pointe :

ALTER TABLE foo ADD CONSTRAINT nom_nn NOT NULL nom NOT VALID;
-- ...plus tard, en heures creuses :
ALTER TABLE foo VALIDATE CONSTRAINT nom_nn;

L'authentification OAuth 2.0 est désormais intégrée, ce qui compte si vous abandonnez les mots de passe de base de données à longue durée de vie au profit de jetons courts émis par votre fournisseur d'identité.

Faut-il migrer ?

Migrez si : votre charge est dominée par les lectures sur du stockage cloud (les E/S asynchrones sont un gain réel), vous utilisez des UUID aléatoires comme clés primaires (UUIDv7 justifie à lui seul la migration), ou vous entretenez une pile d'index composites qui se chevauchent (le skip scan vous permet d'en supprimer).

Attendez si : votre charge est dominée par les écritures, vous dépendez d'extensions non encore certifiées pour la 18, ou vous avez des colonnes générées en production sans les avoir auditées face au changement de défaut VIRTUAL.

Pour ceux qui utilisent Postgres comme socle de fonctionnalités IA — embeddings dans pgvector, mémoire d'agents, files de jobs — les E/S asynchrones sont le premier point à tester. La recherche de similarité vectorielle est dominée par les lectures et les parcours : exactement la forme de charge pour laquelle cette version a été conçue. Si vous choisissez encore votre base vectorielle, notre comparatif des bases vectorielles détaille où pgvector gagne et où il perd, et notre guide Drizzle contre Prisma couvre la couche ORM posée par-dessus.

Mesurez sur un réplica, sur vos données, avec vos requêtes. Les 3x sont réels, mais ce sont les 3x de quelqu'un d'autre.


Besoin d'aide pour planifier une montée de version PostgreSQL ou optimiser une base qui a dépassé ses réglages par défaut ? Parlez à l'équipe Noqta.