Wenn ein Queue-Table nicht mehr ausreicht: Der Wechsel zu RabbitMQ
Werbung
Eine erstaunliche Anzahl von Produktionssystemen handhabt Hintergrundarbeiten auf exakt dieselbe Art und Weise. Sie nutzen eine jobs-Tabelle, eine status-Spalte (pending, processing, done, failed) und einen oder mehrere Worker, die diese Tabelle in einer Endlosschleife abfragen (Pollen), anstehende Zeilen abgreifen und deren Status im laufenden Betrieb aktualisieren.
Das ist ein absolut vernünftiger Weg, um zu starten. Er erfordert absolut null zusätzliche Infrastruktur. Es ist unglaublich einfach zu inspizieren (SELECT * FROM jobs WHERE status = 'failed' ist ein Debugging-Tool, das ohnehin jeder schon bedienen kann). Und bei geringem Volumen funktioniert es ehrlich gesagt für eine sehr lange Zeit völlig reibungslos.
Die Anzeichen dafür, dass ein System diesem Ansatz entwachsen ist, sind normalerweise nicht das abstrakte "es ist einfach zu viel Volumen". Sie sind viel spezifischer als das.
Das Abfragen (Polling) selbst wird zur Last
Jeder einzelne Worker, der die Tabelle alle paar Sekunden prüft, führt eine Query aus – völlig unabhängig davon, ob es überhaupt Arbeit zu erledigen gibt. Bei geringem Volumen ist das komplett vernachlässigbar.
Wenn die Tabelle wächst und mehr Worker hinzugefügt werden müssen, um den Durchsatz zu bewältigen, wird dieses ständige Polling jedoch zu einer konstanten, zermürbenden Hintergrundlast für die Datenbank. Es konkurriert dann aktiv mit den eigentlichen Anwendungsabfragen, die sie eigentlich unterstützen soll. Selbst wenn man SELECT ... FOR UPDATE SKIP LOCKED nutzt (der einzig korrekte Weg, dies sicher zu tun), bedeutet das immer noch, dass jeder einzelne Worker in jedem einzelnen Zyklus auf exakt dieselbe Tabelle einhämmert.
Retries und Fehlerbehandlung werden schmerzhaft neu erfunden
Die Retry-Logik einer Queue-Tabelle besteht meist aus einer retry_count-Spalte und etwas Anwendungscode, der diesen Zähler hochsetzt und den Job wieder in die Queue einreiht – bis er es eben irgendwann aufgibt.
Mit der Zeit sammeln sich Edge Cases langsam als Sonderfälle an. Jobs, die vor dem Fehlschlagen teilweise abgeschlossen waren, Jobs, die versehentlich zweimal abgegriffen werden, weil zwei Worker im exakt selben Moment gepollt haben, oder fehlgeschlagene Jobs, die sich aufstapeln, ohne dass es einen klaren Weg gäbe, den Grund dafür zu analysieren. Das sind keine unmöglichen Probleme, aber es sind exakt die Probleme, die eine dedizierte Message Queue bereits vor Jahren gelöst hat. Sie im eigenen Anwendungscode neu zu lösen bedeutet schlichtweg, dass man sich schmerzhaft auch mit all ihren Edge Cases erneut herumschlagen muss.
Was RabbitMQ tatsächlich ändert
RabbitMQ dreht das Modell vom Abfragen (Polling) zum Schieben (Pushing) komplett um. Ein Producer veröffentlicht (publisht) eine Nachricht an einen Exchange. RabbitMQ leitet sie basierend auf Bindings an eine Queue weiter (Routing). Und Consumer empfangen Nachrichten in dem Moment, in dem sie eintreffen, anstatt wiederholt danach fragen zu müssen:
# Producer
channel.basic_publish(
exchange='',
routing_key='email_notifications',
body=json.dumps({'user_id': 123, 'template': 'welcome'}),
properties=pika.BasicProperties(delivery_mode=2) # persistent
)
# Consumer
def callback(ch, method, properties, body):
process_notification(json.loads(body))
ch.basic_ack(delivery_tag=method.delivery_tag)
channel.basic_consume(queue='email_notifications', on_message_callback=callback)
basic_ack wird erst gefeuert, nachdem die Nachricht erfolgreich verarbeitet wurde. Wenn der Consumer mitten in der Verarbeitung abstürzt, liefert RabbitMQ die Nachricht sofort erneut aus, da sie nie bestätigt (acknowledged) wurde. Fehlgeschlagene Nachrichten können zudem völlig automatisch an einen Dead-Letter-Exchange geroutet werden. Das gibt Ihnen eine komplett separate Queue von "Dingen, die fehlgeschlagen sind", die Sie sauber inspizieren können, ohne dafür auch nur eine einzige Zeile an eigener Logik schreiben zu müssen.
Was gleich bleibt – und was sich nicht übertragen lässt
Das mentale Modell "Ein Job hat einen Payload und einen Status" verschwindet nicht. RabbitMQ ersetzt nicht das eigentliche Konzept; es ersetzt lediglich den Auslieferungsmechanismus.
Was sich jedoch nicht direkt übertragen lässt, ist Ihre Debugging-Gewohnheit. Ein schnelles SELECT * FROM jobs WHERE status = 'failed' wird abgelöst durch das Prüfen einer Dead-Letter-Queue. Das hat eine andere Form (wenn auch eine wohl deutlich bessere, da Nachrichten dort wirklich absolut unzustellbar sind und nicht einfach nur langsam). Auch alles, was darauf basierte, dass die Queue-Tabelle zusammen mit anderen Anwendungsdaten in derselben Datenbank-Transaktion abgefragt werden konnte – wie etwa das atomare Einfügen einer Tabellenzeile und eines dazugehörigen Queue-Eintrags im selben Schritt –, muss massiv überdacht werden, da die Queue nun ein physisch getrenntes System ist.
Wann die Tabelle weiterhin die richtige Wahl ist
Wenn das Volumen niedrig ist, die Jobs unglaublich simpel sind und eine Handvoll Worker, die alle paar Sekunden pollen, in den Datenbank-Metriken nicht einmal als spürbare Last auftauchen, ist eine Queue-Tabelle absolut kein Fehler. Sie ist schlichtweg perfekt auf das exakte Problem zugeschnitten, das Sie gerade haben.
Der Wechsel zu RabbitMQ lohnt sich dann, wenn die Last durch das ständige Polling in den Datenbank-Metriken sichtbar schmerzhaft wird. Er lohnt sich, wenn die Retry- und Fehlerbehandlung im eigenen Code ihr eigenes massives Set an Edge Cases herangezüchtet hat. Oder er lohnt sich, wenn plötzlich mehrere unterschiedliche Services exakt dieselben Events produzieren oder konsumieren müssen. Genau an diesem Punkt hört ein Message-Broker, der exakt dafür entworfen wurde, auf, "zusätzliche Infrastruktur" zu sein, und fängt an, die bei weitem simplere Option zu sein.
Werbung