Aujourd'hui, j'ai passé ma journée à améliorer les performances de mon script Python qui pousse les données dans ma base de données Postgres. J'insérais auparavant des enregistrements en tant que tels:
query = "INSERT INTO my_table (a,b,c ... ) VALUES (%s, %s, %s ...)";
for d in data:
cursor.execute(query, d)
J'ai ensuite réécrit mon script pour qu'il crée un fichier en mémoire qui est utilisé pour la commande COPY
de Postgres, qui me permet de copier les données d'un fichier vers ma table:
f = StringIO(my_tsv_string)
cursor.copy_expert("COPY my_table FROM STDIN WITH CSV DELIMITER AS E'\t' ENCODING 'utf-8' QUOTE E'\b' NULL ''", f)
La méthode COPY
était incroyablement plus rapide .
METHOD | TIME (secs) | # RECORDS
=======================================
COPY_FROM | 92.998 | 48339
INSERT | 1011.931 | 48377
Mais je ne trouve aucune information expliquant pourquoi? En quoi cela fonctionne-t-il différemment d'un multiligne INSERT
de telle sorte qu'il le rend tellement plus rapide?
Voir aussi ce benchmark :
# original
0.008857011795043945: query_builder_insert
0.0029380321502685547: copy_from_insert
# 10 records
0.00867605209350586: query_builder_insert
0.003248929977416992: copy_from_insert
# 10k records
0.041108131408691406: query_builder_insert
0.010066032409667969: copy_from_insert
# 1M records
3.464181900024414: query_builder_insert
0.47070908546447754: copy_from_insert
# 10M records
38.96936798095703: query_builder_insert
5.955034017562866: copy_from_insert
Il y a un certain nombre de facteurs à l'œuvre ici:
COMMIT
coûts, si pour les personnes faisant un commit par insert (vous ne l'êtes pas)COPY
- optimisations spécifiques pour le chargement en masseSi le serveur est distant, vous pourriez "payer" un "prix" à temps fixe par relevé de, disons, 50 ms (1/20e de seconde). Ou bien plus pour certaines bases de données hébergées dans le cloud. Étant donné que l'insertion suivante ne peut pas commencer avant que la dernière ne se termine avec succès, cela signifie que votre maximum taux d'insertions est de 1000/round-trip-latency-in-ms lignes par seconde. À une latence de 50 ms ("temps de ping"), c'est 20 lignes/seconde. Même sur un serveur local, ce délai n'est pas nul. Alors que COPY
remplit juste les fenêtres d'envoi et de réception TCP, et diffuse les lignes aussi rapidement que la base de données peut les écrire et que le réseau peut les transférer. Il n'est pas affecté par la latence beaucoup, et pourrait insérer des milliers de lignes par seconde sur la même liaison réseau.
Il y a aussi des coûts pour analyser, planifier et exécuter une instruction dans PostgreSQL. Il doit prendre des verrous, ouvrir des fichiers de relations, rechercher des index, etc. COPY
essaie de faire tout cela une fois, au début, puis se concentre uniquement sur le chargement des lignes aussi vite que possible.
Il y a des coûts de temps supplémentaires dus au fait que le système d'exploitation doit basculer entre postgres en attente d'une ligne pendant que votre application se prépare et l'envoie, puis votre application attend la réponse de postgres pendant que postgres traite la ligne. Chaque fois que vous passez de l'un à l'autre, vous perdez un peu de temps. Plus de temps est potentiellement perdu à suspendre et à reprendre divers états de noyau de bas niveau lorsque les processus entrent et sortent des états d'attente.
En plus de tout cela, COPY
a quelques optimisations qu'il peut utiliser pour certains types de charges. S'il n'y a pas de clé générée et que les valeurs par défaut sont des constantes par exemple, il peut les pré-calculer et contourner complètement l'exécuteur, chargeant rapidement les données dans la table à un niveau inférieur qui ignore entièrement une partie du travail normal de PostgreSQL. Si vous CREATE TABLE
ou TRUNCATE
dans la même transaction que vous COPY
, il peut faire encore plus d'astuces pour accélérer le chargement en contournant la comptabilité normale des transactions nécessaire dans une base de données multi-clients.
Malgré cela, COPY
de PostgreSQL pourrait encore faire beaucoup plus pour accélérer les choses, des choses qu'il ne sait pas encore faire. Il pourrait ignorer automatiquement les mises à jour d'index puis reconstruire les index si vous modifiez plus d'une certaine proportion de la table. Il pourrait effectuer des mises à jour d'index par lots. Beaucoup plus.
Une dernière chose à considérer est d'engager les coûts. Ce n'est probablement pas un problème pour vous car psycopg2
par défaut, l'ouverture d'une transaction et la non-validation jusqu'à ce que vous le lui disiez. Sauf si vous lui avez dit d'utiliser l'autocommit. Mais pour de nombreux pilotes de base de données, la validation automatique est la valeur par défaut. Dans de tels cas, vous feriez un commit pour chaque INSERT
. Cela signifie une vidange de disque, où le serveur s'assure qu'il écrit toutes les données en mémoire sur le disque et indique aux disques d'écrire leurs propres caches dans un stockage persistant. Cela peut prendre un temps long et varie beaucoup en fonction du matériel. Mon ordinateur portable NVMe BTRFS basé sur SSD ne peut effectuer que 200 fsyncs/seconde, contre 300 000 écritures non synchronisées/seconde. Il ne chargera donc que 200 lignes/seconde! Certains serveurs ne peuvent faire que 50 fsyncs/seconde. Certains peuvent en faire 20 000. Donc, si vous devez valider régulièrement, essayez de charger et de valider par lots, de faire des insertions sur plusieurs lignes, etc. Parce que COPY
ne fait qu'une seule validation à la fin, les coûts de validation sont négligeables. Mais cela signifie également que COPY
ne peut pas récupérer des erreurs en cours de route dans les données; il annule toute la charge en vrac.
La copie utilise le chargement en bloc, ce qui signifie qu'elle insère plusieurs lignes à chaque fois, tandis que l'insertion simple effectue une insertion à la fois, mais vous pouvez insérer plusieurs lignes avec l'insertion en suivant la syntaxe:
insert into table_name (column1, .., columnn) values (val1, ..valn), ..., (val1, ..valn)
pour plus d'informations sur l'utilisation de la charge en bloc, reportez-vous par exemple à Le moyen le plus rapide pour charger 1m de lignes en postgresql par Daniel Westermann .
la question du nombre de lignes que vous devez insérer à la fois dépend de la longueur de la ligne, une bonne règle est d'insérer 100 lignes par instruction d'insertion.
Effectuez des INSERT dans une transaction pour accélérer.
Test en bash sans transaction:
> time ( for((i=0;i<100000;i++)); do echo 'INSERT INTO testtable (value) VALUES ('$i');'; done ) | psql root | uniq -c
100000 INSERT 0 1
real 0m15.257s
user 0m2.344s
sys 0m2.102s
Et avec transaction:
> time ( echo 'BEGIN;' && for((i=0;i<100000;i++)); do echo 'INSERT INTO testtable (value) VALUES ('$i');'; done && echo 'COMMIT;' ) | psql root | uniq -c
1 BEGIN
100000 INSERT 0 1
1 COMMIT
real 0m7.933s
user 0m2.549s
sys 0m2.118s