Je vous propose de découvrir le compte rendu de la présentation de début 2016 de Vladimir Sitnikov "PostgreSQL et JDBC : tirons-en le meilleur parti"


Bonjour ! Je m'appelle Vladimir Sitnikov. Je travaille depuis 10 ans chez NetCracker et je me consacre principalement Ă la performance. Tout ce qui touche Ă Java et tout ce qui concerne SQL, c'est ce que j'aime.
Aujourd'hui, je vais vous parler des défis que nous avons rencontrés dans notre entreprise lorsque nous avons commencé à utiliser PostgreSQL comme serveur de bases de données. Nous travaillons principalement avec Java. Cependant, ce dont je vais parler aujourd'hui concerne également d'autres langages.

Nous allons parler de :
- la sélection de données.
- de la sauvegarde des données.
- et également de la performance.
- Et des piÚges dissimulés qui peuvent surgir.

Commençons par une question simple. Nous sélectionnons une ligne d'une table par clé primaire.

La base de donnĂ©es se trouve sur le mĂȘme hĂŽte. Et tout cela prend 20 millisecondes.

Ces 20 millisecondes, c'est beaucoup. Si vous avez 100 telles requĂȘtes, vous perdez du temps en secondes pour exĂ©cuter ces requĂȘtes, c'est-Ă -dire que vous gaspillez du temps.
Nous n'aimons pas faire ça et nous regardons ce que la base nous propose pour cela. La base nous offre deux options d'exĂ©cution des requĂȘtes.

La premiĂšre option est la requĂȘte simple. Qu'est-ce qui est bien ? C'est que nous la prenons et l'envoyons, et rien de plus.

La base a aussi une requĂȘte Ă©tendue qui est plus astucieuse mais plus fonctionnelle. On peut envoyer sĂ©parĂ©ment des requĂȘtes pour le parsing, l'exĂ©cution, la liaison de variables, etc.
La Super extended query est quelque chose que nous ne couvrirons pas dans cette prĂ©sentation. Peut-ĂȘtre avons-nous certaines attentes vis-Ă -vis de la base de donnĂ©es, et il existe une liste de souhaits qui est formulĂ©e d'une certaine maniĂšre, c'est-Ă -dire ce que nous voulons, mais qui n'est pas rĂ©alisable actuellement et dans un avenir proche. Nous l'avons donc simplement notĂ©e et nous irons voir les personnes clĂ©s.

Ce que nous pouvons faire, c'est utiliser la requĂȘte simple et la requĂȘte Ă©tendue.
Quelle est la particularité de chaque approche ?
La requĂȘte simple est bien adaptĂ©e pour une exĂ©cution unique. On l'exĂ©cute une fois et on oublie. Le problĂšme, c'est qu'elle ne prend pas en charge le format binaire des donnĂ©es, donc pour certains systĂšmes Ă haute performance, elle n'est pas appropriĂ©e.

La requĂȘte Ă©tendue - permet d'Ă©conomiser du temps lors de l'analyse. C'est ce que nous avons fait et commencĂ© Ă utiliser. Cela nous a Ă©tĂ© trĂšs, trĂšs utile. Il n'y a pas seulement des Ă©conomies sur l'analyse. Il y a aussi des Ă©conomies sur la transmission des donnĂ©es. Transmettre des donnĂ©es au format binaire est beaucoup plus efficace.

Passons Ă la pratique. Voici Ă quoi ressemble une application typique. Cela peut ĂȘtre Java, etc.
Nous avons créé une instruction. Nous avons exĂ©cutĂ© la commande. Nous avons créé une fermeture. OĂč est l'erreur ici ? Quel est le problĂšme ? Aucun problĂšme. C'est ce qui est dit dans tous les livres. C'est ainsi que cela doit ĂȘtre Ă©crit. Si vous voulez une performance maximale, Ă©crivez comme ça.

Mais la pratique a montré que cela ne fonctionne pas. Pourquoi ? Parce que nous avons la méthode «close». Et quand nous faisons cela, du point de vue de la base de données, c'est comme si un fumeur travaillait avec la base de données. Nous avons dit «PARSE EXECUTE DEALLOCATE».
Pourquoi créer et décharger ces statements inutiles ? Ils ne sont nécessaires à personne. Mais généralement, dans PreparedStatement, c'est comme ça : quand nous les fermons, ils ferment tout dans la base de données. Ce n'est pas ce que nous voulons.

