Douze secondes. C’est le temps qu’affichait systématiquement le chronomètre de Query Monitor sur la page d’administration listant les dons d’une association, dès que la responsable des adhésions tentait de filtrer par montant supérieur à un seuil ou par statut de reçu fiscal envoyé.
L’association gérait plusieurs milliers de donateurs, chaque don étant enregistré comme un article personnalisé (don) avec des métadonnées : montant, date, statut de synchronisation Stripe, statut d’envoi du reçu fiscal. La page d’administration listait ces dons via une WP_Query avec une meta_query combinant plusieurs critères.
Symptôme : une page qui rampe dès qu’on filtre
Sans filtre actif, la liste des dons récents s’affichait normalement. Dès qu’un filtre combinait deux critères de métadonnées (montant et statut de reçu, par exemple), le temps de chargement s’effondrait littéralement, passant de moins d’une seconde à une douzaine de secondes, au point que certains navigateurs affichaient un avertissement de script long.
Diagnostic : une meta_query à deux clauses sur une table géante

Query Monitor a révélé la requête SQL générée par la meta_query, avec deux jointures successives sur wp_postmeta :
SELECT wp_posts.* FROM wp_posts
INNER JOIN wp_postmeta AS mt1 ON (wp_posts.ID = mt1.post_id)
INNER JOIN wp_postmeta AS mt2 ON (wp_posts.ID = mt2.post_id)
WHERE wp_posts.post_type = 'don'
AND ( (mt1.meta_key = 'montant' AND mt1.meta_value+0 >= 100)
AND (mt2.meta_key = 'statut_recu' AND mt2.meta_value = 'envoye') )
Le cœur du problème tenait dans la clause mt1.meta_value+0 >= 100 : l’opérateur +0, utilisé pour forcer une comparaison numérique sur une colonne meta_value stockée en LONGTEXT, empêche purement et simplement MySQL d’utiliser un quelconque index sur cette colonne. Chaque ligne de wp_postmeta correspondant à la clé montant devait être convertie et comparée individuellement, sur une table qui dépassait les 800 000 lignes toutes métadonnées de dons confondues.
La table wp_postmeta possède bien un index sur meta_key, ce qui permet de restreindre rapidement l’ensemble des lignes candidates à une clé donnée, mais aucun index n’existe nativement sur meta_value, et encore moins sur une conversion numérique de cette colonne à la volée.
Le correctif : une table personnalisée indexée
Plutôt que de tenter d’optimiser la requête meta_query elle-même — dont les marges de manœuvre restent limitées face à la structure générique de wp_postmeta — le choix s’est porté sur une table dédiée aux dons, avec un typage natif et des index adaptés aux filtres réellement utilisés par l’association :
CREATE TABLE wp_dons_index (
post_id BIGINT UNSIGNED PRIMARY KEY,
montant DECIMAL(10,2) NOT NULL,
statut_recu VARCHAR(20) NOT NULL,
date_don DATETIME NOT NULL,
INDEX idx_montant (montant),
INDEX idx_statut (statut_recu)
);
Cette table est tenue à jour via un hook save_post_don, en miroir des métadonnées existantes, sans rien retirer côté wp_postmeta pour ne pas casser la compatibilité avec d’éventuelles extensions tierces qui liraient ces métadonnées directement. La page d’administration a ensuite été réécrite pour interroger cette table via $wpdb plutôt que passer par une meta_query :
$resultats = $wpdb->get_results( $wpdb->prepare(
"SELECT post_id FROM {$wpdb->prefix}dons_index
WHERE montant >= %f AND statut_recu = %s
ORDER BY date_don DESC LIMIT 50",
100, 'envoye'
) );
Résultat mesuré
| Scénario | Avant correctif | Après correctif |
|---|---|---|
| Filtre montant seul | 7,2 s | 0,3 s |
| Filtre montant + statut reçu | 12 s | 0,4 s |
Prévention pour les associations à fort volume
- Éviter les comparaisons numériques ou les tris sur
meta_valuedès que le volume de métadonnées dépasse quelques dizaines de milliers de lignes pour une même clé. - Envisager une table miroir indexée dès qu’une page d’administration doit filtrer régulièrement sur un critère numérique ou une plage de dates.
- Garder
wp_postmetacomme source de vérité pour la compatibilité, la table miroir n’étant qu’un index de lecture optimisé, jamais la source de vérité elle-même.
Une meta_query à deux clauses numériques sur une table de plusieurs centaines de milliers de lignes n’est jamais un problème de configuration : c’est un problème de modèle de données qu’aucun réglage MySQL ne résoudra durablement.
En résumé
Ce dossier ne traite volontairement pas la façon dont Stripe est intégré côté paiement : le problème restait entièrement circonscrit à la couche de lecture et de filtrage des dons dans l’administration WordPress. La leçon la plus généralisable reste que wp_postmeta, conçu pour la flexibilité, atteint vite ses limites de performance dès qu’on lui demande de se comporter comme une table relationnelle typée et indexée.