Interaction PHP et MySQL avec mysqli via mysqlnd

Publié le 10 juillet 2012

Par Julien PauliSite personnelBlog

 

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.

Viadeo Twitter Google Bookmarks ! Facebook Digg del.icio.us MySpace Yahoo MyWeb Blinklist Netvouz Reddit Simpy StumbleUpon Bookmarks Windows Live Favorites      



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 : en 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() ...)

info 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.

info 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.

warning 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
<?php
/* ext/mysql */
$db = mysql_connect(/* params */);
mysql_query("SELECT 'foo'");

/* ext/mysqli */
$db = mysqli_connect(/* params */);
mysqli_query($db, "SELECT 'foo'");
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.

info 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).

info 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);

/* Exactement équivalent à */
$pdo->setAttribute(PDO::MYSQL_ATTR_DIRECT_QUERY, 0);

/* Contre sens, mais c'est comme ça : on doit passer MYSQL_ATTR_DIRECT_QUERY à 0 pour désactiver
   l'émulation, et non pas à 1 comme la logique sémantique le voudrait */
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

/*
PHP Fatal error:  Uncaught exception 'PDOException' with message 'SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax;
check the manual that corresponds to your MySQL server version for the right syntax to use near ''10'' 
*/
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); /* Désactive le parseur interne à PDO et la couche d'émulation */
$stmt = $pdo->prepare("SELECT user_id FROM users LIMIT :limit"); /* Ici, un vrai prepare() sera envoyé au SGBD */
$stmt->bindValue('limit', 10);
$stmt->execute();

$result = $stmt->fetch();
var_dump($result);
/*
array(4) {
  ["user_id"]=>
  string(7) "18"
  [0]=>
  string(7) "18"
}
*/
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); /* Précise à la couche d'émulation qu'il s'agit d'un entier (SQL) */
$stmt->execute();

$result = $stmt->fetch();
var_dump($result);
/*
array(4) {
  ["user_id"]=>
  string(7) "18"
  [0]=>
  string(7) "18"
}
*/
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é".

info 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');
/* PHP Warning:  mysqli_connect(): (42000/1049): Unknown database '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) { }
info 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
<?php
mysqli_report(MYSQLI_REPORT_INDEX);
$db = mysqli_connect('myserver', 'myuser', 'secret', 'my_database');

mysqli_query($db, "SELECT photo FROM Users WHERE source !='' LIMIT 1000");

/* PHP Warning:  mysqli_query(): (00000/0): No index used in query/prepared statement ... */
idea Pour voir précisémment ce qui remonte de MySQL vers PHP lors d'une communication, analysez son protocole en dumpant le traffic ou lisez l'implémentation du protocole entièrement documenté sur en http://forge.mysql.com/wiki/MySQL_Internals_ClientServer_Protocol

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
<?php
$mysqli = mysqli_connect(/*...*/);

/* Requête et transfert immédiat de tous les résultats coté PHP */
$result = mysqli_query($mysqli, "SELECT id, nom, email FROM membres");
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).

info Pour plus d'information sur le fonctionnement de la mémoire en PHP, rendez vous sur fr Mémoire PHP et Zend Memory Manager
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
<?php
$ligne1 = mysqli_fetch_row($result);
mysqli_data_seek($result, mysqli_num_rows($result)); /* Déplaçons nous sur le dernier résultat */
$derniere_ligne = mysqli_fetch_row($result);
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
<?php
mysqli_free_result($result);
info Pour savoir quand PHP libère la mémoire d'une variable, il est conseillé de savoir comment les manipuler. Lisez fr maitrise de la gestion des variables à ce sujet si nécessaire.
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é.
Requête non bufferisée
<?php
$mysqli = mysqli_connect(/*...*/);

/* Requête et transfert partiels des résultats coté PHP, il en reste coté MySQL */
$result = mysqli_query($mysqli, "SELECT id, nom, email FROM membres", MYSQLI_USE_RESULT);

$ligne1 = mysqli_fetch_row($result); /* Appel réseau éventuel pour récupérer le résultat */

/* Ceci mène à une erreur, on ne peut se déplacer dans un jeu de résultats non bufferisé
   car tout ou partie des résultats sont encore coté MySQL */