Nous voulons, comme des gens sains, travailler avec la base. Une fois prĂ©parĂ©, nous exĂ©cutons notre statement plusieurs fois. En fait, plusieurs fois signifie une fois pendant toute la durĂ©e de vie de l'application, Ă chaque fois que nous avons analysĂ©. Et sur diffĂ©rentes REST, nous utilisons le mĂȘme identifiant de statement. Voici notre objectif.

Comment y parvenir ?

C'est trÚs simple - il ne faut pas fermer les statements. Nous écrivons comme ça : «prepare» «execute».


Si nous lançons quelque chose comme ça, il est clair qu'Ă un moment donnĂ©, quelque chose va dĂ©border. Si ce n'est pas clair, nous pouvons le mesurer. Prenons et Ă©crivons un benchmark, oĂč cette mĂ©thode simple sera mise en Ćuvre. Nous crĂ©ons un statement. Nous le lançons sur une certaine version du driver et obtenons qu'il tombe assez rapidement avec une perte totale de la mĂ©moire que nous avons accumulĂ©e.
Il est évident que de telles erreurs sont facilement corrigées. Je ne vais pas en parler. Mais je dirai que dans la nouvelle version, cela fonctionne beaucoup plus rapidement. La méthode est inutile, mais néanmoins.

Comment travailler correctement ? Que devons-nous faire pour cela ?
Dans la réalité, les applications ferment toujours les statements. Tous les livres recommandent de les fermer, sinon la mémoire fuitera.
Et PostgreSQL ne sait pas mettre en cache les requĂȘtes. Il faut que chaque session crĂ©e elle-mĂȘme ce cache.
Et nous ne voulons pas non plus perdre du temps sur l'analyse.

Et comme d'habitude, nous avons deux options.
La premiĂšre option consiste Ă dire que nous allons tout envelopper dans PgSQL. Il y a un cache. Il met tout en cache. Cela devrait bien fonctionner. Nous avons regardĂ© cela. Nous avons 100500 requĂȘtes. Cela ne fonctionne pas. Nous ne sommes pas d'accord pour transformer les requĂȘtes en procĂ©dures manuellement. Non-non.
Nous avons une deuxiĂšme option : prendre et coder nous-mĂȘmes. Nous ouvrons le code source et commençons Ă coder. Nous codons-codons. Il s'est avĂ©rĂ© que ce n'est pas si compliquĂ© Ă faire.

Cela est apparu en aoĂ»t 2015. Maintenant, il y a une version plus moderne. Et tout va bien. Cela fonctionne si bien que nous ne changeons rien dans l'application. Et nous avons mĂȘme cessĂ© de penser en termes de PgSQL, c'est-Ă -dire que cela a suffi Ă rĂ©duire pratiquement Ă zĂ©ro toutes les dĂ©penses.
Les instructions prĂ©parĂ©es du serveur sont activĂ©es lors de la cinquiĂšme exĂ©cution afin de ne pas gaspiller de mĂ©moire dans la base de donnĂ©es pour chaque requĂȘte unique.

On peut demander : oĂč sont les chiffres ? Que recevez-vous ? Et ici, je ne donnerai pas de chiffres car chaque requĂȘte a les siens.
Nous avions des requĂȘtes oĂč nous dĂ©pensions environ 20 millisecondes pour le parsing sur les requĂȘtes OLTP. Il y avait 0,5 millisecondes pour l'exĂ©cution, 20 millisecondes pour le parsing. La requĂȘte â 10 Ko de texte, 170 lignes de plan. C'est une requĂȘte OLTP. Elle demande 1, 5, 10 lignes, parfois plus.
Mais nous ne voulions absolument pas dépenser 20 millisecondes. Nous sommes passés à 0. Tout va bien.
Que pouvez-vous en tirer ? Si vous avez Java, prenez la version moderne du driver et soyez satisfait.
Si vous avez un autre langage, alors pensez : peut-ĂȘtre que vous en avez aussi besoin ? Car du point de vue du langage final, par exemple, si c'est PL 8 ou si vous avez LibPQ, il n'est pas Ă©vident que vous passiez du temps Ă parser, et cela vaut la peine de vĂ©rifier. Comment ? Tout est gratuit.

