Inventory Reservations bereinigen: Wenn Bestellungen und Reservierungen auseinanderlaufen
AI generated
M2
di.xml
Magento 2 · MSI
Inventory Reservations bereinigen
Wenn Bestellungen und Reservierungen auseinanderlaufen

Die verkaufbare Menge in Magento MSI ergibt sich nicht aus dem physischen Bestand allein, sondern aus Bestand minus Reservierungen. Läuft diese Rechnung aus dem Ruder, weil Reservierungen liegen bleiben, obwohl die zugehörige Bestellung längst storniert oder nie bezahlt wurde, sinkt die verkaufbare Menge künstlich, ohne dass physisch etwas fehlt. Dieser Artikel zeigt, wie solche Geister-Reservierungen entstehen, wie sie sich finden lassen und wie ein eigenes Cleanup-Skript die Konsistenz wiederherstellt.

12 Min. Lesezeit Reservations salable_quantity Cleanup CLI MSI Konsistenz

1. Wie das Reservation-System funktioniert

MSI trennt bewusst den physischen Bestand, gespeichert je Source in der Tabelle inventory_source_item, von der verkaufbaren Menge, die sich aus Bestand minus der Summe aller Reservierungen für eine SKU und einen Stock ergibt. Reservierungen selbst liegen in der Tabelle inventory_reservation als einfache, additive Einträge: Eine negative Reservierung reduziert die verkaufbare Menge beim Checkout, eine positive Reservierung mit gleichem Betrag kompensiert sie wieder, sobald der Bestand tatsächlich aus dem Source-Item abgebucht wird.

Dieses additive, nie gelöschte Modell ist bewusst so gewählt, weil es race-condition-sicher ist: Mehrere gleichzeitige Bestellungen können parallele Reservierungen anlegen, ohne dass ein Lock auf die Source-Item-Zeile nötig ist. Die Kehrseite ist, dass die Tabelle theoretisch unbegrenzt wächst und dass jede Reservierung, die nicht korrekt kompensiert wird, dauerhaft in der Berechnung der salable_quantity verbleibt, bis sie manuell bereinigt wird.


-- Salable quantity für eine SKU in einem Stock berechnen
SELECT
    si.sku,
    si.quantity AS physical_quantity,
    COALESCE(SUM(r.quantity), 0) AS reservation_delta,
    si.quantity + COALESCE(SUM(r.quantity), 0) AS salable_quantity
FROM inventory_source_item si
LEFT JOIN inventory_reservation r
    ON r.sku = si.sku AND r.stock_id = 1
WHERE si.sku = 'WEBSHOP-SKU-001'
GROUP BY si.sku, si.quantity;

2. Typische Ursachen für Geister-Reservierungen

Die häufigste Ursache sind abgebrochene Zahlungen: Der Checkout legt bei Bestellbeginn eine Reservierung an, um die Menge für die Dauer des Zahlungsvorgangs zu sichern. Bricht der Kunde die Zahlung ab oder schlägt sie fehl, ohne dass die Bestellung storniert wird, bleibt die Reservierung stehen, obwohl faktisch nie eine gültige Bestellung entstanden ist. Je nach Zahlungsart und Timeout-Konfiguration passiert das häufiger, als man erwarten würde, besonders bei Zahlungsanbietern mit unzuverlässigen Webhook-Callbacks.

Eine zweite Ursache sind fehlgeschlagene Cron-Jobs, insbesondere der Job, der abgelaufene, nicht abgeschlossene Bestellungen automatisch storniert. Bleibt dieser Job wegen eines Fehlers in einer anderen Konsumenten-Warteschlange oder wegen eines Timeouts hängen, sammeln sich offene Reservierungen für Bestellungen, die eigentlich längst als abgebrochen gelten müssten. Auch manuelle Eingriffe im Backend, etwa das direkte Löschen einer Bestellung über die Datenbank statt über den korrekten Cancel-Prozess, hinterlassen zuverlässig verwaiste Reservierungen.

3. Vorhandene CLI-Tools zur Bestandsprüfung

Magento bringt selbst keinen fertigen Befehl mit, der Geister-Reservierungen automatisch findet und bereinigt, es gibt aber Bausteine, auf denen sich eine eigene Lösung aufbauen lässt. Der Befehl bin/magento indexer:reindex cataloginventory_stock stellt sicher, dass die Legacy-Stock-Status-Tabelle mit dem aktuellen MSI-Zustand übereinstimmt, behebt aber keine fehlerhaften Reservierungen selbst, sondern nur deren nachgelagerte Auswirkung auf die Anzeige im Frontend.