mysqli_data_seek($result, mysqli_num_rows($result));

/* Ceci mène à une erreur, on ne peut envoyer une autre requête tant qu'un jeu de résultats
   occupe de la place coté MySQL, il faut le libérer d'abord */
$result2 = mysqli_query($mysqli, "SELECT id, nom, email FROM membres", MYSQLI_USE_RESULT);
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.

idea 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 :
  1. 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 ;
  2. 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) ;
  3. 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
<?php
$m = mysqli_connect(/* params */);
$q = mysqli_query($m, 'SELECT * FROM Users LIMIT 1');
while($r = mysqli_fetch_row($q)) {
    /* do something */
}
mysqli_free_result($r);
Dump MySQL text protocol 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)
<?php
$m = mysqli_connect(/* params */);
$ps = mysqli_prepare($m, 'SELECT * FROM Users LIMIT 1');
mysqli_stmt_execute($ps);
while(mysqli_stmt_fetch($ps)) {
    /* on aurait du lier les colonnes de résultats... */
}
mysqli_stmt_close($ps);
Dump MySQL binary protocol ps
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
<?php
$m = mysqli_connect(/* params */);
$ps = mysqli_prepare($m, 'SELECT id FROM Users LIMIT 10'); /* la colonne 'id 'est de type INTEGER */
mysqli_stmt_execute($ps);
mysqli_stmt_bind_result($ps, $id); /* Lions le résultat sur $id */
while(mysqli_stmt_fetch($ps)) {
    var_dump($id);
}
/*
int(62)
int(64)
int(65)
int(66)
int(67)
int(68)
int(69)
int(70)
int(71)
int(72)
*/
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
<?php
$m = mysqli_connect(/* */);
$q = mysqli_query($m, 'SELECT id FROM users LIMIT 10';

while($r = mysqli_fetch_row($q)) {
    var_dump($r[0]);
}
/*
string(2) "62"
string(2) "64"
string(2) "65"
string(2) "66"
string(2) "67"
string(2) "68"
string(2) "69"
string(2) "70"
string(2) "71"
string(2) "72"
*/
On demande à PHP de transtyper les chaines reçues du réseau, dans le bon type (int ou float), uniquement disponible avec mysqlnd
<?php
$m = mysqli_connect(/* */);
mysqli_options($m, MYSQLI_OPT_INT_AND_FLOAT_NATIVE, true); /* uniquement disponible avec mysqlnd */
$q = mysqli_query($m, 'SELECT id FROM users LIMIT 10');

while($r = mysqli_fetch_row($q)) {
    var_dump($r[0]);
}
/*
int(62)
int(64)
int(65)
int(66)
int(67)
int(68)
int(69)
int(70)
int(71)
int(72)
*/
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
info 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
<?php
$m = mysqli_connect(/* params */);
$ps = mysqli_prepare($m, 'SELECT id, nom FROM Users LIMIT 1000');
mysqli_stmt_execute($ps);
mysqli_stmt_bind_result($ps, $id, $nom);

/* Récupère tous les résultats coté PHP (bufferise) */
mysqli_stmt_store_result($ps);

while(mysqli_stmt_fetch($ps)) {
    /* do something with $id and $nom */
}
mysqli_stmt_close($ps);
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)

$m = mysqli_connect(/* params */);
$ps = mysqli_prepare($m, 'SELECT id, nom FROM Users LIMIT 1000');
mysqli_stmt_execute($ps);

/* Transforme la réponse en jeu de résultats mysqli_result */
$r = mysqli_stmt_get_result($ps);

while($result = mysqli_fetch_row($r)) {
    /* do something */
}
mysqli_free_result($r);
mysqli_stmt_close($ps);
  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
  1. Une licence plus adaptée ;
  2. Une indépendance de PHP vis à vis de libmysql ;
  3. Une consommation mémoire mieux maitrisée ;
  4. Une journalisation de débogage interne très fine ;
  5. Des statistiques de communication très détaillées ;
  6. La possibilité de brancher des plugins ;
  7. Des fonctions PHP supplémentaires uniquement dispo sous mysqlnd.
