Accéder au contenu principal

13min.

Quand le cache de Symfony ralentit votre application…

Mettre une valeur en cache, c’est toujours plus rapide, non ? Et bien, pas forcément ! Cet article partage l’analyse d’un problème de performance causé par la protection « anti-stampede » du composant Cache de Symfony, et sur la manière dont nous l’avons diagnostiqué puis corrigé.

Nous avons récemment corrigé un problème de lenteur sur une application Symfony en production. Le diagnostic nous a pris un certain temps, car la cause était contre-intuitive : le responsable était le composant Cache de Symfony, ou plus précisément sa protection contre le cache stampede, un mécanisme que nous ne connaissions pas vraiment avant cet épisode.

Comme il est peu documenté et qu’il peut concerner beaucoup d’applications, voici le détail du problème, la démarche de diagnostic, et le correctif que nous avons retenu.

Section intitulée le-symptome-une-mediatheque-tres-lenteLe symptôme : une médiathèque très lente

Sur cette application, la médiathèque de l’admin est propulsée par JoliMediaBundle. Depuis quelque temps, son affichage était devenu très lent : à l’ouverture d’un dossier de médias, le premier affichage prenait entre 10 et 20 secondes de TTFB (Time To First Byte), parfois davantage. Les circonstances de cette lenteur étaient assez curieuses :

  • la première visite d’un dossier était lente, mais les visites suivantes étaient instantanées ;
  • l’environnement de préproduction, pourtant identique (même code, même configuration, même stockage), était parfaitement fluide ;
  • sans lien apparent, d’autres parties de l’application souffraient de lenteurs aléatoires : un même endpoint d’API répondait tantôt en 100 ms, tantôt en 5 secondes.

Un problème qui ne se reproduit ni en local ni en préproduction, et qui frappe au hasard : le diagnostic s’annonçait laborieux 😅

Section intitulée les-fausses-pistesLes fausses pistes

Une médiathèque lente, des fichiers sur un montage réseau : le suspect naturel, c’est le stockage. C’est donc par là que j’ai commencé : vérification des options de montage, mesure des I/O, benchmark de lecture ou d’écriture des fichiers… tout allait bien de ce côté.

Au passage, ce n’est d’ailleurs pas si surprenant : JoliMediaBundle est conçu pour rester performant même lorsque le stockage est lent. Les variations d’images sont pré-générées, les métadonnées sont mises en cache, et les pages d’admin ne déclenchent pas de traitement d’image à la volée. Il faut chercher ailleurs.

Deuxième piste : les endpoints d’API aléatoirement lents. L’un d’eux passe par un transformer qui agrège des données coûteuses à calculer – données mises en cache applicatif pour éviter de refaire le calcul à chaque requête. Persuadés que les requêtes SQL sous-jacentes étaient le goulot d’étranglement, nous avons ouvert plusieurs pull requests pour les optimiser : index, réécriture de requêtes, réduction du nombre d’allers-retours…

Résultat : des requêtes plus propres, mais aucune amélioration mesurable en production. L’endpoint continuait de mettre parfois 5, voire 10 secondes à répondre. Quand une optimisation SQL ne change rien, c’est souvent que le temps n’est pas passé dans le SQL.

Section intitulée profiler-plutot-que-supposerProfiler plutôt que supposer

Après ces deux échecs, on a fait ce que nous aurions dû faire dès le début : profiler les transactions lentes en production, plutôt que d’empiler les hypothèses. Sur une trace de la médiathèque, une ligne écrasait toutes les autres :

Symfony\Component\Cache\LockRegistry::compute14,8 secondes de self-time

14,8 secondes passées non pas à calculer quoi que ce soit, mais à attendre un flock(). L’application ne passe pas son temps à lire des fichiers ni à exécuter des requêtes, elle attend qu’un verrou posé par le composant Cache de Symfony se libère. Le cache, ce composant que l’on ajoute précisément pour aller plus vite, est donc d’un coup devenu notre goulot d’étranglement… Oups !

Il est temps d’aller lire son code pour comprendre ce qui se passe dans une $cache->get().

Section intitulée code-lockregistry-code-la-protection-anti-stampede-de-symfonyLockRegistry, la protection anti-stampede de Symfony

Avant ce debugging, je n’étais pas vraiment familier de cette classe et ne savais pas précisément ce qui se passe lorsqu’on écrit ce code pourtant banal :

$value = $this->cache->get('my_key', function (ItemInterface $item): array {
    $item->expiresAfter(3600);

    return $this->computeSomethingExpensive();
});

Imaginez une clé de cache très demandée qui expire. Au moment de l’expiration, toutes les requêtes en cours constatent simultanément le cache miss, et toutes lancent le recalcul de la valeur en parallèle. Si le calcul est coûteux (une grosse requête SQL, un appel d’API externe…), des dizaines de processus exécutent alors le même calcul au même moment, et saturent la base de données ou le service distant. C’est le cache stampede (la ruée vers le cache), et c’est un vrai problème.

Heureusement, Symfony propose contre ce phénomène deux protections complémentaires:

  1. l’expiration probabiliste anticipée (le paramètre « $beta » de CacheInterface::get()) : ce paramètre permet de moduler la probabilité qu’une clé de cache soit re-calculée en avance, même si elle n’est pas encore expirée. Plus une valeur approche de sa date d’expiration, plus il devient probable qu’une requête la recalcule avant l’expiration, ce qui lisse les recalculs dans le temps et évite que tous les recalculs soient groupés à heure fixe ;
  2. le LockRegistry : au moment de recalculer une valeur, un verrou est posé pour que le premier arrivé calcule pendant que les autres attendent le résultat, plutôt que de tous calculer chacun de leur côté. Ainsi, si deux requêtes HTTP nécessitent le recalcul de la clé de cache « foo », la première qui arrive pose un verrou et lance le calcul, tandis que la seconde attend que le verrou se libère pour lire la valeur recalculée. Le recalcul n’est donc effectué qu’une seule fois.

Le diagramme de séquence d'une collision de verrous

Le principe est sain. C’est son implémentation qu’il faut connaître pour comprendre notre problème.

Section intitulée des-verrous-poses-sur-les-fichiers-du-vendorDes verrous posés sur… les fichiers du vendor

Comment poser un verrou partagé entre tous les processus PHP d’une machine (et oui… si un worker et une requête HTTP sont tous deux susceptibles de recalculer une clé de cache, il faut trouver un moyen de partager les verrous entre ces processus), sans dépendre d’un service externe ? La réponse apportée par la classe LockRegistry est astucieuse : en posant des flock() sur des fichiers dont on est sûr qu’ils existent sur toutes les installations… les fichiers PHP du composant Cache lui-même !

// vendor/symfony/cache/LockRegistry.php
private static array $files = [
    __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'AbstractAdapter.php',
    __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'AbstractTagAwareAdapter.php',
    __DIR__.\DIRECTORY_SEPARATOR.'Adapter'.\DIRECTORY_SEPARATOR.'AdapterInterface.php',
    // ... la liste des fichiers du dossier Adapter/ du composant, soit 24 fichiers actuellement
];

Chaque clé de cache est affectée à l’un de ces fichiers par un modulo sur son hash :

$key = self::$files ? abs(crc32($item->getKey())) % \count(self::$files) : -1;

Il faut bien mesurer ce que cela implique :

  • il n’existe que 24 « slots » de verrous pour toute la machine ;
  • ces slots sont partagés par toutes les clés de cache, tous les pools (le pool Redis de vos données métier, le pool système, ceux de vos bundles…), et tous les processus PHP de l’hôte – php-fpm comme CLI ;
  • deux clés qui n’ont rien à voir l’une avec l’autre peuvent tomber sur le même slot, par simple collision de crc32() % 24.

Autrement dit : quand un processus recalcule une valeur, il tient un verrou que n’importe quel autre recalcul, de n’importe quelle autre clé de la machine, a environ une chance sur 24 de devoir attendre. Si le calcul dure quelques millisecondes, personne ne le remarque. S’il dure plusieurs secondes, tout le monde peut le payer.

Ce mécanisme explique au passage l’un de nos symptômes : la médiathèque était rapide en revisite parce que le verrou n’est pris qu’au moment de recalculer une valeur. Tant que la clé est chaude dans le cache, aucun verrou n’est sollicité. Seuls les cache miss paient l’addition.

Section intitulée a-l-assaut-des-coupables-des-workers-de-calculs-couteuxÀ l’assaut des coupables : des workers de calculs coûteux

