Aller au contenu principal
Site en cours de refonte — quelques pages peuvent bouger ou évoluer.
13 mai 2026

Laravel 13.6 : le debounce sur les jobs, ou comment arrêter les tempêtes en prod

Le vrai problème des queues, c’est quand un événement part en rafale et déclenche 200 jobs identiques. Laravel 13.6 ajoute un debounce natif : on voit comment l’utiliser proprement en prod.

11 min de lecture
134 vues
réactions
Partager :
Laravel 13.6 : le debounce sur les jobs, ou comment arrêter les tempêtes en prod

Si tu as déjà géré des queues Laravel en prod, tu connais la scène. Un webhook Stripe se met à « rejouer » un événement. Un écran admin déclenche 50 updates en 10 secondes. Un import touche 10 000 lignes, et chaque ligne balance « un petit job » censé être inoffensif. Et là, ce n’est pas juste « c’est lent ». C’est une tempête : Redis chauffe, la DB prend des rafales, tes workers passent leur vie à refaire la même chose, et la vraie queue prioritaire (email, paiement, etc.) se fait marcher dessus.

Laravel 13.6 apporte un truc très simple et très rentable : des jobs debounceables. L’idée est bête dans le bon sens du terme : au lieu d’exécuter 100 fois le même job, tu laisses passer la rafale et tu n’en exécutes qu’un à la fin. Pas toujours. Pas partout. Mais quand ça colle au cas d’usage, c’est le genre de détail qui évite un incident.

Le scénario typique : un événement « innocent » qui spamme ta queue

Le piège, c’est que tout marche… jusqu’au jour où l’input n’est plus « humain ». Exemple vécu : un webhook (Stripe, CRM, WMS, ce que tu veux) se met à livrer des événements en doublon, ou avec des retries côté provider. Ton code, lui, est propre : tu reçois l’événement, tu fais une update, et tu dispatch un job « Sync » derrière. Sauf qu’en 30 secondes, tu as dispatch 200 jobs qui recalculent la même chose, souvent pour le même ID.

Ce qui fait mal, ce n’est même pas le coût d’un job. C’est le coût de la redondance plus le coût de l’orchestration. Chaque job sérialise du payload, tape Redis/SQS/DB, réveille un worker, refait 3 requêtes, réécrit des caches, repasse sur les mêmes index. Et tout ça pendant que les jobs « importants » attendent derrière.

Dans ce genre d’histoire, tu as deux options classiques. Soit tu sur-scales « pour encaisser ». Soit tu traites la cause racine : tu arrêtes de faire du travail inutile. Le debounce tombe pile dans la deuxième catégorie.

Debounce sur les jobs Laravel 13.6 : ce que ça garantit (et ce que ça ne garantit pas)

Le debounce, ce n’est pas « rendre le job unique ». C’est plutôt « ne garder que le dernier ». Autrement dit : si le même job est dispatch plusieurs fois dans une fenêtre de temps, Laravel va éviter d’en exécuter une rafale et va finir par exécuter un seul job quand la rafale se calme.

Ce que ça garantit, en pratique, c’est un comportement de lissage. Tu évites les cascades inutiles quand un signal est bruité. Ce que ça ne garantit pas, et c’est important de le dire : ça ne rend pas ton système magiquement « correct ». Si ton job n’est pas idempotent, si tu as des side effects irréversibles, si tu as des invariants métier stricts… le debounce peut masquer un symptôme et te créer une incohérence silencieuse.

Je le vois comme un outil de plus dans la boîte « protection anti-bruit ». Comme un rate limiting ou un circuit breaker. Très utile. Pas une excuse pour ignorer la conception des jobs.

Comment l’activer : attribute #[DebounceFor] ou debounceFor() au dispatch

Laravel 13.6 te laisse déclarer le debounce directement sur le job (pratique quand tu veux que ce soit un contrat), ou au moment du dispatch (pratique quand tu veux le faire varier selon le contexte).

Sur le job, ça donne typiquement un attribut. L’idée est que le job « sait » qu’il est debounceable, et sur quelle fenêtre.

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Attributes\DebounceFor;