info 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 :

  1. Un requête bufferisée rappatrie tout le jeu de résultats en mémoire, coté PHP ;
  2. 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 ;
  3. Un jeu de résultats bufferisé en mémoire l'est par la librairie qui le récupère (mysqlnd ou libmysql) ;
  4. 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 :
<?php
$db  = mysqli_connect(/* */);
$result = mysqli_query($db, "SELECT very_huge_blob_column FROM foobar");

while($results[] = mysqli_fetch_row($result)) { }
mysqli_free_result($result); /* Notez que souvent cette étape est oubliée, c'est encore pire! */
foreach($results as $foo) { /* do something */ }
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
<?php
function memory_usage()
{
    $pid = getmypid();
    $r = explode(':',shell_exec("grep VmData /proc/$pid/status"));
    return '->'.trim($r[1])."\n";
}
$db = mysqli_connect(/* */);

echo "initial memory " . memory_usage();
$result = mysqli_query($db,"SELECT very_huge_blob_column FROM foobar");
echo "resultSet stored " . memory_usage();
while($results[] = mysqli_fetch_row($result)) { }
echo "query result saved " . memory_usage();
mysqli_free_result($result);
echo "resultSet freed " . memory_usage();
unset($results);
echo "saved result freed " . memory_usage();
unset($db);
echo "Db closed " . memory_usage();
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.

warning 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 fr 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 fr 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 :
<?php
$mysql = mysqli_connect(/* */);
$result = mysqli_query($mysql, "SELECT data FROM foobar");

while($results[] = mysqli_fetch_assoc($result)) { }
$results['data'] = 'foobarbaz';

while($results2[] = mysqli_fetch_array($result)) { }
echo $results2['data']; /* N'affiche PAS foobarbaz, mais la donnée originale */

/*
$result et $result2 contiennent presque les mêmes données, cependant, la mémoire
est totalement dupliquée entre les 2, malgré l'énorme quantité de données identiques */
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
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.

warning 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 :
  1. Combien de connections SQL actives ai-je ? ;
  2. Combien d'échecs de connection PHP a-t-il essuyé ? ;
  3. Combien de requêtes ont été préparées, mais jamais exécutées ? ;
  4. Combien de requêtes ont été préparées, mais utilisées une seule fois ? ;
  5. Combien y-a-t-il eu de requêtes récupérant des résultats dont la totalité n'a pas été exploitée ? ;
  6. Quel est le ratio requêtes préparées / requêtes normales ? ;
  7. Combien y-a-t-il eu de slow queries ? ;
  8. 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é
<?php
$db = mysqli_connect(/* */);

$result = mysqli_query($db,"SELECT user_id, email FROM users LIMIT 5");
mysqli_data_seek($result, 5);
$data = mysqli_fetch_row($result);
do_something($data);
mysqli_free_result($result);
var_dump(mysqli_get_connection_stats($db));

/*
["buffered_sets"]=>
  string(1) "1"
["rows_fetched_from_server_normal"]=>
  string(1) "5"
["rows_buffered_from_client_normal"]=>
  string(1) "5"
["rows_fetched_from_client_normal_buffered"]=>
  string(1) "1"
["connect_success"]=>
  string(1) "1"
["connect_failure"]=>
  string(1) "0"
["connection_reused"]=>
  string(1) "0"
["reconnect"]=>
  string(1) "0"
["active_connections"]=>
  string(1) "1"
*/
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
<?php
$db = new JPMysqli(/* */);

$result = mysqli_query($db,"SELECT user_id, email FROM users LIMIT 5");
mysqli_data_seek($result, 5);
$data = mysqli_fetch_row($result);
do_something($data);
exit();
/*
Notice : "You didn't use *4* selected results"
*/
Pas mal hein ? Je vous laisse découvrir toutes les autres possibilités.

info 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.

Une fois de plus, j'ai fait cette analyse très principalement en analysant le passionnant code source de mysqlnd. Si le coeur vous en dit, il est disponible sur en http://lxr.php.net/xref/PHP_5_4/ext/mysqlnd/



               Version PDF   Version hors-ligne   Version eBooks

Valid XHTML 1.0 TransitionalValid CSS!

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.

 
 
 
 
Partenaires

Hébergement Web