Magento-Cron-Performance: Jobs entschärfen statt verdrängen
60fps
ms
Performance · Cron · Message Queue · Magento 2
Magento-Cron-Performance
Jobs entschärfen statt verdrängen

Magento verlässt sich auf Cron für Indexierung, E-Mail-Versand und Aufräumarbeiten, doch ohne durchdachte Gruppenkonfiguration wächst die cron_schedule Tabelle und einzelne Jobs blockieren die gesamte Warteschlange. Dieser Artikel zeigt, wie Cron-Gruppen richtig getrennt, lange Jobs identifiziert und schwere synchrone Aufgaben durch Message Queue Consumers ersetzt werden, damit der Shop unter Last stabil bleibt.

13 Min. Lesezeit crontab.xml · cron_groups.xml · cron_schedule Magento 2.4.8 · PHP 8.4 · Supervisor

1. Warum Magento-Cron zum Performance-Flaschenhals wird

Magento nutzt Cron für praktisch jede Hintergrundaufgabe: Indexierung, Versand von Bestellbestätigungen, Bereinigung abgelaufener Warenkörbe, Neuberechnung von Preisregeln und vieles mehr. Der Cron-Daemon von Magento selbst ist nur ein einziger PHP-Prozess, der alle fünf Minuten aus der System-Crontab gestartet wird und dann anhand der Konfiguration in crontab.xml Einträge in die Tabelle cron_schedule schreibt. Solange alle Jobs schnell durchlaufen, fällt das kaum auf. Sobald jedoch ein einzelner Job mehrere Minuten braucht, etwa eine vollständige Neuindexierung oder ein Massenversand von E-Mails, stauen sich alle nachfolgenden Jobs derselben Gruppe, weil Magento pro Gruppe standardmäßig streng sequenziell arbeitet.

Das eigentliche Problem ist selten der einzelne langsame Job, sondern die fehlende Trennung zwischen kritischen und unkritischen Aufgaben. Ein Shop, der Indexierung, Newsletter-Versand und einfache Aufräumjobs in derselben Cron-Gruppe laufen lässt, riskiert, dass ein hängender Newsletter-Versand die Produktindexierung um Stunden verzögert. Die folgenden Abschnitte zeigen, wie Cron-Gruppen, Schedule-Generierung und Message Queue Consumers zusammenspielen, um genau das zu verhindern.

2. Cron-Gruppen im Detail: default, index und consumers

Magento gruppiert alle Cron-Jobs über die Konfigurationsdatei crontab.xml in benannte Gruppen, wobei jede Gruppe in app/etc/cron_groups.xml eigene Steuerparameter besitzt. Von Haus aus liefert Magento unter anderem die Gruppen default, index und consumers mit. Die Gruppe index bündelt alle Indexer, die im Modus Schedule laufen, während default für allgemeine Wartungsjobs wie Cache-Bereinigung, Sitemap-Generierung oder E-Mail-Warteschlangen zuständig ist. Die Gruppe consumers wiederum startet die dort hinterlegten Message Queue Consumer, sofern diese nicht separat über bin/magento queue:consumers:start betrieben werden.

Jede Gruppe wird von einem eigenen internen Cron-Job namens group_name orchestriert, der die eigentlichen Jobs der Gruppe nacheinander abarbeitet. Das bedeutet: Innerhalb einer Gruppe läuft alles sequenziell, zwischen Gruppen jedoch parallel, weil jede Gruppe ihren eigenen Eintrag in der System-Crontab besitzt. Wer eigene Module mit rechenintensiven Jobs entwickelt, sollte deshalb frühzeitig eine dedizierte Gruppe anlegen, statt alles in default zu packen, denn genau diese Vermischung ist die häufigste Ursache für spätere Performance-Probleme.


