Zum Inhalt springen

Verteilte Locks: Arbeit über mehrere Knoten koordinieren

Vermeide Race Conditions in verteilten Systemen: ein Leitfaden zu verteilten Locks mit Redis und PostgreSQL-Advisory-Locks in Node.js.

5 Min. Lesezeit
Diagramm, das mehrere Serverknoten zeigt, die um einen verteilten Lock auf einer gemeinsamen Ressource konkurrieren

Horizontale Skalierung löst Durchsatzprobleme, führt aber zu einer neuen Klasse von Bugs: zwei Instanzen desselben Services führen gleichzeitig dieselbe Arbeit aus. Eine doppelte E-Mail zu versenden, einen Kunden doppelt zu belasten oder einen gemeinsamen Zustand zu korrumpieren — das sind Race Conditions auf Infrastrukturebene, und ein einzelnes SELECT ... FOR UPDATE in der Datenbank rettet dich nicht, wenn der konkurrierende Prozess auf einer anderen Maschine läuft.

Verteilte Locks sind das Werkzeug dafür. Sie sind auch eine der am häufigsten missbrauchten Primitiven im Backend-Engineering. Die Ausfallmodi sind subtil, die Literatur voller Kontroversen (siehe die Redlock-Debatte), und die meisten Tutorials hören auf, bevor sie die Teile abdecken, die in der Produktion wirklich wichtig sind: Ablauf, Erneuerung und was passiert, wenn der Lock-Besitzer stirbt.

Das Kernproblem

Stell dir einen Cron-Job vor, der jede Minute läuft, überfällige Rechnungen verarbeitet und Erinnerungs-E-Mails verschickt. Du hast auf drei Instanzen skaliert. Ohne Koordination wachen alle drei Instanzen zur gleichen Zeit auf, fragen dieselben Zeilen ab und verschicken drei E-Mails pro Kunde.

Die Lösung ist nicht „einfach eine Queue verwenden" — manchmal muss die Arbeit wirklich ein Singleton sein. Geplante Jobs, Leader Election und One-Shot-Migrationen haben alle diese Form. Du brauchst Exactly-One-Ausführungssemantik.

Redis: Die gängige Wahl

Der Befehl SET key value NX PX ttl von Redis ist atomar: er setzt einen Schlüssel nur, wenn er nicht existiert, mit einer Ablaufzeit. Das ist die Primitive, auf der ein verteilter Lock aufbaut.

tstypescript
import { createClient } from "redis";
import { randomUUID } from "crypto";
 
interface Lock {
  key: string;
  token: string;
  release: () => Promise<void>;
}
 
const RELEASE_SCRIPT = `
  if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
  else
    return 0
  end
`;
 
async function acquireLock(
  redis: ReturnType<typeof createClient>,
  key: string,
  ttlMs: number,
): Promise<Lock | null> {
  const token = randomUUID();
  const acquired = await redis.set(key, token, {
    NX: true,
    PX: ttlMs,
  });
 
  if (!acquired) return null;
 
  return {
    key,
    token,
    release: async () => {
      // ✅ Lua script ensures we only delete the key we own
      await redis.eval(RELEASE_SCRIPT, { keys: [key], arguments: [token] });
    },
  };
}

Hier verdienen zwei Dinge Aufmerksamkeit. Erstens wird der token (eine UUID) als Lock-Wert gespeichert, nicht nur als Flagge. Beim Freigeben prüft das Lua-Skript, ob der Wert übereinstimmt, bevor es löscht — das verhindert, dass ein langsamer Prozess einen Lock freigibt, der abgelaufen und von jemand anderem wieder erworben wurde. Zweitens ist die Freigabe atomar: Check-and-Delete geschieht in einem einzigen Roundtrip.

Die Ablauffalle

Ein TTL zu setzen ist Pflicht. Ohne ihn hält ein abgestürzter Prozess den Lock für immer. Aber TTLs bringen ihre eigene Gefahr mit sich: was, wenn die Arbeit länger dauert als erwartet?