à l'exception des erreurs, certaines particularités. Et nous allons justement en parler maintenant. La plupart portera sur l'archéologie industrielle, sur ce que nous avons trouvé, sur ce qui nous a interpellés.

Si la requĂȘte est gĂ©nĂ©rĂ©e dynamiquement. Cela arrive. Quelqu'un concatĂšne des chaĂźnes, ce qui donne une requĂȘte SQL.
Pourquoi est-elle mauvaise ? Elle est mauvaise car à chaque fois nous obtenons en fin de compte une chaßne différente.
Et cette chaĂźne variĂ©e doit recalculer son hashCode. C'est vraiment une tĂąche CPU â trouver un long texte de requĂȘte mĂȘme dans le hash existant n'est pas si simple. Donc, la solution est simple â ne gĂ©nĂ©rez pas de requĂȘtes. Conservez-les dans une variable. Et soyez satisfait.

Le problÚme suivant. Les types de données sont importants. Il existe des ORM qui affirment que peu importe quel NULL, peu importe lequel. Si c'est un Int, alors nous disons setInt. Et si c'est NULL, alors que ce soit toujours VARCHAR. Et quelle différence cela fait-il au final quel type de NULL ? La base de données comprendra tout seule. Et ce tableau ne fonctionne pas.
Dans la pratique, la base de données ne se soucie pas du tout. Si vous avez dit une premiÚre fois que c'était un nombre, et la seconde fois que c'était VARCHAR, il est impossible de réutiliser les déclarations préparées du serveur. Dans ce cas, il faut recréer notre déclaration.

Si vous exĂ©cutez la mĂȘme requĂȘte, faites attention Ă ne pas mĂ©langer les types de donnĂ©es dans la colonne. Il faut veiller au NULL. C'est une erreur frĂ©quente que nous avons rencontrĂ©e aprĂšs avoir commencĂ© Ă utiliser les PreparedStatements.

D'accord, nous avons activĂ©. Nous avons peut-ĂȘtre pris le driver. Et la performance a chutĂ©. Tout est devenu mauvais.
Comment cela se fait-il ? Un bug ou une fonctionnalitĂ© ? Malheureusement, nous n'avons pas pu comprendre â est-ce un bug ou une fonctionnalitĂ©. Mais il existe un scĂ©nario assez simple pour reproduire ce problĂšme. Il nous a pris par surprise. Et cela concerne une sĂ©lection littĂ©rale Ă partir d'une seule table. Nous avions bien sĂ»r plus de telles requĂȘtes. En gĂ©nĂ©ral, elles impliquaient deux Ă trois tables, mais voici un scĂ©nario de reproduction. Prenez n'importe quelle version de votre base et reproduisez.

L'idée est que nous avons deux colonnes, chacune indexée. Dans une colonne avec la valeur NULL, il y a un million de lignes. Et dans l'autre colonne, il n'y a que 20 lignes. Lorsque nous exécutons sans variables liées, tout fonctionne bien.
Si nous commençons Ă exĂ©cuter avec des variables liĂ©es, c'est-Ă -dire que nous exĂ©cutons le « ? » ou « $1 » pour notre requĂȘte, que recevons-nous finalement ?

PremiĂšre exĂ©cution â comme il se doit. DeuxiĂšme â un peu plus rapide. Quelque chose s'est mis en cache. TroisiĂšme-quatriĂšme-cinquiĂšme. Puis soudain â et comme ça. Et le pire, c'est que cela se produit Ă la sixiĂšme exĂ©cution. Qui savait qu'il fallait faire exactement six exĂ©cutions pour comprendre quel Ă©tait vraiment le plan d'exĂ©cution ?