Für die eigentliche Diagnose muss man selbst SQL gegen inventory_reservation und die Order-Tabellen fahren. Ein sinnvoller erster Schritt ist ein Abgleich zwischen allen offenen Reservierungen mit negativem Betrag und dem Status der zugehörigen Bestellung: Reservierungen, deren Bestellung storniert, geschlossen oder gar nicht mehr auffindbar ist, sind starke Kandidaten für eine manuelle Kompensation.

4. Eigenes Cleanup-Skript als Konsistenzprüfung

Ein eigener CLI-Befehl, der als Command in Console/Command registriert wird, kann diesen Abgleich automatisieren. Der Befehl liest alle Reservierungen mit einem Metadaten-Objekt, das die zugehörige Order-Id referenziert, prüft den aktuellen Bestellstatus über das OrderRepositoryInterface und legt für jede Reservierung, deren Bestellung als storniert oder nicht mehr existent gilt, eine kompensierende positive Reservierung an. Wichtig ist, niemals bestehende Zeilen zu ändern oder zu löschen, sondern immer additiv zu kompensieren, damit das race-condition-sichere Grundprinzip von MSI erhalten bleibt.

Der Befehl sollte zunächst im Dry-Run-Modus laufen und lediglich eine Liste betroffener Reservierungen mit SKU, Menge und referenzierter Bestellung ausgeben, bevor er im Ausführungsmodus tatsächlich kompensiert. Das erlaubt eine manuelle Prüfung vor dem ersten produktiven Einsatz und schafft Vertrauen in die Logik, bevor sie automatisiert per Cron läuft.


<?php
declare(strict_types=1);

namespace Mironsoft\InventoryReservationCleanup\Console\Command;

use Magento\InventoryReservationsApi\Model\ReservationBuilderInterface;
use Magento\InventoryReservationsApi\Model\AppendReservationsInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Finds and compensates orphaned inventory reservations whose linked order
 * is canceled or no longer exists.
 */
class CleanupOrphanedReservationsCommand extends Command
{
    /**
     * @param OrphanedReservationFinder $finder
     * @param ReservationBuilderInterface $reservationBuilder
     * @param AppendReservationsInterface $appendReservations
     */
    public function __construct(
        private readonly OrphanedReservationFinder $finder,
        private readonly ReservationBuilderInterface $reservationBuilder,
        private readonly AppendReservationsInterface $appendReservations
    ) {
        parent::__construct();
    }

    /**
     * Configures the command name and the --apply option.
     *
     * @return void
     */
    protected function configure(): void
    {
        $this->setName('mironsoft:reservation:cleanup');
        $this->addOption('apply', null, InputOption::VALUE_NONE, 'Actually write compensating reservations');
    }

    /**
     * Executes the cleanup: dry run by default, writes compensations with --apply.
     *
     * @param InputInterface $input
     * @param OutputInterface $output
     * @return int
     */
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $orphaned = $this->finder->find();
        $reservations = [];
        foreach ($orphaned as $entry) {
            $output->writeln(sprintf(
                '%s: qty %.4f from order %s (status: %s)',
                $entry->getSku(),
                $entry->getQuantity(),
                $entry->getOrderIncrementId(),
                $entry->getOrderStatus()
            ));
            $reservations[] = $this->reservationBuilder
                ->setSku($entry->getSku())
                ->setStockId($entry->getStockId())
                ->setQuantity(abs($entry->getQuantity()))
                ->setMetadata((string) json_encode(['cleanup' => true]))
                ->build();
        }
        if ($input->getOption('apply') && $reservations !== []) {
            $this->appendReservations->execute($reservations);
            $output->writeln(sprintf('Compensated %d orphaned reservations.', count($reservations)));
        }
        return Command::SUCCESS;
    }
}

5. Abgrenzung zu abgebrochenen Warenkörben

Nicht jede Reservierung ohne zugehörige finale Bestellung ist automatisch fehlerhaft. Ein Kunde, der den Checkout gerade erst begonnen hat, besitzt für die Dauer der Session eine gültige, temporäre Reservierung, die noch keiner Bestellung zugeordnet ist, weil die Bestellung selbst erst am Ende des Checkouts entsteht. Ein Cleanup-Skript muss deshalb eine Karenzzeit einplanen, typischerweise mehrere Stunden, bevor eine Reservierung ohne finale Bestellung als Kandidat für eine Kompensation gilt.

Diese Karenzzeit sollte konfigurierbar sein und sich an der eigenen Checkout- und Zahlungsanbieter-Konfiguration orientieren. Zahlungsarten mit asynchronen Bestätigungen, etwa Rechnungskauf oder bestimmte Sofortüberweisungsvarianten, benötigen mitunter deutlich länger als klassische Kreditkartenzahlungen, bevor eine Bestellung final als gescheitert gelten darf.

6. Monitoring statt einmaliger Bereinigung