tstypescript
// ❌ Fixed TTL — expires while work is still in progress
const lock = await acquireLock(redis, "invoice:process", 5_000);
await processAllInvoices(); // takes 12 seconds on a bad day
 
// ✅ Watchdog extends the lock while work is ongoing
const lock = await acquireLock(redis, "invoice:process", 10_000);
if (!lock) return; // another instance is working
 
const watchdog = setInterval(async () => {
  await redis.pExpire(lock.key, 10_000);
}, 4_000); // renew every 4s, TTL is 10s
 
try {
  await processAllInvoices();
} finally {
  clearInterval(watchdog);
  await lock.release();
}

Das Watchdog-Muster hält einen Lock so lange am Leben, wie der Prozess gesund ist. Wenn der Prozess abstürzt, stoppt das Intervall, der TTL läuft natürlich ab und eine andere Instanz kann den Lock erwerben. So gehen Redlock und die meisten ernsthaften verteilten Lock-Bibliotheken mit dem Ablaufproblem um.

!

Die Erneuerung durch den Watchdog geht davon aus, dass dein Prozess lebt und Fortschritte macht. Ein Prozess kann lebendig, aber steckengeblieben sein — in einem Deadlock, einer langen GC-Pause oder einem hängenden externen Aufruf. Erwäge Heartbeats auf Anwendungsebene hinzuzufügen, die bestätigen, dass die Arbeit tatsächlich vorankommt, und nicht nur, dass der Prozess läuft.

PostgreSQL Advisory Locks

Redis ist nicht immer im Stack. PostgreSQL hat einen nativen Mechanismus für verteilte Locks: Advisory Locks. Sie sind session- oder transaktionsbezogen, werden im Shared Memory gehalten und sind über alle Verbindungen zur selben Datenbank hinweg sichtbar.

sqlsql
-- Session-scoped: held until explicitly released or session ends
SELECT pg_try_advisory_lock(hashtext('invoice:process'));
 
-- Transaction-scoped: released automatically at commit/rollback
SELECT pg_try_advisory_xact_lock(hashtext('invoice:process'));

Von TypeScript aus mit pg oder einem Query Builder:

tstypescript
import { Pool } from "pg";
 
async function withAdvisoryLock<T>(
  pool: Pool,
  lockKey: string,
  fn: () => Promise<T>,
): Promise<T | null> {
  const client = await pool.connect();
  try {
    // pg_try_advisory_xact_lock is transaction-scoped: auto-released on commit/rollback
    await client.query("BEGIN");
    const { rows } = await client.query<{ acquired: boolean }>(
      "SELECT pg_try_advisory_xact_lock(hashtext($1)) AS acquired",
      [lockKey],
    );
 
    if (!rows[0].acquired) {
      await client.query("ROLLBACK");
      return null; // someone else holds it
    }
 
    const result = await fn();
    await client.query("COMMIT");
    return result;
  } catch (err) {
    await client.query("ROLLBACK");
    throw err;
  } finally {
    client.release();
  }
}

Die transaktionsbezogene Variante ist sicherer: wenn der Prozess mitten in der Arbeit abstürzt, rollt PostgreSQL die Transaktion zurück und gibt den Lock automatisch frei. Kein TTL abzustimmen, kein Watchdog nötig. Der Kompromiss: der Lock wird nur für die Dauer der Datenbanktransaktion gehalten — wenn deine Arbeit Nebeneffekte außerhalb der Datenbank beinhaltet (HTTP-Aufrufe, Dateischreibvorgänge), bietet der transaktionsbezogene Lock nach dem Commit keinen Schutz mehr.

Den richtigen Ansatz wählen

SzenarioEmpfohlener AnsatzWarum
Arbeit umfasst nur DB-SchreibvorgängePostgreSQL Advisory Lock (xact)Automatische Freigabe bei Absturz, kein TTL-Tuning
Arbeit umfasst externe AufrufeRedis mit WatchdogTTL übersteht Verbindungsverlust
Multi-Datenbank oder kein PostgresRedis mit WatchdogFunktioniert unabhängig von der Speicherschicht
Langlaufender HintergrundjobRedis mit WatchdogExplizite Kontrolle über die Erneuerung
Leader ElectionRedis NX oder Postgres Session LockStandardmuster für beide

