Leases für eine sichere Ressourcenkoordination in Multi-Instance-Anwendungen nutzen

Stellen Sie sich eine Anwendungsbereitstellung vor, bei der ein einzelner Prozess sowohl einen Webservice als auch einen Hintergrundprozessor für Jobs innerhalb derselben Service-Komponente ausführt. Dieser Hintergrundprozessor arbeitet mit gemeinsam genutzten Ressourcen, etwa mit Aufgaben, die in einer Datenbanktabelle gespeichert sind und als Work Queue dienen. Wenn mehrere Replikate der Anwendung gleichzeitig laufen, kann es passieren, dass mehr als eine Instanz dieselbe Aufgabe verarbeitet. Das kann Race Conditions oder doppelte Verarbeitung verursachen. Noch schwieriger wird die Situation, wenn die Anwendung als Monolith aufgebaut ist und sich die Worker-Logik nicht vom Webservice trennen lässt oder die Anzahl der Replikate nicht reduziert werden kann. In einem solchen Fall ist die Koordination des Zugriffs auf gemeinsam genutzte Ressourcen deutlich anspruchsvoller.

Es gibt verschiedene Möglichkeiten, dieses Problem zu lösen, doch Leases bieten eine besonders einfache und skalierbare Methode zur Verwaltung gemeinsam genutzter Ressourcen. In diesem Beitrag wird erklärt, wie sich Leases in Multi-Instance-Umgebungen einsetzen lassen, um hohe Verfügbarkeit und gute Skalierbarkeit in modernen Anwendungen sicherzustellen.

Verfügbare Ansätze

Zentralisierte Sperren

Ein zentraler Dienst kann verwendet werden, um den Zugriff auf gemeinsame Ressourcen zu steuern. Auch wenn dieser Ansatz funktionieren kann, bringt er das Risiko mit sich, zum Flaschenhals oder zu einem Single Point of Failure zu werden.

Verteilte Transaktionen

Eine weitere Möglichkeit besteht darin, verteilte Transaktionen einzusetzen, damit Aktualisierungen über mehrere Instanzen hinweg atomar erfolgen. Dieser Ansatz ist jedoch oft komplex in der Umsetzung und schwierig zu skalieren.

Leases

Leases ermöglichen einen vorübergehenden, zeitlich begrenzten Zugriff auf Ressourcen. Sie sind eine praktikable und skalierbare Antwort auf viele Herausforderungen bei der Verwaltung gemeinsam genutzter Ressourcen.

Ein Lease gewährt den Zugriff auf eine Ressource für einen festgelegten Zeitraum und entzieht diesen automatisch wieder, falls er nicht rechtzeitig verlängert wird. Dadurch lassen sich Race Conditions vermeiden, und gleichzeitig wird sichergestellt, dass Ressourcen auch bei einem Ausfall eines Prozesses wieder freigegeben werden.

Zentrale Eigenschaften von Leases

Zeitlich begrenzter Zugriff

Ein Lease gewährt exklusiven Zugriff für eine definierte Dauer.

Unterstützung für Verlängerungen

Der aktuelle Inhaber kann den Zugriff verlängern, solange die Erneuerung vor dem Ablauf erfolgt.

Automatisches Auslaufen

Ressourcen werden ohne manuelle Eingriffe automatisch wieder freigegeben.

Fehlertoleranz

Wenn der Lease-Inhaber abstürzt oder nicht mehr reagiert, steht die Ressource einer anderen Instanz wieder zur Verfügung.

Werkzeuge zur Umsetzung von Leases

Redis

Redis kann Time-to-Live-Werte verwenden, um Leases zu verwalten. Ein Schlüssel, der eine Ressource repräsentiert, wird mit einer TTL versehen. Läuft diese ab, wird die Ressource automatisch wieder verfügbar.

Consul

Consul stellt einen verteilten Key-Value-Store bereit, der sich für leasebasierte Koordination einsetzen lässt. Sitzungsbasierte Sperrmechanismen helfen dabei, gemeinsam genutzte Ressourcen zu verwalten und einen konsistenten Zustand über mehrere Instanzen hinweg aufrechtzuerhalten.

Zookeeper

Zookeeper bietet Bausteine für Lease-Handling in verteilten Systemen, etwa ephemere Knoten, die automatisch verschwinden, sobald die Client-Sitzung endet. Dadurch lassen sich sowohl Ressourcen koordinieren als auch Leader-Election-Muster für verteilte Workloads umsetzen.

