Synchron oder Asynchron? Eine Architekturentscheidung
Die Architekturentscheidung: Wann du synchron rufst und wann du besser eine Queue dazwischenlegst.
Einleitung
Du baust ein Feature, das einen externen Service aufrufen muss, und fragst dich: Reicht ein einfacher HTTP-Call, oder muss eine Message Queue her? Ich musste diese Frage in den letzten Wochen für ein kleines Team beantworten, das eine Bestell-API und einen nachgelagerten Versandprozess verbinden will. Die Entscheidung klingt theoretisch einfach, ist es aber nur, bis Fehlerfälle, Latenz und Betriebsaufwand dazukommen.
Kontext
Das Projekt besteht aus drei Teilen: einem Node.js-Frontend, einer Go-basierten Bestell-API und einem Python-Worker für Versandetiketten. Traffic ist überschaubar — etwa 500 bis 800 Bestellungen pro Stunde, mit Spitzen bei Promoaktionen. Das Team hat zwei Vollzeitentwickler, keine dedizierten DevOps und möchte Fehler möglichst schnell erkennen, statt sie später in Queues zu suchen.
Was auf dem Spiel steht: Transparenz im Betrieb, einfache Fehlerbehandlung und schnelle Deployments. Non-Goals: Hochperformante Event-Streams mit zehntausend Nachrichten pro Sekunde, komplexe Routing-Logiken oder multi-regionale Replikation.
Option A: Synchron über HTTP
Jeder Prozess ruft den nächsten direkt über ein REST-API auf. Fehler werden sofort sichtbar, der Aufrufer bekommt ein 200 oder ein 5xx zurück.
Pros:
- Einfach zu debuggen: Jeder Request hat einen Trace in Logs und APM-Tools
- Keine zusätzliche Infrastruktur — kein Broker, kein Heartbeat, kein Consumer-Setup
- Klare Fehlersemantik: Der Aufrufer weiß sofort, ob es geklappt hat
Cons:
- Kopplung auf Zeit: Der Aufrufer wartet, bis der Worker antwortet — Latenz addiert sich
- Bei Lastspitzen oder Worker-Ausfällen eskaliert der Fehler sofort zum Nutzer
- Retries müssen im Aufrufer implementiert werden; idempotenz wird schnell komplex
# Beispiel: Synchroner HTTP-Call der Bestell-API zum Worker
POST /orders
{
"orderId": "ORD-2041",
"customer": "B2B-Partner",
"items": [{"sku": "PANDA-001", "qty": 3}]
}
# Der Worker antwortet direkt
HTTP/1.1 202 Accepted
{
"status": "label_queued"
}
Option B: Asynchron über Message Queue
Die Bestell-API legt die Nachricht in eine Queue, der Worker konsumiert sie wann immer er kann. Die Antwort kommt über Callback oder Polling zurück.
Pros:
- Entkopplung: Der Worker kann ausfallen, ohne dass Bestellungen verloren gehen
- Lastspitzen werden geglättet — die Queue puffert, statt dass Requests kaskadieren
- Bessere Skalierbarkeit: Mehr Worker können parallel konsumieren
Cons:
- Fehler werden verzögert sichtbar — eine falsch formatierte Nachricht taucht erst Minuten später im Log auf
- Betriebsaufwand: Du brauchst einen Broker, Heartbeat-Monitoring und ein Dead-Letter-Queuing-Konzept
- Komplexere Tests: Du musst die Queue mocks statt HTTP-Fixtures verwenden
# Beispiel: Worker-Konsument mit aio-pika
import aio_pika
async def consume():
connection = await aio_pika.connect_robust("amqp://guest:guest@rabbitmq/")
channel = await connection.channel()
queue = await channel.declare_queue("shipping_labels", durable=True)
async with queue.iterator() as it:
async for message in it:
async with message.process():
order = json.loads(message.body)
generate_label(order)
await message.ack()
Entscheidung
Ich habe mich für Synchron über HTTP entschieden — zumindest für den ersten Schritt.
Die Begründung ist betriebs- und teamorientiert: Wir wollen Fehler sofort sehen, Debugging soll ohne zusätzliche Infrastruktur gehen, und der Betriebsaufwand für eine Queue steht im Moment in keinem Verhältnis zum Nutzen. Die direkte Antwort des Workers macht es dem Team leicht, Probleme beim Kunden zu reproduzieren, statt sie aus Logs von gestern nachzustellen.
Der Trade-off: Wenn die Bestellmenge in sechs Monaten auf 10.000 pro Stunde wächst, werden wir eine Queue dazwischenlegen müssen. Aber die Wahrscheinlichkeit ist gering, und die Migrationskosten sind überschaubar: Statt HTTP-Calls kann der Aufrufer die Nachricht in eine Queue legen, ohne dass sich das Datenmodell ändert. Ich akzeptiere dieses Risiko zugunsten der aktuellen Transparenz und Geschwindigkeit.
Implementierung
Der Einstieg ist ein einziger Endpunkt in der Bestell-API:
// Beispiel: Go-Endpunkt, der synchron zum Worker aufruft
func createOrder(w http.ResponseWriter, r *http.Request) {
order := parseOrder(r.Body)
resp, err := http.Post("http://worker:3000/labels", "application/json", order.JSON())
if err != nil {
log.Error("worker unreachable", "orderId", order.ID)
http.Error(w, "Shipping service unavailable", http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
log.Error("worker rejected", "orderId", order.ID, "status", resp.StatusCode)
http.Error(w, "Label generation failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusAccepted)
}
Ein Timeout auf Client-Seite, ein Retry mit Exponential Backoff und ein strukturiertes Log reichen für den Anfang. Kein Broker, keine Consumer-Gruppen, keine Dead-Letter-Queue.
Fazit
Synchron bleibt die richtige Wahl, wenn du schnelle Fehlererkennung, einfache Deployments und geringen Betriebsaufwand brauchst. Asynchron zahlt sich erst aus, wenn Lastspitzen, Entkopplung oder nachgelagerte Verarbeitung kritisch werden.
Die Metrik, die für uns zählt: Die Zeit vom Auftreten eines Fehlers bis zur ersten Benachrichtigung des Teams ging von geschätzten zwanzig Minuten auf unter eine Minute runter — weil der Fehler sofort im Antwortcode des Workers steht und nicht erst später in einer Queue auftaucht.
Hat jemand von euch den umgekehrten Weg gemacht — also von Async auf Sync migriert? Ich bin neugierig, welche Betriebsgründe dagegen gesprochen haben.