Qui est responsable ? Que s'est-il passĂ© ? La base de donnĂ©es contient une optimisation. Et elle est, en quelque sorte, optimisĂ©e pour un cas gĂ©nĂ©rique. Et, par consĂ©quent, aprĂšs un certain temps, elle passe Ă un plan gĂ©nĂ©rique, qui, hĂ©las, peut s'avĂ©rer ĂȘtre diffĂ©rent. Il peut ĂȘtre le mĂȘme, ou il peut ĂȘtre diffĂ©rent. Et il y a une certaine valeur seuil qui conduit Ă ce comportement.
Que peut-on en faire ? Ici, il est bien sĂ»r plus difficile de faire des hypothĂšses. Il existe une solution simple que nous utilisons. C'est +0, OFFSET 0. Vous connaissez sĂ»rement de telles solutions. On prend juste et on ajoute « +0 » Ă la requĂȘte et tout va bien. Je vais le montrer plus tard.
Il y a aussi une autre option : regarder les plans de plus prĂšs. Le dĂ©veloppeur doit non seulement Ă©crire la requĂȘte, mais aussi dire « explain analyze » six fois. Si c'est cinq, ça ne convient pas.
Et il y a aussi une troisiĂšme option : Ă©crire un mail Ă pgsql-hackers. J'ai Ă©crit, mais pour l'instant, ce n'est pas clair â est-ce un bug ou une fonctionnalitĂ©.

Pendant que nous rĂ©flĂ©chissons â est-ce un bug ou une fonctionnalitĂ©, rĂ©parons-le. Prenons notre requĂȘte et ajoutons « +0 ». Tout va bien. Deux symboles et mĂȘme pas besoin de penser Ă comment cela fonctionne. C'est trĂšs simple. Nous avons simplement empĂȘchĂ© la base de donnĂ©es d'utiliser l'index sur cette colonne. Nous n'avons pas d'index sur la colonne « +0 » et tout va bien, la base de donnĂ©es n'utilise pas l'index.

Voici la rĂšgle des six « explain ». Dans les versions actuelles, il faut le faire six fois si vous avez des variables liĂ©es. Si vous n'avez pas de variables liĂ©es, alors nous faisons ainsi. Et en fin de compte, c'est justement cette requĂȘte qui Ă©choue. Ce n'est pas sorcier.
On pourrait penser, jusqu'Ă quand ? Il y a un bug ici, un bug lĂ . Il y a vraiment des bugs partout.

Regardons encore. Par exemple, nous avons deux schĂ©mas. SchĂ©ma A avec la table Y et schĂ©ma B avec la table Y. La requĂȘte â sĂ©lectionner des donnĂ©es de la table. Que se passera-t-il alors ? Nous aurons une erreur. Nous aurons tout ce qui a Ă©tĂ© mentionnĂ© prĂ©cĂ©demment. La rĂšgle est la suivante : bug partout, nous aurons tout ce qui a Ă©tĂ© mentionnĂ© prĂ©cĂ©demment.

Maintenant, la question : « Pourquoi ? ». On pourrait penser qu'il y a de la documentation indiquant que, si nous avons un schĂ©ma, il y a une variable « search_path », qui indique oĂč chercher la table. On pourrait penser qu'il y a une variable.
Quel est le problÚme ? Le problÚme est que les instructions préparées par le serveur ne soupçonnent pas que quelqu'un peut changer le search_path. Cette valeur reste, en quelque sorte, constante pour la base de données. Et certaines parties peuvent ne pas saisir les nouvelles valeurs.

Bien sĂ»r, cela dĂ©pend de la version sur laquelle vous testez. Cela dĂ©pend de la mesure dans laquelle vos tables diffĂšrent. Et la version 9.1 exĂ©cutera simplement les anciennes requĂȘtes. Les nouvelles versions peuvent dĂ©tecter des anomalies et indiquer que vous avez une erreur.

Comment y remédier ? Il existe une recette simple : ne faites pas cela. Ne changez pas le search_path pendant l'exécution de l'application. Si vous le changez, mieux vaut créer une nouvelle connexion.
Nous pouvons en discuter, c'est-Ă -dire l'ouvrir, en discuter, complĂ©ter. Peut-ĂȘtre convaincrons-nous les dĂ©veloppeurs de la base de donnĂ©es que, lorsque quelqu'un change une valeur, la base de donnĂ©es devrait le signaler au client : « Regardez, votre valeur a Ă©tĂ© mise Ă jour. Peut-ĂȘtre devez-vous rĂ©initialiser les instructions, les recrĂ©er ? ». Actuellement, la base de donnĂ©es se comporte discrĂštement et ne signale pas du tout que quelque chose a changĂ© Ă l'intĂ©rieur des instructions.
Et je vais de nouveau insister - c'est quelque chose de peu typique pour Java. Nous verrons la mĂȘme chose dans PL/pgSQL un pour un. Mais lĂ , elle sera reproduite.

Essayons encore de sélectionner des données. Nous sélectionnons, sélectionnons. Nous avons une table d'un million de lignes. Chaque ligne fait un kilooctet. Environ un gigaoctet de données. Et nous avons une mémoire vive de machine Java de 128 mégaoctets.
Comme recommandé dans tous les livres, nous utilisons le traitement par flux. C'est-à -dire que nous ouvrons resultSet et lisons les données petit à petit. Est-ce que cela fonctionnera ? Va-t-il planter par manque de mémoire ? Va-t-il lire un peu à la fois ? Faisons confiance à la base, faisons confiance à Postgres. Nous ne faisons pas confiance. Va-t-on avoir un OutOfMemory ? Qui a déjà eu un OutOfMemory ? Et qui a réussi à réparer cela ? Quelqu'un a-t-il réussi à récupérer ?
Si vous avez un million de lignes, vous ne pouvez pas simplement sélectionner ainsi. Il est impératif d'utiliser OFFSET/LIMIT. Qui est pour cette option ? Et qui est pour l'idée de jouer avec autoCommit ?
Ici, comme d'habitude, la solution la plus inattendue s'avĂšre ĂȘtre la bonne. Et si vous dĂ©sactivez autoCommit, cela aidera. Pourquoi cela ? La science ne le sait pas.

Mais par défaut, tous les clients se connectant à la base de données Postgres sélectionnent toutes les données. PgJDBC n'est pas une exception dans ce cas, il sélectionne toutes les lignes.
Il existe une variation sur le thĂšme FetchSize, c'est-Ă -dire que l'on peut au niveau d'une instruction individuelle dire ici, s'il vous plaĂźt, sĂ©lectionnez les donnĂ©es par 10, 50. Mais cela ne fonctionne pas tant que vous n'avez pas dĂ©sactivĂ© autoCommit. Vous avez dĂ©sactivĂ© autoCommit â cela commence Ă fonctionner.
Mais parcourir le code et mettre setFetchSize partout n'est pas pratique. C'est pourquoi nous avons créé un paramÚtre qui définit la valeur par défaut pour toute la connexion.

Nous l'avons dit. Nous avons configuré le paramÚtre. Et qu'est-ce que cela nous a donné ? Si nous choisissons peu de lignes, par exemple 10 lignes, notre surcharge est assez élevée. Il faut donc fixer cette valeur à environ une centaine.

Idéalement, bien sûr, il faudrait aussi apprendre à limiter en octets, mais la recette est la suivante : fixez defaultRowFetchSize à plus de cent et réjouissez-vous.

Passons Ă l'insertion de donnĂ©es. L'insertion est plus simple, il existe diffĂ©rentes options. Par exemple, INSERT, VALUES. C'est une bonne option. On peut dire 'INSERT SELECT'. En pratique, c'est la mĂȘme chose. Il n'y a pas de diffĂ©rence de performance.
Les livres disent qu'il faut exĂ©cuter des Batch statements, les livres disent qu'on peut exĂ©cuter des commandes plus complexes avec plusieurs parenthĂšses. Et dans Postgres, il y a une merveilleuse fonction â on peut faire COPY pour le rendre plus rapide.

Si nous mesurons, nous pouvons faire plusieurs découvertes intéressantes. Comment voulons-nous que cela fonctionne ? Nous voulons éviter de parser et de ne pas exécuter de commandes inutiles.

En pratique, TCP ne nous permet pas de faire cela. Si le client est occupĂ© Ă envoyer une requĂȘte, la base de donnĂ©es, en essayant de nous envoyer les rĂ©ponses, ne lit pas les requĂȘtes. Au final, le client attend la base de donnĂ©es, pendant que celle-ci attend que le client lise la rĂ©ponse.

C'est pourquoi le client est contraint d'envoyer réguliÚrement un paquet de synchronisation. Interactions réseau inutiles, perte de temps superflue.
Et plus nous en ajoutons, pire c'est. Le pilote est trĂšs pessimiste et les ajoute assez souvent, environ toutes les 200 lignes, selon la taille des lignes, etc.

Il arrive que vous corrigiez une seule ligne et que tout s'accélÚre dix fois. Cela arrive. Pourquoi ? Comme toujours, une constante avait déjà été utilisée quelque part. Et la valeur '128' signifiait - ne pas utiliser le batching.

C'est bien que cela ne soit pas tombé dans la version officielle. Nous l'avons découvert avant de commencer à publier la version. Toutes les valeurs que je mentionne sont basées sur des versions récentes.

Mesurons. Nous mesurons InsertBatch simple. Nous mesurons InsertBatch multiple, c'est-Ă -dire la mĂȘme chose, mais avec beaucoup de valeurs. Une manĆuvre astucieuse. Tout le monde ne sait pas faire cela, mais c'est un simple tour, bien plus simple que COPY.

On peut faire COPY.

Et vous pouvez le faire avec des structures. Déclarez le type User par défaut, transmettez un tableau et insérez directement dans la table.
Si vous ouvrez le lien : pgjdbc/ubenchmsrk/InsertBatch.java, le code se trouve sur GitHub. Vous pouvez voir prĂ©cisĂ©ment quelles requĂȘtes y sont gĂ©nĂ©rĂ©es. Ce n'est pas le plus important.

Nous avons lancé. Et la premiÚre chose que nous avons comprise, c'est qu'il est tout simplement impossible de ne pas utiliser le batch. Toutes les options de batching sont nulles, c'est-à -dire que le temps d'exécution est pratiquement égal à zéro par rapport à une exécution unique.

Nous insérons des données. Il s'agit d'une table trÚs simple. Trois colonnes. Et que voyons-nous ici ? Nous voyons que ces trois options sont à peu prÚs comparables. Et COPY, bien sûr, est le meilleur.

C'est quand nous insĂ©rons par morceaux. Quand nous avons dit, une valeur VALUES, deux valeurs VALUES, trois valeurs VALUES ou nous les avons spĂ©cifiĂ©es 10 par des virgules. C'est exactement ce qui est maintenant horizontal. 1, 2, 4, 128. On voit que l'Insertion par lot, reprĂ©sentĂ©e en bleu, est nettement plus lĂ©gĂšre. C'est-Ă -dire que lorsque vous insĂ©rez un par un ou mĂȘme quatre, ça devient deux fois mieux, simplement parce que nous avons mis un peu plus dans VALUES. Moins d'opĂ©rations EXECUTE.
Utiliser COPY sur de petits volumes est extrĂȘmement peu prometteur. Je n'ai mĂȘme pas dessinĂ© sur les deux premiers. Ils vont vers les cieux, c'est-Ă -dire que ces chiffres verts pour COPY.
Il faut utiliser COPY quand vous avez au moins plus de cent lignes de donnĂ©es. Les frais gĂ©nĂ©raux pour ouvrir cette connexion sont Ă©levĂ©s. Et, honnĂȘtement, je ne me suis pas penchĂ© lĂ -dessus. J'ai optimisĂ© le batch, pas COPY.
Que faisons-nous ensuite ? Nous mesurons. Nous comprenons qu'il faut utiliser soit des structures, soit un batch astucieux combinant plusieurs valeurs.

Que faut-il retenir de la présentation d'aujourd'hui ?
- PreparedStatement est notre essentiel. Cela apporte énormément pour la performance. Cela laisse un grand seau de goudron.
- Et il faut faire un EXPLAIN ANALYZE 6 fois.
- Et il faut diluer OFFSET 0, et des astuces comme +0 pour corriger le pourcentage restant de nos requĂȘtes problĂ©matiques.
Source : habr.com
