Sur la plupart des sites dits « 2.0 », vous trouverez quelque part un petit module « Un article au hasard », et un « Dernier article ».

Souvent, le programmeur derrière ne se pose malheureusement pas de questions pour récupérer cet enregistrement… et pourtant, il devrait ! Car selon les méthodes utilisées et la taille de la table, il peut améliorer sa requête de plusieurs ordres de grandeur.

On est parti pour voir ça…

Protocole de test

Tables

J'effectue les tests sur une table moyennement chargée (10 000 enregistrements) nommée BENCH_Table dans la suite, simulant une bonne base d'articles.
Les calculs se font sur la colonne ID, qui est notée comme PRIMARY auto_increment.

Requêtes SQL

BENCHMARK

Pour tester les requêtes SQL présentées ici, je me suis servi de l'instruction BENCHMARK qui permet de calculer plusieurs fois une expression.

Pour cet article, j'ai effectué les requêtes 10 000 000 de fois, sauf contre-ordre (dans certains cas vraiment peu optimisés, il aurait fallu des heures de calcul, montrant l'inadéquation de la solution). Cette valeur assez large permet d'obtenir un résultat fiable.

Petit exemple de requête avec BENCHMARK :

--Répéter 10 000 000 de fois la récupération du titre de l'élément 5
SELECT BENCHMARK(10000000,(

SELECT Titre
FROM BENCH_Table
WHERE ID = 5
))

Attention ! BENCHMARK renvoie toujours 0. Pour récupérer le temps de la requête, il faut utiliser autre chose : le client MySQL en ligne de commande par exemple, qui affiche le temps passé.

EXPLAIN

Pour justifier le temps obtenu et l'expliquer, j'utiliserai EXPLAIN, une instruction indispensable pour les gestionnaires de base de données qui affiche le schéma prévu par le compilateur. Une requête EXPLAIN n'exécute pas la demande, mais la compile et affiche les plans de l'exécuteur (je simplifie). EXPLAIN est un vaste sujet, je n'entrerai pas dans le détail et décrirai uniquement les résultats obtenus au cours des tests. Pour un cours complet sur EXPLAIN, RTFM !

Petit exemple de requête avec EXPLAIN :

EXPLAIN SELECT Titre
FROM BENCH_Table
WHERE ID = 5

Et le résultat :

id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE BENCH_Table const PRIMARY PRIMARY 4 const 1  

Sélectionner un élément aléatoire

C'est sûrement la partie la plus intéressante… mais aussi la plus délaissée par les webmasters !

Présentation de RAND()

Dès que l'on parle d'aléatoire, on aura besoin d'une fonction génératrice de nombres pseudo-aléatoires. En MySQL, il s'agit de l'instruction RAND(), classée avec les fonctions mathématiques. RAND() renvoie un nombre aléatoire flottant entre 0 et 1 (1 non compris).

Sélectionner un élément aléatoire

Si vous vous promenez un peu sur les forums, c'est toujours la même solution qui est donnée :

--Tri par rand
SELECT ID FROM BENCH_Table
ORDER BY RAND()
LIMIT 1

C'est logique : on trie aléatoirement et on ne récupère que le premier élément. Sauf que… c'est long, très long : sur la table de tests décrite ci-dessus, il faut 7,6 secondes pour effectuer 1 000 calculs. Si j'avais pris les 100 000 000 prévus, on serait donc à 21 heures de calcul… presque une journée !
Pourquoi est-ce si long ? Il va falloir jouer du EXPLAIN :

EXPLAIN SELECT ID FROM BENCH_Table
ORDER BY RAND()
LIMIT 1

