← Alle Beiträge

Shopware 6: „Cart with token … is locked due to concurrent write operation“ verstehen und beheben

Shopware 6: „Cart with token … is locked due to concurrent write operation“ verstehen und beheben

Nach einem Update auf eine aktuelle 6.6er- oder 6.7er-Version stehen plötzlich neue request.CRITICAL-Einträge im Log. Beschwert hat sich niemand. Trotzdem kommen schnell ein paar hundert dieser Einträge zusammen, und hinter jedem steckt ein Warenkorb-Request, der nicht durchgegangen ist.

Die Fehlermeldung

request.CRITICAL: Uncaught PHP Exception Shopware\Core\Checkout\Cart\CartException:
"Cart with token <token> is locked due to concurrent write operation. Please try again later."
at /var/www/shop/vendor/shopware/core/Checkout/Cart/CartException.php line 628

Die Zeilennummer schwankt je nach Version (625, 628 und 631 haben wir gesehen). Der Text bleibt gleich. Im JSON der Store API steht der Fehlercode CHECKOUT__CART_LOCKED mit HTTP-Status 409 Conflict.

Was dahintersteckt

Shopware sperrt seit kurzem den Warenkorb, während er verändert wird. Auslöser war Issue #8724: Drei schnell hintereinander abgeschickte Add-to-Cart-Requests mit demselben Context-Token liefen parallel. Alle drei kamen mit 200 zurück, im Warenkorb lagen danach aber nur zwei Artikel. Der erste war schlicht überschrieben worden, weil jeder Request den Warenkorb lädt, ändert und wieder speichert.