<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
    <!-- Custom cron group, isolated from default to avoid blocking maintenance jobs -->
    <group id="mironsoft_maintenance">
        <job name="mironsoft_cleanup_expired_quotes" instance="Mironsoft\CronOptimizer\Cron\CleanupExpiredQuotes" method="execute">
            <schedule>0 */2 * * *</schedule>
        </job>
        <job name="mironsoft_sync_stock_levels" instance="Mironsoft\CronOptimizer\Cron\SyncStockLevels" method="execute">
            <schedule>*/15 * * * *</schedule>
        </job>
    </group>

    <!-- Built-in index group runs scheduled indexers separately from maintenance -->
    <group id="index">
        <job name="indexer_update_all_views" instance="Magento\Indexer\Cron\UpdateMview" method="execute">
            <schedule>*/1 * * * *</schedule>
        </job>
    </group>
</config>

3. Schedule-Generierung und das Wachstum der cron_schedule-Tabelle

Für jeden konfigurierten Job erzeugt Magento im Voraus Einträge in der Tabelle cron_schedule, gesteuert durch die Parameter schedule_generate_every, schedule_ahead_for und schedule_lifetime in cron_groups.xml. schedule_generate_every legt fest, wie oft neue Einträge generiert werden, schedule_ahead_for bestimmt, wie weit im Voraus geplant wird, und schedule_lifetime definiert, wie lange ein Eintrag gültig bleibt, bevor er als missed markiert wird. Werden diese Werte nicht an die tatsächliche Jobfrequenz angepasst, wächst die Tabelle deutlich schneller, als die history_cleanup-Routine sie wieder aufräumen kann.

In der Praxis sammeln sich in stark frequentierten Shops schnell mehrere hunderttausend Zeilen in cron_schedule an, was Lesezugriffe auf die Tabelle spürbar verlangsamt und selbst kleine Cron-Läufe verzögert. history_success_lifetime und history_failure_lifetime steuern, wie lange erfolgreiche beziehungsweise fehlgeschlagene Einträge aufbewahrt werden. Ein sinnvoller Ausgangswert sind 604800 Sekunden für erfolgreiche und ein deutlich kürzerer Wert für fehlgeschlagene Einträge, kombiniert mit einem Index auf den Spalten status und job_code, um Abfragen während der Cleanup-Phase zu beschleunigen.


<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:cron:etc/cron_groups.xsd">
    <group id="mironsoft_maintenance">
        <!-- Regenerate schedule entries every 5 minutes -->
        <schedule_generate_every>5</schedule_generate_every>
        <!-- Plan 30 minutes ahead so long-running jobs are not missed -->
        <schedule_ahead_for>30</schedule_ahead_for>
        <!-- Mark unrun entries as missed after 15 minutes -->
        <schedule_lifetime>15</schedule_lifetime>
        <!-- Cleanup history table every 10 minutes -->
        <history_cleanup_every>10</history_cleanup_every>
        <!-- Keep successful entries for 7 days -->
        <history_success_lifetime>604800</history_success_lifetime>
        <!-- Keep failed entries for 3 days to allow diagnosis -->
        <history_failure_lifetime>259200</history_failure_lifetime>
        <use_separate_process>1</use_separate_process>
    </group>
</config>

4. Lange laufende Jobs identifizieren, die die Queue blockieren

Ein Job, der in der Tabelle cron_schedule dauerhaft den Status running trägt, obwohl seine geplante Ausführungszeit längst überschritten ist, blockiert alle nachfolgenden Jobs derselben Gruppe, weil Magento pro Gruppenprozess strikt sequenziell arbeitet. Die Spalten scheduled_at, executed_at und finished_at erlauben eine gezielte Diagnose: Ist finished_at leer, während executed_at bereits vor einer Stunde liegt, ist der Job mit hoher Wahrscheinlichkeit hängen geblieben, etwa durch eine Endlosschleife, einen Deadlock in der Datenbank oder einen externen API-Aufruf ohne Timeout.

