Si votre TTFB explose dès que vous ajoutez un filtre “prix”, “ville” ou “disponible” basé sur des champs personnalisés, il y a de fortes chances que ce soit WP_Query + meta_query qui mette MySQL à genoux.
WordPress 6.9.4 (avril 2026) reste très performant… tant que vous évitez deux classiques : les requêtes sur wp_postmeta non indexées et l’absence de cache applicatif sur des listes recalculées à chaque hit. Je vois ce scénario chaque semaine sur des sites Elementor/Divi/Avada qui ont “juste” ajouté quelques filtres.
Le problème de performance
Le symptôme typique : pages de listing (blog, catalogue, annuaire) qui passent de 200–400 ms à 2–8 s dès qu’un filtre “par méta” est activé. En prod, ça se traduit par :
- TTFB élevé (serveur lent à répondre), qui tire LCP vers le bas et dégrade les Core Web Vitals.
- CPU MySQL qui monte, souvent avec des requêtes qui font des full scans sur
wp_postmeta. - Cache page inefficace (pages filtrées = beaucoup de variantes d’URL, donc moins de hits cache).
Impact concret : baisse SEO (crawl moins efficace, LCP/INP), hausse du taux de rebond sur mobile, et un back-office qui devient lent quand des widgets “derniers posts filtrés” tournent en boucle.
À la fin, vous saurez :
- mesurer précisément le coût d’un
WP_Query(temps + SQL) ; - réécrire une
meta_querypour éviter les patterns qui empêchent les index ; - ajouter des index MySQL ciblés (en comprenant les risques) ;
- mettre en cache avec transients + invalidation réaliste ;
- vérifier l’amélioration avec des mesures reproductibles.
Résumé rapide
- Évitez
meta_queryavecLIKE,ORetCAST()quand vous voulez de la perf : ça casse souvent l’usage des index. - Indexez
wp_postmetasur(meta_key, meta_value)ou(meta_key, post_id)selon vos filtres, mais testez sur un clone (risque de verrouillage). - Cachez les IDs (ou les résultats agrégés) avec des transients + versioning, plutôt que de recalculer à chaque requête.
- Réduisez ce que WP_Query demande :
fields => 'ids',no_found_rows => true,update_post_meta_cache => falsesi vous n’en avez pas besoin. - Mesurez avant/après avec Query Monitor +
SAVEQUERIES+EXPLAINsur les requêtes lentes.
Diagnostic avec du code
1) Activer un diagnostic “safe” sur un environnement de staging
Sur un clone (ou au minimum avec un trafic faible), activez le log des requêtes et des erreurs. Dans wp-config.php :
<?php
// wp-config.php
// Journaliser les erreurs PHP (évitez l'affichage en production)
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
// Mesurer les requêtes SQL (coûteux, à activer temporairement)
define('SAVEQUERIES', true);
Piège courant : activer SAVEQUERIES en prod sur un site à fort trafic. J’ai déjà vu des sites ralentir de 10–20% juste à cause de ça.
2) Query Monitor : identifier rapidement la requête WP_Query fautive
Installez Query Monitor (plugin officiel) et repérez :
- les requêtes SQL les plus lentes ;
- les appels répétés (N+1) ;
- les requêtes sur
wp_postmetaavec des temps anormaux.
Source : Query Monitor (wordpress.org).
3) Logger le SQL exact et le temps d’un WP_Query (sans plugin)
Ajoutez un mini-mu-plugin pour mesurer un point précis (ex: template d’archive). Créez wp-content/mu-plugins/bpcab-perf-probe.php :
<?php
/**
* Plugin Name: BPCAB Perf Probe
* Description: Sonde simple pour mesurer WP_Query et afficher SQL/temps dans les logs.
*/
if (!defined('ABSPATH')) {
exit;
}
add_action('wp', function () {
if (!defined('SAVEQUERIES') || !SAVEQUERIES) {
return;
}
// Évitez de logguer pour tous les visiteurs si vous êtes en prod
if (!current_user_can('manage_options')) {
return;
}
global $wpdb;
$start = microtime(true);
// Exemple : requête volontairement "lourde" à diagnostiquer
$q = new WP_Query([
'post_type' => 'post',
'posts_per_page' => 12,
'meta_query' => [
[
'key' => 'city',
'value' => 'Paris',
'compare' => '='
],
],
]);
$elapsed = (microtime(true) - $start) * 1000;
// Log minimal : temps + SQL généré
error_log(sprintf('[PerfProbe] WP_Query: %.1fms - SQL: %s', $elapsed, $q->request));
// Log des 5 requêtes SQL les plus lentes sur la page
$queries = $wpdb->queries;
usort($queries, function ($a, $b) {
return $b[1] <=> $a[1];
});
$top = array_slice($queries, 0, 5);
foreach ($top as $item) {
// $item = [sql, time, caller]
error_log(sprintf('[PerfProbe] SQL %.3fs - %s - caller: %s', $item[1], $item[0], $item[2]));
}
}, 999);
Vous obtenez un SQL exploitable pour faire un EXPLAIN côté MySQL.
4) Slow Query Log MySQL (quand le problème est “intermittent”)
Si vous avez accès serveur, activez le slow query log côté MySQL/MariaDB (la config dépend de votre stack). L’objectif : capturer les requêtes > 0,5s et vérifier si elles viennent de wp_postmeta.
5) WP-CLI : reproduire et profiler sans navigateur
Exemples utiles :
# Vérifier la santé globale
wp core version
wp plugin list --status=active
wp theme list
# Vérifier la taille des tables (souvent révélateur pour wp_postmeta)
wp db size --tables
# Exporter une requête EXPLAIN (vous copiez/collez ensuite le SQL)
wp db query "SHOW INDEX FROM wp_postmeta;"
Référence WP-CLI : WP-CLI Commands (developer.wordpress.org).
Étape 1 : Réduire le coût des meta_query (et éviter les pièges)
Voici ce qui se passe en coulisses : une meta_query ajoute des JOIN sur wp_postmeta. Sur un site qui a des milliers de posts et des dizaines de metas par post, wp_postmeta devient gigantesque. Sans index adaptés, MySQL scanne trop.
Cas 1 : “LIKE %…%” (lent) vs normalisation (rapide)
Je croise souvent des metas multi-valeurs stockées en texte (CSV, JSON, sérialisation PHP) puis filtrées avec LIKE. Ça marche, mais c’est un anti-pattern performance.
AVANT (lent)
<?php
// Recherche d'un "tag" stocké dans une chaîne : "wifi,parking,pool"
$q = new WP_Query([
'post_type' => 'hotel',
'meta_query' => [
[
'key' => 'amenities',
'value' => 'wifi',
'compare' => 'LIKE', // Souvent LIKE '%wifi%' => index inutilisable
],
],
]);
APRÈS (optimisé) : passer par taxonomie (ou table dédiée)
Si c’est filtrable, transformez-le en taxonomie (indexée via les tables de termes). Sur WordPress 6.9.4, les requêtes de taxonomies sont généralement plus “prévisibles” côté SQL.
<?php
// Filtrer via une taxonomie "amenity" (wifi, parking, pool)
$q = new WP_Query([
'post_type' => 'hotel',
'tax_query' => [
[
'taxonomy' => 'amenity',
'field' => 'slug',
'terms' => ['wifi'],
],
],
]);
Mesure typique observée (sites réels) : une page qui passait de ~1200 ms à ~250–400 ms, sans même toucher aux index MySQL, juste en sortant de LIKE sur postmeta.
Cas 2 : comparer des nombres sans casser l’index
Le filtre “prix < 100” est un classique. Le piège : stocker des nombres comme chaînes incohérentes (“100”, “100.00”, “€100”) ou forcer des conversions qui empêchent l’index d’aider.
AVANT (lent et parfois faux)
<?php
// Meta "price" stockée parfois avec des formats différents => comparaisons instables
$q = new WP_Query([
'post_type' => 'product',
'meta_query' => [
[
'key' => 'price',
'value' => 100,
'compare' => '<=',
'type' => 'NUMERIC', // WP cast côté SQL, mais si la donnée est sale => MySQL souffre
]
],
]);
APRÈS (optimisé) : stocker une méta numérique normalisée
Gardez votre champ “prix affiché” si vous voulez, mais ajoutez un champ technique price_cents (entier). Ça réduit les casts et rend l’indexation utile.
<?php
// Filtre sur un entier (cents) : plus stable, plus indexable
$max_eur = 100;
$q = new WP_Query([
'post_type' => 'product',
'meta_query' => [
[
'key' => 'price_cents',
'value' => $max_eur * 100,
'compare' => '<=',
'type' => 'NUMERIC',
]
],
]);
Si vous migrez, faites-le via WP-CLI (batch) pour éviter les timeouts HTTP.
Cas 3 : OR dans meta_query (souvent catastrophique)
Une requête du type “ville = Paris OU Lyon” est tentante. En SQL, le OR sur des jointures de postmeta peut empêcher un plan d’exécution efficace.
AVANT (lent)
<?php
$q = new WP_Query([
'post_type' => 'event',
'meta_query' => [
'relation' => 'OR',
[
'key' => 'city',
'value' => 'Paris',
'compare' => '=',
],
[
'key' => 'city',
'value' => 'Lyon',
'compare' => '=',
],
],
]);
APRÈS (souvent plus rapide) : IN sur une seule clause
<?php
$q = new WP_Query([
'post_type' => 'event',
'meta_query' => [
[
'key' => 'city',
'value' => ['Paris', 'Lyon'],
'compare' => 'IN',
],
],
]);
Ça ne règle pas tout, mais ça simplifie le SQL et aide MySQL à mieux utiliser un index sur (meta_key, meta_value).
Étape 2 : Ajouter des index MySQL ciblés (postmeta, options) sans casser WordPress
Quand wp_postmeta dépasse quelques millions de lignes, les index par défaut ne suffisent plus pour certains patterns. WordPress core ne crée pas d’index “magiques” parce que les usages varient, mais vous pouvez en ajouter.
Risque réel : ajouter un index sur une table volumineuse peut verrouiller la table et provoquer une indisponibilité. Faites-le sur un clone, mesurez, puis planifiez une fenêtre de maintenance. Sur certaines configs,
ALGORITHM=INPLACE/LOCK=NONEpeut aider, mais ce n’est pas garanti selon moteur/version.
1) Vérifier les index existants
wp db query "SHOW INDEX FROM wp_postmeta;"
wp db query "SHOW TABLE STATUS LIKE 'wp_postmeta';"
2) Index recommandés selon votre usage
Deux index sont souvent utiles (pas toujours les deux) :
- (meta_key, meta_value) : pour
meta_key = X AND meta_value = Y(égalité, IN). - (meta_key, post_id) : pour récupérer vite les metas d’un ensemble de posts, ou certaines jointures.
3) Ajouter un index (exemples SQL)
Option A : index pour filtres “clé + valeur”.
wp db query "ALTER TABLE wp_postmeta ADD INDEX bpcab_key_value (meta_key(191), meta_value(191));"
Pourquoi (191) ? Sur certains charsets/collations, indexer toute la longueur d’un LONGTEXT ou TEXT est impossible. Un préfixe est un compromis courant. Testez : si votre meta_value est souvent longue (JSON), cet index aura un intérêt limité.
Option B : index pour patterns “clé + post_id”.
wp db query "ALTER TABLE wp_postmeta ADD INDEX bpcab_key_post (meta_key(191), post_id);"
4) Index sur wp_options (transients/autoload)
Quand vous abusez des transients (ou que des plugins les laissent pourrir), wp_options gonfle. Deux points :
- Les options
autoload = 'yes'sont chargées à chaque requête (front et admin). - Les transients “options” sont stockés dans
wp_optionssi vous n’avez pas d’object cache persistant.
Vérifiez l’autoload :
wp db query "SELECT SUM(LENGTH(option_value)) AS bytes FROM wp_options WHERE autoload='yes';"
wp db query "SELECT option_name, LENGTH(option_value) AS bytes FROM wp_options WHERE autoload='yes' ORDER BY bytes DESC LIMIT 20;"
WordPress a déjà des index sur option_name. Si votre table est énorme, le problème est plus souvent “trop d’autoload” que “pas assez d’index”. Dans mon expérience, réduire l’autoload a un impact plus net que d’ajouter des index ici.
Référence DB schema (core) : schema.php (github.com/WordPress).
Étape 3 : Mettre en cache intelligemment (transients + invalidation)
Le cache “qui marche” sur les listings filtrés, c’est souvent : mettre en cache la liste d’IDs (ou un résultat agrégé), puis refaire une requête légère pour hydrater les posts. Ça évite de réexécuter la grosse jointure meta/tax à chaque hit.
1) AVANT : recalcul à chaque requête
<?php
function bpcab_get_featured_hotels_ids_slow(): array {
$q = new WP_Query([
'post_type' => 'hotel',
'posts_per_page' => 24,
'meta_query' => [
[
'key' => 'city',
'value' => 'Paris',
'compare' => '=',
],
[
'key' => 'rating',
'value' => 4,
'compare' => '>=',
'type' => 'NUMERIC',
],
],
'orderby' => 'date',
'order' => 'DESC',
]);
return $q->posts; // objets WP_Post => plus lourd
}
2) APRÈS : cache des IDs + versioning d’invalidation
Je préfère le versioning à la suppression “au jugé”. Vous stockez une version (un compteur) qui change quand un contenu pertinent change. La clé du transient inclut cette version.
<?php
/**
* Retourne une version (entier) qui change quand un hôtel est modifié.
* Avantage : pas besoin de parcourir toutes les clés de transients pour les supprimer.
*/
function bpcab_hotels_cache_version(): int {
$v = (int) get_option('bpcab_hotels_cache_v', 1);
return max(1, $v);
}
/**
* Incrémente la version quand un contenu "hotel" change.
* Attention : hook appelé souvent, gardez-le léger.
*/
add_action('save_post_hotel', function ($post_id, $post, $update) {
// Sécurité : ne pas invalider sur autosave/révisions
if (wp_is_post_autosave($post_id) || wp_is_post_revision($post_id)) {
return;
}
// Évitez les boucles si un autre hook met à jour le post
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
$v = bpcab_hotels_cache_version();
update_option('bpcab_hotels_cache_v', $v + 1, false); // autoload=false
}, 10, 3);
/**
* Récupère des IDs filtrés avec cache.
*/
function bpcab_get_featured_hotels_ids_cached(string $city, int $min_rating, int $limit = 24): array {
$v = bpcab_hotels_cache_version();
// Clé courte et stable (évitez de dépasser la limite de taille)
$key = 'bpcab_hotels_' . md5($v . '|' . $city . '|' . $min_rating . '|' . $limit);
$cached = get_transient($key);
if (is_array($cached)) {
return $cached;
}
$t0 = microtime(true);
$q = new WP_Query([
'post_type' => 'hotel',
'posts_per_page' => $limit,
'fields' => 'ids', // On ne veut que les IDs
'no_found_rows' => true, // Pas de pagination => pas de SQL_CALC_FOUND_ROWS
'update_post_meta_cache' => false, // On n'hydrate pas tout
'update_post_term_cache' => false,
'meta_query' => [
[
'key' => 'city',
'value' => $city,
'compare' => '=',
],
[
'key' => 'rating',
'value' => $min_rating,
'compare' => '>=',
'type' => 'NUMERIC',
],
],
'orderby' => 'date',
'order' => 'DESC',
]);
$ids = array_map('intval', $q->posts);
// TTL : ajustez selon votre besoin (ici 10 minutes)
set_transient($key, $ids, 10 * MINUTE_IN_SECONDS);
$ms = (microtime(true) - $t0) * 1000;
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf('[HotelsCache] MISS %.1fms city=%s rating=%d', $ms, $city, $min_rating));
}
return $ids;
}
/**
* Hydrate ensuite les posts avec une requête IN, souvent plus simple.
*/
function bpcab_get_hotels_posts_from_ids(array $ids): array {
if (!$ids) {
return [];
}
$q = new WP_Query([
'post_type' => 'hotel',
'post__in' => $ids,
'orderby' => 'post__in', // Conserver l'ordre des IDs
'posts_per_page' => count($ids),
]);
return $q->posts;
}
Pourquoi ça accélère : la requête “lourde” ne tourne plus à chaque hit. Et quand elle tourne, elle retourne des IDs seulement, sans charger meta/terms inutilement.
3) Bonus : object cache persistant = transients plus efficaces
Sans object cache persistant, les transients sont en base, donc vous remplacez une grosse requête par une requête plus simple sur wp_options. Avec Redis/Memcached, vous sortez du disque.
Référence : Transients API (developer.wordpress.org).
Étape 4 : Micro-optimisations WP_Query (fields, no_found_rows, tax_query)
Ce n’est pas “le” gain principal, mais sur un site à trafic, ces réglages font une vraie différence.
1) Désactiver le comptage total quand vous ne paginez pas
AVANT
<?php
$q = new WP_Query([
'post_type' => 'post',
'posts_per_page' => 10,
]);
APRÈS
<?php
$q = new WP_Query([
'post_type' => 'post',
'posts_per_page' => 10,
'no_found_rows' => true, // Évite la requête de comptage
]);
2) Ne chargez pas les caches meta/terms si vous ne les utilisez pas
Sur des modules Elementor/Divi qui affichent juste titres + images, j’ai souvent vu update_post_meta_cache inutilement à true (par défaut). Sur des pages avec 6–10 modules de listing, ça multiplie les requêtes.
<?php
$q = new WP_Query([
'post_type' => 'post',
'posts_per_page' => 6,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
]);
3) fields => ‘ids’ pour les requêtes “intermédiaires”
Pattern recommandé : une requête “filtrante” en IDs, puis une requête “affichage” plus simple.
<?php
$ids_q = new WP_Query([
'post_type' => 'event',
'posts_per_page' => 50,
'fields' => 'ids',
'no_found_rows' => true,
// ... filtres meta/tax coûteux
]);
$posts_q = new WP_Query([
'post_type' => 'event',
'post__in' => $ids_q->posts,
'orderby' => 'post__in',
'posts_per_page' => 50,
]);
4) Compatibilité Divi 5 / Elementor / Avada : où mettre ce code
Sur des sites avec page builders, le code de requête est souvent dans :
- un plugin “snippets” (pratique, mais j’ai vu des snippets casser après une mise à jour) ;
- un thème enfant (plus stable) ;
- un plugin custom (idéal pour la maintenance).
Si vous devez exposer ces résultats à un builder :
- Elementor : utilisez un shortcode qui retourne du HTML, ou un widget custom si vous avez déjà une base de dev.
- Divi 5 : même approche, shortcode ou module custom ; évitez de recalculer la requête dans render sans cache.
- Avada : shortcode compatible Fusion Builder, même logique.
Exemple minimal de shortcode qui exploite le cache d’IDs :
<?php
add_shortcode('bpcab_hotels', function ($atts) {
$atts = shortcode_atts([
'city' => 'Paris',
'min_rating' => 4,
'limit' => 12,
], $atts, 'bpcab_hotels');
$ids = bpcab_get_featured_hotels_ids_cached(
(string) $atts['city'],
(int) $atts['min_rating'],
(int) $atts['limit']
);
$posts = bpcab_get_hotels_posts_from_ids($ids);
// HTML volontairement simple (à styler via le builder)
$out = '<div class="bpcab-hotels">';
foreach ($posts as $p) {
$out .= sprintf(
'<div class="bpcab-hotel"><a href="%s">%s</a></div>',
esc_url(get_permalink($p)),
esc_html(get_the_title($p))
);
}
$out .= '</div>';
return $out;
});
Configuration serveur
Sur ce sujet précis (WP_Query/meta_query/index/transients), la config serveur utile sert surtout à : (1) mieux observer, (2) éviter les limites qui masquent le problème, (3) activer un object cache.
1) wp-config.php : journalisation et garde-fous
<?php
// wp-config.php
// Limiter le nombre de révisions (réduit wp_posts/wp_postmeta sur certains sites)
define('WP_POST_REVISIONS', 20);
// Désactiver l'édition de fichiers dans l'admin (sécurité)
define('DISALLOW_FILE_EDIT', true);
Référence : wp-config.php (developer.wordpress.org).
2) PHP (php.ini) : OPcache (si vous avez la main)
OPcache ne résout pas un mauvais SQL, mais évite d’ajouter du CPU PHP par-dessus. Exemple (à adapter à votre hébergeur) :
; php.ini (exemple)
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=2
Référence : PHP OPcache (php.net).
3) Object cache persistant (Redis/Memcached)
Pour que les transients et l’object cache aient un vrai impact, installez un object cache persistant (via plugin + drop-in object-cache.php). Le choix dépend de votre infra. Vérifiez ensuite que wp_cache_get() est bien backed par Redis/Memcached.
Référence : wp_cache_get() (developer.wordpress.org).
4) .htaccess : rien de magique pour MySQL, mais évitez les surcouches
Je le dis parce que je le vois : ajouter des règles “cache” Apache ne corrigera pas une meta_query lente sur des URLs uniques (filtrées). Travaillez d’abord la requête et le cache applicatif.
Vérification des résultats
Vous voulez des mesures reproductibles, pas un “ça a l’air mieux”. Voici une routine simple.
1) Mesurer le temps côté PHP (avant/après)
<?php
function bpcab_bench(callable $fn, int $n = 10): array {
$times = [];
for ($i = 0; $i < $n; $i++) {
$t0 = microtime(true);
$fn();
$times[] = (microtime(true) - $t0) * 1000;
}
sort($times);
return [
'min_ms' => $times[0],
'p50_ms' => $times[(int) floor($n * 0.5)],
'p90_ms' => $times[(int) floor($n * 0.9)],
'max_ms' => $times[$n - 1],
];
}
// Exemple d'usage (à lancer en admin seulement)
add_action('admin_init', function () {
if (!current_user_can('manage_options')) {
return;
}
$res = bpcab_bench(function () {
bpcab_get_featured_hotels_ids_cached('Paris', 4, 24);
}, 15);
error_log('[Bench] ' . wp_json_encode($res));
});
2) Vérifier le plan d’exécution (EXPLAIN)
Copiez le SQL depuis Query Monitor / logs, puis :
wp db query "EXPLAIN SELECT ...VOTRE_SQL..."
Vous cherchez :
- type : éviter
ALLsurwp_postmeta(scan complet) ; - key : voir votre index
bpcab_key_valueoubpcab_key_postutilisé ; - rows : estimation beaucoup plus basse après index.
3) Core Web Vitals : vérifier l’effet “TTFB”
Le SQL plus rapide se voit souvent directement sur le TTFB. Pour isoler, testez sans cache page et comparez. Ensuite testez avec cache page + CDN.
Si les performances ne s’améliorent pas
Quand l’index + cache ne changent presque rien, le problème est souvent ailleurs, ou plus subtil.
1) Votre requête n’utilise pas l’index (à cause du SQL généré)
- Compare :
LIKE,REGEXP,NOT EXISTSpeuvent ruiner l’indexation. - Type :
type => 'NUMERIC'+ données sales = conversions coûteuses. - OR : relation
ORsur plusieurs clauses meta.
Vérifiez le SQL exact et son EXPLAIN. C’est la seule vérité.
2) Vous avez un N+1 de metas/terms après la requête
Vous optimisez le WP_Query, puis un template appelle get_post_meta() 30 fois dans une boucle, et vous perdez tout. Query Monitor le montre très bien.
3) Conflit avec cache page / variations d’URL
Les pages filtrées (paramètres GET) peuvent bypasser le cache page selon votre plugin/CDN. Dans ce cas, le cache transients + object cache devient encore plus important.
4) Un plugin reconstruit la requête
Sur Elementor/Divi/Avada, certains modules ajoutent des filtres pre_get_posts ou des requêtes secondaires. Désactivez temporairement les modules de listing et isolez.
Pièges et erreurs courantes
Erreurs que je vois souvent en dépannage
- Copier un
ALTER TABLEtrouvé sur un forum et le lancer en prod en pleine journée (verrouillage, timeout). - Ajouter un index mais sur la mauvaise table (préfixe
wp_différent) ou sur une base différente. - Mettre le code d’invalidation de cache sur
save_postsans filtrer autosaves/révisions (version qui incrémente 15 fois). - Oublier de vider le cache (page/CDN) et conclure que “ça ne marche pas”.
- Coller un snippet dans un plugin de snippets qui s’exécute trop tôt (fonction appelée avant chargement complet), ou avec une parenthèse manquante (fatal error).
- Suivre un vieux tutoriel qui recommande des constantes/approches obsolètes, ou du code non compatible PHP 8.1 (warnings qui deviennent fatals selon config).
Tableau de diagnostic
| Symptôme | Cause probable | Vérification | Solution |
|---|---|---|---|
| TTFB > 2s sur pages filtrées | meta_query sur wp_postmeta sans index adapté |
Query Monitor : requête lente sur postmeta + EXPLAIN = scan ALL |
Réécrire meta_query (IN, éviter LIKE/OR), ajouter index (meta_key, meta_value) |
| Amélioration faible après ajout d’index | SQL généré non indexable (LIKE %…%, CAST, OR) | EXPLAIN : colonne key vide ou index non choisi |
Normaliser données (taxonomie, champ numérique dédié), simplifier la requête |
| MySQL CPU haut même quand la page est “cachée” | Cache page bypass (paramètres GET, cookies, headers) | Logs serveur / plugin cache : “MISS” systématiques | Cache applicatif (transients + object cache), règles de cache adaptées |
| Beaucoup de requêtes après optimisation WP_Query | N+1 via get_post_meta()/get_the_terms() dans la boucle |
Query Monitor : répétitions “duplicated queries” | Précharger caches (ou au contraire les désactiver si inutiles), regrouper les accès |
| Admin lent partout | wp_options autoload énorme |
SQL SUM(LENGTH(option_value)) WHERE autoload='yes' |
Réduire autoload (plugins bavards), nettoyer transients, object cache persistant |
| Erreur 500 après ajout du code | Snippet cassé (point-virgule, hook, fichier au mauvais endroit) | wp-content/debug.log + logs PHP-FPM/Apache |
Déployer via mu-plugin, valider syntaxe, rollback, tests sur staging |
Conseils de maintenance
- Documentez vos index custom (nom, date, raison). Un index “mystère” finit souvent supprimé par erreur.
- Surveillez la croissance de
wp_postmetaetwp_options(mensuel). Les problèmes reviennent quand un plugin ajoute des metas en masse. - Testez vos requêtes filtrées avec des données réalistes (même volume qu’en prod). Sur 500 posts, tout paraît rapide.
- Versionnez vos clés de cache (comme montré) plutôt que de multiplier les
delete_transient()fragiles. - Évitez de mettre des requêtes complexes dans des shortcodes appelés 8 fois sur la même page (modules builder). Cachez et mutualisez.
Ressources
- WP_Query (developer.wordpress.org)
- WP_Meta_Query (developer.wordpress.org)
- Transients API (developer.wordpress.org)
- Query Monitor (wordpress.org)
- Schéma des tables WordPress (github.com/WordPress)
- WordPress Core Trac (core.trac.wordpress.org)
- OPcache (php.net)
FAQ
Est-ce que je dois toujours ajouter un index sur wp_postmeta ?
Non. Si vos requêtes n’utilisent presque pas meta_query, ou si votre volume est faible, vous ajoutez surtout du coût en écriture (insert/update plus lents). Ajoutez un index quand vous avez un symptôme mesuré et un SQL qui en bénéficierait.
Quel index choisir : (meta_key, meta_value) ou (meta_key, post_id) ?
Si vous filtrez principalement par “clé = X et valeur = Y/IN”, (meta_key, meta_value) est souvent plus rentable. Si vous récupérez des metas d’un ensemble de posts (ou si vos jointures s’alignent mieux), (meta_key, post_id) peut aider. Le EXPLAIN tranche.
Pourquoi mon index n’est pas utilisé après l’avoir ajouté ?
Parce que le SQL généré ne permet pas à MySQL de l’exploiter (LIKE avec joker au début, OR, casts, faible sélectivité). Ou parce que l’index est mal dimensionné (préfixe trop court) et MySQL estime qu’il n’apporte rien.
Les transients sont-ils “sûrs” pour des données critiques ?
Non. Un transient peut expirer ou être évincé (surtout avec Redis). Il faut toujours prévoir un recalcul. Pour des données critiques, stockez en base (options, tables dédiées) et utilisez le cache comme accélérateur.
Je n’ai pas Redis/Memcached. Les transients servent quand même ?
Oui, mais l’impact est moindre : vous remplacez une grosse requête par une lecture dans wp_options. Ça reste souvent gagnant, surtout si vous cachez des IDs et que la clé est bien choisie.
Comment éviter que mon cache serve des résultats obsolètes ?
Utilisez une stratégie d’invalidation explicite : versioning (compteur) sur save_post_{post_type}, et TTL raisonnable. Évitez l’invalidation “globale” trop fréquente qui annule l’intérêt du cache.
Est-ce que no_found_rows casse la pagination ?
Oui. À activer uniquement si vous n’avez pas besoin de found_posts / pagination. Sur un listing paginé, laissez à false.
Pourquoi fields => 'ids' est si efficace ?
Parce que WordPress évite de construire des objets WP_Post complets et limite certains chargements associés. Couplé à la désactivation des caches meta/terms, vous réduisez le travail PHP et les requêtes secondaires.
Mon builder (Elementor/Divi/Avada) affiche un listing : où placer l’optimisation ?
Le plus stable : un plugin custom ou un mu-plugin. Ensuite exposez les résultats via un shortcode ou un widget/module custom. Évitez les requêtes lourdes directement dans le rendu d’un module sans cache.
Que faire si mon filtre doit absolument utiliser du texte (recherche partielle) ?
Si c’est une vraie recherche, envisagez un moteur adapté (full-text, Elastic/OpenSearch) ou une structure de données différente (taxonomie, table dédiée). LIKE %...% sur postmeta ne passera pas à l’échelle.