Que voit-on dans la colonne Extra ? « Using index ; Using temporary ; Using filesort ». Ouch, expliquons un peu.

  • « Using index » : ça c'est bien ; on n'utilise que le fichier d'index. Bon, sur une vraie requête, il y a de fortes chances que vous récupériez autre chose que l'ID (par exemple, le titre de l'article sélectionné aléatoirement) auquel cas cet avantage disparaîtra ; sauf si votre index contient ID et Titre.
  • « Using temporary » : ah, moins bien. Pour calculer les résultats, MySQL est obligé de créer une table temporaire – ici, qui contient les valeurs attribuées par RAND() à chaque colonne.
  • « Using filesort » : il va y avoir une phase de tri supplémentaire à la fin pour classer les lignes.

On comprend mieux : lors de l'exécution, MySQL va ajouter à chaque ligne une colonne contenant la valeur de RAND() (soit 10 000 nouvelles valeurs), puis trier sur cette colonne et ne récupérer que le plus petit résultat.
Faut-il dire en quoi c'est stupide ? Trier est une opération coûteuse (au mieux en O(n × log(n))), alors qu'on ne veut récupérer qu'un élément et qu'on se fiche d'avoir une table triée. C'est un peu comme si en PHP vous faisiez ceci pour récupérer la plus petite valeur d'un tableau :


Mais, comment faire alors ? Hé bien, on réfléchit. Allez allez, on se creuse un peu la tête…
Déjà, on ne veut plus de ORDER BY. C'est une perte de temps et de mémoire pour ce qui nous intéresse…
La première idée, c'est de ruser avec une sub-query, un SELECT à l'intérieur de SELECT.

Par exemple :

--Sélection d'un élément
SELECT ID
FROM BENCH_Table
WHERE ID =
	(
	SELECT CEIL( COUNT(*) * RAND( ) )
	FROM BENCH_Table
	)

À priori, c'est bien ! On calcule la sous-requête, on obtient alors un nombre aléatoire entier, et on récupère cet ID. Comme on récupère à partir d'un index, l'opération « = » sera extrêmement rapide et tout devrait aller pour le mieux.
Sauf que… exécuter une seule fois cette requête demande 14s ! Tu parles d'une amélioration… qu'est ce qui a bien pu se passer ? Encore une fois, EXPLAIN est notre ami :

id select_type table type possible_keys key key_len ref rows Extra
1 PRIMARY BENCH_TABLE index NULL PRIMARY 4 NULL 10000 Using where; Using index
2 UNCACHEABLE SUBQUERY BENCH_TABLE index NULL PRIMARY 4 NULL 10000 Using index

La deuxième ligne nous informe de notre erreur : UNCACHEABLE SUBQUERY. Autrement dit, il va falloir recalculer la sous-requête sur chaque ligne ! (Une bref lecture du manuel sur la page « How the query cache operates » aurait pu nous renseigner : la présence d'un RAND() empêche la mise en cache. )

Forcément, calculer un CEIL( COUNT( * ) * RAND( ) ) sur 10 000 lignes, c'est coûteux…

J'ai un peu cherché comment forcer la mise en cache d'une requête, sans résultat. Alors j'ai arrêté de pleurnicher, et j'ai fait fonctionner à nouveau la matière grise.
L'idée ? Utiliser un JOIN. On sélectionne une valeur aléatoire dans ce JOIN, et on joint notre table dessus. Pour ceux qui ne seraient pas familiers avec les différentes jointures, précisons que JOIN fonctionne « des deux côtés » : c'est une sorte de fonction « intersection », contrairement à un LEFT JOIN / RIGHT JOIN qu'on pourrait qualifier d'« union » – oui, je simplifie, mais encore une fois ce n'est pas le but de l'article.

Donc, un JOIN :

--Jointure de l'élément
SELECT ID
FROM BENCH_Table
JOIN
	(
	SELECT FLOOR( COUNT( * ) * RAND( ) ) AS ValeurAleatoire
	FROM BENCH_Table
	) AS V ON BENCH_Table.ID = V.ValeurAleatoire

Cette fois, les performances sont au rendez-vous : sur 10 000 000 de tests, on ne monte qu'à 1,278s (à comparer avec la presque-journée de la première solution).

Un petit EXPLAIN pour la route :

id select_type table type possible_keys key key_len ref rows Extra
1 PRIMARY <derived2> system NULL NULL NULL NULL 1  
1 PRIMARY BENCH_Table const PRIMARY PRIMARY 4 const 1 Using index
2 DERIVED BENCH_Table index NULL PRIMARY 4 NULL 8352 Using index

Ce qui est intéressant ici, c'est la colonne type :

  • « system » : c'est le mieux qu'on puisse rêver, la table n'a qu'une seule ligne.
  • « const » : ici aussi, il n'y a qu'une seule ligne qui sera calculée au tout début de la requête. Notez que si on n'avait pas de contraintes PRIMARY (ou UNIQUE) l'optimiseur ne pourrait rien dire et serait obligé de lire.
  • « index » : c'est moins superbe, il faut lire chaque ligne – mais on utilise un index, ce qui accélère les choses.

Par acquit de conscience, j'ai testé avec COUNT(*) et MAX(ID) (je suis en InnoDB, ça a peut-être son importance) : le résultat est mieux avec COUNT(*). À la réflexion, ça paraît logique vu que MAX(ID) est obligé de lire toutes les lignes et de comparer à chaque fois avec le maximum courant. Je pensais cependant qu'il suffisait de lire la dernière ligne du fichier d'index… à priori non donc.

Note sur les tables « creuses »

Les techniques exposées ici fonctionnent parfaitement si vous ne supprimez pas de lignes de votre table.
Sinon, vous risquez d'avoir un problème et de ne récupérer aucune ligne (cas V.ValeurAleatoire = un_index_supprimé). Il faut modifier légèrement la requête pour prendre en compte ce genre de cas :

--Jointure de l'élément avec gestion des index supprimés
--La différence ? LA jointure se fait sur >=, et pas sur =
SELECT ID
FROM BENCH_Table
JOIN
	(
	SELECT FLOOR( COUNT( * ) * RAND( ) ) AS ValeurAleatoire
	FROM BENCH_Table
	) AS V ON BENCH_Table.ID >= V.ValeurAleatoire
LIMIT 1

Forcément, ça ira un peu moins vite (20 secondes, une bonne perte quand même)…

Et dans le futur

Forcément, on se prend à rêver de pouvoir exécuter le code suivant :

--Code imaginaire, NE FONCTIONNE PAS
SELECT ID
FROM BENCH_Table
LIMIT FLOOR( COUNT( * ) * RAND( ) ),1

Mais ce n'est pas pour tout de suite… dommage !

Encore une idée

On peut aussi coupler deux langages (on choisit un enregistrement en PHP/Whatever, puis on le récupère) ou avec des variables SQL :

--Utilisation d'une variable aléatoire
SELECT @ValeurAleatoire := (RAND() * COUNT(*)) FROM BENCH_Table;
SELECT ID FROM BENCH_Table WHERE ID = @ValeurAleatoire; 

Mais là, je ne peux pas benchmarker ! En théorie, ça marche…

Récupérer le dernier élément

Deuxième section donc, pour récupérer le dernier élément.

C'est encore plus simple que pour le RAND(), et les trois approches décrites ci-dessus sont possibles :

--Sous requête
SELECT ID
FROM BENCH_Table
WHERE ID =
	(
	SELECT MAX( ID )
	FROM BENCH_Table
	)
--Tri ID DESC
SELECT ID
FROM BENCH_Table
ORDER BY ID DESC
LIMIT 1 
--Jointure
SELECT ID
FROM BENCH_Table
JOIN (SELECT MAX(ID) AS Max FROM BENCH_Table) AS M ON M.Max = BENCH_Table.ID

Les résultats sont dans un mouchoir de poche : 356ms pour la sous-requête, 366ms pour le tri décroissant, et 441ms.

Si la sous-requête fonctionne bien ici, c'est parce qu'elle peut être mise en cache et n'a pas à être recalculée à chaque ligne. Un EXPLAIN nous indique « Select tables optimized away ».

Le tri décroissant lui se fait sur un index (« using index »), ce qui explique sa vélocité.

La jointure est un peu défavorisée (trois jointures) et devraient donc être oubliée pour ce cas particulier.

Conclusion

En conclusion : pour un élément aléatoire, ne pas utiliser ORDER BY RAND() ! En revanche, pour récupérer le dernier élément tout se vaut à peu près niveau temps de calcul. Pour la mémoire, c'est une autre paire de manches !

Et surtout, réfléchir à ce qu'on veut et ce qui est fait : la distinction est importante !