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.
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äne | Besitzer | Mutation-Pfad | Konfliktstrategie |
|---|---|---|---|
| Editor-owned | Admin-Benutzer | Standard-Save | Kein Konflikt (nur Redakteure schreiben) |
| System-owned | Worker/Integrationen | Transaction Layer mit Locks | Retry bei Konflikt |
| Shared | Beide | Transaction Layer mit Konfliktauflösung | Konfigurierbar: 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:
| Scope | Key-Muster | Anwendungsfall |
|---|---|---|
| Element | lock:product:123 | Vollständiges Objekt speichern |
| Feldgruppe | lock:product:123:generatedAssets | Partielles Update (nur Thumbnails) |
| Operation | lock:product:123:thumbnail:en | Einzelne 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:
| Operation | Key-Komponenten | Beispiel |
|---|---|---|
| Zur Wunschliste hinzufügen | customerId + productVariantId + wishlistId | wish:cust_123:var_456:wl_789 |
| Bewertung abgeben | customerId + productId | review:cust_123:prod_456 |
| Benachrichtigung senden | recipient + category + entityRef + dayBucket | notify:sara@beispiel.de:stock:var_456:2026-04-20 |
| Treuepunkte einlösen | customerId + orderId + points | redeem:cust_123:ord_789:500 |
| ERP-Import-Record | sourceRecordId + importBatchId | import: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
-
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.
-
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.
-
Globales Lock-Disable. Ein globales
disable()-Flag bricht, wenn mehrere Operationen gleichzeitig laufen. Verwende referenzgezählte, scoped Guards. -
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.
-
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.
-
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.
-
Falsche Lock-Granularität. Element-Level-Locks serialisieren alles. Feldgruppen-Locks ermöglichen Parallelismus, wo er sicher ist. Wähle den engsten sicheren Scope.
-
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
Verwandte Guides
Event-Driven Architecture in der Praxis: Was wirklich schiefgeht
Echte Event-Driven-Architecture-Muster aus der Produktion. Event Storms, bidirektionale Sync-Schleifen, Dead Letters, Idempotency Stores und die Wahl zwischen Kafka, RabbitMQ, BullMQ und Symfony Messenger.
Guide lesenPimcore Workflow-Design für Enterprise: Die Architektur hinter 20 Redakteuren
Wie du Pimcore-Workflows für Enterprise-Teams designst. Drei-Schichten-State-Separation, Field Ownership, Event-Steuerung, Versionsverwaltung und ERP-Importsicherheit.
Guide lesenUnternehmenshandbuch zu Agentischen KI-Systemen
Technischer Leitfaden zu agentischen KI-Systemen in Unternehmen. Erfahre mehr ueber Architektur, Faehigkeiten und Anwendungen autonomer KI-Agenten.
Guide lesenBereit, 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