Une grosse session d'optimisation de Leek Wars

Récemment, j'ai passé plusieurs sessions à plonger sous le capot du site, avec Claude, pour traquer les ralentissements et accélérer le chargement des pages. Voici un résumé des changements et des résultats.

Le constat de départ

Au départ, le profilage Chrome donnait sur la home connectée :

Le diagnostic faisait apparaître plusieurs catégories de problèmes : un boot trop chargé côté frontend, une partie du backend qui faisait du travail inutile, et quelques choix d'infrastructure à revoir.

Côté backend

OPcache JIT activé. PHP 8.2 propose un JIT en mode "tracing" qui compile à la volée les chemins chauds. Activation : 5 lignes de configuration. Effet mesuré sur le compute pur PHP : 0,9 ms → 0,33 ms par requête, soit −63%.

Indexation matchmaking. Le matchmaking solo et farmer faisait un Seq Scan complet sur la table des farmers (115 000 lignes) pour chaque requête. Ajout d'un index partiel sur (talent) WHERE banned = false AND in_garden = true : 126 ms → 0,9 ms, soit 140× plus rapide pour les utilisateurs en milieu/haut de classement. Pour les nouveaux comptes (range talent très large), le planner garde le Seq Scan, donc pas de régression.

Quelques requêtes redondantes nettoyées. Sur chaque chargement de page HTML, le code faisait 8 requêtes distinctes pour récupérer le rang de chaque poireau du joueur, plus deux requêtes séparées pour des champs déjà chargés en mémoire. Tout passé en une seule requête batchée. Une autre requête de comptage des notifications non-lues a été simplifiée et passe de 0,13 ms à 0,011 ms (12× plus rapide).

Monitoring DB allégé. Un exporter Prometheus scrutait la base toutes les 5 secondes, ce qui représentait 10% du temps SQL global pour des métriques dont l'évolution se voit très bien à 30 secondes d'intervalle. Passé à 30s, la base souffle.

Plusieurs indexes manquants ajoutés. En profilant les requêtes SQL réelles via pg_stat_statements, j'ai repéré quelques chemins fréquents qui ne profitaient d'aucun index dédié. La recherche d'un farmer par login (à chaque connexion) faisait un Seq Scan sur 115 000 lignes : ajout d'un index → 38 ms à 27 µs. Pareil pour le reset password (sur LOWER(mail)), pour le compteur de notifications non lues d'un joueur (index partiel WHERE read = false), pour la liste des filleuls d'un parrain, et quelques autres. Au total, sept nouveaux indexes, avec des gains entre 100× et plusieurs milliers× selon les requêtes.

Le cron mailing des notifications, vraiment optimisé. Une tâche tournait toutes les 5 minutes pour marquer comme "à mailer" les notifications non lues depuis plus de 10 minutes. La requête scannait 68 millions de lignes en table notification pour, en pratique, en mettre à jour entre zéro et dix. Un index partiel (date) WHERE read = false AND mail = false règle le truc : la même requête passe de 7,4 secondes à 4 microsecondes dans le cas dominant (rien à mailer). Sur 24h, plusieurs minutes de CPU économisées sans rien casser.

WAL compression et checkpoints espacés. Postgres écrit ses journaux de transaction sans compression par défaut, et déclenche un checkpoint toutes les 5 minutes. Chaque checkpoint génère beaucoup de "Full Page Images" (la page disque entière réécrite à sa première modification après un checkpoint). Activation de la compression WAL en zstd côté Postgres, et passage à 15 minutes entre checkpoints : ~50% d'écritures Postgres en moins, ce qui libère de la bande passante I/O et ralentit l'usure des disques.

Materialized views rafraîchies sans lock. Quatre vues matérialisées (classements leek, farmer, équipe, et "fun ranking" qui agrège plein de stats marrantes) sont reconstruites toutes les minutes. Un REFRESH MATERIALIZED VIEW standard prend un lock exclusif pendant le rebuild (jusqu'à 1,5 s pour la plus grosse). Solution : passer en REFRESH MATERIALIZED VIEW CONCURRENTLY, plus aucun lock côté lecteurs. Au passage, la fun_ranking passe à toutes les 5 minutes au lieu de chaque minute, cohérent avec le cache applicatif (TTL 300s) qui suit. ~80 secondes de CPU économisées par heure sur cette seule vue.

Côté frontend

Service Worker repensé. Le SW interceptait toutes les navigations et faisait un cache stale-while-revalidate sur le HTML, ce qui ajoutait 50 à 100 ms de TTFB perçu pour un gain discutable. Passage à une stratégie Network-First avec NavigationPreload, fallback cache en cas de coupure réseau, versioning du cache pour éviter la croissance illimitée. Au passage, plusieurs bugs latents ont été corrigés (filtre /api/ trop laxiste, code mort, URL hardcodée, etc.).

HTTP/3 (QUIC) activé. Une simple config Traefik. Le navigateur économise un round-trip à l'établissement de la connexion. Le gain est marginal en local (ping ~12 ms) mais significatif sur mobile/4G/3G où le RTT peut dépasser 100 ms. Aujourd'hui, toutes les requêtes assets passent en h3.

LCP image préchargée. Le grand poireau décor en bas de page est l'élément qui déclenche la métrique LCP. Avant, Chrome ne le découvrait qu'après avoir téléchargé et exécuté Vue. Maintenant le ` est dans l'HTML statique, avec fetchpriority="high"`, et la variante (claire ou foncée selon le thème du joueur) est choisie côté serveur via cookie. Le load delay du LCP est passé de 353 ms à 39 ms, soit −89%.

Boot bundle dégonflé. Plusieurs dialogs et panneaux d'historique étaient chargés systématiquement même quand l'utilisateur ne les ouvrait pas. Ils sont maintenant chargés à la demande via defineAsyncComponent. Le critical path JS s'est raccourci.

Forced reflow chat éliminé. Le handler de scroll du chat lisait offsetHeight/scrollHeight puis modifiait des refs Vue (qui invalident le layout) puis relisait scrollTop. Browser obligé de recalculer la layout entre les deux lectures. Tout regroupé en début de fonction, plus de thrash.

Les chiffres

Quelques mesures avant/après sur les chemins chauds :

| Métrique | Avant | Après | |---|---|---| | LCP load delay | 353 ms | 39 ms | | TTFB serveur (compute pur PHP) | ~30 ms | ~10 ms | | /api/garden/get-farmer-opponents (matchmaking) | 280-450 ms | 18 ms | | Recherche d'un compte par login | 38 ms | 27 µs | | Cron mailing notifications (cas vide) | 7,4 s | 4 µs | | Compteur de notifs non-lues d'un joueur actif | 156 ms | 30 µs | | Critical path JS | 1567 ms | 1493 ms | | Volume WAL Postgres écrit par jour | ~140 GB | ~70 GB (projeté) | | Slow queries de fond inutiles | plusieurs par heure | 0 |

La suite

Il reste pas mal d'optimisations possibles, dont les plus prometteuses :

Bref, il y aura matière à de futurs articles. En attendant, le site devrait répondre plus vite, et ça m'a fait plaisir.