Ein einfacher, aber wirkungsvoller Check besteht darin, regelmäßig per Skript nach Einträgen mit Status running und einer Laufzeit über einem definierten Schwellenwert zu suchen und den zugehörigen PHP-Prozess über seine PID zu identifizieren. Wichtig ist, jeden eigenen Cron-Job mit einem klaren Timeout und einer Ressourcenobergrenze zu versehen, statt sich allein auf externe Beobachtung zu verlassen. Ohne eigene Absicherung bleibt ein hängender Job so lange blockierend, bis er manuell in der Datenbank auf error gesetzt oder der Prozess hart beendet wird.


#!/usr/bin/env bash
# Find cron jobs stuck in "running" state for longer than 60 minutes
bin/mysql -e "
  SELECT schedule_id, job_code, status, created_at, executed_at
  FROM cron_schedule
  WHERE status = 'running'
    AND executed_at < NOW() - INTERVAL 60 MINUTE
  ORDER BY executed_at ASC;
"

# Cross-reference with running PHP processes to find the stuck job's PID
ps aux | grep "bin/magento cron:run" | grep -v grep

# Force a stuck entry to error state so the group can continue
bin/mysql -e "
  UPDATE cron_schedule
  SET status = 'error', messages = 'Manually terminated: exceeded timeout'
  WHERE schedule_id = 48213;
"

5. Cron-Gruppen auf separate Prozesse und Server verteilen

Da jede Cron-Gruppe über einen eigenen Eintrag in der System-Crontab gestartet wird, lässt sich jede Gruppe unabhängig auf einem anderen Server oder zumindest in einem anderen Prozess ausführen. In Setups mit hoher Last empfiehlt es sich, die Gruppe index auf einem dedizierten Applikationsserver mit ausreichend Arbeitsspeicher laufen zu lassen, während default und consumers auf dem Webserver verbleiben, da Indexierungsjobs häufig deutlich mehr Speicher und CPU-Zeit benötigen als einfache Wartungsjobs.

Der Befehl bin/magento cron:run akzeptiert den Parameter --group, wodurch pro Server gezielt nur die dort gewünschte Gruppe ausgeführt wird, statt alle Gruppen redundant auf jedem Knoten zu starten, etwa bin/magento cron:run --group=index auf dem Indexer-Server und bin/magento cron:run --group=default auf dem Webserver. Bei mehreren Applikationsservern hinter einem Load Balancer ist zusätzlich sicherzustellen, dass Cron nur auf einem einzigen Knoten pro Gruppe aktiv ist, da parallele Ausführung derselben Gruppe auf mehreren Servern zu doppelt verarbeiteten Jobs und Race Conditions in cron_schedule führen kann. Ein einfacher Lock-Mechanismus über flock in der Crontab selbst schützt zusätzlich vor Überlappungen.

6. Eigene Cron-Jobs performant implementieren

Beim Schreiben eines eigenen Cron-Jobs ist die wichtigste Regel, große Datenmengen niemals in einem einzigen Durchlauf zu verarbeiten. Stattdessen sollte der Job in Batches arbeiten, den Fortschritt persistieren und bei Überschreiten eines Zeitbudgets kontrolliert abbrechen, damit der nächste geplante Lauf dort fortsetzen kann, wo der vorherige aufgehört hat. Das verhindert, dass ein wachsender Datenbestand irgendwann die schedule_lifetime überschreitet und der Job dauerhaft als missed markiert wird, ohne jemals vollständig zu laufen.

Zusätzlich sollte jeder Job seinen eigenen Ressourcenverbrauch überwachen und Ausnahmen sauber behandeln, statt sie unkontrolliert nach oben durchzureichen, denn eine unbehandelte Exception setzt den Job zwar auf error, hinterlässt aber häufig inkonsistente Zwischenzustände in der Datenbank. Ein Zeitlimit über eine einfache Stoppuhr im Job selbst, kombiniert mit Logging von Start, Batch-Fortschritt und Ende, macht spätere Diagnosen erheblich einfacher als das nachträgliche Rekonstruieren aus verstreuten Logdateien.


<?php

declare(strict_types=1);

namespace Mironsoft\CronOptimizer\Cron;

use Magento\Framework\App\ResourceConnection;
use Psr\Log\LoggerInterface;