Reste maintenant à comprendre qui monopolise ces verrous. Sur notre application, les serveurs frontaux ne font pas que servir du HTTP : ils font aussi tourner les workers « Messenger » – une dizaine de processus par machine, qui consomment des messages en continu. Parmi ces messages, certains déclenchent des résolutions DNS effectuées en PHP, dont les résultats sont mis en cache applicatif avec un TTL de 120 secondes, pour éviter de solliciter inutilement les serveurs de noms. Le traitement de certains messages peut même nécessiter plusieurs résolutions DNS, et donc plusieurs accès au cache.

Faisons le calcul :

  • une résolution DNS peut être lente : plusieurs requêtes en série vers plusieurs serveurs de noms, avec des timeouts qui se cumulent – le callback de cache peut durer plusieurs secondes ;
  • un TTL de 120 secondes sur des milliers de domaines vérifiés en continu, cela signifie des recalculs permanents ;
  • 10 workers par machine qui enchaînent ces recalculs, cela signifie qu’à tout instant, une bonne partie des 24 slots de LockRegistry est tenue par un worker en train d’attendre une réponse DNS.

Pendant ce temps, côté php-fpm, une requête d’admin arrive : la médiathèque doit calculer les métadonnées d’un dossier froid, appelle $cache->get(), tombe par collision sur un slot tenu par un worker… et attend. Parfois quelques centaines de millisecondes, parfois 15 secondes. Pire encore, il peut très bien arriver que, pour un lock donné, plusieurs « perdants » s’accumulent derrière le verrou, chacun attendant que le précédent libère le slot. Le TTFB de la médiathèque devient alors très variable, pouvant même parfois mener à des timeouts côté navigateur.

Pour vérifier cette hypothèse, nous avons simplement arrêté les workers concernés sur les trois frontaux : la médiathèque est instantanément redevenue rapide 🎉

Section intitulée oui-les-workers-cli-utilisent-code-lockregistry-codeOui, les workers CLI utilisent LockRegistry

En lisant le code du composant, on pourrait croire que la protection anti-stampede est désactivée en CLI : la méthode setCallbackWrapper() de ContractsTrait contient un opt-out explicite lorsque PHP_SAPI vaut cli, les processus CLI étant supposés courts et peu concurrents.

Mais cet opt-out ne fonctionne que sur les branches 4.4 et 5.4. Depuis la branche 6.0, le passage aux propriétés typées) a ajouté directement dans doGet() une initialisation de $this->callbackWrapper ??= LockRegistry::compute(...);, et lors du merge de 5.4 dans 6.0, cette ligne a été conservée. Depuis Symfony 6.0, le wrapper est donc systématiquement initialisé à LockRegistry::compute() lors de doGet(), rendant ainsi inopérant l’opt-out pour le CLI. Les workers Messenger, processus CLI de longue durée, utilisent donc bien les mêmes verrous flock() que php-fpm… Et c’est exactement ce qui pose souci dans notre configuration.

Section intitulée isoler-les-callbacks-lents-tout-en-conservant-des-locksIsoler les callbacks lents tout en conservant des locks

Évidemment, on ne va pas désactiver la protection anti-stampede de Symfony car elle est très utile. Elle fonctionne très bien pour protéger de race conditions lors de recalculs rapides. Le vrai problème, c’est le mélange des genres : des callbacks qui durent plusieurs secondes ne devraient pas partager leurs verrous avec le reste de l’application.

L’approche que nous avons choisie consiste donc à doter les callbacks lents, peu critiques, de leur propre pool de cache, avec leur propre stratégie de verrouillage. Le reste de l’application continue de bénéficier de LockRegistry pour des callbacks rapides.

Section intitulée un-pool-dedieUn pool dédié

Premier ingrédient du correctif : un pool de cache spécifique pour le résolveur DNS – même serveur Redis que le pool partagé, mais un adapter distinct :

# config/packages/cache.yaml
framework:
    cache:
        pools:
            dns.cache:
                adapter: cache.adapter.redis
                provider: 'redis://%env(REDIS_HOST)%'

Le service consommateur cible explicitement ce pool grâce à l’attribut #[Target] :

final readonly class DnsResolver
{
    public function __construct(
        #[Target('dns.cache')]
        private CacheInterface $cache,
        ...
    ) {
    }
}

Section intitulée remplacer-lockregistry-par-un-verrouillage-base-sur-les-clesRemplacer LockRegistry par un verrouillage basé sur les clés

Second ingrédient : remplacer, sur ce pool uniquement, la stratégie de LockRegistry par un verrouillage par clé de cache, en s’appuyant sur le composant Lock et un store Redis. Deux résolutions DNS de domaines différents peuvent ainsi se calculer en parallèle sans se gêner, et surtout sans gêner personne d’autre.

Les adapters de cache de Symfony exposent le point d’extension qu’il nous faut : AbstractAdapter::setCallbackWrapper(), qui permet de substituer son propre callable à LockRegistry::compute(). Notre wrapper en reproduit la sémantique :

final readonly class DnsCacheStampedeProtection
{
    private const int RETRY_DELAY_US = 100_000;

    public function __construct(
        private LockFactory $lockFactory,
        private float $lockTtl = 30.0,
        private float $maxWait = 10.0,
    ) {
    }

    public function __invoke(callable $callback, ItemInterface $item, bool &$save, CacheInterface&CacheItemPoolInterface $pool, \Closure $setMetadata, ?LoggerInterface $logger = null, ?float $beta = null): mixed
    {
        $lock = $this->lockFactory->createLock('dns-cache:' . $item->getKey(), $this->lockTtl);
        $deadline = microtime(true) + $this->maxWait;

        while (true) {
            if ($lock->acquire()) {
                // nous avons gagné la course : on calcule, on sauve, on libère
                try {
                    $value = $callback($item, $save);

                    if ($save) {
                        $setMetadata($item);
                        $pool->save($item->set($value));
                        $save = false;
                    }

                    return $value;
                } finally {
                    $lock->release();
                }
            }

            // quelqu'un d'autre calcule cette clé : on attend un peu, puis on
            // tente de relire la valeur qu'il a sauvegardée (avec beta = 0,
            // pour ne pas déclencher d'expiration anticipée)
            usleep(self::RETRY_DELAY_US);

            // ... relecture du pool, et calcul sans verrou si le délai
            // d'attente maximal est dépassé : le verrouillage ne doit
            // jamais empêcher d'obtenir une valeur
        }
    }
}

L’extrait ci-dessus est abrégé ; la version complète gère la relecture du pool avec beta = 0, le timeout d’attente et un mode dégradé : si le store de verrous est injoignable ou si l’attente dépasse $maxWait, on calcule sans verrou. Une protection anti-stampede qui empêcherait l’application de fonctionner serait en effet un remède pire que le mal.

Section intitulée une-compiler-pass-pour-installer-ce-wrapperUne compiler pass pour installer ce wrapper

Reste à « brancher » ce wrapper sur le pool. Petit piège d’intégration : en environnement de dev, le profiler de Symfony décore chaque pool d’un TraceableAdapter, qui n’expose pas setCallbackWrapper(). Un appel effectué à l’exécution sur le service injecté échouerait donc en dev. La solution consiste à passer par une compiler pass, qui ajoute l’appel de méthode sur la définition du service – la CacheCollectorPass de Symfony sait ensuite déplacer ces appels sur l’adapter interne lorsqu’elle installe sa décoration :

final readonly class DnsCachePoolPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container): void
    {
        $container->getDefinition('dns.cache')
            ->addMethodCall('setCallbackWrapper', [new Reference(DnsCacheStampedeProtection::class)])
        ;
    }
}

Et c’est tout : le reste de l’application n’a pas bougé d’une ligne, et continue de bénéficier de LockRegistry pour ses callbacks rapides. On aurait peut-être aussi pu jouer avec la priorité du décorateur, mais cette solution n’a pas été explorée.

Section intitulée une-autre-approche-agrandir-le-pool-de-verrousUne autre approche : agrandir le pool de verrous

Maintenant que nous avons isolé les callbacks lents, la situation est déjà bien plus saine. Cela dit, on peut quand même considérer que la limitation à seulement 24 fichiers de lock, c’est finalement assez peu, surtout si ça peut suffire à provoquer des collisions entre clés de cache qui n’ont rien à voir les une avec les autres.

Nous avons donc choisi d’agrandir le nombre des verrous disponibles, afin que, pour les pools qui emploient encore LockRegistry, le risque de collision et d’attente indue soit restreint.

En effet, rien n’oblige à se limiter aux 24 fichiers du vendor. Par exemple, on peut choisir de générer, au moment du déploiement, un dossier contenant un millier de fichiers immutables, qui seront utilisés par LockRegistry comme « supports » des appels à flock() pour diluer fortement la probabilité de collision.

L’appel doit être fait le plus tôt possible (en tout cas avant le premier $cache->get()) ; Kernel::boot() est un bon candidat, puisqu’il couvre à la fois les entrées HTTP et les processus CLI :

// src/Kernel.php
use Symfony\Component\Cache\LockRegistry;

class Kernel extends BaseKernel
{
    public function boot(): void
    {
        parent::boot();

        // 1000 fichiers immutables créés au déploiement
        LockRegistry::setFiles(glob($this->getProjectDir() . '/var/cache-locks/*.lock'));
    }
}

C’est simple et efficace pour réduire les collisions, mais cela ne fait que repousser le problème : tant que des callbacks de plusieurs secondes cohabitent avec le trafic web dans le même mécanisme de verrouillage, la contention finira par revenir.

Section intitulée en-conclusionEn conclusion

Après déploiement du correctif, la médiathèque est redevenue fluide, y compris à la première visite d’un dossier – les 10 à 22 secondes de TTFB ont disparu, et les différents endpoints d’API aléatoirement lents se sont « assagi ». Sur l’un d’eux, qui utilise le cache dans un transformer, le premier appel « froid » est ainsi passé de 5 secondes à 50 millisecondes sans avoir besoin de toucher ni au SQL ni au code métier, mais uniquement en cessant d’attendre derrière des résolutions DNS qui ne nous concernaient pas.

Au final, il y a quelques bonnes leçons à tirer de cette expérience. D’abord, mettre en cache, ce n’est pas une opération gratuite. On a tendance à considérer $cache->get() comme un réflexe sans risque : « au pire, ça ne servira à rien, et au mieux, ça accélèrera les choses ». Ce n’est pas tout à fait vrai :

  • dès lors qu’une protection anti-stampede entre en jeu, chaque recalcul de valeur interagit avec un système de verrous partagé, et un callback lent peut pénaliser des parties de l’application qui n’ont rien à voir avec lui. Avant de mettre en cache un calcul, posez-vous la question : combien de temps dure-t-il, au pire ?
  • et au-delà de cette considération, nous sommes assez partisans de l’approche « moins de code = moins de bugs » : si on n’a pas strictement besoin de cache, autant s’en passer !

Exécuter des workers (asynchrones) sur les mêmes machines que des services web (synchrones) amplifie le problème. Même si c’est une pratique économique et courante, cela pose des soucis car LockRegistry raisonne par machine : des workers qui recalculent en continu des valeurs coûteuses partagent par défaut leurs 24 slots de verrous avec les requêtes web.. Et ça peut nous réserver de (mauvaises) surprises !

Le profiling a bien aidé pour comprendre ce problème. L’attente d’un flock() est invisible dans les métriques classiques : le niveau du CPU reste bas, les requêtes SQL sont rapides, les logs sont muets. En gros, on a l’impression que tout va bien..! Si nous n’avions pas eu sous la main une trace qui montre où le temps s’écoule réellement, nous chercherions encore. Peu importe l’outil utilisé pour profilerBlackfire 💛, Sentry, ou tout autre outil capable d’afficher le temps passé fonction par fonction : l’important est d’en avoir un en production, et de le consulter avant de formuler des hypothèses.

Enfin, une approche défensive consiste à toujours isoler les callbacks lents dans des pools dédiés, avec un mécanisme de lock construit sur mesure. C’est la leçon la plus actionnable : si certains de vos callbacks de cache sont structurellement lents (appels réseau, calculs lourds), donnez-leur leur propre pool et une stratégie de verrouillage par clé. Quelques dizaines de lignes de code suffisent, et le reste de votre application vous dira merci!

Commentaires et discussions

Nos formations sur ce sujet

Notre expertise est aussi disponible sous forme de formations professionnelles !

Voir toutes nos formations

Ces clients ont profité de notre expertise