#[DebounceFor(30)] // 30 secondes de "silence" avant d'exécuter
class SyncUserToSearch implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public int $userId) {}

    public function handle(): void
    {
        // Recalcule et push dans ton index
    }
}

Et si tu préfères le faire au dispatch, tu peux appliquer un debounce « à la volée ». C’est typiquement ce que je fais quand la fenêtre dépend d’un contexte (par exemple un écran admin où l’utilisateur peut spammer « enregistrer », versus un webhook où je veux être plus agressif).

SyncUserToSearch::dispatch($user->id)
    ->debounceFor(30);

Petit réflexe terrain : si tu introduces ça, logge au début. Pas en mode « debug permanent », juste le temps de vérifier que tu as bien le comportement attendu. C’est le genre de feature qui « marche » mais que tu peux mal cibler, et tu veux le voir noir sur blanc.

Les cas où le debounce est franchement une bonne idée : « ne garder que le dernier »

Le debounce brille quand ton job représente un état final, pas une suite d’événements qui doivent tous être pris en compte. Si tu recalcules un agrégat, si tu rebuild un cache, si tu synchronises un document vers un moteur de recherche, ce qui t’intéresse à la fin, c’est « la version la plus récente ».

Concrètement, c’est parfait pour un job de réindexation quand un modèle est modifié 20 fois en 10 secondes. Pareil pour un « refresh » de cache dérivé d’une entité. Ou encore pour les recalculs d’analytics internes quand tu sais que tu vas retoucher la même ressource plusieurs fois pendant un import.

Le gain est double. Tu réduis la charge sur la queue, et tu réduis la charge sur les systèmes aval (DB, Elasticsearch/Meilisearch/OpenSearch, APIs internes). Et souvent, c’est précisément l’aval qui craque pendant une tempête.

Les cas où c’est une mauvaise idée : side effects, facturation, emails

Le debounce devient dangereux dès que « chaque occurrence compte ». Si tu factures, si tu envoies des emails transactionnels, si tu écris dans une compta, si tu fais des appels API qui déclenchent des actions externes… « ne garder que le dernier » n’a plus rien d’un lissage, c’est une perte d’information.

Le piège, c’est de se dire « c’est bon, ça évite les doublons ». Non. Ça évite les rafales dans une fenêtre de temps, et ça peut supprimer des occurrences légitimes. Si ton workflow a besoin de traiter chaque événement (même si c’est le même userId), le debounce n’est pas la bonne arme.

Et même sur des jobs « safe », garde une obsession : idempotence. Parce qu’entre un retry, une relivraison, un worker kill -9 ou un timeout, tu auras de toute façon des répétitions. Le debounce réduit le bruit, il n’abolit pas la réalité.

Debounce vs jobs uniques vs rate limiting : trois outils, trois comportements

Laravel a déjà des mécanismes pour éviter les doublons, notamment les jobs « uniques ». La nuance qui compte : un job unique, en général, va plutôt garantir « un seul job à la fois » ou « le premier gagne » pendant une durée. Le debounce, lui, est plus orienté « le dernier gagne ».

En pratique, je m’en sers différemment. Les jobs uniques, je les aime quand j’ai une ressource à protéger et que je ne veux pas deux traitements concurrents (exemple : deux jobs qui finalisent une commande). Le debounce, je l’aime quand j’ai un signal spammy et que je veux réduire le travail. Et le rate limiting, je le garde pour les cas où je dois protéger un service aval avec une limite stricte (API externe, DB sous contrainte, etc.), même si ça veut dire « on met en attente » au lieu de « on drop ».

Tu peux aussi les combiner, mais là tu dois savoir ce que tu fais. Debounce + unique mal pensés peuvent te donner un comportement difficile à lire en incident, parce que tu ne sais plus ce qui a été ignoré, relâché, ou retardé.

Retries, backoff, Horizon : les interactions qui te surprennent en prod