Die Sperre kam in zwei Schritten:

  • Ab 6.6.10.5 bzw. 6.7.0.0 sperrt CartOrderRoute den Warenkorb beim Bestellabschluss.
  • Ab 6.6.10.6 (Release vom 22.07.2025) bzw. 6.7.1.0 kommt die Klasse Shopware\Core\Checkout\Cart\CartLocker dazu, und mit ihr werden auch CartItemAddRoute, CartItemRemoveRoute, CartItemUpdateRoute und CartDeleteRoute gesperrt (PR #11229).

Der Mechanismus ist bewusst simpel. CartLocker holt sich über die Symfony-LockFactory einen Lock mit dem Schlüssel cart-lock<token> und einer TTL von 5 Sekunden. Entscheidend ist der Aufruf $lock->acquire() ohne Argument. Laut Symfony-Doku ist das ein nicht blockierender Versuch: Hält schon ein anderer Request den Lock, gibt acquire() sofort false zurück. Shopware wartet nicht und versucht es auch nicht noch einmal, sondern wirft direkt die Exception.

Wer als Zweiter kommt, bekommt sofort einen 409.

Im Storefront hängt es dann vom Controller ab, was der Kunde sieht. changeQuantity fängt jede Exception ab und zeigt die allgemeine Fehlermeldung als Flash-Message. addLineItems fängt nur ProductNotFoundException und RoutingException. Der Cart-Lock rutscht dort durch, wird als „Uncaught PHP Exception“ geloggt, und der Artikel landet nicht im Warenkorb. Deshalb stammen die meisten Log-Einträge in der Praxis vom Add-to-Cart.

Drei Situationen, die den Fehler auslösen

Nicht jeder Eintrag hat dieselbe Ursache, und die Lösung hängt davon ab, welche Situation bei dir vorliegt.

Der häufigste Fall ist echte Parallelität: Doppelklick auf „In den Warenkorb“, ein Theme oder Plugin, das mehrere Artikel per Promise.all gleichzeitig an die Store API schickt, ein Headless-Frontend, das beim Seitenwechsel mehrere Cart-Calls mit demselben sw-context-token abfeuert. Hier macht der Lock genau seinen Job. Ohne ihn würdest du keinen Fehler sehen, sondern verschwundene Artikel.

Unangenehmer ist der zweite Fall, in dem der Lock sich selbst blockiert. In 6.6.10.6/6.6.10.7 und 6.7.1.x war der Lock nicht reentrant. Ruft ein Event-Subscriber während einer gesperrten Cart-Operation selbst wieder eine sperrende Route auf (etwa ein Plugin, das beim Hinzufügen eines Artikels automatisch eine Zugabe in den Warenkorb legt), dann findet der Request seinen eigenen Lock vor und scheitert, und zwar jedes Mal. Shopware hat das in 6.6.10.8 bzw. 6.7.2.0 behoben: Der Lock hängt seitdem am SalesChannelContext, und verschachtelte Aufrufe innerhalb desselben Requests überspringen ihn (Changelog „Fix recursive cart lock usage“).

Der dritte Fall betrifft Setups mit mehreren App-Servern und produziert streng genommen zu wenige Fehler. Shopware setzt LOCK_DSN per Default auf flock, also auf Dateisperren im lokalen Dateisystem. Die Symfony-Doku ist da eindeutig: Ein FlockStore funktioniert im HTTP-Kontext nicht über mehrere Frontend-Server hinweg. Landen zwei Requests auf unterschiedlichen Nodes, sieht keiner den Lock des anderen, und das Problem aus #8724 ist wieder da.

Prüfen, welcher Fall es ist

Zuerst die Version, denn davon hängt fast alles ab:

composer show shopware/core | grep versions

Dann die Häufung. Ein Doppelklick produziert vereinzelte Einträge, verteilt über den Tag. Ein Plugin, das sich selbst sperrt, produziert bei jedem einzelnen Add-to-Cart einen Fehler, also ungefähr so viele Einträge, wie es Warenkorb-Aktionen gibt:

grep -h "is locked due to concurrent write operation" var/log/prod-*.log \
  | cut -c2-14 | sort | uniq -c | sort -rn | head

Wenn du auf einer Version vor 6.6.10.8 bzw. 6.7.2.0 bist und der Fehler bei jedem Hinzufügen eines bestimmten Produkts auftritt, schau dir an, welche Plugins auf Events wie BeforeLineItemAddedEvent oder AfterLineItemAddedEvent reagieren und dabei den CartService aufrufen:

grep -rln "LineItemAddedEvent" custom/plugins/*/src | xargs grep -l "CartService"

Für den Mehr-Server-Fall reicht ein Blick in die .env:

grep LOCK_DSN .env .env.local 2>/dev/null

Steht da nichts, gilt der Default flock.

Die Lösung

Für den sich selbst sperrenden Lock gibt es einen echten Fix: Update auf mindestens 6.6.10.8 oder 6.7.2.0. Wer das gerade nicht kann, muss den Subscriber umbauen, sodass er den Warenkorb nicht über eine zweite Route verändert, sondern am übergebenen Cart-Objekt arbeitet. Das ist ein Workaround, kein Fix, und meist Aufgabe des Plugin-Herstellers.

Bei echter Parallelität liegt die Lösung im Frontend. Requests auf denselben Warenkorb gehören hintereinander, nicht nebeneinander:

// statt Promise.all(items.map(addToCart))
for (const item of items) {
    await addToCart(item);
}

Im Storefront heißt das konkret: Button während des Requests deaktivieren, keine eigenen parallelen Ajax-Calls auf /checkout/line-item/add. In Headless-Frontends lohnt es sich, eine 409-Antwort mit CHECKOUT__CART_LOCKED gezielt abzufangen und den Request nach kurzer Pause einmal zu wiederholen. Der Lock wird freigegeben, sobald der andere Request fertig ist, spätestens nach der TTL von 5 Sekunden (bei Redis; flock kennt keine TTL, gibt die Sperre aber mit dem Ende des Prozesses frei).

Bei mehreren App-Servern stellst du den Lock-Store auf Redis um:

# .env.local
LOCK_DSN=redis://redis:6379

Danach Cache leeren und die PHP-FPM-Worker neu starten. Rechne damit, dass die Zahl der Cart-Lock-Einträge danach eher steigt. Das ist richtig so, denn jetzt greift der Lock auch zwischen den Servern.

Was du nicht tun solltest: den Lock per Decorator ausbauen oder die Exception still schlucken, um das Log sauber zu bekommen. Der Fehler ist lästig, aber er ersetzt einen schlimmeren, unsichtbaren. Ein verlorener Artikel im Warenkorb taucht in keinem Log auf. Er taucht nur in der Conversion auf.

Wenn das nicht hilft

Siehst du den Fehler beim Bestellabschluss (CartOrderRoute), schickt oft ein Payment-Plugin oder ein Checkout-Script den Bestell-Request doppelt ab, etwa nach einem Redirect. Ob das bei dir der Fall ist, sagt dir das Access-Log: zwei POST /checkout/order mit gleichem Session-Cookie innerhalb einer Sekunde.

Alles, was über diese Fälle hinausgeht, gehört mit Shopware-Version, LOCK_DSN und der beteiligten Route in ein Issue auf github.com/shopware/shopware.

Ob dein Shop gerade fünf oder fünfhundert dieser Meldungen am Tag schreibt, merkst du nicht, solange niemand ins Log schaut. ShopSignal zählt Fehler und Warnungen deines Shopware-Shops, macht die Logs per Volltextsuche durchsuchbar und schlägt per Alert-Regel an, wenn die Zahl steigt. Gleich daneben siehst du Bestellungen und Conversion, also genau die Kennzahl, an der ein kaputter Warenkorb zuerst auffällt.