0

PostgreSQL 18 : des IO asynchrones performantes !

twitterlinkedinmail

 

Le 25 septembre dernier, PostgreSQL sortait sa toute dernière version, PostgreSQL 18.
Cette version apporte de nombreux correctifs mais aussi des nouveautés et des améliorations sur les performances globales.

Une nouveauté est particulièrement pertinente pour cette version. Il s’agit des “asynchronous IO” (AIO).

Présentation

Une lecture en mode synchrone se fait lorsque PostgreSQL demande au kernel l’accès à une page disque. PostgreSQL attend alors sa mise à disposition pour être traitée et montée en mémoire partagée.
L’OS écoute la requête de la part de PostgreSQL et ne peut anticiper les éventuelles demandes.
Dans ce contexte, PostgreSQL est très dépendant des performances disques avec d’éventuels “bottlenecks”.

Avec le mode asynchrone IO, l’OS est capable de traiter en parallèle les demandes faites par PostgreSQL et peut donc facilement travailler sur plusieurs requêtes en lecture.

 

Synchronous IO                                                                                                            Asynchronous IO

   

 

 

Un premier travail avait été fait avec les versions récentes de PostgreSQL afin de faire du “prefetch” de données via la méthode système “posix_fadvise“, ceci permettait d’anticiper les déclarations d’accès à certaines données ou pages de fichiers en déclarant un offset de départ et une longueur défini. Ceci évite des allers-retours sur le même fichier traitant des pages contiguës.
Malheureusement, avec PostgreSQL, ce mécanisme ne permet pas de monter les pages dans le “shared_buffer“, mais s’appuie sur le cache disque.

Avec les asynchronous IO, PostgreSQL est particulièrement performant lors des lectures séquentielles (seq scans, bitmap heap scan).
Un parallèle peut être fait avec le mécanisme d’offloading fait par Oracle Exadata et les “smart scans” montés directement vers la PGA.

 

Paramétrage

 

Avec la version 18, PostgreSQL met l’accent sur les lectures asynchrones avec de nouveaux paramètres.

io_method qui peut prendre 3 valeurs différentes

  • sync : exécute les IO en mode synchrone comme pour les versions PostgreSQL antérieures
  • worker : valeur par défaut. Les lectures se font via des processus en parallèles lancés par la requête parente (3 par défaut). Ces processus font des appels auprès du kernel et montent les données dans le “shared_buffers” pour être traitées.
  • io_uring : utilisation de la méthode “io_uring” qui consiste, depuis la version 5.1 du kernel Linux, à utiliser des “shared ring buffers”. Ce procédé utilise des “queue rings” entre PostgreSQL et le kernel , avec “completion queue” pour la partie kernel et “submission queue” géré pour les demandes PostgreSQL.
    Je vous invite à lire cet article pour plus de précisions sur ce mécanisme.

 

io_worker représentant le nombre de process en parallèle qui vont traiter les demandes de mise en cache des pages.
Par défaut, le nombre est de 3, mais nous pouvons monter au-delà.

effective_io_concurrency est déjà présent sur les anciennes versions de PostgreSQL. Cependant, dans le cas des IO asychrones (io_method=worker ou io_methode=io_uring), les IO se font en concurrence directement dans PostgreSQL.

io_combine_limit est la taille maximale d’IO.

 

Système

 

Vérifier que votre serveur Linux est compatible avec la méthode “io_uring”. Pour cela exécuter cette commande, qui doit vous renvoyer 0

 

$ cat /proc/sys/kernel/io_uring_disabled
0

Sinon, forcer la valeur à 0, sous “root”.

# echo 0 > /proc/sys/kernel/io_uring_disabled

 

La validation peut se faire également sur la configuration du kernel au démarrage

 # cat /boot/config-6.12.48+deb13-cloud-amd64 | grep -i io_uring
CONFIG_IO_URING=y

 

PostgreSQL

 

Pour utiliser le mode “io_method=io_uring“, il faudra compiler PostgreSQL 18 avec ce mode -> with-liburing.

Il faut donc récupérer les sources depuis le site officiel PostgreSQL

Puis configurer avec l’option suivante :

# ./configure --with-liburing
# make
# make install

 

Benchmark

 

Pour nos tests, nous utilisons l’outil “pgbench”.
Nous allons créer un jeu de test avec un facteur de 500 sur le nombre de lignes créées par défaut dans les tables. Notre plus grosse table “pgbench_accounts” devrait donc dépasser les 50M de lignes.

Attention, chaque test se fait avec données “à froid”. Le cache est vidé à chaque interrogation. C’est à la première exécution que le test est le plus représentatif car PostgreSQL n’a pas de pages dans son cache et doit donc solliciter le kernel.

 

$ pgbench -i pgbench -s 500
dropping old tables...
NOTICE: table "pgbench_accounts" does not exist, skipping
NOTICE: table "pgbench_branches" does not exist, skipping
NOTICE: table "pgbench_history" does not exist, skipping
NOTICE: table "pgbench_tellers" does not exist, skipping
creating tables...
generating data (client-side)...
vacuuming...
creating primary keys...
done in 42.40 s (drop tables 0.00 s, create tables 0.01 s, client-side generate 25.13 s, vacuum 3.35 s, primary keys 13.92 s).

 

La suite consiste à lancer un “SELECT COUNT” sur la table “pgbench_accounts” et relever les temps d’exécution entre les différentes versions de PostgreSQL et les différentes méthodes de lectures asynchrones.

 

PostgreSQL 17

$  select count(abalance) from "pgbench_accounts";
count
----------
50000000
(1 row)

Time: 48937.120 ms (00:48.937)

 

Nous mettons un peu plus de 49 secondes pour compter les 50M de lignes de la table.

le plan d’exécution est le suivant

$  explain (analyze, buffers) select count(abalance) from "pgbench_accounts";
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=1081089.88..1081089.89 rows=1 width=8) (actual time=69053.114..69053.219 rows=1 loops=1)
Buffers: shared hit=2518 read=817155
- Gather (cost=1081089.67..1081089.88 rows=2 width=8) (actual time=69051.505..69053.209 rows=3 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=2518 read=817155
- Partial Aggregate (cost=1080089.67..1080089.68 rows=1 width=8) (actual time=69021.569..69021.571 rows=1 loops=3)
Buffers: shared hit=2518 read=817155
- Parallel Seq Scan on pgbench_accounts (cost=0.00..1028006.33 rows=20833333 width=4) (actual time=4.259..41669.680 rows=16666667 loops=3)
Buffers: shared hit=2518 read=817155
Planning Time: 0.056 ms
Execution Time: 69053.251 ms

 

Le “parallel seq scan” sur “pgbench_accounts” est fait avec 2 workers en parallèle.

 

PostgreSQL 18

 

Avec la valeur “io_methode=sync“, nous retrouvons à peu près le même temps, même si notre serveur sur PostgreSQL 18 est peu plus puissant et dispose de plus de RAM.Le “shared_buffer” a été taillé en conséquence.

 

pgbench=# select count(abalance) from "pgbench_accounts";
count
----------
10000000
(1 row)

Time: 49704.009 ms (00:49.704)

 

le plan est à peu près le même avec nos 2 workers effectuant du “parallel seq scan” sur “pgbench_accounts”.

 

  - Parallel Seq Scan on public.pgbench_accounts (cost=0.00..205601.67 rows=4166667 width=4) (actual time=0.838..42937.512 rows=3333333.33 loops=3)
Output: aid, bid, abalance, filler
Buffers: shared read=163935
Buffers: shared read=54826

 

Avec “io_method=worker” = 3, là, nous gagnons déjà quelques secondes en terme de temps d’exécution puisque notre requête est proche des 40 sec.

Puis avec “io_method=io_uring“, c’est là que nous sommes le plus performant puisque nous descendons à moins de 25 secondes.

 

Nous obtenons les résultats suivants

 

 

 

A noter que pendant des opérations de lectures asynchrones, nous pouvons suivre l’évolution de celles ci via la vue ‘pg_aios” mise à disposition par PostgreSQL.

 

$  select * from pg_aios;
pid | io_id | io_generation | state | operation | off | length | target | handle_data_len | raw_result | result | target_desc | f_sync | f_localmem | f_buffered
-------+-------+---------------+-----------+-----------+-----------+--------+--------+-----------------+------------+---------+--------------------------------------------------+--------+------------+------------
41329 | 192 | 9233 | SUBMITTED | readv | 570818560 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200752..200767 in file "base/16440/16456" | f | f | t
41329 | 193 | 9290 | SUBMITTED | readv | 570556416 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200720..200735 in file "base/16440/16456" | f | f | t
41329 | 194 | 9239 | SUBMITTED | readv | 570949632 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200768..200783 in file "base/16440/16456" | f | f | t
41329 | 195 | 9229 | SUBMITTED | readv | 570687488 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200736..200751 in file "base/16440/16456" | f | f | t
41329 | 197 | 9225 | SUBMITTED | readv | 571473920 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200832..200847 in file "base/16440/16456" | f | f | t
41329 | 198 | 6372 | SUBMITTED | readv | 569638912 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200608..200623 in file "base/16440/16456" | f | f | t
41329 | 200 | 6420 | SUBMITTED | readv | 571080704 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200784..200799 in file "base/16440/16456" | f | f | t
41329 | 201 | 9247 | SUBMITTED | readv | 571211776 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200800..200815 in file "base/16440/16456" | f | f | t
41329 | 202 | 9248 | SUBMITTED | readv | 571604992 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200848..200863 in file "base/16440/16456" | f | f | t
41329 | 203 | 9221 | SUBMITTED | readv | 569769984 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200624..200639 in file "base/16440/16456" | f | f | t
41329 | 207 | 9223 | SUBMITTED | readv | 569507840 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200592..200607 in file "base/16440/16456" | f | f | t
41329 | 208 | 9226 | SUBMITTED | readv | 571736064 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200864..200879 in file "base/16440/16456" | f | f | t
41329 | 210 | 282085 | SUBMITTED | readv | 571342848 | 131072 | smgr | 16 | NULL | UNKNOWN | blocks 200816..200831 in file "base/16440/16456" | f | f | t
(13 rows)

 

Cette vue est alimentée via la fonction ‘pg_get_aios”.

Nous pouvons voir l’offset de la page sur laquelle la lecture pointe ainsi que la longueur de l’opération IO en cours.

Conclusion

Pour profiter de l’efficacité des lectures asynchrones, ne pas oublier de configurer la valeur de “io_method” à “worker” ou “io_uring 

A volumes équivalents, les plans d’exécutions nous permettent de voir que l’on traite un nombre de “shared read” similaire entre PostgreSQL 18 qu’avec PostgreSQL 17 mais avec un temps plus faible pour PostgreSQL 18.

Attention cependant, les gains sont effectifs sur les opérations de type “seq scan” ou “bitmap heap scan”. Pas de gain possible sur des opérations d’écritures.

En d’autres termes, la configuration Asynchrones IO sera parfaitement adaptée pour des requêtes décisionnelles avec datawarehouse volumineux.

Autre point à savoir, il a été relevé également des risques en terme de sécurité avec le mode io_uring configuré dans le kernel.
Certains sites comme celui-ci font états de potentiels processus de type “malwares” qui pourraient s’attaquer au kernel et mettre en péril la sécurité du serveur.

Sur l’article Wikipédia dédié à “io_uring“, il est d’ailleurs noté ->

“In June 2023, Google’s security team reported that 60% of the exploits submitted to their bug bounty program in 2022 were exploits of the Linux kernel’s io_uring vulnerabilities. As a result, io_uring was disabled for apps in Android, and disabled entirely in ChromeOS as well as Google servers.[11] Docker also consequently disabled io_uring from their default seccomp profile”

 

Bonne fin de journée !

Continuez votre lecture sur le blog :

twitterlinkedinmail

Emmanuel RAMI

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.