Tout répond correctement sur votre boutique, sauf une page catégorie qui met plusieurs secondes avant d’apparaître. Neuf fois sur dix, c’est le même coupable : le module de recherche à facettes. Et le geste réflexe, en général, c’est de poser des index au jugé puis de vider des caches. Moi, je fonctionne dans l’autre sens. Mesurez d’abord.
Un avertissement avant d’attaquer. Aucun chiffrage du gain ici, et c’est voulu : ce que vous gagnerez tient à votre catalogue, à votre machine, aux filtres restés allumés (celui qui vous annonce 40 % de mieux n’a jamais vu ni l’un ni l’autre). Je vous livre un protocole de mesure, plus les réglages dont je suis allé contrôler le comportement dans les sources du module et du cœur, en 1.7.8 puis en 8.x et 9.x.

Mesurer avant de toucher à quoi que ce soit
Un profileur est livré d’origine avec PrestaShop. Page par page, il déballe en bas d’écran le temps consommé par chaque requête SQL, chaque hook, chaque module. Son interrupteur, c’est la constante _PS_DEBUG_PROFILING_. Sur 8.x comme sur 9.x, il se trouve dans Paramètres avancés puis Performances, au bloc du mode debug. Sur 1.7.8, aucun bouton. Vous devrez poser cette constante au fond d’un fichier.
Et attention, pas n’importe lequel. Celui du cœur, ce n’est pas lui qu’on ouvre. PrestaShop charge config/defines_custom.inc.php avant config/defines.inc.php, si bien que c’est le premier qui l’emporte sur cette constante, et vos réglages franchissent les montées de version sans y laisser de plumes. Pour ma part, je ne l’allume que le temps du relevé, et sur une recette chaque fois que j’en ai une, parce qu’un profileur alourdit l’affichage et déballe des informations techniques sous le nez du premier venu. En production, coupez-le sitôt le relevé bouclé, en relevant à quelle heure précise il a tourné (vous jetterez ensuite les chiffres que son propre poids a faussés).
<?php
// config/defines_custom.inc.php
// Inclus par config/config.inc.php avant config/defines.inc.php.
// PrestaShop lit ce fichier en priorite pour _PS_MODE_DEV_ et
// _PS_DEBUG_PROFILING_ : vous ne touchez donc aucun fichier du core.
// Le profileur charge tools/profiling/*.php et affiche, en bas de page,
// le detail des requetes SQL, des hooks et des modules.
define('_PS_DEBUG_PROFILING_', true);
// A n'activer que sur une copie de recette, ou sur la production
// pendant la seule duree de la mesure : le profileur ralentit les pages
// et expose des informations techniques a tous les visiteurs.
define('_PS_MODE_DEV_', true);
// Sur PrestaShop 8 et 9, le meme interrupteur existe dans
// Parametres avances > Performances > Mode debug > Debug profiling.
// Sur 1.7.8, il n'y a pas d'interrupteur : ce fichier est la seule voie.
Lire le profileur et le journal des requêtes lentes
Le profileur ne s’intéresse qu’à une chose, la portion du temps de rendu qu’absorbe le module de facettes. Le pourquoi, lui, lui échappe. Enchaînez donc sur ce que MySQL consigne dans son journal des requêtes lentes : la durée effective de chacune et, point capital, le nombre de lignes parcourues. Le rapport entre lignes parcourues et lignes retournées, voilà ce que je lis en premier : c’est lui qui dénonce une table balayée de bout en bout. Et si cette part SQL demeure modeste pendant que la page s’éternise quand même, le coût s’est déplacé vers ce qui transite : travailler le poids des images du catalogue vous rendra alors davantage que le plus beau des index.
Descendez le seuil autour d’une seconde, puis laissez tourner sur une tranche de trafic qui ressemble à vos vraies journées. En sortie table, la lecture se fait en SQL, sans avoir à pister un fichier dont l’emplacement change selon l’hébergeur. Avant la moindre modification, mettez de côté la requête qui coûte le plus, avec son plan d’exécution : privé de cette photo initiale, vous ne pourrez jamais dire si tel changement a produit le moindre effet, et on se retrouve avec une pile de réglages dont personne ne sait plus lequel a joué.
-- Prefixe ps_ suppose : adaptez-le a votre installation.
-- 1. Etat du journal des requetes lentes. long_query_time est en secondes
-- et accepte les decimales : sur une page catalogue, 1 seconde laisse
-- passer l'essentiel de ce qu'on cherche.
SHOW VARIABLES LIKE 'slow_query_log';
SHOW VARIABLES LIKE 'slow_query_log_file';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'log_output';
-- 2. Activation temporaire, le temps de la mesure. Ces reglages sont
-- perdus au redemarrage du serveur, ce qui est voulu.
-- SET GLOBAL log_output = 'TABLE';
-- SET GLOBAL long_query_time = 1;
-- SET GLOBAL slow_query_log = 'ON';
-- 3. Avec log_output a TABLE, les requetes lentes sont lisibles en SQL.
SELECT start_time,
query_time,
rows_examined,
rows_sent,
LEFT(CONVERT(sql_text USING utf8mb4), 300) AS debut_requete
FROM mysql.slow_log
WHERE CONVERT(sql_text USING utf8mb4) LIKE '%layered%'
OR CONVERT(sql_text USING utf8mb4) LIKE '%category_product%'
ORDER BY query_time DESC
LIMIT 20;
-- 4. Ce qui tourne en ce moment, utile pendant un pic.
SELECT id, time, state, LEFT(info, 200) AS debut_requete
FROM information_schema.processlist
WHERE command <> 'Sleep'
ORDER BY time DESC;
Ce que le module interroge réellement
Au moment d’afficher, rien ne renvoie le module vers les tables d’origine des combinaisons et des prix. Son terrain de jeu, ce sont des tables à plat dont il assure seul l’entretien, préfixées layered (vestige de l’ancien nom du module). La première retient l’intervalle de prix, décliné par produit, par devise, par boutique et par pays (quatre dimensions, et un volume qui enfle vite). La deuxième relie produits et attributs. La dernière mémorise, catégorie après catégorie, la configuration des blocs de filtres.
Par-dessus, son adaptateur MySQL monte les jointures selon les filtres actifs du moment : stock, fournisseurs, marques, caractéristiques, combinaisons, catégories. Ajoutez un filtre au modèle, c’est une jointure de plus et un comptage de plus. En général, c’est donc le nombre de filtres allumés qui pèse, davantage que la taille du catalogue, et je commence par ouvrir cette case-là quand je configure le module. Voici deux requêtes : elles vous listent les index présents et ce que pèsent vraiment ces tables (ayez ces deux relevés devant vous avant d’entamer la moindre discussion sur l’optimisation).
-- Inventaire des index reellement presents sur les tables du module
-- et sur les tables du catalogue qu'il joint.
SELECT TABLE_NAME,
INDEX_NAME,
SEQ_IN_INDEX,
COLUMN_NAME,
NON_UNIQUE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN (
'ps_layered_price_index',
'ps_layered_product_attribute',
'ps_layered_category',
'ps_layered_filter_block',
'ps_category_product',
'ps_product_attribute_combination',
'ps_stock_available',
'ps_feature_product',
'ps_product_shop'
)
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
-- Volumetrie et taille des index, pour savoir ou se joue la lenteur.
SELECT TABLE_NAME,
TABLE_ROWS,
ROUND(DATA_LENGTH / 1024 / 1024, 1) AS donnees_mo,
ROUND(INDEX_LENGTH / 1024 / 1024, 1) AS index_mo,
ENGINE
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME LIKE 'ps_layered%'
ORDER BY DATA_LENGTH DESC;
Les index : vérifier avant d’ajouter
Côté catalogue, je trouve l’indexation déjà faite. Sur la table de stock, une clé unique couvre le quadruplet produit, déclinaison, boutique et groupe, les combinaisons se contentent d’une clé primaire doublée d’un index inverse, et l’association catégorie-produit empile deux index secondaires par-dessus sa clé primaire composite : ajouter le vôtre à cet existant se paie en écriture sans vous rapporter quoi que ce soit en lecture.
Le gros problème, c’est l’index envolé. Un moteur de stockage changé en chemin, une restauration incomplète, un import passé par script : voilà ce qui laisse des tables amputées de leurs clés. Alors confrontez la structure que vous avez à celle que le module installe au départ, et ne rétablissez rien d’autre que la clé absente, une par une, sur une base dont vous aurez pris une sauvegarde. Sur une table épaisse, l’écriture se verrouille pendant toute la reconstruction. Programmez-le de nuit (croyez-moi, tout sauf un mardi à 15h).
-- A n'executer QUE si la requete precedente montre un index absent. -- Ces definitions sont celles que le module cree lui-meme a l'installation : -- on restaure l'existant, on n'invente pas de nouvel index. -- Structure attendue de la table de prix : -- PRIMARY KEY (id_product, id_currency, id_shop, id_country) -- INDEX id_currency (id_currency) -- INDEX price_min (price_min) -- INDEX price_max (price_max) SHOW CREATE TABLE ps_layered_price_index; -- Exemples de restauration, un index a la fois, sur une base sauvegardee. -- ALTER TABLE ps_layered_price_index ADD INDEX id_currency (id_currency); -- ALTER TABLE ps_layered_price_index ADD INDEX price_min (price_min); -- ALTER TABLE ps_layered_price_index ADD INDEX price_max (price_max); -- Structure attendue de la table des attributs indexes : -- PRIMARY KEY (id_attribute, id_product, id_shop) -- UNIQUE id_attribute_group (id_attribute_group, id_attribute, id_product, id_shop) SHOW CREATE TABLE ps_layered_product_attribute; -- Apres toute modification, on rafraichit les statistiques de l'optimiseur, -- puis on relit le plan d'execution de la requete qui posait probleme. ANALYZE TABLE ps_layered_price_index, ps_layered_product_attribute, ps_category_product; -- EXPLAIN SELECT ... ;
Le cache de blocs de filtres
Le module traîne son propre cache applicatif, qui n’a rien de commun avec Smarty ni avec celui de Symfony. Il ne prend pas la place de ce qui se joue côté serveur : la compression GZip et le cache navigateur se paramètrent dans le htaccess, et portent sur ce qui transite, quand lui porte sur ce qui se calcule. Ce qu’il dépose dans sa table, c’est le rendu déjà fabriqué pour chaque combinaison de filtres, rangé sous l’empreinte de la requête. Réglage coupé, et voilà chaque visiteur qui relance le calcul entier, y compris les comptages valeur de filtre par valeur de filtre.
Il se vide tout seul à la moindre occasion : une catégorie retouchée, un groupe d’attributs retouché, un attribut retouché. Sur une boutique dont le catalogue remue sans arrêt, il repart à zéro plusieurs fois par heure sans jamais rien rendre (je l’ai rencontré chez un catalogue qu’un ERP alimente). D’où ma règle, deux mesures. Une cache froid, une cache chaud. Rien d’autre ne vous dira si ça coince sur le calcul ou sur le rythme des invalidations. Les requêtes du dessous vous disent combien d’entrées sommeillent en cache, ce qu’elles pèsent, où en sont les réglages associés, et par où passe la purge manuelle (exactement ce que lance le bouton).
-- Le cache de blocs de filtres est une simple table cle-valeur :
-- une empreinte de 32 caracteres et le bloc serialise.
SELECT COUNT(*) AS entrees,
ROUND(SUM(LENGTH(data)) / 1024 / 1024, 1) AS poids_mo
FROM ps_layered_filter_block;
-- L'interrupteur qui commande ce cache.
SELECT name, id_shop, value
FROM ps_configuration
WHERE name IN (
'PS_LAYERED_CACHE_ENABLED',
'PS_LAYERED_FULL_TREE',
'PS_LAYERED_FILTER_CATEGORY_DEPTH',
'PS_LAYERED_SHOW_QTIES',
'PS_LAYERED_INDEXED'
);
-- Purge manuelle. C'est exactement ce que fait le bouton Vider le cache
-- du module, et ce que declenche l'URL clearCache.
TRUNCATE TABLE ps_layered_filter_block;
-- Le cache se remplit a nouveau au fil des visites : mesurez donc deux
-- fois, une fois cache froid, une fois cache chaud.
Réduire le périmètre des filtres
Trois paramètres du module, j’y reviens toujours, jouent directement sur la charge de travail. Jusqu’où le filtrage descend dans l’arborescence, d’abord, catégorie par catégorie. Ensuite, le fait d’embarquer ou non les produits rangés dans les sous-catégories, ce qui gonfle l’ensemble interrogé. Enfin, l’affichage du compteur de produits correspondants, qui oblige à compter valeur par valeur, soit une requête d’agrégation supplémentaire par bloc affiché. Ces combinaisons engendrent en prime des milliers d’adresses que les moteurs iront toutes visiter : empêcher les robots de crawler les URL à facettes vous épargne de financer ce calcul pour des passages sans retour.
J’entends souvent l’inverse, pourtant rien dans le module ne masque nativement les valeurs sans produit. Il lui faut calculer les populations, et c’est exactement ce calcul qui coûte cher. Ma parade, c’est de plafonner les filtres d’un même modèle et de manœuvrer la limite de résultats, qui se règle pour chacun. Sur les catégories du bas de l’arborescence, là où le visiteur a déjà bien resserré, dégraissez le modèle. L’ergonomie y perd presque rien. Le calcul y gagne énormément. Je creuse ce point dans mes interventions sur mesure.
Automatiser la réindexation
Le module embarque un contrôleur de tâches planifiées, et trois actions avec : refaire l’index de prix, refaire attributs et caractéristiques, purger le cache de blocs. Sa page de configuration affiche les trois URL en entier, jeton inclus. Recopiez-les telles quelles au lieu de les reconstituer vous-même, ce jeton dérivant d’une empreinte interne.
Restent les vieilles entrées posées à la racine du module, celles dont le nom était celui d’un fichier PHP : le code les signale comme dépréciées et lève une alerte. Leur relève est assurée par le contrôleur de tâches. Calez la reconstruction des prix sur la nuit, bornez sa durée d’exécution pour que deux passages ne se télescopent pas, et achevez systématiquement par la purge du cache. Oubliez-la, et vos blocs distribueront encore des comptages antérieurs à la réindexation, avec des compteurs décalés par rapport aux produits de la liste.
#!/bin/bash
# Les trois URL sont affichees dans la page de configuration du module
# ps_facetedsearch, section Indexes et caches. Le jeton y est deja
# insere : copiez-les depuis le back-office plutot que de les reconstruire.
#
# action=indexPrices reprend l'indexation des prix manquants
# action=indexPrices&full=1 reconstruit tout l'index de prix
# action=indexAttributes reconstruit attributs, caracteristiques et groupes
# action=clearCache vide ps_layered_filter_block
BASE="https://www.ma-boutique.fr/module/ps_facetedsearch/cron"
TOKEN="xxxxxxxxxx"
# Reconstruction complete des prix, la nuit. --max-time borne l'execution
# pour qu'un script bloque ne s'empile pas avec le suivant.
curl -sS --max-time 3600 "${BASE}?action=indexPrices&full=1&token=${TOKEN}" -o /dev/null
# Attributs et caracteristiques.
curl -sS --max-time 1800 "${BASE}?action=indexAttributes&token=${TOKEN}" -o /dev/null
# Purge du cache de blocs de filtres, une fois les index refaits.
curl -sS --max-time 300 "${BASE}?action=clearCache&token=${TOKEN}" -o /dev/null
# Exemple de crontab correspondante (une seule ligne, 3 h 20 du matin) :
# 20 3 * * * /usr/local/bin/reindex-facettes.sh >> /var/log/facettes.log 2>&1
Ce que je vérifie après
Je recommence à l’identique la mesure du départ, même catégorie, mêmes cases de filtres cochées, un tour cache vide et un tour cache plein : changez un seul paramètre du protocole et la comparaison ne signifie plus rien. Je ressors également le plan d’exécution que j’avais relevé sur cette requête, histoire de m’assurer que l’optimiseur attrape bien la clé prévue.
Vient ensuite le contrôle de cohérence, une réindexation ratée faisant plus de dégâts qu’une page lente : les compteurs de chaque filtre doivent tomber juste face aux produits réellement montrés, et les bornes affichées par le filtre de prix face aux tarifs des fiches. Surveillez aussi les journaux plusieurs jours de suite, jusqu’à ce que chacune des tâches planifiées ait tourné une fois au minimum, et contrôlez qu’aucune ne s’est fait interrompre par un dépassement de durée ou de mémoire. Le jour où il emporte la page et non plus la seule tâche, vous avez changé de problème : vous voilà dans une erreur 500 qui suit une mise à jour. Ici, trois journaux différents se partagent le travail, et le script plus bas vous dira à quoi sert chacun. D’autres décorticages du même genre vous attendent sur le blog.
#!/bin/bash # Trois journaux differents racontent trois choses differentes. # 1. Le journal d'erreurs PHP : requetes interrompues, memoire epuisee, # temps d'execution depasse pendant une reindexation. tail -n 200 var/logs/php_errors.log 2>/dev/null | grep -iE 'memory|maximum execution|layered|facetedsearch' # 2. Le journal applicatif de PrestaShop, alimente par PrestaShopLogger. # Il est lisible dans Parametres avances > Journaux, ou en base. # (voir la requete SQL sur ps_log plus bas dans l'article) # 3. Le journal des requetes lentes de MySQL. Le chemin depend de la # variable slow_query_log_file : demandez-la au serveur plutot que # de la deviner. mysql -e "SHOW VARIABLES LIKE 'slow_query_log%'; SHOW VARIABLES LIKE 'long_query_time';" # Une fois le chemin connu, isolez les requetes qui touchent les tables # du module. mysqldumpslow regroupe les requetes identiques. mysqldumpslow -s t -t 20 /var/log/mysql/mysql-slow.log 2>/dev/null | grep -A3 -iE 'layered|category_product'
Questions fréquentes
Comment mesurer la part réelle du module dans le temps de chargement ?
Le profileur natif s’en charge, sous le commandement d’une constante, _PS_DEBUG_PROFILING_. Un interrupteur vous attend en PrestaShop 8 et 9, du côté de Performances, sous Paramètres avancés. En 1.7.8, la constante se pose dans config/defines_custom.inc.php, fichier que le cœur ouvre avant le sien et que les montées de version épargnent. Le détail arrive en bas de page, requête SQL par requête SQL, hook par hook, module par module.
Il livre la part. La cause, non. Appuyez-vous en plus sur le journal MySQL des requêtes lentes, qui affiche pour chacune combien de lignes ont été parcourues : un écart marqué entre lignes parcourues et lignes retournées signe une table balayée en entier, et ça, le profileur tout seul ne vous le dira jamais.
Faut-il ajouter des index sur les tables du module ?
Rarement. Jamais, en tout cas, avant d’avoir regardé ce qui est en place. Le module pose ses tables avec les clés qui vont avec : du côté des prix, trois index secondaires et une clé primaire composite, du côté des attributs, une clé unique et une clé primaire. Les tables du catalogue auxquelles elle se joint, le cœur s’en est chargé de la même façon.
Sur le terrain, je bute plutôt sur des index égarés en route, à la suite d’un moteur de stockage changé, d’une restauration incomplète ou d’un import : allez voir la structure telle qu’elle existe dans le catalogue système, posez-la en face de celle que le module fabrique, puis rétablissez le seul absent, base sauvegardée au préalable, et reprenez le plan d’exécution derrière.
Le cache de filtres est-il activé par défaut ?
Oui. L’option est positionnée par le module au moment de l’installation. Rien n’empêche de la changer depuis la page de configuration du module, cela dit, et je la découvre coupée bien plus souvent qu’on ne l’imagine (reliquat d’un test que personne n’a rallumé, le plus souvent). Elle manque, et tous les comptages se refont à chaque affichage.
Méfiance quand même : une foule d’événements du catalogue l’invalident, la retouche d’une catégorie, d’un groupe d’attributs ou d’un attribut en premier lieu. Catalogue qui remue en permanence, cache purgé si fréquemment qu’il ne rend plus service. D’où mon réflexe de toujours relever les deux états, froid puis chaud.
Peut-on masquer les valeurs de filtre sans produit ?
Le module n’a pas prévu ça. Afficher ses compteurs lui impose de calculer la population de chaque filtre, et le poids est là, dans ce calcul. Chercher à faire disparaître les valeurs vides, c’est donc redemander le travail qu’on espérait s’épargner.
Vos vraies marges de manœuvre sont ailleurs : la limite de résultats que chaque modèle règle filtre par filtre, l’affichage ou non du compteur de produits correspondants, la prise en compte ou non des produits logés dans les sous-catégories, et la profondeur à laquelle le filtrage descend par catégorie. Le geste le plus payant reste de faire baisser le nombre de filtres allumés dans chaque modèle, chacun d’eux amenant sa jointure.
À quelle fréquence planifier la réindexation ?
Ça tient au rythme auquel bougent vos prix et vos combinaisons. Personnellement, je cale sur la nuit une reconstruction intégrale des prix, ce qui suffit à la grande majorité des catalogues. Quand un flux de tarifs tombe plusieurs fois dans la journée, resserrez le passage et limitez-le aux seuls prix manquants au lieu de tout refaire.
Deux précautions tiennent quelle que soit la situation. Plafonnez le temps d’exécution alloué à chaque appel, sans quoi deux passages se marcheront dessus, et terminez en vidant le cache de blocs, sans quoi vos filtres ressortiront des comptages d’avant la réindexation. Envie que je jette un œil chez vous ? Le formulaire de contact vous attend.