Vorteile von Leases

  • Leases vereinfachen den Umgang mit Ressourcen, weil deren Freigabe automatisiert wird.
  • Durch zeitliche Begrenzungen senken sie außerdem das Risiko von Deadlocks und Split-Brain-Situationen.
  • Darüber hinaus erhöhen sie die Fehlertoleranz, da Ressourcen nach Störungen oder Ausfällen wieder zurückgewonnen werden können.

Beispielanwendung: Task Processor mit verwaltetem PostgreSQL

Die Beispielanwendung zeigt eine vollständige Implementierung eines zentralen Lease-Service, eines Task-Management-Service und mehrerer Task-Prozessoren. Zusätzlich ist ein Hilfswerkzeug enthalten, das künstliche Arbeit erzeugt, indem zufällige Wartezeiten generiert werden, die anschließend von den Prozessoren verarbeitet werden.

In den folgenden Abschnitten werden die einzelnen Bestandteile der Beispielanwendung beschrieben. Dabei wird gezeigt, wie sich Leases in einem Task-Processor-Service einsetzen lassen, der auf einer verwalteten Application-Plattform betrieben wird.

Datenbank

Zum Einsatz kommt PostgreSQL, weil es sich um eine bewährte, vollständig ACID-konforme transaktionale Datenbank mit starken Garantien handelt. Statt ACID-Verhalten selbst nachzubauen, wird die Komplexität der Sperrlogik an die Datenbank delegiert.

Die Anwendung nutzt Prisma, um Schemaänderungen über Migrationen zu verwalten und Modelle für Entitäten zu erzeugen.

Arbeiten mit der Datenbank

Da die Abstraktionen von Prisma nicht sämtliche PostgreSQL-spezifischen Funktionen abdecken, die in dieser Anwendung verwendet werden, kommen an den entsprechenden Stellen rohe SQL-Abfragen über queryRaw zum Einsatz.

Lease-Service

Dieser Service ist für die Verwaltung des Lebenszyklus der Worker-Prozesse zuständig. Er stellt sicher, dass immer nur eine Worker-Instanz gleichzeitig einen Lease für eine Aufgabe oder eine andere Ressource halten kann. Dadurch werden Race Conditions verhindert und sichere Zustandsänderungen ermöglicht.

Die Implementierung ist bewusst generisch gehalten und enthält kein Wissen über konkrete Task-Verarbeitungslogik. Über die API-Routen übernimmt der Service das Erstellen, Verlängern, Freigeben und Abrufen von Leases.

Leases-Client

Der Leases-Client ist ein wichtiger Bestandteil der Lease-Verwaltungsschicht. Er kommuniziert mit der Lease-API, um Leases anzufordern, zu verlängern und freizugeben. So entsteht ein strukturierter Mechanismus, mit dem Ressourcen korrekt reserviert und bei Bedarf verlängert werden können. Als zusätzlicher Komfort kann der Client Leases auf Wunsch automatisch erneuern.

Datenbankschema der Beispielanwendung

Das folgende Schema definiert die Tabelle leases:

CREATE TABLE IF NOT EXISTS public.leases
(
    id integer NOT NULL DEFAULT nextval('leases_id_seq'::regclass),
    resource text COLLATE pg_catalog."default" NOT NULL,
    holder text COLLATE pg_catalog."default",
    created_at timestamp(3) without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
    renewed_at timestamp(3) without time zone,
    released_at timestamp(3) without time zone,
    expires_at timestamp(3) without time zone,
    CONSTRAINT leases_pkey PRIMARY KEY (id)
)

API-Routen der Beispielanwendung

/api/leases

GET: Gibt den Status des Workers zusammen mit der vollständigen Liste aller Leases zurück.

POST: Legt einen neuen Lease an oder aktualisiert einen vorhandenen Lease, sofern dieser bereits abgelaufen ist.

Request Body:

{
    "resource": "resource_name",
    "holder": "holder_name"
}

So funktioniert die Erstellung eines Leases

Die POST-Methode des Lease-Service ist dafür ausgelegt, entweder einen neuen Lease einzufügen oder einen bereits abgelaufenen vorhandenen Lease zu aktualisieren. Die folgende SQL-Anweisung bildet diese Logik ab:

INSERT INTO leases (resource, holder, expires_at)
                VALUES (${resource}, ${holder}, NOW() + INTERVAL '30 seconds')
            ON CONFLICT (resource) 
            DO UPDATE 
                SET 
                    holder = ${holder},
                    created_at = NOW(),
                    renewed_at = null,
                    released_at = null,
                    expires_at = NOW() + INTERVAL '30 seconds'
            WHERE leases.expires_at <= NOW()
            RETURNING *;

Neuen Lease einfügen: Die Anweisung INSERT INTO leases versucht, eine neue Zeile mit der angegebenen Ressource, dem Holder und einer Ablaufzeit 30 Sekunden nach dem aktuellen Zeitstempel anzulegen.

Konflikte behandeln: Die Klausel ON CONFLICT (resource) definiert das Verhalten, wenn bereits ein Lease für dieselbe Ressource existiert.

Vorhandenen Lease aktualisieren: Der Block DO UPDATE ersetzt den Holder, setzt created_at auf die aktuelle Zeit zurück, leert renewed_at und released_at und setzt expires_at erneut auf 30 Sekunden in die Zukunft.

Bedingte Aktualisierung anwenden: Die Bedingung WHERE leases.expires_at <= NOW() sorgt dafür, dass nur bereits abgelaufene Leases ersetzt werden. Aktive Leases bleiben unberührt.

Ergebnis zurückgeben: Die Klausel RETURNING * liefert den neu eingefügten oder aktualisierten Lease-Datensatz zurück.

Warum dieses Muster für die Lease-Erstellung verwenden?

Atomares Verhalten: Eine einzelne SQL-Anweisung in Kombination mit ON CONFLICT sorgt dafür, dass das Einfügen oder Aktualisieren atomar ausgeführt wird, wodurch das Risiko von Race Conditions sinkt.

Konfliktbehandlung: Wenn mehrere Anfragen gleichzeitig versuchen, einen Lease für dieselbe Ressource zu erstellen, stellt diese Logik sicher, dass immer nur ein aktiver Lease für diese Ressource bestehen bleibt.

Kontrolle über das Ablaufdatum: Die Bedingung WHERE leases.expires_at <= NOW() gewährleistet, dass ausschließlich abgelaufene Datensätze ersetzt werden und aktive Leases erhalten bleiben.

Effizienz: Das Zusammenführen von Insert- und Update-Logik in einer einzigen SQL-Anweisung reduziert die Anzahl der Datenbankoperationen.

Mit diesem Ansatz kann der Lease-Service zuverlässig Leases anlegen und aktualisieren, ohne die Integrität des Systems zu gefährden oder Race Conditions zu begünstigen.

/api/leases/active

GET: Gibt alle aktiven Leases zurück.

/api/leases/expired

GET: Gibt alle abgelaufenen Leases zurück.

/api/leases/renew

PUT: Verlängert die Ablaufzeit eines vorhandenen Leases.

Request Body:

{
    "resource": "resource_name",
    "holder": "holder_name"
}

So funktioniert die Lease-Verlängerung

Die PUT-Methode des Lease-Service verlängert einen bestehenden Lease, indem sie dessen Ablaufzeit weiter in die Zukunft verschiebt. Die Logik dafür sieht so aus:

UPDATE leases
    SET 
        renewed_at = NOW(),
        expires_at = NOW() + INTERVAL '30 seconds'
    WHERE
        holder = ${holder} 
        AND resource = ${resource}
        AND expires_at > NOW()
        AND released_at is null
    RETURNING *;

Update-Anweisung: Die Anweisung UPDATE leases verändert eine vorhandene Zeile in der Tabelle leases.

SET-Klausel: renewed_at = NOW() speichert den aktuellen Zeitpunkt der Verlängerung, während expires_at = NOW() + INTERVAL '30 seconds' den Lease um weitere 30 Sekunden verlängert.

WHERE-Klausel:

holder = ${holder} stellt sicher, dass der richtige Holder den Lease verlängert.

resource = ${resource} stellt sicher, dass die richtige Ressource angesprochen wird.

expires_at > NOW() stellt sicher, dass nur noch nicht abgelaufene Leases verlängert werden.

released_at is null stellt sicher, dass nur nicht freigegebene Leases aktualisiert werden.

RETURNING-Klausel: RETURNING * gibt den aktualisierten Lease-Datensatz zurück, damit das Ergebnis überprüft werden kann.

Warum Lease-Freigaben wichtig sind

Atomares Update: Die Abfrage führt die Freigabe in einem einzigen atomaren Schritt aus und reduziert dadurch das Risiko von Race Conditions.

Bedingte Aktualisierung: Die WHERE-Klausel stellt sicher, dass nur Leases aktualisiert werden, die noch nicht als freigegeben markiert sind.

Genaue Zeitstempel: Durch die Aktualisierung von released_at und expires_at lassen sich Freigabezeit und Ablaufzeit präzise nachvollziehen.

Sofortiges Feedback: Die Klausel RETURNING * liefert den aktualisierten Datensatz unmittelbar zurück, sodass die Anwendung direkt mit aktuellen Lease-Informationen reagieren kann.

/api/leases/renewed

GET: Gibt alle verlängerten Leases zurück.

/api/leases/released

GET: Gibt alle freigegebenen Leases zurück.

/api/leases/[id]

GET: Gibt einen einzelnen Lease anhand seiner ID zurück.

DELETE: Gibt einen Lease anhand seiner ID frei. Das Verhalten entspricht der Route DELETE /api/leases/release, allerdings wird die Lease-ID zusätzlich in die WHERE-Klausel der Update-Anweisung aufgenommen.

Request Body:

{
    "resource": "resource_name",
    "holder": "holder_name"
}

Task-Service

Der Task-Service verwaltet Aufgaben innerhalb der Anwendung. Er stellt API-Endpunkte bereit, um Aufgaben zu erstellen, zu aktualisieren, abzurufen und ihren Lebenszyklus zu steuern. Zusätzlich kapselt er die Leases-API, damit eine einzelne Aufgabe immer nur von genau einem Worker verarbeitet werden kann. Voraussetzung dafür ist selbstverständlich, dass sich die Worker an die Regeln der API halten und die Verarbeitung beenden, sobald ein Lease abgelaufen ist.

Datenbankschema des Task-Service

Das folgende Schema definiert die Tabelle tasks:

CREATE TABLE IF NOT EXISTS public.tasks
(
    id integer NOT NULL DEFAULT nextval('tasks_id_seq'::regclass),
    task_data jsonb NOT NULL,
    scheduled_at timestamp(3) without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
    processor text COLLATE pg_catalog."default",
    last_heartbeat_at timestamp(3) without time zone,
    must_heartbeat_before timestamp(3) without time zone,
    started_at timestamp(3) without time zone,
    processed_at timestamp(3) without time zone,
    task_output jsonb,
    CONSTRAINT tasks_pkey PRIMARY KEY (id)
)

API-Routen des Task-Service

Nächste Aufgabe abrufen

POST /api/tasks/next

Die POST-Methode dieser Route ruft die nächste verfügbare Aufgabe zur Verarbeitung ab und stellt dabei sicher, dass ein Lease für genau diese Aufgabe reserviert wird.

Die folgende Abfrage wird verwendet, um die nächste Aufgabe zu finden, die zur Verarbeitung bereitsteht:

SELECT * 
FROM tasks 
WHERE 
    processed_at is null
ORDER BY scheduled_at ASC
LIMIT 1
OFFSET ${tasksToSkip}
FOR UPDATE;

SELECT * FROM tasks

Dieser Teil der Abfrage wählt alle Spalten aus der Tabelle tasks aus.

WHERE processed_at is null

Diese Bedingung begrenzt das Ergebnis auf Aufgaben, die noch nicht verarbeitet wurden. Ist processed_at null, befindet sich die Aufgabe weiterhin in der Warteschlange.

ORDER BY scheduled_at ASC

Dadurch werden Aufgaben anhand ihres Zeitstempels scheduled_at aufsteigend sortiert, sodass früher geplante Aufgaben zuerst bearbeitet werden.

LIMIT 1

Damit wird sichergestellt, dass nur genau eine passende Aufgabe zurückgegeben wird.

OFFSET ${tasksToSkip}

Diese Klausel überspringt eine konfigurierbare Anzahl an Aufgaben, bevor ein Ergebnis geliefert wird. Der Wert ${tasksToSkip} kann dynamisch angepasst werden und ist hilfreich, wenn die zuerst geprüften Aufgaben bereits verleast oder anderweitig nicht verfügbar sind.

FOR UPDATE

Diese Klausel sperrt die ausgewählte Zeile für ein Update. Dadurch wird verhindert, dass andere Transaktionen dieselbe Zeile verändern oder auswählen, solange die aktuelle Transaktion läuft. So wird sichergestellt, dass kein anderer Prozessor die Aufgabe übernehmen kann, während sie gerade zugewiesen wird.

Warum die Get-Next-Task-Abfrage verwenden?

Priorisierung: Die Sortierung nach scheduled_at stellt sicher, dass früher eingeplante Aufgaben vor späteren verarbeitet werden.

Nebenläufigkeitskontrolle: Die Klausel FOR UPDATE ist entscheidend, um Race Conditions zu vermeiden. Sobald eine Aufgabe ausgewählt ist, wird sie für die laufende Transaktion gesperrt. Andere Prozessoren können sie dadurch nicht gleichzeitig übernehmen, und ein Lease kann sicher angefordert werden.

Dynamisches Überspringen: Mit OFFSET ${tasksToSkip} lassen sich Aufgaben überspringen, die bereits verleast oder aktuell nicht verfügbar sind. So kann die nächste tatsächlich nutzbare Aufgabe effizient gefunden werden.

Heartbeat-Task

/api/tasks/[id]/heartbeat

Die PUT-Route aktualisiert den Heartbeat einer Aufgabe. Dadurch wird bestätigt, dass weiterhin der richtige Prozessor an der Aufgabe arbeitet, und gleichzeitig wird der Lease verlängert, damit kein anderer Prozessor dieselbe Aufgabe übernimmt. Wenn ein Task-Prozessor Heartbeats nicht rechtzeitig sendet, läuft der zugrunde liegende Lease ab. In diesem Fall muss der Prozessor seine aktuelle Arbeit abbrechen und eine neue Aufgabe anfordern.

Transaktion

Der Code wird innerhalb einer Transaktion mit der Methode $transaction von Prisma ausgeführt, damit Atomarität und Konsistenz erhalten bleiben.

Abrufen der Aufgabe

Innerhalb dieser Transaktion wird die Aufgabe mit der angegebenen ID über eine rohe SQL-Abfrage mit FOR UPDATE geladen, sodass die Zeile gesperrt wird.

Validierung des Prozessors

Der Service prüft, ob die Aufgabe dem angegebenen Prozessor zugewiesen ist. Ist das nicht der Fall, wird eine Antwort mit 200 OK und einer entsprechenden Meldung zurückgegeben.

Prüfung auf abgeschlossene Verarbeitung

Der Service prüft außerdem, ob die Aufgabe bereits verarbeitet wurde. Falls ja, folgt eine Antwort mit 409 Conflict und einer Meldung, dass die Aufgabe schon verarbeitet wurde.

Lease-Verlängerung

Der Service sendet eine PUT-Anfrage an den Lease-Service, um den zugehörigen Lease der Aufgabe zu verlängern.

Schlägt die Lease-Verlängerung mit einem anderen Status als 404 fehl, gibt die Route eine Antwort mit 500 Internal Server Error und einer passenden Fehlermeldung zurück.

Ist der Lease bereits abgelaufen und lautet der Status 404, gibt die Route eine Antwort mit 409 Conflict zurück, die erklärt, dass der Task-Lease abgelaufen ist.

Aktualisierung der Aufgabe

Ist die Lease-Verlängerung erfolgreich, werden die Felder lastHeartBeatAt und mustHeartBeatBefore der Aufgabe mit den Zeitstempeln des verlängerten Leases aktualisiert.

Anschließend liefert die Route die aktualisierte Aufgabe mit dem Status 202 Accepted zurück.

Warum die Heartbeat-Route verwenden?

Atomare Operationen: Eine Transaktion stellt sicher, dass Heartbeat-Aktualisierung und Lease-Verlängerung atomar ablaufen, wodurch das Risiko von Race Conditions sinkt.

Nebenläufigkeitskontrolle: Die Klausel FOR UPDATE sperrt die Aufgabenzeile, sodass andere Transaktionen sie nicht gleichzeitig verändern können.

Lease-Management: Durch die Verlängerung des Leases bleibt die Aufgabe demselben Prozessor zugeordnet und kann nicht von einer anderen Instanz übernommen werden.

Aufgabe abschließen

PUT /api/tasks/[id]/complete

Die PUT-Route markiert eine Aufgabe als abgeschlossen. Sie prüft, ob die Aufgabe dem korrekten Prozessor gehört, verlängert den Lease, um eine erneute Zuweisung während des Abschlusses zu verhindern, und aktualisiert anschließend den Status der Aufgabe.

Transaktion

Die Logik läuft innerhalb einer Transaktion mit der Prisma-Methode $transaction, um Konsistenz und Atomarität zu wahren.

Abrufen der Aufgabe

Innerhalb der Transaktion wird die Aufgabe mit der angegebenen ID über eine rohe SQL-Abfrage geladen, die FOR UPDATE verwendet, um die Zeile zu sperren.

Wird die Aufgabe nicht gefunden, gibt die Route eine Antwort mit 200 OK und einer entsprechenden Information zurück.

Validierung des Prozessors

Der Service prüft, ob die Aufgabe dem in der Anfrage angegebenen Prozessor zugeordnet ist. Falls nicht, folgt ebenfalls eine Antwort mit 200 OK und einem entsprechenden Hinweis.

Prüfung auf abgeschlossene Verarbeitung

Wenn die Aufgabe bereits verarbeitet wurde, gibt die Route eine Antwort mit 409 Conflict zurück, die mitteilt, dass die Aufgabe bereits abgeschlossen ist.

Lease-Verlängerung

Der Service sendet eine PUT-Anfrage an den Lease-Service, um den Task-Lease zu verlängern.

Schlägt die Verlängerung mit einem anderen Status als 404 fehl, gibt die Route eine Antwort mit 500 Internal Server Error und einer passenden Meldung zurück.

Ist der Lease bereits abgelaufen und lautet der Status 404, gibt die Route eine Antwort mit 409 Conflict zurück, die darauf hinweist, dass der Task-Lease abgelaufen ist.

Aktualisierung der Aufgabe

Wenn die Lease-Verlängerung erfolgreich war, werden die Felder lastHeartBeatAt, mustHeartBeatBefore, processedAt und taskOutput der Aufgabe mit den Zeitstempeln des verlängerten Leases sowie dem übergebenen Task-Ergebnis aktualisiert.

Danach gibt die Route die aktualisierte Aufgabe mit dem Status 202 Accepted zurück.

Warum die Complete-Task-Route verwenden?

Atomare Operationen: Eine Transaktion sorgt dafür, dass Task-Abschluss und Lease-Verlängerung gemeinsam und atomar erfolgen, wodurch das Risiko von Race Conditions sinkt.

Nebenläufigkeitskontrolle: Die Klausel FOR UPDATE sperrt die Aufgabenzeile, sodass sie nicht gleichzeitig von einer anderen Transaktion verändert werden kann.

Lease-Management: Die Verlängerung des Leases sorgt dafür, dass die Aufgabe während des Abschlusses demselben Prozessor zugeordnet bleibt. Das ist besonders hilfreich, wenn das SQL-Update vorübergehende Probleme hat und der Vorgang nach Freigabe der Zeilensperre erneut versucht werden muss, ohne dass der Lease in diesem Zeitfenster abläuft.

Diese Routen verwenden einfache Abfragen, um mit der Task-Liste zu arbeiten:

  • Aufgabe per ID abrufen (/api/tasks/[id])
  • Verarbeitete Aufgaben abrufen (/api/tasks/processed)
  • Gestartete Aufgaben abrufen (/api/tasks/started)
  • Alle Aufgaben abrufen (/api/tasks)

Task-Worker

Der Task-Worker ist ein Worker-Prozess, der fortlaufend Aufgaben über den Task-Service abruft, verarbeitet und abschließt. Er stellt eine zuverlässige Bearbeitung sicher, indem er regelmäßig Heartbeats sendet, um Leases zu verlängern, und Fehler kontrolliert behandelt.

Wie der Worker Aufgaben ausführt und Leases verwaltet

Der Worker arbeitet in einer strukturierten Schleife, in der er wiederholt Aufgaben abruft und verarbeitet. Dabei nutzt er einen Lease-Mechanismus, um eine sichere Ausführung zu gewährleisten. So wird verhindert, dass mehrere Worker dieselbe Aufgabe gleichzeitig bearbeiten, und gleichzeitig eine verlässliche Fertigstellung sichergestellt.

Abrufen einer Aufgabe

Der Worker beginnt damit, die Task-Queue nach der nächsten verfügbaren Aufgabe zu durchsuchen. Wird keine passende Aufgabe gefunden, wartet er kurz und versucht es dann erneut. Dadurch wird unnötige Last auf der Queue vermieden, ohne dass neue Aufgaben zu spät aufgenommen werden.

Lease-Verwaltung mit Heartbeats

Sobald eine Aufgabe zugewiesen wurde, startet der Worker eine Heartbeat-Schleife, um seinen Lease aktiv zu halten. Dieser Lease verhindert, dass andere Worker dieselbe Aufgabe beanspruchen, während sie verarbeitet wird. Der Worker muss dazu in regelmäßigen Abständen Heartbeats senden, um den Lease zu verlängern. Geschieht das nicht, läuft der Lease ab und die Aufgabe kann einer anderen Instanz neu zugewiesen werden.

  • Läuft der Lease ab, stoppt der Worker die Verarbeitung und verwirft die Aufgabe.
  • Wird der Lease erfolgreich erneuert, setzt der Worker die Ausführung fort und kann gelegentlich hohe Latenzen simulieren, um die Robustheit zu testen.

Ausführung der Aufgabe und Fehlerbehandlung

Während der Ausführung verarbeitet der Worker die Aufgabe schrittweise. Dabei werden zufällige Fehler mit einer Wahrscheinlichkeit von 5 % eingebaut, um unerwartete Abstürze zu simulieren.

Zusätzlich werden Latenzspitzen mit einer Wahrscheinlichkeit von 10 % erzeugt, um Netzwerkverzögerungen oder Performance-Engpässe nachzustellen, durch die Heartbeats verpasst werden könnten.

Tritt ein simulierter Fehler auf, beendet sich der Worker sofort, damit die Aufgabe später erneut versucht werden kann. Andernfalls läuft die Verarbeitung weiter, bis die Aufgabe vollständig abgeschlossen ist.

Abschluss der Aufgabe

Nach der Verarbeitung markiert der Worker die Aufgabe als abgeschlossen, damit das System sie aus der Queue entfernen kann.

  • Anschließend stoppt er den Heartbeat-Timer.
  • Danach sucht er sofort nach einer neuen Aufgabe und startet den Ablauf erneut.
  • Dieses Design stellt sicher, dass immer nur ein Worker gleichzeitig eine Aufgabe verarbeitet.
  • Zugleich ermöglicht es eine automatische Wiederherstellung über das Auslaufen von Leases.
  • Wenn die Anwendung abstürzt, startet die Hosting-Plattform den Prozess erneut.

Regelmäßige Heartbeats helfen dabei, verlassene Aufgaben nicht in einem blockierten oder unverarbeiteten Zustand zurückzulassen.

  • Simulierte Fehler und Latenzen machen es möglich, die Belastbarkeit des Systems realistisch zu testen.
  • Die künstliche Latenz dient dabei gezielt dazu, verpasste Heartbeats nachzustellen.

Mit diesem Ansatz entsteht eine fehlertolerante, verteilte Task-Ausführung, die sich effizient skalieren lässt.

Workflow

Task-Generator

Es ist wichtig zu prüfen, wie Worker sich unter realistischen Bedingungen verhalten. Der Task-Generator wurde entwickelt, um eine Arbeitslast zu simulieren, indem er fortlaufend Aufgaben mit zufälligen Ausführungszeiten erzeugt. Diese Aufgaben stehen stellvertretend für reale Arbeiten, die ein Worker verarbeiten könnte, beispielsweise Bildskalierung, Video-Transkodierung, E-Mail-Versand oder andere Prozesse, die zuverlässig abgearbeitet werden müssen.

Durch den leasebasierten Aufbau kann immer nur eine Instanz des Generators aktiv Aufgaben erzeugen. So werden Duplikate vermieden, obwohl mehrere Instanzen gleichzeitig bereitgestellt sein können. Dieses Setup hilft dabei, das Verhalten des Systems zu beobachten, die Leistung der Worker zu überprüfen und mögliche Schwachstellen bei Task-Ausführung und Lease-Management sichtbar zu machen.

Task-Erzeugung in einer Load-Balanced-Umgebung

In einem verteilten System, in dem mehrere Instanzen eines Dienstes hinter einem Load Balancer laufen, ist es schwierig, einen Singleton-Prozess aufrechtzuerhalten. Ein Singleton-Prozess ist ein Prozess, der immer nur einmal gleichzeitig aktiv sein darf. Der Task-Generator verdeutlicht dieses Problem, indem sichergestellt wird, dass immer nur eine Instanz aktiv Aufgaben erstellt, obwohl alle Instanzen weiterhin auf Statusanfragen reagieren können und dabei teils irreführende Ergebnisse liefern.