Umgang mit der fehlgeschlagenen Erwerbung

Zu entscheiden, was zu tun ist, wenn acquireLock null zurückgibt, ist genauso wichtig wie der Lock selbst. Die meisten Aufrufer sollten überspringen, nicht blind wiederholen.

tstypescript
async function runInvoiceJob(redis: ReturnType<typeof createClient>) {
  const lock = await acquireLock(redis, "jobs:invoice-reminders", 30_000);
 
  if (!lock) {
    // ✅ Another instance is handling it — this is normal, not an error
    console.info({ msg: "Lock unavailable, skipping run" });
    return;
  }
 
  const watchdog = setInterval(
    () => redis.pExpire(lock.key, 30_000),
    10_000,
  );
 
  try {
    await sendInvoiceReminders();
  } catch (err) {
    // Log but don't swallow — let the caller decide on retry policy
    console.error({ msg: "Invoice job failed", err });
    throw err;
  } finally {
    clearInterval(watchdog);
    await lock.release();
  }
}

Bei null früh zurückzukehren ist der richtige Standard für Cron-Jobs und Hintergrund-Worker. Wenn du brauchst, dass die Arbeit irgendwann abgeschlossen wird, verwende eine Queue statt eines Locks — Queues bieten durable Retry-Semantik, Locks nicht.

~

Wenn du zu verteilten Locks greifst, um einen Queue-Consumer zu schützen, hör auf. Queues wie BullMQ serialisieren die Arbeit bereits durch Job-Visibility-Timeouts. Ein Lock darüberzulegen bedeutet meist, dass die Queue die falsche Abstraktion ist — oder dass die Fan-out-Logik der Jobs überdacht werden muss.

Was verteilte Locks nicht garantieren können

Verteilte Locks bieten unter normalen Bedingungen gegenseitigen Ausschluss. Sie bieten ihn nicht bedingungslos. Clock Skew, Netzwerkpartitionen und GC-Pausen können alle dazu führen, dass ein Prozess glaubt, einen gültigen Lock zu halten, obwohl das nicht der Fall ist. Martin Kleppmanns Kritik an Redlock lohnt sich, um das ganze Bild zu sehen.

Die praktische Konsequenz: für Operationen, bei denen eine Doppelausführung schwere Folgen hat (Finanztransaktionen, irreversible Löschungen), kombiniere verteilte Locks mit einem Idempotency-Key oder einer Datenbank-Constraint. Der Lock reduziert die Wahrscheinlichkeit einer gleichzeitigen Ausführung nahezu auf null; die Constraint macht eine Doppelausführung unmöglich, selbst wenn der Lock versagt.

Wichtige Erkenntnisse

  1. Immer einen eindeutigen Token als Lock-Wert speichern — das verhindert, dass ein langsamer Prozess nach Ablauf einen Lock freigibt, der ihm nicht mehr gehört.
  2. Für langlaufende Arbeit einen Watchdog verwenden — feste TTLs laufen ab, während die Arbeit läuft; erneuere proaktiv, solange der Prozess lebt.
  3. PostgreSQL Advisory Locks werden untergenutzt — wenn dein kritischer Abschnitt bereits in einer Datenbanktransaktion liegt, bekommst du mit xact-bezogenen Locks automatische Bereinigung geschenkt.
  4. Bei Fehlschlag früh zurückzukehren ist korrekt — ein Cron-Job, der einen Lauf überspringt, weil eine andere Instanz arbeitet, funktioniert wie entworfen.
  5. Locks reduzieren Wahrscheinlichkeit, nicht Unmöglichkeit — für wirklich kritische Operationen füge als zweite Verteidigungslinie eine Idempotency-Constraint in der Speicherschicht hinzu.
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX