Technischer Leitfaden

Concurrency und Datenintegrität: Die Patterns, die unsere Produktion gerettet haben

Produktions-Concurrency-Patterns für Enterprise-Systeme. Field Ownership, Optimistic Locking, kooperative Leases, Idempotency Stores, Versionsmanagement und Transaction Governance Layers.

24. Januar 202616 Min. LesezeitOronts Engineering Team

Die Race Condition, die du erst in der Produktion siehst

Race Conditions sind in der Entwicklung unsichtbar. Dein lokaler Rechner läuft mit einem Prozess. Deine Test-Suite läuft sequenziell. Alles funktioniert. Dann deployest du in die Produktion mit 4 Web-Pods und 3 Worker-Pods, und zwei Prozesse modifizieren denselben Record gleichzeitig. Einer überschreibt die Änderungen des anderen. Daten werden stillschweigend korrumpiert. Niemand bemerkt es, bis sich ein Kunde beschwert.

Wir haben Race Conditions in mehreren Enterprise-Systemen behoben: CMS-Plattformen mit 20 Redakteuren und Background-Workern, Commerce-Plattformen mit gleichzeitiger Bestellverarbeitung und KI-Systeme mit parallelen Agenten-Workflows. Die Patterns in diesem Artikel sind die, die sich bewährt haben.

Für den breiteren Kontext siehe unseren System Architecture Guide und unseren Event-Driven Architecture Guide. Für CMS-spezifische Concurrency behandelt unser Pimcore Workflow Guide diese Patterns im Detail.

Field Ownership: Wer darf was schreiben

Die Grundursache der meisten Enterprise-Concurrency-Bugs: Mehrere Writer modifizieren denselben Record über denselben Save-Pfad ohne Koordination.

Ein CMS hat Redakteure, die Produktbeschreibungen schreiben, und Worker, die Thumbnails generieren. Beide rufen save() auf. Beide persistieren das gesamte Objekt. Wenn der Worker nach dem Laden, aber vor dem Speichern des Redakteurs speichert, überschreibt der Save des Redakteurs das Thumbnail des Workers. Wenn der Redakteur zuerst speichert, überschreibt der Save des Workers die Beschreibung des Redakteurs.

Die Lösung: Weise jedes Feld einem Besitzer zu.

field_ownership:
    Product:
        editor_owned:
            - name
            - description
            - images
        system_owned:
            - thumbnail
            - searchIndex
            - checksum
            - lastSyncTimestamp
        shared:
            - categories
            - price
            - availability
DomäneBesitzerMutation-PfadKonfliktstrategie
Editor-ownedAdmin-BenutzerStandard-SaveKein Konflikt (nur Redakteure schreiben)
System-ownedWorker/IntegrationenTransaction Layer mit LocksRetry bei Konflikt
SharedBeideTransaction Layer mit KonfliktauflösungKonfigurierbar: Retry, Skip, Merge

Editor-owned Felder gehen über den Standard-Save-Pfad. System-owned Felder gehen über einen Transaction Layer mit Locks und Version Checks. Shared Felder verwenden explizite Konfliktauflösungsstrategien.

Ohne Field Ownership verlässt du dich auf Glück. Mit Field Ownership erzwingt das System, wer was schreiben darf, und löst Konflikte deterministisch auf.

Optimistic Locking mit Version Checks

Optimistic Locking nimmt an, dass Konflikte selten sind. Statt vor der Modifikation zu sperren, prüft es, ob sich der Record zwischen Laden und Speichern geändert hat.

async function updateWithOptimisticLock(
    productId: string,
    updateFn: (product: Product) => void,
    maxRetries: number = 3,
): Promise<Product> {
    for (let attempt = 0; attempt < maxRetries; attempt++) {
        const product = await productRepo.findById(productId, { force: true });
        const versionBefore = product.versionCount;

        updateFn(product);

        // Prüfe, ob die Version sich nicht geändert hat vor dem Speichern
        const currentVersion = await productRepo.getVersionCount(productId);
        if (currentVersion !== versionBefore) {
            if (attempt === maxRetries - 1) {
                throw new ConcurrencyError(
                    `Product ${productId} was modified concurrently (version ${versionBefore} -> ${currentVersion})`
                );
            }
            continue; // Erneut versuchen mit frischen Daten
        }

        await product.save();
        return product;
    }
}

Der Version Check ist in diesem Beispiel nicht atomar. Für echte Atomarität nutze Datenbank-Level-Support:

-- PostgreSQL: atomischer Version Check + Update
UPDATE products
SET name = $1, version_count = version_count + 1
WHERE id = $2 AND version_count = $3;

-- Falls 0 betroffene Zeilen: gleichzeitige Modifikation erkannt
// TypeORM: @VersionColumn für automatisches Optimistic Locking
@Entity()
class Product {
    @VersionColumn()
    version!: number;
    // TypeORM prüft die Version automatisch beim Speichern
    // Wirft OptimisticLockVersionMismatchError bei Konflikt
}

Optimistic Locking funktioniert gut, wenn Konflikte selten sind (< 5% der Writes). Für High-Contention-Szenarien (mehrere Worker verarbeiten denselben Record) verwende stattdessen kooperative Locks.

Kooperative Lease-basierte Locks

Wenn mehrere Worker um dieselbe Ressource konkurrieren, serialisiert ein kooperativer Lock den Zugriff. Anders als verteilte Mutexe verwenden kooperative Locks Lease-basierte Semantik: Der Lock läuft nach einer TTL ab und verhindert so Deadlocks durch abgestürzte Worker.

// Redis: atomisches SET NX EX mit Token-basiertem Ownership
class RedisLockProvider {
    async acquire(key: string, ttlSeconds: number = 30): Promise<Lock | null> {
        const token = crypto.randomBytes(16).toString('hex');
        const acquired = await this.redis.set(key, token, 'NX', 'EX', ttlSeconds);
        return acquired ? new Lock(key, token, ttlSeconds) : null;
    }

    async release(lock: Lock): Promise<void> {
        // Atomisches Check-and-Delete per Lua-Skript
        const script = `
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
        `;
        await this.redis.eval(script, 1, lock.key, lock.token);
    }

    async extend(lock: Lock, ttlSeconds: number): Promise<boolean> {
        const script = `
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('expire', KEYS[1], ARGV[2])
            else
                return 0
            end
        `;
        return !!(await this.redis.eval(script, 1, lock.key, lock.token, ttlSeconds));
    }
}

Lock Heartbeat

Lock TTL allein reicht nicht. Wenn eine Operation länger dauert als erwartet, läuft der Lock ab und ein anderer Worker übernimmt ihn. Jetzt laufen zwei Worker gleichzeitig.

async function executeWithLock(key: string, operation: () => Promise<void>) {
    const lock = await lockProvider.acquire(key, 30);
    if (!lock) throw new LockError(`Could not acquire lock: ${key}`);

    // Heartbeat: Lock verlängern, falls die Operation zu lange dauert
    const heartbeat = setInterval(async () => {
        const extended = await lockProvider.extend(lock, 30);
        if (!extended) {
            clearInterval(heartbeat);
            // Lock wurde gestohlen, Operation abbrechen
            throw new LockError(`Lock stolen during operation: ${key}`);
        }
    }, 15000); // Alle 15s verlängern (50% der TTL)

    try {
        await operation();
    } finally {
        clearInterval(heartbeat);
        await lockProvider.release(lock);
    }
}

Lock Scope Hierarchie

Verschiedene Operationen brauchen unterschiedliche Lock-Granularität:

ScopeKey-MusterAnwendungsfall
Elementlock:product:123Vollständiges Objekt speichern
Feldgruppelock:product:123:generatedAssetsPartielles Update (nur Thumbnails)
Operationlock:product:123:thumbnail:enEinzelne spezifische Operation
Gleiches Produkt + gleiche Feldgruppe   -> warten/retry (seriell)
Gleiches Produkt + verschiedene Gruppen -> parallel (sicher)
Verschiedene Produkte                   -> immer parallel

Engere Scopes ermöglichen mehr Parallelismus. Ein Thumbnail-Worker und ein Search-Indexer können dasselbe Produkt gleichzeitig verarbeiten, wenn sie unterschiedliche Feldgruppen locken.

Idempotency Stores

Netzwerk-Retries, Message-Redelivery und Workflow-Replays führen dazu, dass dieselbe Operation mehrfach ausgeführt wird. Ohne Idempotenz bekommst du doppelte Records, doppelte Abbuchungen oder wiederholte E-Mails.

interface IdempotencyEntry {
    key: string;           // Geschäftsrelevanter Schlüssel
    scope: string;         // Operationskategorie
    status: string;        // PENDING | COMPLETED | FAILED
    requestHash: string;   // SHA-256 des normalisierten Inputs
    resultId?: string;     // ID der erstellten Ressource
    expiresAt: Date;       // TTL für Cleanup
    createdAt: Date;
}

class IdempotencyStore {
    async checkAndAcquire(key: string, scope: string, requestHash: string): Promise<IdempotencyResult> {
        try {
            await this.db.insert('idempotency_keys', {
                key, scope, requestHash,
                status: 'PENDING',
                expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000),
            });
            return { acquired: true };
        } catch (error) {
            if (isDuplicateKeyError(error)) {
                const existing = await this.db.findOne({ key, scope });
                if (existing.status === 'COMPLETED') {
                    return { acquired: false, cached: true, resultId: existing.resultId };
                }
                if (existing.status === 'PENDING' && isStale(existing)) {
                    // Veralteter PENDING-Eintrag: vorheriger Versuch ist abgestürzt, Retry erlauben
                    await this.db.update({ key, scope }, { status: 'PENDING', createdAt: new Date() });
                    return { acquired: true };
                }
                return { acquired: false, inProgress: true };
            }
            throw error;
        }
    }

    async complete(key: string, scope: string, resultId: string): Promise<void> {
        await this.db.update({ key, scope }, { status: 'COMPLETED', resultId });
    }
}

Key-Design

Der Idempotency Key muss geschäftsrelevant sein:

OperationKey-KomponentenBeispiel
Zur Wunschliste hinzufügencustomerId + productVariantId + wishlistIdwish:cust_123:var_456:wl_789
Bewertung abgebencustomerId + productIdreview:cust_123:prod_456
Benachrichtigung sendenrecipient + category + entityRef + dayBucketnotify:sara@beispiel.de:stock:var_456:2026-04-20
Treuepunkte einlösencustomerId + orderId + pointsredeem:cust_123:ord_789:500
ERP-Import-RecordsourceRecordId + importBatchIdimport:erp_456:batch_20260420

Zwei verschiedene Idempotenz-Modelle:

  • API-Idempotenz: Für benutzerinitierte Mutationen. Client liefert den Key oder er wird aus dem Input-Hash generiert. Gecachte Response wird bei Duplikaten wiedergegeben.
  • Job-Idempotenz: Für Background-Processing. Dedupe Key im Job-Payload. Nutzt DB-Constraints, Completion-Marker oder Business-Key-Checks.

Niemals beides mischen. Sie haben unterschiedliche Lebenszyklen und Cleanup-Strategien.

Das Version-Explosion-Problem

In Systemen, in denen Background-Worker save() aufrufen, erzeugt jedes Save eine Version. Bei 6 Workern, die jede Produktänderung verarbeiten, generiert ein einzelner Editor-Save 6+ unnötige Versionen. Über Monate sammeln sich bei Produkten tausende Versionen an, die Speicher verbrauchen, die Versionsverlauf-UI verlangsamen und es unmöglich machen, tatsächliche redaktionelle Änderungen zu finden.

Die Lösung: Scoped Version Guards, die die Versionserstellung bei System-Operationen unterdrücken und gleichzeitig Versionen bei Editor-Saves erhalten.

// Referenz-gezählter Version Guard
class ScopedVersionGuard {
    private static refCount = 0;

    suppress(): void {
        ScopedVersionGuard.refCount++;
        if (ScopedVersionGuard.refCount === 1) {
            VersionManager.disable();
        }
    }

    restore(): void {
        ScopedVersionGuard.refCount--;
        if (ScopedVersionGuard.refCount === 0) {
            VersionManager.enable();
        }
    }
}

// Verwendung: verschachtelte Operationen funktionieren korrekt
const outerGuard = new ScopedVersionGuard();
outerGuard.suppress();
try {
    product.setThumbnail(asset);
    product.save(); // Keine Version erstellt

    const innerGuard = new ScopedVersionGuard();
    innerGuard.suppress();
    try {
        product.setChecksum(hash);
        product.save(); // Immer noch keine Version
    } finally {
        innerGuard.restore(); // refCount geht von 2 auf 1, noch unterdrückt
    }
} finally {
    outerGuard.restore(); // refCount geht von 1 auf 0, Versionen wieder aktiviert
}

Das Ergebnis: Editor-Saves erstellen Versionen (Audit-Trail erhalten). Worker-Saves erstellen Operations-Log-Einträge (Observability ohne Versions-Bloat).

Wie wir das in Pimcore konkret umsetzen, beschreibt unser Pimcore Workflow Guide, der PimTx's Version Guard im Detail behandelt.

Häufige Stolperfallen

  1. Kein Field Ownership. Ohne Field Ownership konkurriert jeder Writer um jedes Feld über denselben Save-Pfad. Definiere, wer was besitzt, bevor du die erste Zeile konkurrierenden Code schreibst.

  2. Optimistic Locking ohne Retry. Den Konflikt zu erkennen reicht nicht. Die Operation muss mit frischen Daten erneut versucht werden. Setze ein maximales Retry-Limit und handle die Erschöpfung sauber.

  3. Globales Lock-Disable. Ein globales disable()-Flag bricht, wenn mehrere Operationen gleichzeitig laufen. Verwende referenzgezählte, scoped Guards.

  4. Idempotency Keys ohne geschäftliche Bedeutung. Eine zufällige UUID als Idempotency Key verhindert nichts. Der Key muss die Geschäftsoperation kodieren: Wer, Was, Wann.

  5. Kein Heartbeat bei langlebigen Locks. Wenn die Operation die TTL überlebt, läuft der Lock ab und ein anderer Worker startet. Verlängere den Lock bei 50% der TTL.

  6. Veraltete PENDING-Einträge ignorieren. Wenn ein Worker abstürzt, während er einen PENDING Idempotency Key hält, ist die Operation permanent blockiert. Erkenne und bereinige veraltete Einträge.

  7. Falsche Lock-Granularität. Element-Level-Locks serialisieren alles. Feldgruppen-Locks ermöglichen Parallelismus, wo er sicher ist. Wähle den engsten sicheren Scope.

  8. Keine Versionsmanagement-Strategie. Dass jedes save() eine Version erstellt, ist der Standard. In Systemen mit Workern erzeugt das tausende nutzlose Versionen. Unterdrücke Versionen bei System-Operationen.

Kernerkenntnisse

  • Field Ownership verhindert die häufigste Race Condition. Definiere, welche Felder Redakteuren gehören, welche Workern und welche geteilt werden. Das Ownership-Register bestimmt die Locking-Strategie und Konfliktauflösung.

  • Optimistic Locking für Low-Contention Writes. Prüfe den Version Count vor dem Speichern. Retry bei Konflikt. Nutze Datenbank-Level-Support (TypeORM @VersionColumn, PostgreSQL Conditional Update) für Atomarität.

  • Kooperative Leases für High-Contention-Ressourcen. Redis SET NX EX mit Token-basiertem Ownership. Heartbeat bei 50% TTL. Lua-Skripte für atomische Operationen. Verwende niemals verteilte Mutexe ohne TTL.

  • Idempotency Keys müssen geschäftsrelevant sein. Kodiere die Operationssemantik (Wer + Was + Wann) in den Key. Trenne API-Idempotenz von Job-Idempotenz.

  • Version Guards bewahren Audit-Trails ohne Explosion. Referenzgezählte Unterdrückung für System-Operationen. Verschachtelung funktioniert korrekt. Editor-Saves erstellen weiterhin Versionen.

Wir setzen diese Patterns in unseren Custom-Software-Projekten und Data-Engineering-Pipelines ein. Wenn du mit Concurrency-Problemen in der Produktion kämpfst, sprich mit unserem Team oder fordere ein Angebot an.

Behandelte Themen

Concurrency PatternsRace ConditionsOptimistic LockingDatenintegritätDistributed LocksField OwnershipIdempotenzVersionsmanagement

Bereit, produktionsreife KI-Systeme zu bauen?

Unser Team ist spezialisiert auf produktionsreife KI-Systeme. Lass uns besprechen, wie wir deinem Unternehmen helfen können.

Gespräch starten