Ein einmaliger Cleanup-Lauf löst nur das Symptom, nicht die Ursache. Sinnvoller ist ein regelmäßiger, per Cron laufender Monitoring-Check, der die Anzahl offener, nicht kompensierter Reservierungen pro Tag erfasst und bei einem ungewöhnlichen Anstieg eine Benachrichtigung auslöst. Ein plötzlicher Sprung deutet fast immer auf ein technisches Problem hin, etwa einen ausgefallenen Zahlungs-Webhook oder einen fehlerhaften Consumer in der Message Queue, das dringender gelöst werden sollte als das reine Nachräumen der Symptome.

Für das Monitoring bietet sich eine einfache Kennzahl an: das Verhältnis zwischen der Anzahl neu angelegter negativer Reservierungen und der Anzahl kompensierender positiver Reservierungen im selben Zeitraum. Weicht dieses Verhältnis dauerhaft von eins ab, läuft im System strukturell etwas auseinander, das über ein einzelnes Cleanup-Skript hinaus untersucht werden sollte.

7. Performance bei stark gewachsener Reservation-Tabelle

Da inventory_reservation nie gelöschte Einträge enthält, wächst die Tabelle bei stark frequentierten Shops kontinuierlich. Für die salable_quantity-Berechnung selbst ist das dank passender Indizes meist unproblematisch, ein eigenes Cleanup-Skript, das die gesamte Historie durchsucht, kann bei mehreren Millionen Zeilen jedoch selbst zur Last werden. Eine zeitliche Eingrenzung, etwa nur Reservierungen der letzten dreissig Tage zu prüfen, reduziert die Laufzeit erheblich, ohne die Erkennungsrate relevant zu verschlechtern.

Für sehr alte, längst kompensierte Reservierungen lohnt sich zusätzlich ein separates Archivierungs-Skript, das abgeschlossene Reservierungspaare, also negative und passende positive Einträge, nach einer konfigurierbaren Aufbewahrungsfrist in eine Archivtabelle verschiebt. Das hält die produktive Tabelle klein, ohne historische Daten für Audits vollständig zu verlieren.

8. Konfigurierbarkeit über system.xml

Analog zu jedem anderen Mironsoft-Modul gehört eine eigene system.xml-Sektion dazu, in der sich die Karenzzeit für offene Reservierungen, die zeitliche Eingrenzung des Scans und der Empfänger für Monitoring-Benachrichtigungen konfigurieren lassen. So kann das Fulfillment-Team die Schwellenwerte an die eigene Zahlungsanbieter-Konfiguration anpassen, ohne dass dafür ein Deployment nötig ist.

Eine eigene acl.xml beschränkt den Zugriff auf diese sensible Konfiguration, da ein zu aggressiv eingestelltes Cleanup theoretisch gültige, laufende Reservierungen fälschlich kompensieren und damit kurzzeitig zu viel verkaufbaren Bestand vortäuschen könnte. Ein eigener Menüpunkt unter Stores Configuration macht die Einstellung für das zuständige Team direkt auffindbar.

9. Typische Fehler beim Reservation-Cleanup

Der schwerwiegendste Fehler ist das direkte Löschen von Zeilen aus inventory_reservation statt einer additiven Kompensation. Gelöschte negative Reservierungen erhöhen die salable_quantity sofort, ohne dass geprüft wurde, ob nicht doch noch eine parallele, gültige Bestellung auf genau diese Reservierung angewiesen ist. Das additive Modell existiert gerade deshalb, damit niemals in bestehende Zeilen eingegriffen werden muss.

Ein zweiter häufiger Fehler ist eine zu kurz gewählte Karenzzeit, die aktive Checkouts fälschlich als Geister-Reservierung einstuft. Das führt dazu, dass Kunden mitten im Zahlungsvorgang plötzlich eine Fehlermeldung wegen nicht mehr verfügbarem Bestand sehen, obwohl ihre Bestellung technisch korrekt unterwegs war. Eine großzügige, an die eigene Zahlungsanbieter-Konfiguration angepasste Karenzzeit vermeidet dieses Problem zuverlässig.

Ursache Typisches Muster Erkennung Maßnahme
Abgebrochene Zahlung Reservierung ohne finale Bestellung nach Karenzzeit Abgleich Reservierung gegen Order-Status Kompensierende positive Reservierung anlegen
Fehlgeschlagener Cron Bestellung längst abgelaufen, aber nicht storniert Prüfung des Order-Cron-Logs auf Fehler Cron reparieren, betroffene Bestellungen manuell stornieren
Manueller DB-Eingriff Bestellung in DB entfernt, Reservierung bleibt Reservierung ohne auffindbare Order-Id Kompensation nach begründeter manueller Prüfung
Aktiver Checkout Reservierung ohne Bestellung, aber innerhalb Karenzzeit Zeitstempel unter konfigurierter Schwelle Nicht anfassen, reguläre Bereinigung abwarten
Asynchrone Zahlungsart Verzögerte Bestätigung über Webhook Zahlungsart-spezifische, längere Karenzzeit Karenzzeit je Zahlungsart konfigurierbar halten

Mironsoft

Magento-Entwicklung, Modul-Beratung und Systemarchitektur

Magento-Projekt, das eine zweite Meinung oder erfahrene Umsetzung braucht?

Wir entwickeln individuelle Magento-Module, beraten bei Architekturentscheidungen und übernehmen komplexe Umsetzungen, von der Service-Contract-Planung bis zum produktionsreifen Deployment.

Architektur-Beratung

Modul- und Systemarchitektur vor der Umsetzung fundiert durchdenken lassen.

Custom-Modul-Entwicklung

Individuelle Magento-Module nach Best Practices sauber umsetzen.

Code-Review & Audit

Bestehende Module auf Performance, Sicherheit und Wartbarkeit prüfen lassen.

10. Zusammenfassung

Reservation Cleanup: Das Wichtigste auf einen Blick

salable_quantity

Ergibt sich aus physischem Bestand minus der Summe aller offenen Reservierungen je SKU und Stock.

Additiv kompensieren

Niemals Zeilen löschen, sondern immer eine positive Gegenreservierung anlegen.

Karenzzeit einplanen

Aktive Checkouts nicht mit Geister-Reservierungen verwechseln, Zeitpuffer konfigurierbar machen.

Monitoring statt Einmalfix

Regelmäßiger Cron-Check erkennt strukturelle Probleme früher als ein manuelles Aufräumen.

11. FAQ: Reservation Cleanup: Das Wichtigste auf einen Blick

1Wie wird die verkaufbare Menge in MSI berechnet?
Als physischer Bestand aus inventory_source_item minus der Summe aller Reservierungen aus inventory_reservation für dieselbe SKU und denselben Stock. Beide Werte werden nicht kombiniert gespeichert, sondern bei jeder Abfrage neu berechnet.
2Was ist eine Geister-Reservierung?
Eine negative Reservierung, deren zugehörige Bestellung storniert, fehlgeschlagen oder gar nicht mehr existent ist, ohne dass jemals eine kompensierende positive Reservierung angelegt wurde. Sie senkt die verkaufbare Menge, obwohl physisch kein Bestand fehlt.
3Darf man Zeilen direkt aus inventory_reservation löschen?
Nein, das widerspricht dem additiven, race-condition-sicheren Modell von MSI. Stattdessen sollte immer eine kompensierende positive Reservierung mit demselben Betrag angelegt werden.
4Warum reicht ein einfacher indexer:reindex nicht aus?
Der Reindex bringt lediglich die Legacy-Stock-Anzeige mit dem aktuellen MSI-Zustand in Einklang, er korrigiert aber keine fehlerhaften Reservierungen selbst. Die eigentliche Bereinigung muss separat erfolgen.
5Wie lange sollte die Karenzzeit vor einer Kompensation sein?
Das hängt von der Checkout- und Zahlungsanbieter-Konfiguration ab, typischerweise mehrere Stunden. Zahlungsarten mit asynchroner Bestätigung wie Rechnungskauf brauchen oft eine deutlich längere Karenzzeit als Kreditkartenzahlungen.
6Wie oft sollte ein Cleanup-Skript laufen?
Am besten regelmäßig per Cron, etwa täglich, kombiniert mit einem Monitoring, das ungewöhnliche Anstiege an offenen Reservierungen meldet. Ein einmaliger Lauf löst nur das Symptom, nicht die zugrunde liegende Ursache.
7Was ist der häufigste technische Auslöser für Geister-Reservierungen?
Abgebrochene oder fehlgeschlagene Zahlungen, bei denen die Bestellung nicht korrekt storniert wird, sowie fehlgeschlagene Cron-Jobs, die abgelaufene Bestellungen automatisch aufräumen sollten.
8Wie erkennt man ein strukturelles statt ein einmaliges Problem?
Über das Verhältnis zwischen neu angelegten negativen und kompensierenden positiven Reservierungen im selben Zeitraum. Weicht es dauerhaft von eins ab, liegt meist ein technischer Fehler zugrunde, keine normale Kundenabbruchrate.
9Wird eine große inventory_reservation-Tabelle zum Problem?
Für die salable_quantity-Berechnung selbst meist nicht, dank passender Indizes. Ein eigenes Cleanup-Skript sollte den Scan aber zeitlich eingrenzen und alte, abgeschlossene Reservierungspaare bei Bedarf archivieren.
10Sollte das Cleanup-Skript automatisch oder manuell laufen?
Zunächst im Dry-Run-Modus zur manuellen Prüfung, danach mit einer expliziten Freigabe im Ausführungsmodus. Erst wenn Vertrauen in die Logik besteht, sollte es unbeaufsichtigt per Cron laufen.