Le debounce ne remplace pas une stratégie de retry. Si ton job échoue parce qu’un service aval est down, tu veux des retries et un backoff cohérent. Ce que tu veux éviter, par contre, c’est le scénario où une panne aval déclenche à la fois des retries et une rafale de nouveaux jobs identiques. C’est là que ça part en feu.

Si tu utilises Horizon, la question à te poser est simple : est-ce que tu vois une chute du « dispatched » utile, ou juste une explosion du « processed » qui ne sert à rien. Quand tu ajoutes le debounce sur un job qui était spammy, tu dois voir une baisse nette du volume traité, et souvent une baisse du lag sur les queues critiques. Si tu ne vois rien bouger, c’est que tu n’as pas ciblé le bon job, ou pas la bonne clé logique (par exemple tu debounce globalement alors que tu devrais debounce par ressource).

Autre point : pense « priorités ». Un bon debounce posé sur une queue dédiée, c’est propre. Un debounce posé sur une queue partagée avec du critique, ça peut créer des effets de bord si tu as mal séparé tes files et tes workers. Ce n’est pas un défaut du debounce, c’est un défaut de discipline sur les queues.

Observabilité : comment prouver que tu as arrêté la tempête

Quand tu corriges une tempête de jobs, tu veux des preuves. Pas juste « on a l’impression que c’est mieux ». Sur le terrain, ce que je regarde, c’est l’écart entre les jobs dispatchés et les jobs réellement exécutés, l’évolution de la longueur de queue (ou du backlog), et la latence entre dispatch et run. Si tu as un dashboard Horizon, c’est généralement visible en quelques minutes pendant un pic.

Tu peux aussi le rendre ultra concret avec des logs. Avant, tu avais 100 lignes « SyncUserToSearch started userId=42 » en 30 secondes. Après, tu veux en voir une ou deux, avec une exécution un peu plus tardive. C’est simple, mais ça met tout le monde d’accord, y compris en post-mortem.

Et oui, surveille aussi l’infra. Une tempête se voit souvent dans Redis (CPU, latence), dans la DB (connexions, locks, pics de requêtes répétées), et dans ton provider queue (SQS, etc.) via le volume de messages.

Les pièges que je vois passer : TTL, clé de dédoublonnage, multi-tenant

Le premier piège, c’est de choisir une fenêtre au pif. 30 secondes peuvent être parfaites pour un job de « refresh cache » qui n’a pas besoin d’être instantané, et catastrophiques pour un job qui pilote une expérience utilisateur. Le bon réglage, c’est celui qui absorbe la rafale typique sans rendre le système mou. Et ça, tu le trouves en regardant tes pics réels.

Le deuxième piège, c’est la notion de « même job ». Dans une app multi-tenant, tu veux rarement debounce « globalement ». Tu veux debounce par ressource et par tenant. Sinon tu vas finir par faire patienter un tenant à cause du bruit d’un autre, ce qui est un bug de produit, pas un bug technique.

Le troisième piège, c’est de croire que le debounce te dispense de rendre ton job propre. Si un job fait des effets de bord, protège-toi par conception : idempotence, verrous quand c’est nécessaire, invariants métiers clairs, et retries avec backoff. Le debounce doit rester une optimisation de charge, pas un pansement sur une logique fragile.

Conclusion : un « petit » debounce, c’est souvent un gros gain de stabilité

Je suis content de voir Laravel ajouter ce genre de feature, parce que c’est exactement ce qui manque quand tu passes du « ça marche » à « ça encaisse ». Le debounce sur les jobs, ce n’est pas glamour. Mais si tu as déjà vu une queue partir en vrille à cause d’un signal bruité, tu sais que ce sont ces détails qui font la différence.

Mon conseil : commence par un job clairement « état final » (index, cache, agrégats), pose un debounce conservateur, mesure l’avant/après sur un vrai pic, puis élargis. Et garde en tête la règle simple : si chaque occurrence compte, ne debounce pas. Tu ne veux pas un système calme et faux.

Sources

Cet article vous a plu ?

Commentaires

Laisser un commentaire

Entre 10 et 2000 caractères

Les commentaires sont modérés avant publication.

Aucun commentaire pour le moment.

Soyez le premier à donner votre avis !