/**
 * Processes expired quotes in time-budgeted batches instead of a single long run.
 */
class CleanupExpiredQuotes
{
    private const BATCH_SIZE = 500;
    private const TIME_BUDGET_SECONDS = 240;

    /**
     * @param ResourceConnection $resourceConnection Database connection for batch deletes.
     * @param LoggerInterface $logger Dedicated logger channel for cron diagnostics.
     */
    public function __construct(
        private readonly ResourceConnection $resourceConnection,
        private readonly LoggerInterface $logger
    ) {
    }

    /**
     * Deletes expired quotes in bounded batches and stops before the time budget is exceeded.
     *
     * @return void
     */
    public function execute(): void
    {
        $startedAt = microtime(true);
        $connection = $this->resourceConnection->getConnection();
        $table = $this->resourceConnection->getTableName('quote');
        $totalProcessed = 0;

        while (true) {
            if ((microtime(true) - $startedAt) > self::TIME_BUDGET_SECONDS) {
                $this->logger->info('CleanupExpiredQuotes: time budget reached, deferring rest to next run', [
                    'processed' => $totalProcessed,
                ]);
                return;
            }

            $ids = $connection->fetchCol(
                $connection->select()
                    ->from($table, ['entity_id'])
                    ->where('is_active = 0')
                    ->where('updated_at < DATE_SUB(NOW(), INTERVAL 30 DAY)')
                    ->limit(self::BATCH_SIZE)
            );

            if (empty($ids)) {
                break;
            }

            $connection->delete($table, ['entity_id IN (?)' => $ids]);
            $totalProcessed += count($ids);
        }

        $this->logger->info('CleanupExpiredQuotes: finished', ['processed' => $totalProcessed]);
    }
}

7. Message Queue Consumers statt schwerer synchroner Jobs

Nicht jede Hintergrundaufgabe gehört in Cron. Aufgaben, die durch ein Ereignis ausgelöst werden, etwa der Versand einer Bestellbestätigung oder die Synchronisation mit einem externen ERP-System, lassen sich über Magentos Message Queue deutlich robuster lösen als über einen Cron-Job, der die komplette Warteschlange bei jedem Lauf erneut abfragt. Ein Consumer, gestartet über bin/magento queue:consumers:start, verarbeitet Nachrichten, sobald sie in der Queue ankommen, statt auf das nächste Cron-Intervall zu warten, und bleibt dabei als eigener langlebiger Prozess unabhängig von der Cron-Gruppen-Logik.

In der Produktion sollten Consumer nicht manuell im Terminal gestartet, sondern über einen Prozessmanager wie Supervisor überwacht werden, der abgestürzte Consumer automatisch neu startet und die Anzahl paralleler Worker pro Consumer-Typ begrenzt. Der Parameter --max-messages beendet einen Consumer-Prozess nach einer definierten Anzahl verarbeiteter Nachrichten kontrolliert, was Speicherlecks in lang laufenden PHP-Prozessen vorbeugt und dem Prozessmanager einen sauberen Neustart ermöglicht, ohne dass Nachrichten verloren gehen.


#!/usr/bin/env bash
# Start a single consumer manually for testing (never in production)
bin/magento queue:consumers:start product.price.update --max-messages=1000

# Production setup: supervisord manages consumers as long-lived processes
cat <<'EOF' > /etc/supervisor/conf.d/magento-consumers.conf
[program:magento-consumer-price-update]
process_name=%(program_name)s_%(process_num)02d
command=/usr/bin/php /var/www/html/bin/magento queue:consumers:start product.price.update --max-messages=5000
numprocs=2
autostart=true
autorestart=true
user=www-data
stdout_logfile=/var/log/magento/consumer-price-update.log
stderr_logfile=/var/log/magento/consumer-price-update-error.log
EOF

supervisorctl reread && supervisorctl update
supervisorctl status magento-consumer-price-update:*

8. Monitoring, Logging und Alerting für Cron-Jobs

Ohne aktives Monitoring bleibt ein hängender oder dauerhaft fehlschlagender Cron-Job oft tagelang unbemerkt, weil Magento selbst keine Benachrichtigung auslöst. Ein regelmäßiger Check, der die Tabelle cron_schedule nach Einträgen mit Status error der letzten Stunde durchsucht und die Ergebnisse an ein Monitoring-System wie Grafana, Zabbix oder einen simplen Slack-Webhook meldet, schließt diese Lücke zuverlässig. Ebenso wichtig ist ein Alert, wenn seit der letzten erfolgreichen Ausführung einer kritischen Gruppe wie index deutlich mehr Zeit vergangen ist, als das konfigurierte Intervall erwarten lässt.

Die Logdatei var/log/cron.log, kombiniert mit dem individuellen Logging jedes eigenen Jobs, liefert die Basis für jede Diagnose, sollte aber über Log-Rotation begrenzt werden, da Cron-Jobs bei hoher Frequenz erhebliche Mengen an Text erzeugen können. Ein Dashboard, das die durchschnittliche Laufzeit pro Job-Code über die Zeit visualisiert, macht schleichende Verschlechterungen sichtbar, lange bevor sie zu einem vollständigen Stau der Warteschlange führen.

9. Cron und Message Queue im direkten Vergleich

Die Entscheidung zwischen einem klassischen Cron-Job und einem Message Queue Consumer hängt von der Art der Aufgabe ab: wiederkehrende, zeitgesteuerte Arbeit gehört in Cron, ereignisgesteuerte Arbeit in die Queue. Die folgende Übersicht zeigt, wofür sich welcher Ansatz konkret eignet.

Aufgabentyp Cron-Ansatz Message-Queue-Ansatz Empfehlung
Bestellbestätigung versenden Verzögerung bis zum nächsten Intervall Sofortige Verarbeitung bei Eingang Message Queue Consumer
Vollständige Produktindexierung Geplante Stapelverarbeitung planbar Ungeeignet für periodische Vollläufe Cron-Gruppe index
Externe ERP-Synchronisation Pollt Warteschlange bei jedem Lauf neu ab Reagiert ereignisbasiert ohne Polling Message Queue Consumer
Tägliche Sitemap-Generierung Einfache zeitgesteuerte Ausführung Kein Trigger-Ereignis vorhanden Cron-Gruppe default
Massenversand Warenkorb-Reminder Blockiert Gruppe bei großem Volumen Parallele Worker skalieren Durchsatz Message Queue Consumer

In der Praxis ergänzen sich beide Mechanismen: Cron bleibt zuständig für alles, was nach einem festen Zeitplan laufen muss, während die Message Queue jede Aufgabe übernimmt, die durch ein konkretes Ereignis ausgelöst wird. Wer synchrone, ereignisgesteuerte Arbeit fälschlicherweise über Cron abbildet, produziert unnötige Verzögerung und Gruppen, die durch Polling unnötig belastet werden.

Mironsoft

Magento Cron-Performance, Message-Queue-Architektur und Server-Infrastruktur

Cron-Jobs, die eure Warteschlange nicht mehr blockieren?

Wir analysieren eure Cron-Gruppen, identifizieren blockierende Jobs und richten Message Queue Consumer für ereignisgesteuerte Aufgaben ein, inklusive Supervisor-Konfiguration und Monitoring für stabilen Betrieb unter Last.

Cron-Audit

Analyse aller Cron-Gruppen, Identifikation blockierender und überflüssiger Jobs

Queue-Architektur

Message Queue Consumer für ereignisgesteuerte Aufgaben einrichten und mit Supervisor betreiben

Monitoring-Setup

Alerting für hängende Jobs und Dashboards für Laufzeiten pro Job-Code

10. Zusammenfassung

Die wichtigsten Hebel für Magento-Cron-Performance lösen immer dasselbe Grundproblem: Ein einzelner überlasteter oder hängender Job darf niemals die gesamte Gruppe blockieren. Sauber getrennte Cron-Gruppen über crontab.xml und cron_groups.xml trennen kritische von unkritischen Aufgaben. Realistisch konfigurierte Werte für schedule_lifetime und history_cleanup_every verhindern, dass cron_schedule unkontrolliert wächst. Eigene Jobs mit Zeitbudget und Batch-Verarbeitung bleiben auch bei wachsenden Datenmengen beherrschbar.

Der größte strukturelle Hebel liegt jedoch oft außerhalb von Cron selbst: Ereignisgesteuerte Aufgaben gehören in die Message Queue, nicht in eine Cron-Gruppe, die bei jedem Lauf erneut pollt. bin/magento queue:consumers:start in Kombination mit Supervisor sorgt für robuste, dauerhaft laufende Verarbeitung, ohne dass ein einzelner schwerer Job jemals wieder die komplette Cron-Warteschlange entschärfen muss, weil er sie gar nicht erst verdrängt.

Magento-Cron-Performance: Das Wichtigste auf einen Blick

Cron-Gruppen trennen

crontab.xml und cron_groups.xml nutzen, um default, index und consumers unabhängig zu steuern.

cron_schedule im Griff

schedule_lifetime und history_cleanup korrekt konfigurieren, um Tabellenwachstum zu vermeiden.

Blockierende Jobs erkennen

Status running mit überschrittener Laufzeit gezielt überwachen und Timeouts erzwingen.

Message Queue nutzen

queue:consumers:start mit Supervisor für ereignisgesteuerte Aufgaben statt schwerer synchroner Cron-Jobs.

11. FAQ: Magento-Cron-Performance

1Was sind die Standard-Cron-Gruppen in Magento?
default für Wartungsjobs, index für Indexer im Schedule-Modus, consumers für Message Queue Consumer. Eigene Module sollten für rechenintensive Jobs eine dedizierte Gruppe anlegen.
2Was steuert schedule_ahead_for in cron_groups.xml?
Legt fest, wie viele Minuten im Voraus Einträge in cron_schedule generiert werden. Zu niedrig riskiert verpasste lange Jobs, zu hoch lässt die Tabelle unnötig wachsen.
3Woran erkenne ich einen hängenden Cron-Job?
Status running, executed_at liegt lange zurück, finished_at ist leer. Abgleich mit ps aux bestätigt, ob der Prozess tatsächlich noch aktiv ist.
4Wie verhindere ich, dass cron_schedule zu groß wird?
history_success_lifetime und history_failure_lifetime realistisch setzen, history_cleanup_every regelmäßig laufen lassen, Index auf status und job_code anlegen.
5Wie teile ich Cron-Gruppen auf mehrere Server auf?
bin/magento cron:run --group=name pro Server gezielt einsetzen, zum Beispiel index auf dem Applikationsserver. Ein flock-Lock verhindert doppelte Ausführung.
6Wann sollte ich einen Message Queue Consumer statt eines Cron-Jobs verwenden?
Bei ereignisgesteuerten Aufgaben wie Bestellbestätigungen oder externer API-Synchronisation. Cron eignet sich für rein zeitgesteuerte, wiederkehrende Arbeit.
7Wie starte ich Message Queue Consumer produktiv?
Über einen Prozessmanager wie Supervisor, der Consumer überwacht, neu startet und die Worker-Anzahl begrenzt. Manueller Start eignet sich nur zum Testen.
8Was macht --max-messages bei queue:consumers:start?
Beendet den Consumer kontrolliert nach einer definierten Anzahl verarbeiteter Nachrichten. Beugt Speicherlecks vor und ermöglicht sauberen Neustart durch den Prozessmanager.
9Wie überwache ich Cron-Jobs im Produktivbetrieb?
Regelmäßiger Check auf Status error in cron_schedule plus Alert bei ausbleibender erfolgreicher Ausführung kritischer Gruppen. var/log/cron.log liefert Details.
10Kann ich eigene Cron-Gruppen anlegen?
Ja, über eine eigene group id in crontab.xml mit passenden Parametern in cron_groups.xml. Empfohlen, um eigene Module von default und index zu isolieren.