Zwei Queue-Spuren statt einer: was ich beim Bau meines Monitoring-SaaS gelernt habe
Eine einzige Queue für alle Monitoring-Jobs sieht solide aus – bis ein Schwall stündlicher Checks deine minütlichen Probes blockiert. Hier ist die kleine Architekturentscheidung, die das Problem aus der Welt geschafft hat.
Vor ein paar Tagen habe ich Portchecks live geschaltet – einen kleinen TCP-Monitoring-Dienst, der prüft, ob bestimmte Ports offen oder geschlossen sind, und nur dann eine Mail schickt, wenn der Zustand vom Soll abweicht.
Klingt simpel. Ist es im Kern auch. Aber es gibt eine Stelle in der Architektur, die ich nicht von Anfang an richtig hatte – und die in produktiver Last sehr schnell weh tut.
Das Problem: Eine Queue für alles
Im ersten Wurf landeten alle Checks in derselben Queue. Egal ob ein Server alle 60 Sekunden geprüft wird oder einmal am Tag, ein Worker hat sie der Reihe nach abgearbeitet. Lokal mit einer Handvoll Checks fällt das nicht auf.
In dem Moment, in dem ein Account dann fünfzig Server mit zehn stündlichen Checks anlegt, hast du ein Problem: Wenn diese 500 Probes zufällig zur vollen Stunde fällig sind, schiebt sich vor jeden minütlichen Check ein Stapel, der erstmal abgearbeitet werden muss. Eine Probe, die alle 60 Sekunden laufen soll, läuft dann effektiv alle 90 oder 120 Sekunden. Genau die Garantie, die deine Kund:innen kaufen, ist als erstes weg.
Das nennt sich Queue-Starvation und ist das Standard-Problem, sobald du Jobs mit unterschiedlicher Zeitkritikalität in den gleichen Topf wirfst.
Die Lösung: Zwei Spuren
Die Korrektur ist klein und ohne neue Infrastruktur machbar. Statt einer einzigen Queue bekommen sub-stündliche Checks (1, 10, 15, 30 Minuten) eine eigene Lane, alles ab stündlich teilt sich die andere.
Im Job sieht das so aus:
public static function queueFor(PortCheck $check): string
{
return $check->interval->isSubHourly()
? 'portchecks-minutely'
: 'portchecks';
}
public static function dispatchFor(PortCheck $check): PendingDispatch
{
return self::dispatch($check)->onQueue(self::queueFor($check));
}
Im Deployment laufen dann zwei Worker-Pools: einer auf portchecks-minutely, einer auf portchecks. Beide auf den gleichen Servern, beide auf dem gleichen Code – sie ziehen nur aus unterschiedlichen Buckets. Stündliche Probes können sich gerne zur vollen Stunde stauen, die minütlichen merken davon nichts.
Warum nicht einfach mehr Worker?
Weil das die falsche Stellschraube ist. Mehr Worker auf eine gemeinsame Queue beschleunigt zwar den Durchsatz, ändert aber nichts an der Reihenfolge: Die minütliche Probe steht weiterhin hinter dem stündlichen Block. Sie wird nur etwas schneller hinter ihm fertig.
Was du eigentlich willst, ist nicht Geschwindigkeit, sondern eine Garantie für die kritische Klasse. Die bekommt man nur, indem man sie physisch trennt.
Wann sich der Aufwand lohnt
Diese Trennung ist nichts, was du in jedem Laravel-Projekt brauchst. Sie lohnt sich, sobald du in einer Anwendung Jobs mit klar unterschiedlicher Zeitkritikalität hast – Monitoring, Alerting, eingehende Webhooks parallel zu Reportings, Echtzeit-Operationen neben Batch-Jobs. Überall dort, wo „dauert manchmal halt etwas länger" ein anderes Wort für „kaputt" ist.
In Portchecks war die Umstellung am dritten Tag dran. Seitdem ist die p99-Latenz der minütlichen Checks unauffällig flach, egal wie groß der stündliche Block daneben ist. Eine winzige Designentscheidung, die einem stillen, mit der Zeit immer größeren Problem den Boden entzieht.