Wie der Task-Generator funktioniert

Sicherstellen, dass immer nur eine Instanz aktiv ist

  • Der Generator fordert einen Lease an, der wie eine Sperre wirkt und nur einer Instanz erlaubt, aktiv Aufgaben zu erzeugen.
  • Andere Instanzen bleiben untätig, reagieren aber weiterhin auf API-Anfragen.

Kontinuierliches Erstellen von Aufgaben

  • Der Generator fügt neue Aufgaben in eine PostgreSQL-Tabelle ein. Jede Aufgabe enthält ein JSON-Objekt wie {"sleep_duration_seconds": 5}, das vorgibt, wie lange die Verarbeitung dauern soll.
  • Diese Aufgaben werden anschließend von Worker-Prozessen übernommen.

Umgang mit loadverteilten Anfragen

  • Da API-Anfragen von unterschiedlichen Instanzen bearbeitet werden können, hängen die Antworten davon ab, welche Instanz die jeweilige Anfrage erhält.
  • Nur die Instanz, die den Lease aktuell hält, meldet, dass sie Aufgaben erzeugt. Alle anderen Instanzen verbleiben in einem passiven Zustand.

Warum der Status schwanken kann

Beim Prüfen des Generator-Status können widersprüchliche Antworten auftreten.

  • Eine Anfrage kann STARTED zurückgeben, was bedeutet, dass sie die Instanz mit dem aktiven Lease erreicht hat.
  • Eine andere Anfrage kann STOPPED zurückgeben, weil sie an eine Instanz ging, die den Lease nicht hält.
  • Auch das Betätigen von Stop funktioniert möglicherweise nicht sofort, weil die Anfrage an eine Instanz gelangen kann, die den Lease gerade nicht besitzt. Unter Umständen sind mehrere Versuche nötig, bis die leasehaltende Instanz erreicht wird.
  • Dieses Verhalten macht eine grundlegende Herausforderung sichtbar, die beim Betrieb von singletonartigen Diensten in loadbalancierten Umgebungen entsteht.

So lässt sich der Generator stoppen

Damit der Generator gestoppt werden kann, muss die Stop-Anfrage die Instanz erreichen, die den Lease aktuell hält. Da Anfragen zufällig verteilt werden:

  • Kann es mehrere Versuche benötigen, bis die Instanz mit dem aktiven Lease die Anfrage tatsächlich erhält.
  • Eine verlässlichere Methode wäre es, die zentral verwalteten Lease-Informationen zu nutzen, über den Lease-Service zu ermitteln, welche Instanz den Lease besitzt, und die Stop-Anfrage anschließend gezielt an genau diese Instanz weiterzuleiten.

Bereitstellung des Task-Processors

Die vollständige Anwendung ist im GitHub-Repository verfügbar. Um sie auf einer verwalteten Application-Plattform bereitzustellen:

  • Öffnen Sie das Dashboard der Plattform oder nutzen Sie die im README beschriebene Deployment-Schaltfläche des Repositorys.
  • Stellen Sie zwei verwaltete PostgreSQL-Entwicklungsdatenbanken bereit, um Aufgaben und Leases zu speichern.
  • Der Task-Generator-Service erstellt die benötigten Datenbanktabellen automatisch und befüllt die Task-Queue.

Hinweis: Entfernen Sie die Anwendung nach Abschluss der Tests wieder. Andernfalls können durch die weitere Nutzung tatsächliche Kosten auf Ihrem Konto entstehen.

Fazit

Leases sind ein wirkungsvoller Ansatz, um gemeinsam genutzte Ressourcen in Multi-Instance-Umgebungen zu verwalten. Durch die Umsetzung mit PostgreSQL lassen sich sicherer, zeitlich begrenzter Zugriff, automatisches Auslaufen und eine vereinfachte Wiederherstellung nach Fehlern realisieren. Verwaltete Datenbankdienste machen diesen Ansatz leicht und zuverlässig nutzbar. Wer die Beispielanwendung ausprobiert, erhält einen direkten Eindruck von den Vorteilen leasebasierter Ressourcenkoordination.

Quelle: digitalocean.com

Jetzt 200€ Guthaben sichern

Registrieren Sie sich jetzt in unserer ccloud³ und erhalten Sie 200€ Startguthaben für Ihr Projekt.

Das könnte Sie auch interessieren: