Interaction PHP et MySQL avec mysqli via mysqlnd
Publié le 10 juillet 2012
Par
Julien Pauli

Cet article a pour but de montrer les interactions entre PHP et MySQL et de présenter des concepts internes et souvent peu maitrisés, comme par exemples
les requêtes directes, les requêtes bufferisées ou les requêtes préparées.
Nous allons ainsi détailler une partie de l'extension ext/mysqli de PHP et parler performances et pièges à éviter. Ceci passera nécessairement par l'étude du connecteur mysqlnd, très interessant
car offrant pas mal de possibilités, comme le cache de requêtes côté client, la gestion d'un parc de réplication MySQL (maitre-esclaves) ou encore une consommation mémoire bien maitrisée coté PHP.
I. Introduction et rappel sur l'architecture de PHP vis à vis de MySQL
I-A. Les connecteurs
I-A-1. libmysql
I-A-2. mysqlnd
I-B. Les extensions
I-B-1. ext/mysql
I-B-2. ext/mysqli
I-B-3. ext/pdo_mysql
II. Utilisation de ext/mysqli
II-A. Gestion des erreurs
II-B. Jeux de caractères
II-C. Jeux de résultats bufferisés
II-D. Requêtes préparées
III. Présentation de mysqlnd
III-A. Optimisations mémoire
III-B. Statistiques
III-C. Plugins
IV. Conclusions
I. Introduction et rappel sur l'architecture de PHP vis à vis de MySQL
PHP communique avec MySQL au moyen d'une librairie, historiquement : libmysql. Depuis PHP5.3, le langage est devenu indépendant de MySQL et embarque un nouveau connecteur : mysqlnd.
Quant à l'utilisateur, PHP lui expose 3 APIs, au travers de 3 extensions, pour qu'il puisse intéragir avec MySQL depuis son code : ext/mysql, ext/mysqli et ext/pdo_mysql.

PHP communique avec MySQL au travers de libmysql

PHP communique avec MySQL au travers de mysqlnd
Nous allons détailler comment PHP communique avec MySQL, et nous allons voir que si l'on utilise mal certaines fonctionnalités, celles-ci peuvent s'avérer couteuses en terme de performances,
que ce soit côté MySQL ou côté PHP.
I-A. Les connecteurs
I-A-1. libmysql
libmysql est le connecteur C fourni par MySQL pour accéder à son API. Nous n'allons pas la détailler ici, mais donner un exemple simple d'utilisation :
| Exemple très basique d'utilisation de libmysql |
#include <stdio.h>
#include <stdlib.h>
#include <mysql/mysql.h>
#include "mysql_example.h" /* Pour MYSQL_HOST, MYSQL_USER, MYSQL_PASS */
int main(int argv, char *argc[])
{
MYSQL_RES *results = NULL; MYSQL_ROW row; MYSQL *db = NULL;
db = (MYSQL *)malloc(sizeof(MYSQL));
mysql_init(db);
mysql_options(db, MYSQL_INIT_COMMAND, "SET NAMES UTF8;");
if(!mysql_real_connect(db, MYSQL_HOST, MYSQL_USER, MYSQL_PASS, NULL, 0, NULL, 0)) {
fprintf(stderr, "Failed to connect to host %s (%s)", MYSQL_HOST, mysql_error(db));
exit(EXIT_FAILURE);
}
mysql_set_character_set(db, "utf8");
mysql_select_db(db, "my_database");
mysql_query(db , "SELECT user_id AS id FROM users WHERE user_description='member' LIMIT 10000");
results = mysql_use_result(db);
while(row = mysql_fetch_row(results)) {
printf("%s\n", row[0]);
}
mysql_free_result(results);
exit(EXIT_SUCCESS);
}
|
Pour jouer ce code, il faut le compiler en le liant à la libmysql, avec le switch "-lmysql" de GCC, et bien sûr, posséder la libmysql.
Sous Debian, vous pouvez l'installer avec un "apt-get install libmysql-dev" , attention, il y a pas mal de dépendances.
Evidemment, la doc est en ligne, par ici :
http://dev.mysql.com/doc/refman/5.0/en/c.html
L'API de libmysql n'est pas complexe, les structures cachent pas mal la complexité sous-jascente et serviront souvent aussi à bufferiser les résultats, un peu comme la libc et ses fonctions
sur les flux (fopen(), fprintf(), fflush(), fclose() ...) bufferisent l'API bas niveau du Kernel (open(), close(), read(), write(), lseek() ...)
 |
L'API de libmysql a été reprise pour créer ext/mysql et plus tard, ext/mysqli. Les développeurs de PHP ont tendance à reprendre l'API C qu'ils encapsulent pour PHP, ainsi, un appel PHP à
mysqli_set_server_option() mènera vers un appel sur la libmysql à la fonction mysql_set_server_option().
Voila donc d'où vient le noms de la plupart des fonctions de PHP : des librairies C qu'il utilise.
|
I-A-2. mysqlnd
Depuis PHP5.3, PHP peut être compilé pour que ses extensions concernant MySQL utilisent non plus la libmysql du système, mais un nouveau connecteur : mysqlnd.
mysqlnd signifie "mysql native driver", et est fortement documenté sur
http://www.php.net/mysqlnd
La licence de la libmysql est assez contraignante, elle est "dual GPL - FOSS". En gros, si vous décidez de l'utiliser pour un logiciel commercial, à sources fermées, vous devez alors payer une licence
commerciale à Oracle (actuel propriétaire de MySQL). Ainsi, la licence de libmysql n'est pas compatible avec la licence PHP utilisée par PHP, et donc, la libmysql ne peut pas être distribuée avec PHP (depuis PHP 5.0).
Aussi, la libmysql n'est mise à jour que lors de la sortie d'une nouvelle version du serveur MySQL. Ceci est assez embettant pour les administrateurs souhaitant séparer les 2 dans le cadre de mises à jour ou de
développements spécifiques
Les développeurs de PHP ont donc décidé, en 2007, de réécrire un connecteur vers MySQL, inspiré certes de libmysql, mais compatible au niveau licence avec PHP. Mysqlnd est né. Aussi, ils en ont profité pour intégrer dans ce connecteur
des liaisons très fortes avec PHP, car il est uniquement destiné à être utilisé par PHP. Ainsi, mysqlnd permet pas mal de choses que libmysql ne permet pas nativement, et son utilisation activera certaines fonctionnalités des
extensions MySQL de PHP qui ne sont pas disponibles si utilisées au travers de la libmysql. Enfin, de nombreuses optimisations en terme de performance ont été introduites dans mysqlnd, et, selon les cas, vous pouvez vous retrouver
avec une consommation mémoire côté PHP beaucoup plus optimisée lorsque vous utilisez mysqlnd, par rapport à libmysql.
 |
Pour mettre toutes les choses au clair et résumer : mysqlnd est une extension PHP (on l'appelle souvent aussi "ext/mysqlnd" pour le rappeler). mysqlnd ne propose rien coté PHP, si ce n'est de modifier le comportement interne de toutes les fonctions
des 3 extensions PHP concernant MySQL (nous verrons ça en détail plus loin).
mysqlnd n'est pas une nouvelle API, il n'y a pas de fonctions mysqlnd_***() en PHP.
mysqlnd est fournie avec les sources de PHP, est maintenue par les développeurs de PHP, et est sous licence PHP.
mysqlnd permet d'obtenir, dans une grande majorité des cas, de meilleures performances de PHP qu'avec la libmysql.
mysqlnd est une "copie" du code de libmysql, mais modifié et adapté à PHP. PHP est le seul à proposer un connecteur comme celui-ci.
|
I-B. Les extensions
Pour se connecter et échanger avec un serveur MySQL depuis PHP, il faut installer obligatoirement au moins une extension dans ce but. Cette extension utilisera une librairie pour discuter avec MySQL, vous
avez ainsi le choix entre libmysql, ou mysqlnd.
PHP propose 3 extensions pour se connecter à MySQL. Elles ont toutes les 3 une API différente, expliquons rapidement pourquoi 3, et pourquoi 3 API différentes.
I-B-1. ext/mysql
ext/mysql existe quasiment depuis les débuts de PHP (PHP 2.0). Historiquement, ext/mysql reprend l'API de libmysql et la publie coté PHP (fonctions mysql_**()), ni plus, ni moins. Elle reprend l'API de la libmysql version 3.23,
c'est-à-dire que cette API est très vieille. En effet, elle ne supporte pas les requêtes préparées, les procédures stockées, les transactions, les requêtes multiples et elle ne propose pas d'API orientée objet.
 |
Aujourd'hui, il est clairement dit qu'il ne faut plus utiliser ext/mysql. C'est d'ailleurs précisé sur le manuel de PHP. ext/mysql étant encore, lourd historique oblige, très utilisée dans des applications ou dans des ouvrages, elle reste
disponible, mais pourrait un jour, disparaitre de PHP, au moins de la distribution de base.
Nous nous arrêterons ici pour ext/mysql, il n'y a rien à d'autre à dire, le Web regorge d'exemples l'utilisant.
|
I-B-2. ext/mysqli
ext/mysqli est apparue avec PHP5 et épouse la nouvelle API de la libmysql pour les versions 4.1 et supérieures. Le 'i' de mysqli signifie "improved" (amélioré).
Les développeurs ont proposé au travers de ext/mysqli une API très semblable à celle de ext/mysql, mais elle est plus complète et propose une double-API procédurale ou orientée objet.
La principale différence est qu'il est nécessaire de passer les handles (de connexion notamment) explicitement avec ext/mysqli là où ext/mysql les mémorisait dans une variable globale :
| Principale différence dans l'API mysqli par rapport à mysql : le handle |
|
Après, on voit à la lecture de la documentation que ext/mysqli est beaucoup plus complète que ext/mysql ; on a notamment des classes (ou des handles si vous utilisez l'API procédurales) pour les résultats (mysqli_result),
et les requêtes préparées (mysqli_stmt), l'API est plus propre et claire.
 |
Rappel: ext/mysqli est maintenue alors que ext/mysql ne l'est plus et est appelée à disparaitre un jour ou l'autre. N'utilisez plus ext/mysql aujourd'hui.
L'API de ext/mysqli a été créee de manière à ressembler beaucoup à celle de ext/mysql pour faciliter les migrations d'une extension vers l'autre.
|
I-B-3. ext/pdo_mysql
Cette extension est reservée à l'utilisation de PDO. PDO est un connecteur générique qui permet d'abstraire l'accès à différentes bases de données, pdo_mysql est l'extension qui permet à PDO et son API d'accéder à des serveurs MySQL.
Comme PDO est multi-SGBD, pdo_mysql ne propose pas de fonctionnalités aussi avancées que ext/mysqli, mais elle propose en revanche tout ce qu'il faut pour le web.
Ce qu'il faut savoir de PDO, c'est qu'elle inclut un parseur SQL qui permet d'émuler les requêtes préparées lorsque le SGBD sous-jascent ne les supporte pas, et cette couche agit différement de la couche native des SGBD (lorsqu'ils en ont).
Cependant, concernant MySQL, la couche d'émulation des requêtes préparées est activée par défaut, et il est hautement recommandé de la désactiver pour faire intervenir la librairie sous-jascente (libmysql ou mysqlnd) et non pas PDO lui-même.
L'émulateur de requêtes préparées dans PDO est dépassé et buggué, il a été conçu à l'origine pour auto-échapper les paramètres préparés et créer un semblant de sécurité, mais il est buggué et n'est plus maintenu. Or, dans le cas de MySQL, celui-ci sait très
bien gérer les requêtes préparées, et même encore mieux lorsque mysqlnd est utilisé (nous allons y revenir).
 |
La couche d'émulation de PDO permet à l'utilisateur PHP d'avoir accès à une API de requêtes préparées, mais en réalité, elle envoie une requête normale au pilote sous-jascent.
Avant MySQL5.1.17, le cache de requêtes ne fonctionnait pas avec les requêtes préparées, c'est aussi pour ça que PHP a décidé d'activer la couche d'émulation des requêtes préparées dans ext/pdo_mysql
|
| Désactiver le parseur SQL interne à PDO : très fortement recommandé |
<?php
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, 0);
$pdo->setAttribute(PDO::MYSQL_ATTR_DIRECT_QUERY, 0);
|
Concrêtement, que fait ce parseur ? Il prend la main sur prepare() et utilise une implémentation interne à PDO pour remplacer les paramètres préparés par leur valeur lors de l'opération de bind(), puis il envoie une requête tout à fait normale au SGBD (et non une requête préparée).
Mais comme il est générique et adapté à tous les SGBD, il ne connait pas les bases, les tables et les colonnes sur lesquelles la requête travaille, il va donc echapper et entourer de guillemets tous les paramètres qu'on lui passe, ceci peut
générer des problèmes, regardez plutot :
| Une requête préparée |
<?php
$stmt = $pdo->prepare("SELECT user_id FROM users LIMIT :limit");
$stmt->bindValue('limit', 10);
$stmt->execute();
$result = $stmt->fetch();
var_dump($result);
|
| Le parseur interne de PDO se trompe |
|
On le voit bien à l'erreur, le parseur a remplacé :limit par '10', mais le paramètre ne doit pas être entouré de guillements, car c'est un entier.
Il existe 2 solutions à ce problème : indiquer de ne pas utiliser le parseur PDO (fortement recommandé), et donc bénéficier de l'API du pilote sous-jascent, ou alors indiquer à la requête lors de la liaison, le type de la colonne.
| Désactiver le parseur SQL interne à PDO et passer immédiatement par la couche sous-jascente : le pilote (libmysql ou mysqlnd) |
<?php
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, 0);
$stmt = $pdo->prepare("SELECT user_id FROM users LIMIT :limit");
$stmt->bindValue('limit', 10);
$stmt->execute();
$result = $stmt->fetch();
var_dump($result);
|
| Utiliser le parseur SQL interne à PDO en lui indiquant le type de colonne à traiter |
<?php
$stmt = $pdo->prepare("SELECT user_id FROM users LIMIT :limit");
$stmt->bindValue('limit', 10, PDO::PARAM_INT);
$stmt->execute();
$result = $stmt->fetch();
var_dump($result);
|
On le voit bien, la couche d'émulation des requêtes préparées de PDO transtype tout en chaines SQL. De manière générale, il convient de toujours indiquer à PDO le type de variables que l'on utilise.
Notez qu'il existe aussi une astuce, encore plus "tordue" dans PDO. Nous avons vu que par défaut, il prend la main sur la syntaxe d'une requête préparée et l'analyse lui-même pour faire le boulot du SGBD
(la fameuse "émulation" des requêtes préparées, activée par défaut). Il est possible de la désactiver, ce que nous avons vu juste précédemment.
L'astuce vient du fait qu'elle n'est pas totalement désactivée... Le parseur de PDO intervient encore.
Couche d'émulation désactivée, PDO va se renseigner sur les capacité du SGBD sous-jascent à traiter les paramètres d'une requête préparée. Ceux-ci peuvent être de 2 types : "anonymes", des points d'interrogation, ou
"nommés", avec la syntaxe double-points ":monparam". Or, tous les SGBD ne supportent pas la deuxième syntaxe : paramètres préparés de type "nommés" et c'est le cas de MySQL.
Ainsi, MySQL ne supporte pas un paramètre ":limit", il ne sait pas l'interpréter, pourtant la requête n'échoue pas... En fait, PDO va passer sur la syntaxe de la requête et changer tous les paramètres nommés en paramètres
anonymes. Ainsi, même avec sa couche d'émulation désactivée, le parseur interne passe quand même sur la requête pour rendre sa syntaxe compatible avec le SGBD sous-jascent. Toute une histoire...
PDO fait définitivement beaucoup de choses à la place de l'utilisateur, voulant lui rendre service, il existe des cas où il lui joue des tours. Il faut bien être conscient que PDO est un peu "une usine à gaz" en interne,
sensé simplifier la vie de l'utilisateur (le programmeur PHP), ce dernier doit rester conscient de tout ce qu'il se passe en interne, afin de ne pas être surpris par un comportement qu'il pourrait juger de "buggué".
 |
Il est prévu de réécrire PDO : PDO2. Mais des problèmes de licences et d'idées sur l'API freinent actuellement son évolution. En tout cas clairement aujourd'hui, l'API de PDO n'est plus adaptée aux besoins comme ce fut le cas
lors de sa sortie, en 2004 tout de même.
|
II. Utilisation de ext/mysqli
On peut reprocher à ext/mysqli d'avoir une API un peu bizarre, mais il faut rappeler que celle-ci est née du fait de vouloir simplifier le passage de ext/mysql vers ext/mysqli. Elle propose ainsi des
fonctions dont l'API est quasi identique à celles présentent dans ext/mysql.
Ajoutez à cela la présence d'une double API procédurale/orientée objet, et vous obtenez ceci :

API Mysqli
Le but de cet article n'étant pas de recopier le manuel de PHP, nous allons insister sur quelques points souvent ignorés des développeurs, et pourtant bien présents, dans le manuel officiel ou dans la source du code.
II-A. Gestion des erreurs
On reproche souvent à ext/mysqli de ne pas utiliser d'exceptions pour sa gestion d'erreurs. Comme avec PDO : ce n'est en effet pas le cas par défaut, mais on peut le lui demander au moyen de mysqli_report().
La gestion d'erreurs classique fait emettre des Warning PHP pour la connexion, et est gérée par le couple mysqli_error()/mysqli_errno() sinon.
| Gestion d'erreurs classique |
<?php
$db = mysqli_connect('myserver', 'myuser', 'secret', 'unknown_database');
|
| Gestion d'erreurs sous forme d'exceptions |
<?php
mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
try {
$db = mysqli_connect('myserver', 'myuser', 'secret', 'unknown_database');
} catch (mysqli_sql_exception $e) {
exit($e->getMessage());
}
try {
mysqli_query($db, "SELECT foo FROM bar");
} catch(mysqli_sql_exception $e) { }
|
 |
Voyez le manuel de mysqli_report() pour les options et les constantes.
|
ext/mysqli peut même vous remonter les erreurs de MySQL concernant les index de colonnes pas ou mal utilisés :
| Avertir en cas de non/mal utilisation d'index de colonnes |
|
II-B. Jeux de caractères
Le manuel de MySQL et de son connecteur libmysql vous prévient clairement : envoyer une requête "SET NAMES" à MySQL n'est pas le bon moyen de changer le jeu de caractères.
En effet, mysqli_real_escape_string() n'utilisera pas le jeu de caractères déclaré tant qu'il n'aura pas été changé correctement : au moyen de mysqli_set_character_name().
II-C. Jeux de résultats bufferisés
La notion de "jeu de résultats bufferisé" est importante, car elle concerne la consommation mémoire et le transit réseau des données entre MySQL et son client (PHP dans notre cas). La notion est pourtant simple.
Lorsque PHP effectue une requête menant à la création d'un jeu de résultats, comme une requête SELECT typiquement, l'ensemble des résultats est extrait des tables MySQL et est stocké en mémoire. En mémoire, mais où ? Dans le processus MySQL,
ou dans le processus PHP ? Voila la notion de "requête bufferisée" (requête dont le jeu de résultats est bufferisé).
Dans une requête bufferisée, le jeu de résultats est intégralement transféré sur le client (PHP).
Dans une requête non bufferisée, le jeu de résultats reste du coté MySQL.
ext/mysqli utilise par défaut des résutats de requêtes bufferisés pour les résultats de requêtes simples, et non bufferisés pour les résultats de requêtes préparées
Tout est dit. Sur un jeu de résultats gros, comportant beaucoup de données (ou encore des champs lourds, de type 'BLOB'), la différence entre les 2 modes pourra être sensible.
Le fait de bufferiser le jeu de résultats d'une requête libère la mémoire côté MySQL, et occupe de la mémoire coté PHP. Comme la mémoire MySQL est libérée rapidement, MySQL pourra traiter plus de requêtes de manière simultanée (sur une même connexion), c'est pour cela
que ce mode a été choisi par défaut par PHP. Il faut bien être conscient que les données vont immédiatement transiter sur le réseau entre MySQL et PHP.
Comme le jeu de résultats est intégralement stocké côté PHP, il est possible de les compter, au moyen de mysqli_num_rows(), ou encore de se ballader dedans avec mysqli_data_seek().
| Exemple de jeu de résultats bufferisé coté PHP |
|
Si vous utilisez memory_get_usage() pour voir l'empreinte mémoire PHP monter, vous remarquerez que vous ne voyez rien, ou que vous voyez quelque chose... ?
Si vous utilisez libmysql comme connecteur, c'est la libmysql qui va allouer les tampons nécessaires pour stocker le jeu de résultats, ça ne compte donc pas dans la consommation mémoire relevée par
memory_get_usage() car ca court-circuite Zend Memory Manager.
En revanche, si vous utilisez ext/mysqlnd comme connecteur bas niveau, celui-ci est lié à Zend Memory Manager, et vous noterez donc une augmentation de la consommation mémoire de PHP (nous allons revenir sur ce point plus tard).
Une fois le jeu de résultats bufferisé coté PHP, vous devez l'exploiter et le transformer en données PHP, au moyen des fonctions mysqli_fetch_***()
| Exploitation du jeu de résultats |
|
Enfin, lorsque vous avez fini d'utiliser le jeu de résultats, vous devez le libérer au moyen de mysqli_free_result(), PHP le libèrera lorsqu'il considèrera que la variable qui retient le jeu de résultats
est à détruire.
| Libération du jeu de résultats |
|
Pour utiliser un jeu de résultats non bufferisé, il faut l'indiquer lors de la requête, au moyen d'une constante. Dans un jeu de résultats non bufferisé, comme les données restent du côté MySQL, vous ne pouvez les
compter ni vous balader dedans. Aussi, vous ne pouvez enchainer sur une autre requête avant de libérer le jeu de résultats de la précédente : MySQL ne possède qu'une seule place pour un jeu de résultats.
Enfin, à chaque interpretation du jeu via PHP (mysqli_fetch_**()), les données en question sont rappatriées via le réseau vers PHP, la charge réseau totale est donc plus importante sur un jeu non bufferisé que
sur un jeu bufferisé.
Théoriquement, en mode non bufferisé, tout appel à fetch() entrainera une lecture sur le réseau et le transfert du résultat correspondant de MySQL vers PHP. Ce n'est pas exactement vrai, car PHP bufferise tout de même
une partie des résultats, mais pas tous comme dans le cas d'une requête bufferisée (mode "STORE"). Si votre jeu de résultats est lourd, PHP n'en bufferisera qu'une partie, et au fur et à mesure des appels à fetch(), des
transferts réseaux s'effectueront.
 |
Si vous souhaitez mettre en évidence les différents transferts réseaux, utilisez une sonde sur votre interface, avec tcpdump ou wireshark par exemple. Tout sera alors très clair.
|
Un appel à mysqli_free_result() permet de libérer les ressources allouées jusqu'à présent au jeu de résultats. Libérer côté PHP, ou MySQL, en fonction du type de requête (bufferisée, ou pas).
Là encore, sur des requêtes rappatriant de lourdes charges, libérer explicitement le résultat dans un traitement batch par exemple, vous permettra d'agir méthodiquement sur la mémoire consommée, qui
peut très vite grimper.
II-D. Requêtes préparées
Les requêtes préparées sont souvant utilisées, mais mal comprises. Je ne vais pas revenir sur l'API des requêtes préparées, je suppose que vous savez les utiliser avec PHP. Là où l'on va creuser, c'est sur
ce qu'il se passe "en dessous", en regardant les différences avec les requêtes non préparées, dites "normales".
Il existe des différences fondamentales entre requêtes normales et préparées :
- Les résultats des requêtes préparées ne sont pas bufferisés par défaut, mysqli_stmt_store_result() permet de bufferiser un jeu de résultats explicitement ;
- Le protocole de communication entre PHP et MySQL n'est pas du tout le même avec des requêtes préparées (protocole binaire) ;
- La charge réseau de communication entre PHP et MySQL est généralement plus élevée dans le cas d'utilisation de requêtes préparées, cela dépend de beaucoup de choses.
Rapidement : les requêtes préparées impliquent plusieurs va-et-viens entre PHP et MySQL. Sur des requêtes normales, globalement, le schéma est simple : "Envoi de la requête", "Récupération des résultats éventuels", c'est fini.
En terme de charge réseau, c'est plutôt court, voyons celà :
| Exemple de requête simple |
|

Dump MySQL text protocol simple
Comme on le remarque, les étapes sont simples, la réponse, si trop grosse, est coupée dans plusieurs trames, rien de bien complexe. On remarquera que ce dump montre une requête simple, c'est à dire
utilisant le protocole textuel de MySQL. Le protocole textuel n'est pas hyper efficace, dans la mesure où tout ce qui transite est du texte. Certes, c'est facile à déboguer pour un humain (on voit et on comprend le contenu
des trames), mais cela demande un effort particulier à MySQL pour transformer les données de différents types (INT, BLOB, ENUM ...), en textes.
Coté PHP, comme on ne reçoit sur le réseau que du texte, il est impossible de récupérer un entier, par exemple. Une colonne de type INT en MySQL arrivant sous forme de texte dans PHP, il ne pourra pas vous servir un entier, à moins
de retranstyper à l'arrivée, ce qui n'est pas fait car ce serait alors très consommateur en resources au final (on brule des cycles CPU coté MySQL et côté PHP). En réalité c'est faisable, nous allons y venir.
| Exemple de requête préparée (inutilement utilisée, pour l'exemple) |
|

Dump MySQL binary protocol ps
Comme on peut le remarquer sur l'image des dumps, les requêtes préparées impliquent plus de vas-et-vient sur le réseau que les requêtes normales. La charge réseau est donc plus lourde, enfin, ce n'est pas tout à fait vrai.
Si vous regardez le contenu des paquets (les images des dumps ici ne le montre pas), vous remarquerez que sur la requête préparée, MySQL a utilisé le protocole binaire, et a donc encodé les entiers, les décimaux et les types non textuels
de manière naturelle : il les a envoyés tel quel.
Si vous révisez vos types de données, en C et MySQL, vous remarquerez que c'est, ou pas, avantageux. Par exemple, le TINYINT 200, en binaire, pèse 1 octet. Dans le protocole textuel, il est transformé en chaine, et la chaine pèse 4 octets : "200", ça fait 3 octets
+ la taille de la chaine, codée sur un octet.
Pour transmettre un TINYINT, la charge réseau brute du protocole textuel est quatre fois plus lourde que pour le protocole binaire. Evidemment, ça se complique beaucoup pour estimer la charge réseau d'une requête complète, qui comporte beaucoup de champs,
et éventuellement des opérations de liaison ("bind") dans les requêtes préparées. Chaque liaison, par exemple au moyen de mysqli_stmt_bind_param(), représente une trame sur le réseau.
Dans une requête préparée, le client (PHP) indique au serveur, via le réseau, comment construire la requête : c'est un vrai dialogue qui est échangé.
Comme le protocole binaire retourne les données brutes, et non pas sous forme de chaines systématiquement, PHP peut récupérer différents types directement, comme par exemple des entiers ou des booléens. Voyons ça :
| On récupère bien des entiers PHP |
|
Cette "prouesse" n'est pas magique, la couche basse de communication (libmysql ou mysqlnd) fait la correspondance entre les types, et gère les overflows éventuels.
Il reste possible, sur le protocole textuel, de demander à la couche basse de PHP de transtyper les chaines dans les bons formats en fonction des méta données des colonnes retournées par MySQL dans chaque résultat.
Ceci n'est possible que si cette couche basse est capable de savoir représenter les données binaires dans les types de PHP, bingo! , la libmysql ne sait pas faire ça, mais mysqlnd si.
| Le protocole textuel de MySQL transmet des chaines sur le réseau |
|
| On demande à PHP de transtyper les chaines reçues du réseau, dans le bon type (int ou float), uniquement disponible avec mysqlnd |
|
Comme vous êtes paresseux, ou que vous avez oublié, voici un petit tableau récapitulatif à peu près juste (certaines valeurs sont dépendantes de la plateforme) :
| Type |
Octets (protocole textuel) |
Octets (protocole binaire) |
| DECIMAL |
chaine binaire |
chaine binaire |
| TINYINT |
2...5 |
1 |
| SMALLINT |
2...7 |
2 |
| MEDIUMINT |
2...9 |
3 |
| INT |
2...12 |
4 |
| BIGINT |
2...21 |
8 |
| FLOAT |
2...11 |
4 |
| DOUBLE |
2...20 |
8 |
| DATE |
11 |
4 |
| DATETIME |
20 |
4...7 |
| TIME |
2...10 |
8 |
| YEAR |
3 / 5 |
2 |
| *CHAR |
chaine binaire |
chaine binaire |
| *BLOB |
chaine binaire |
chaine binaire |
| SET,ENUM |
chaine binaire |
chaine binaire |
Récapitualitif du poids réseau des différents types en fonction du protocole
 |
Rappel : On appelle une "chaine binaire" une chaine qui embarque sa taille avec elle (typiquement un char* et un size_t dans une même structure). Elle peut donc contenir tout type de caractère y compris \0.
|
Coté jeu de résultats, ce n'est pas bufferisé avec des requêtes préparées, car tout le protocole a été prévu pour de multiples transmissions sur le réseau. Il en va donc de même pour les résultats, qui seront récupérés à la demande,
à chaque opération fetch(). Il reste possible de tout transmettre d'un coup (de bufferiser les résultats), mais vous devrez le demander explicitement au moyen de mysqli_stmt_store_result().
| Bufferisation manuelle des résultat d'une requête préparée |
|
Nous venons de voir que l'on peut, à la demande, bufferiser le résultat d'une requête préparée. En revanche, pour lire ce résultat, il est obligatoire de lier les colonnes à des variables PHP, au moyen de
mysqli_stmt_bind_result(). Ce n'est pas tout à fait vrai si vous tournez sous ext/mysqlnd, vous avez alors accès à une fonction qui transforme le résultat de la requête préparée (bufferisé ou non), en résultat mysqli_result: mysqli_stmt_get_result()
| Récupération d'un jeu de résultats depuis une requête préparée (mysqlnd uniquement) |
|
| |
Requête normale |
Requête préparée |
| Résultat bufferisé côté client par défaut |
OUI |
NON |
| SELECT + 1 execution |
1 |
2 |
| SELECT + n exécutions |
n |
1+n |
| Liaison du résultat sur var PHP individuelles |
NON |
OUI |
| Support des résultats mysqli_result |
OUI |
OUI (mysqli_stmt_get_result()) |
| Résultat bufferisé côté client |
OUI (défaut) |
OUI (via mysqli_stmt_store_result()) |
| Résultat non bufferisé côté client |
NON par défaut (utiliser MYSQLI_USE_RESULT) |
OUI par défaut |
| Types PHP conservés |
NON (Utiliser MYSQLI_OPT_INT_AND_FLOAT_NATIVE) |
OUI |
| Echappement auto des paramètres |
NON |
OUI |
Récapitulatif requêtes normales/préparées
III. Présentation de mysqlnd
Après le court chapitre consacré à mysqlnd en introduction, nous pouvons donc dire que mysqlnd est une réécriture de la libmysql, spécialement conçue pour PHP. Ainsi, elle prend en compte les deux protocoles de communication vers MySQL, textuel et binaire.
Le protocole textuel est utilisé par des requêtes normales alors que le protocole binaire est utilisé par les requêtes préparées.
Avantages de mysqlnd sur libmysql pour PHP
- Une licence plus adaptée ;
- Une indépendance de PHP vis à vis de libmysql ;
- Une consommation mémoire mieux maitrisée ;
- Une journalisation de débogage interne très fine ;
- Des statistiques de communication très détaillées ;
- La possibilité de brancher des plugins ;
- Des fonctions PHP supplémentaires uniquement dispo sous mysqlnd.
 |
Rappel : ext/mysqlnd s'active au moyen d'un switch à la compilation sous PHP5.3, et est activé par défaut avec PHP5.4. ext/mysqlnd n'a aucune dépendance (si ce n'est libc).
|
Nous allons maintenant passer en revue quelques caractéristiques sympathiques de mysqlnd, avec quelques exemples à l'appui.
III-A. Optimisations mémoire
Pour comprendre cette partie, il convient de rappeler quelques notions :
- Un requête bufferisée rappatrie tout le jeu de résultats en mémoire, coté PHP ;
- Un requête non-bufferisée rappatrie une partie des résultats du jeu en mémoire, coté PHP, le reste arrivera au fur et à mesure de la demande ;
- Un jeu de résultats bufferisé en mémoire l'est par la librairie qui le récupère (mysqlnd ou libmysql) ;
- PHP peut transformer ce jeu de résultats pour l'exploiter à son tour, cette opération matérialisée par l'appel à une fonction "fetch", crée les structures PHP pour accueillir le résultat.
Là où je veux en venir, c'est qu'avec la libmysql (et uniquement la libmysql), exécuter ce code-là est totalement contre-productif côté mémoire :
| Exemple de ce qu'il y a de pire en terme de consommation mémoire, à ne pas faire : |
|
Prouvons ce que l'on avance :
| Analyse de la consommation mémoire du tas de PHP dans le cas de requêtes SQL SELECT lourdes |
|
| Utilisation, avec un PHP compilé avec libmysql : |
>ZEND_MM_MEM_TYPE=mmap_anon php53libmysql/bin/php -dmemory_limit=-1 poc_mysqli.php
initial memory ->3348 kB
resultSet stored ->72724 kB
query result saved ->149012 kB
resultSet freed ->81156 kB
saved result freed ->25348 kB
Db closed ->24260 kB
|
On voit que, dès le mysqli_query() exécuté, la consommation mémoire est multipliée par 20 environ, passant de 3.3Mo à 70Mo. C'est normal, car par défaut, mysqli_query() est en mode bufferisé ("STORE"), donc
la libmysql sous-jascente va immédiatement transférer dans ses tampons l'intégralité du jeu de résultats. Comme nous sélectionnons une hypothétique très lourde colonne de type 'blob', sans clause limit ni where,
on rappatrie tout, environ 70Mo de données dans l'exemple ci-dessus.
 |
Ces 70Mo sont stockés dans un tampon de la libmysql. memory_get_usage() ne vous sera d'aucune aide ici, il faut lire directement le tas du processus pour voir la mémoire augmenter, ce que nous faisons dans nos exemples, en
passant par /proc
|
Le fait de transformer tout le jeu de résultats dans une boucle while(), en tableau PHP, fait littéralement exploser la mémoire.
A ce stade-là, la libmysql contient toujours les données issues de la requête (le jeu de résultats complet), et en plus PHP a dupliqué chaque donnée du jeu dans un tableau, et a placé toutes ces données-là dans un super tableau
(vous connaissez mysqli_fetch_row()). On passe de 70Mo à 140Mo : on a doublé.
Histoire d'être tout à fait convaincu, voici le graphe massif obtenu avec la libmysql, la duplication du tampon vers PHP est évidente :
99.92% (257,473,815B) (heap allocation functions) malloc/new/new[], --alloc-fns, etc.
->52.90% (136,314,880B) 0x69A01E: _zend_mm_alloc_int (zend_alloc.c:1908)
| ->52.60% (135,528,448B) 0x69A1DE: _estrndup (zend_alloc.c:2503)
| | ->52.60% (135,528,448B) 0x533BCE: php_mysqli_fetch_into_hash (mysqli.c:1191)
| | ->52.60% (135,528,448B) 0x53F2E1: zif_mysqli_fetch_row (mysqli_nonapi.c:352)
| | ->52.60% (135,528,448B) 0x70186A: zend_do_fcall_common_helper_SPEC (zend_vm_execute.h:320)
| | ->52.60% (135,528,448B) 0x6D9D96: execute (zend_vm_execute.h:107)
| | ->52.60% (135,528,448B) 0x6B4B98: zend_execute_scripts (zend.c:1236)
| | ->52.60% (135,528,448B) 0x663D0C: php_execute_script (main.c:2308)
| | ->52.60% (135,528,448B) 0x73BCDC: main (php_cli.c:1184)
| |
| ->00.31% (786,432B) in 1+ places, all below ms_print's threshold (01.00%)
|
->45.85% (118,130,675B) 0x52DD010: my_malloc (my_malloc.c:37)
| ->45.84% (118,112,344B) 0x52E0583: alloc_root (my_alloc.c:219)
| | ->45.83% (118,096,024B) 0x5307A40: cli_read_rows (client.c:1418)
| | | ->45.83% (118,096,024B) 0x5305955: mysql_store_result (client.c:2957)
| | | ->45.83% (118,096,024B) 0x53EF09: zif_mysqli_query (mysqli_nonapi.c:540)
| | | ->45.83% (118,096,024B) 0x70186A: zend_do_fcall_common_helper_SPEC (zend_vm_execute.h:320)
| | | ->45.83% (118,096,024B) 0x6D9D96: execute (zend_vm_execute.h:107)
| | | ->45.83% (118,096,024B) 0x6B4B98: zend_execute_scripts (zend.c:1236)
| | | ->45.83% (118,096,024B) 0x663D0C: php_execute_script (main.c:2308)
| | | ->45.83% (118,096,024B) 0x73BCDC: main (php_cli.c:1184)
|
On libère ensuite le jeu de résultats :
mysqli_free_result(), qu'est ce que ça signifie ? Ca signifie qu'on demande à la libmysql de libérer le tampon qu'elle a alloué lors du
mysqli_query() pour stocker, de son coté,
le jeu de résultats. On retombe donc à 80Mo de mémoire, c'est 10Mo de plus que les 70Mo théoriques qu'on devrait retrouver, on peut supposer qu'il s'agit du tampon de la libc (free() ne libère pas forcément tous les blocs, c'est d'ailleurs pour ça
qu'on a utilisé
ZEND_MM_MEM_TYPE pour PHP, lisez
Mémoire PHP et Zend Memory Manager).
A ce stade, la libmysql a libéré son tampon grâce à l'appel de
mysqli_free_result(), et
$results contient toujours un énorme tableau de tableaux de résultats, sous forme de variables PHP.
Sans surprise, détruire l'énorme
$results fait retomber la mémoire, pas à son état initial, vous devriez savoir pourquoi si vous maitrisez le comportement de
Zend Memory Manager
Ce qui est intéressant de noter, c'est la duplication des données lorsque PHP lit le buffer de libmysql pour en créer ses propres variables, dans le gros tableau.
Il duplique, car les données contenues dans le buffer de la libmysql ne sont pas adaptées à l'utilisation PHP et si vous bouclez une fois sur le résultat, vous modifiez son contenu, et vous rebouclez à nouveau sur le résultat ? Vous obtenez bien la donnée
originale, et non la donnée modifiée la première fois : elle a bien été dupliquée et l'est à chaque fois.
| Sous libmysql, toute opération fetch() va dupliquer la donnée du buffer libmysql vers PHP : |
|
C'est là que mysqlnd intervient et va nous faire réaliser des économies. Reprenons l'exemple précédant avec mysqlnd à la place de libmysql cette fois :
| Utilisation, avec un PHP compilé avec mysqlnd : |
>ZEND_MM_MEM_TYPE=mmap_anon php53mysqlnd/bin/php -dmemory_limit=-1 poc_mysqli.php
initial memory ->3208 kB
resultSet stored ->70452 kB
query result saved ->71220 kB
resultSet freed ->81148 kB
saved result freed ->19196 kB
Db closed ->19196 kB
|
C'est clair par rapport à libmysql : au moment où la requête est jouée (mysqli_query()), elle est toujours en mode bufferisé, donc mysqlnd rappatrie tous les résultats coté PHP, là, rien ne change.
Mais au moment où PHP tente de les lire pour les interpréter, au moyen de mysqli_fetch_row(), quasiment aucune différence n'est notée côté mémoire.
mysqlnd récupère directement les valeurs depuis MySQL sous forme de zval PHP, et l'opération "fetch" ne fait que créer un tableau qui pointe sur ces zvals, là où il y a duplication complète avec la libmysql.
Notez aussi que comme mysqlnd est une extension PHP, toutes les allocations mémoires qu'elle effectue passent par Zend Memory Manager, et sont donc comptées dans memory_get_usage() et memory_limit (entre autre).
Ce n'est pas le cas de la libmysql, qui court-circuite totalement le gestionnaire de mémoire PHP.
Enfin, le facteur le plus important est que lorsque mysqlnd décrypte le protocole textuel ou le protocole binaire de MySQL, elle en crée des zval directement (des variables PHP), qu'elle remonte à la couche supérieure,
là où libmysql ne remonte que des tampons mémoires.
III-B. Statistiques
Comme mysqlnd gère tout le traffic entre PHP et MySQL, et que la couche est intégrée dans PHP sous forme d'extension, il est possible à mysqlnd de scruter et mémoriser tout ce qu'il se passe entre PHP et MySQL.
mysqlnd va être capable d'analyser le traffic entre les 2 parties, de manière très fine, et de remonter des informations de statistiques très poussées. Voyons quelques exemples.

phpinfo() avec mysqlnd et les statistiques activées
Le phpinfo() montre une quantité redoutable de statistiques, j'ai donc tronqué l'image ci-dessus pour la mise en page.
 |
Attention, les statistiques peuvent ne pas être collectées. Vérifiez vos paramètres ini mysqlnd.collect_statistics et mysqlnd.collect_memory_statistics.
La collecte de statistiques n'est pas fondamentalement consommatrice de ressources. Faites vos propres benchmarks si nécessaire.
|
Toutes les statistiques sont détaillées sur le manuel de PHP, sur
http://www.php.net/mysqlnd.stats, nous n'allons donc pas répéter le manuel ici, mais nous pencher
sur quelques statistiques qui sortent du lot en terme d'interêt.
Quelques réponses que les stats mysqlnd peuvent apporter :
- Combien de connections SQL actives ai-je ? ;
- Combien d'échecs de connection PHP a-t-il essuyé ? ;
- Combien de requêtes ont été préparées, mais jamais exécutées ? ;
- Combien de requêtes ont été préparées, mais utilisées une seule fois ? ;
- Combien y-a-t-il eu de requêtes récupérant des résultats dont la totalité n'a pas été exploitée ? ;
- Quel est le ratio requêtes préparées / requêtes normales ? ;
- Combien y-a-t-il eu de slow queries ? ;
- Combien de requêtes ont mal utilisé un index ?
Comme on peut le voir, ces questions sont plutot interéssantes non ? Et bien mysqlnd donne des réponses à toutes (et bien plus).
| Exemple de statistiques montrant un gaspillage de ressources : 5 résultats seléctionnés, mais un seul exploité |
|
Voyez plutot le code ci-dessus, qui selectionne 5 résultats, les rappatrie côté PHP, se déplace directement sur le 5ème, l'utilise, et détruit tout, gaspillant ainsi les 4 résultats précédants.
Les statistiques obtenues au moyen de mysqli_get_connection_stats() l'indiquent : rows_fetched_from_server_normal montre le nombre de résultats récupérés dans MySQL par PHP sur des requêtes normales, chez nous : 5. rows_fetched_from_client_normal_buffered indique
le nombre de résultats effectivement utilisés par l'appli PHP ("fetchés"), depuis le jeu de résultats rappatrié.
Si la différence entre rows_fetched_from_server_normal et rows_fetched_from_client_normal_buffered est positive, alors il y a eu gaspillage de ressources, car l'appli PHP a récupéré depuis MySQL des données qu'elle n'a pas exploitées.
Montons une petite API simple par dessus mysqli :
| Etendons la classe mysqli pour qu'elle nous avertisse de gaspillages éventuels |
<?php
class JPMysqli extends Mysqli
{
public function __destruct()
{
$stats = $this->get_connection_stats();
$this->close();
if($diff = $stats["rows_fetched_from_server_normal"] - ($stats["rows_fetched_from_client_normal_unbuffered"] + $stats["rows_fetched_from_client_normal_buffered"])) {
trigger_error("You didn't use *$diff* selected results", E_USER_NOTICE);
}
}
}
|
Relançons le même code, mais avec cette classe :
| Exploitation de notre classe JPMysqli |
|
Pas mal hein ? Je vous laisse découvrir toutes les autres possibilités.
 |
Rappel : mysqlnd rajoute des fonctions à PHP, pas sous la forme mysqlnd_***() mais bien mysqli_***() , ne vous y trompez pas. Ainsi, si vous compilez PHP sans le support de mysqlnd (libmysql), ces fonctions là seront tout
simplement absentes. Vous les trouvez en manuel officiel, je citerai rapidement (non exhaustif) : mysqli_get_connection_stats(), mysqli_get_client_stats(), mysqli_get_cache_stats(),
mysqli_stmt_get_result(), mysqli_fetch_all().
|
III-C. Plugins
Là encore la documentation de PHP comporte des pages détaillées à leur sujet, nous allons donc simplement citer les plugins actuels et leur rôle.
En revanche, veillez à la version des plugins, certains sont taggués beta ou alpha, sachez-le.
Rappelons qu'un plugin mysqlnd est une extension PHP, dont le but est d'enrichir le comportement de mysqlnd; vous ne pourrez ainsi pas charger un plugin mysqlnd dans PHP si ext/mysqlnd elle-même n'est pas
activée (logique). Une fois le plugin chargé (le fichier .so chargé), il convient de lui indiquer via un paramètre ini qu'il doit être actif, sinon, il ne fera rien.
mysqlnd_qc - Query Cache : Comme son nom l'indique, il permet une mise en cache des résultats de requêtes MySQL, dans un backend à choisir (APC, fichier, SQLite, Memcache, User-defined).
Le principe repose sur un analyseur syntaxique SQL embarqué, qui va scruter toutes les requêtes utilisées pour voir si elles commencent par le commentaire spécial /* qc=on */, auquel cas les résultats
de cette requête seront mis en cache.
Oui : cela impose de réécrire toutes ses requêtes, mais comme vous utilisez sans doute déja des couches qui les écrivent pour vous, type Doctrine2, c'est pas très difficile à centraliser.
mysqlnd_ms - Master Slave : Implémente une distribution des requêtes entre plusieurs serveurs de type architecture maitre/esclave. Il y a une gestion embarquée du failover, mais un peu limitée. Le but premier de ce
plugin est d'automatiquement choisir le serveur sur lequel exécuter la requête en l'analysant et en déterminant son type (lecture ou écriture). Ca permet d'éviter de faire cela côté applicatif, en gérant dans l'application
PHP plusieurs connections MySQL et en choisissant à la main laquelle utiliser à quel moment. Intéressant.
mysqlnd_uh - User Handler hooks : Le programmeur PHP branche des fonctions de callbacks PHP dans l'API de mysqlnd. La puissance de ce plugin est sans limite pour ceux qui ne veulent pas se frotter à l'écriture d'un plugin
spécifique, en C. Ce plugin-là permet en fait d'écrire tout type de comportement de plugin, mais en PHP. Quelques cas d'utilisations pratiques pourraient être la surveillance des requêtes éxecutées, la détection de l'utilisation
de telle ou telle base de données, la protection contre les injection SQL de type bas niveau, la balance de charge manuelle, etc...
IV. Conclusions
Comme nous l'avons vu, mysqlnd est une extension PHP qui réimplémente toute la couche de communication à MySQL. C'est une extension "bas niveau" en ce sens qu'elle est utilisée de manière entièrement transparente par les 3 extensions
PHP qui permettent d'intéragir avec MySQL : ext/mysql, ext/mysqli et ext/pdo_mysql. Elle ne change donc pas du tout l'existant d'un point de vue fonctionnel.
Intégrée profondément dans PHP, elle apporte de gros avantages par rapport à l'historique connecteur libmysql : économie de mémoire vive, statistiques sur le traffic PHP-MySQL détaillées, fonctions supplémentaires, possibilité d'écrire
ou télécharger des plugins pour personnaliser le comportement, etc.
Enfin, nous avons vu que malgré la forte poussé de ext/PDO, ext/mysqli demeure une extension extrêmement puissante et efficace en ce qui concerne la communication avec MySQL, bien plus que PDO.
Certes, elle a une API plutot bizarre, mais celle-ci a été conçue dans le but de faciliter les migrations depuis ext/mysql, l'extension historique de PHP, complètement dépassée aujourd'hui.


Copyright © 2012 Julien PAULI. Aucune reproduction, même partielle, ne peut être faite
de ce site et de l'ensemble de son contenu : textes, documents, images, etc.
sans l'autorisation expresse de l'auteur.
Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 €
de dommages et intérêts.
Cette page est déposée.