Shopware Performance-Probleme – bitte um Unterstützung

Hallo zusammen,

ich habe zwei Shopware-Installationen mit unterschiedlichem Umfang:

Shop 1:

  • 100 Kategorien
  • 40.000 einfache Artikel
  • 3 Eigenschaften (Properties)
  • 5 Sprachen
  • 3 Währungen

Die Ladezeiten sind gut, teilweise sogar sehr gut. Das ist auch nicht überraschend – 40.000 Artikel sind für ein leistungsfähiges Shopsystem eigentlich keine Herausforderung, besonders nicht bei der Server-Hardware, die ich verwende.

Shop 2:

  • 300 Kategorien
  • 1.000.000 einfache Artikel
  • 3 Eigenschaften
  • Zunächst ebenfalls 5 Sprachen und 3 Währungen – mittlerweile reduziert auf nur noch 1 Sprache
  • Gleicher Server, gleiche Konfiguration

Hier sind die Ladezeiten katastrophal. Ich bitte dringend um Hilfe – was habe ich falsch gemacht?

Hintergrund:

Ich arbeite festangestellt in einer Firma, die aktuell einen Magento-1-Shop betreibt. Ich bin kein Freelancer oder Teil einer Agentur.
Seit über 13 Jahren entwickle ich fast ausschließlich mit Magento, sowohl in Agenturen als auch in größeren Unternehmen gearbeitet. Ich bin auch Magento-zertifiziert.

Bitte keine Fragen nach URLs oder dem Firmennamen. Ich bin lediglich ein Entwickler, der die Aufgabe erhalten hat, zu prüfen, ob Shopware eine Alternative für uns darstellt.

Ich möchte hier keine Werbung für Magento machen oder die Systeme direkt vergleichen, aber als Referenz:
Unser Magento 2 Test Shop kommt mit 1 Million Artikeln, 3 filterbaren Attributen und 15 Sprachen/StoreViews auf Ladezeiten unter 1 Sekunde.

Fragen zum Shopware HTTP-Cache:

  1. Wie genau funktioniert der HTTP-Cache in Shopware?
    Ist er vergleichbar mit einem Full Page Cache (FPC)?
  2. Was genau wird gespeichert?
    Wird die komplette HTML-Seite gecacht?
  3. Werden dynamische Blöcke (wie z. B. der Warenkorb) als Platzhalter eingefügt, ähnlich wie in Magento mit ESI-Tags oder ähnlichem?
  4. Verstehe ich das richtig, dass der HTTP-Cache automatisch deaktiviert wird, sobald sich ein Benutzer einloggt oder einen Artikel in den Warenkorb legt?
    Gilt das dann auch für die Startseite, Kategorieseiten und Produktseiten?
  5. Wo werden die Cache-Daten abgelegt?
    Ich vermute in var/cache/prod_xxx/pools/app – korrekt?
    Ich habe auch mit Redis getestet.
  6. Hat jemand Erfahrungen mit einem Shop dieser Größenordnung (1 Mio Artikel) in Shopware? Gibt es dafür Best Practices oder Hinweise auf technische Grenzen?

Server-Setup:

  • Dedizierter Root-Server (ausschließlich für dieses Projekt)
  • 14 CPU (20 Threads)
  • 64 GB RAM
  • 500 GB SSD
  • Plesk
  • NGINX
  • PHP 8.3
  • OPCache aktiviert
  • memory_limit = 20G
  • MariaDB 11.4.6

MariaDB 11.4.6 und Einstellungenfü DB:

[mysqld]

GRUNDEINSTELLUNGEN

###################

Zeichensatz für internationale Shops

character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci

Grundlegende Verbindungseinstellungen

max_connections = 500
max_allowed_packet = 64M
connect_timeout = 30
wait_timeout = 600
interactive_timeout = 600
net_read_timeout = 120
net_write_timeout = 120
table_open_cache = 8192
table_definition_cache = 4096
open_files_limit = 65535

SPEICHER-OPTIMIERUNG

###################
innodb_buffer_pool_size = 30G

Buffer-Instanzen (1 pro CPU-Kern, ideal für 14 CPU-Kerne)

innodb_buffer_pool_instances = 14

Query-Cache (für Shopware/Magento mit vielen Produkten)

query_cache_type = 1
query_cache_size = 256M
query_cache_limit = 8M

Thread-Cache

thread_cache_size = 32

Temporäre Tabellen (erhöht für 64GB RAM)

tmp_table_size = 512M
max_heap_table_size = 512M

Sortierung und Joins

sort_buffer_size = 8M
join_buffer_size = 4M
read_buffer_size = 3M
read_rnd_buffer_size = 4M

STORAGE-ENGINE-EINSTELLUNGEN (InnoDB)

##################

Transaktionseinstellungen

innodb_flush_method = O_DIRECT
innodb_flush_log_at_trx_commit = 1 # Für ACID-Compliance

Alternativ: innodb_flush_log_at_trx_commit = 2 # Für höhere Performance

Redo-Log

innodb_log_file_size = 1G
innodb_log_buffer_size = 64M

Concurrent Threads (angepasst für 14 CPU-Kerne)

innodb_read_io_threads = 14
innodb_write_io_threads = 14
innodb_thread_concurrency = 28

Dateikonfiguration

innodb_file_per_table = ON

Performance-Schemaoptionen

performance_schema = ON
performance_schema_max_table_instances = 400
performance_schema_max_table_handles = 256
performance_schema_max_mutex_instances = 15000

TRANSAKTIONEN UND LOCKS

###################

Deadlock-Erkennung

innodb_deadlock_detect = ON
innodb_lock_wait_timeout = 120

Isolation Level (für E-Commerce gut geeignet)

transaction_isolation = READ-COMMITTED

SPEZIELLE MAGENTO/SHOPWARE-OPTIMIERUNGEN

###################

Magento kann viele temporäre Tabellen erstellen

max_prepared_stmt_count = 32768
binlog_format = ROW
sync_binlog = 1

Für lange Produktnamen etc.

innodb_strict_mode = OFF

Für umfangreiche Indizierungen

sort_buffer_size = 16M
myisam_sort_buffer_size = 64M

Für große Anzahl komplexer Abfragen

max_length_for_sort_data = 8192
optimizer_switch = ‚index_merge_intersection=on‘

Verbesserte I/O-Handhabung

innodb_io_capacity = 2000
innodb_io_capacity_max = 4000
innodb_adaptive_flushing = ON
innodb_lru_scan_depth = 4096

OpenSearch mit -Xms8g/-Xmx8g

Die Produkte habe durch eigenen Plugin erzeugt.

Die Einstellungen in .env.local

OPENSEARCH_URL=http://localhost:9200

SHOPWARE_ES_ENABLED=1
SHOPWARE_ES_INDEXING_ENABLED=1
SHOPWARE_ES_INDEX_PREFIX=sw6
SHOPWARE_ES_THROW_EXCEPTION=1
SHOPWARE_HTTP_CACHE_ENABLED=1
SHOPWARE_HTTP_DEFAULT_TTL=7200

Ich freue mich über jede fachliche Rückmeldung oder Hinweise, was ich optimieren oder beachten sollte.

Vielen Dank im Voraus!

Zur Generierung von 1 Million Artikeln habe ich ein eigenes Plugin geschrieben.
Vielleicht mache ich dabei etwas falsch – eventuell werden unnötige oder inkonsistente Daten erzeugt?

/src/custom/plugins/NasadImportProducts/Command/ImportProductsCommand.php

<?php declare(strict_types=1);

namespace NasadImportProducts\Command;

use NasadImportProducts\Core\Content\Product\ProductImportService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Input\InputOption;

class ImportProductsCommand extends Command
{
    protected static string $defaultName = 'nasad:import:products';

    public function __construct(
        private readonly ProductImportService $productImportService
    ) {
        parent::__construct(self::$defaultName);
    }

    protected function configure(): void
    {
        $this
            ->setName(self::$defaultName)
            ->setDescription('Import products with dimensions')
            ->addOption(
                'count',
                'c',
                InputOption::VALUE_OPTIONAL,
                'Number of products to import',
                100
            )
            ->addOption(
                'offset',
                'o',
                InputOption::VALUE_OPTIONAL,
                'Starting offset for product numbers',
                0
            );
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $count = (int)$input->getOption('count');
        $offset = (int)$input->getOption('offset');

        try {
            $io->section(sprintf('Starting products import (Count: %d, Offset: %d)', $count, $offset));

            // Importiere Produkte
            $io->text('Importing products...');
            $this->productImportService->importProducts($count, $offset);

            $io->success(sprintf('%d products imported successfully', $count));
            return Command::SUCCESS;
        } catch (\Exception $e) {
            $io->error('Error during import: ' . $e->getMessage());
            return Command::FAILURE;
        }
    }
}

/src/custom/plugins/NasadImportProducts/Command/ImportCategoriesCommand.php

<?php declare(strict_types=1);

namespace NasadImportProducts\Command;

use NasadImportProducts\Core\Content\Category\CategoryImportService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

class ImportCategoriesCommand extends Command
{
    protected static string $defaultName = 'nasad:import:categories';

    public function __construct(
        private readonly CategoryImportService $categoryImportService
    ) {
        parent::__construct(self::$defaultName);
    }

    protected function configure(): void
    {
        $this
            ->setName(self::$defaultName)
            ->setDescription('Import categories')
            ->addOption(
                'count',
                'c',
                InputOption::VALUE_OPTIONAL,
                'Number of categories to import',
                100
            );
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $count = (int) $input->getOption('count');

        try {
            $io->section(sprintf('Starting categories import (Count: %d)', $count));
            $categoryIds = $this->categoryImportService->importCategories($count);
            $io->success(sprintf('%d categories imported successfully', $count));
            return Command::SUCCESS;
        } catch (\Exception $e) {
            $io->error('Error during import: ' . $e->getMessage());
            return Command::FAILURE;
        }
    }
}

/src/custom/plugins/NasadImportProducts/Command/ImportAttributesCommand.php

<?php declare(strict_types=1);

namespace NasadImportProducts\Command;

use NasadImportProducts\Core\Service\AttributeService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

class ImportAttributesCommand extends Command
{
    protected static string $defaultName = 'nasad:import:attributes';

    public function __construct(
        private readonly AttributeService $attributeService
    ) {
        parent::__construct(self::$defaultName);
    }

    protected function configure(): void
    {
        $this
            ->setName(self::$defaultName)
            ->setDescription('Import attributes');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);

        try {
            $io->section('Starting attributes import');
            $this->attributeService->createAttributes();
            $io->success('Attributes created successfully');
            return Command::SUCCESS;
        } catch (\Exception $e) {
            $io->error('Error during import: ' . $e->getMessage());
            return Command::FAILURE;
        }
    }
}

/src/custom/plugins/NasadImportProducts/Core/Content/Category/CategoryImportService.php

<?php declare(strict_types=1);

namespace NasadImportProducts\Core\Content\Category;

use NasadImportProducts\Core\Service\DummyDataGenerator;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\Uuid\Uuid;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;


class CategoryImportService
{
    private DummyDataGenerator $dataGenerator;

    public function __construct(
        private readonly EntityRepository $categoryRepository,
        private readonly EntityRepository $salesChannelRepository
    ) {
        $this->dataGenerator = new DummyDataGenerator();
    }

    public function importCategories(int $count = 100): array
    {
        $context = Context::createDefaultContext();
        $categoryIds = [];

        // Finde den Standard-Storefront-Sales-Channel
        $criteria = new Criteria();


        $salesChannel = $this->salesChannelRepository->search($criteria, $context)->first();

        if (!$salesChannel) {
            throw new \RuntimeException('Default sales channel not found');
        }

        // Hole die Navigations-Kategorie
        $navigationCategoryId = $salesChannel->getNavigationCategoryId();
        $criteria = new Criteria([$navigationCategoryId]);
        $navigationCategory = $this->categoryRepository->search($criteria, $context)->first();

        if (!$navigationCategory) {
            throw new \RuntimeException('Navigation category not found');
        }

        // Begrenze die Anzahl auf mindestens 1 und maximal 100
        $count = max(1, min(100, $count));

        for ($i = 1; $i <= $count; $i++) {
            $categoryId = Uuid::randomHex();
            $categoryIds[] = $categoryId;

            $category = [
                'id' => $categoryId,
                'parentId' => $navigationCategory->getId(),
                'translations' => $this->getCategoryTranslations($i),
                'displayNestedProducts' => true,
                'active' => true,
                'visible' => true
            ];

            $this->categoryRepository->create([$category], $context);
        }

        return $categoryIds;
    }



    private function getCategoryTranslations(int $number): array
    {
        $translations = [];
        $supportedLocales = ['de-DE', 'en-GB', 'fr-FR', 'nl-NL', 'it-IT', 'es-ES'];

        foreach ($supportedLocales as $locale) {
            $translations[$locale] = [
                'name' => $this->dataGenerator->generateCategoryName($number, $locale),
                'description' => $this->dataGenerator->generateCategoryDescription($locale),
                'metaTitle' => $this->dataGenerator->generateMetaTitle(
                    $this->dataGenerator->generateCategoryName($number, $locale),
                    $locale
                ),
                'metaDescription' => $this->dataGenerator->generateMetaDescription($locale)
            ];
        }

        return $translations;
    }


    public function deleteAllCategories(): void
    {
        $context = Context::createDefaultContext();
        $categories = $this->categoryRepository->search(new Criteria(), $context);

        if ($categories->getTotal() === 0) {
            return;
        }

        $ids = array_map(function ($category) {
            return ['id' => $category->getId()];
        }, $categories->getElements());

        $this->categoryRepository->delete(array_values($ids), $context);
    }
}

/src/custom/plugins/NasadImportProducts/Core/Content/Product/ProductImportService.php

<?php declare(strict_types=1);

namespace NasadsImportProducts\Core\Content\Product;

use NasadImportProducts\Core\Service\DummyDataGenerator;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\Uuid\Uuid;
use NasadImportProducts\Core\Service\AttributeService;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexerRegistry;

class ProductImportService
{
    private DummyDataGenerator $dataGenerator;
    private array $attributeIds;
    private array $categoryIds = [];
    private array $propertyOptionIds = [];

    public function __construct(
        private readonly EntityRepository $productRepository,
        private readonly EntityRepository $categoryRepository,
        private readonly AttributeService $attributeService,
        private readonly EntityRepository $propertyGroupOptionRepository
    ) {
        $this->dataGenerator = new DummyDataGenerator();
    }

    /**
     * Import products with random categories and attributes
     *
     * @param int $count Number of products to import (default: 100, max: 1000000)
     * @return void
     * @throws \RuntimeException when no categories are found
     */
    public function importProducts_old(int $count = 100): void
    {
        $context = Context::createDefaultContext();
        $products = [];
        $batchSize = 1000;

        // Lade alle verfügbaren Kategorien und Attribute
        $this->loadCategories($context);
        $this->loadAttributes();

        // Begrenze die Anzahl auf mindestens 1 und maximal 1.000.000
        $count = max(1, min(10, $count));

        for ($i = 1; $i <= $count; $i++) {
            $productId = Uuid::randomHex();

            // Zufällige Dimensionen zwischen 1 und 10
            $width = mt_rand(1, 10);
            $height = mt_rand(1, 10);
            $length = mt_rand(1, 10);

            $products[] = [
                'id' => $productId,
                'productNumber' => 'PROD-' . $width . '-' . $height . '-' . $length . '-' . $i,
                'stock' => 100,
                'translations' => $this->getProductTranslations($width, $height, $length),
                'price' => [['currencyId' => 'b7d2554b0ce847cd82f3ac9bd1c0dfca',
                    'gross' => (string)$this->dataGenerator->getRandomPrice(), // Explizit als String
                    'net' => (string)$this->dataGenerator->getRandomPrice(),   // Explizit als String
                    'linked' => true]],
                'tax' => ['name' => '19%', 'taxRate' => 19],
                'categories' => $this->getRandomCategoryIds(),
                'properties' => [
                    ['id' => Uuid::randomHex(), 'groupId' => $this->attributeIds['width'], 'name' => $width . ' cm'],
                    ['id' => Uuid::randomHex(), 'groupId' => $this->attributeIds['height'], 'name' => $height . ' cm'],
                    ['id' => Uuid::randomHex(), 'groupId' => $this->attributeIds['length'], 'name' => $length . ' cm']
                ],
                'active' => true
            ];

            if (count($products) >= $batchSize) {
                $this->productRepository->create($products, $context);
                $products = [];
            }
        }

        if (!empty($products)) {
            $this->productRepository->create($products, $context);
        }
    }

    private function loadOrCreatePropertyOptions(Context $context): void
    {
        // Cache für Property-Optionen erstellen
        if (empty($this->propertyOptionIds)) {
            $this->propertyOptionIds = [
                'width' => [],
                'height' => [],
                'length' => []
            ];

            // Für jede Dimension (1-10 cm) Property-Optionen laden oder erstellen
            foreach (['width' => $this->attributeIds['width'],
                         'height' => $this->attributeIds['height'],
                         'length' => $this->attributeIds['length']] as $type => $groupId) {

                for ($value = 1; $value <= 10; $value++) {
                    $name = $value . ' cm';

                    // Prüfen, ob Option bereits existiert
                    $criteria = new Criteria();
                    $criteria->addFilter(new EqualsFilter('groupId', $groupId));
                    $criteria->addFilter(new EqualsFilter('name', $name));

                    $existing = $this->propertyGroupOptionRepository->search($criteria, $context)->first();

                    if ($existing) {
                        $this->propertyOptionIds[$type][$value] = $existing->getId();
                    } else {
                        // Neu erstellen wenn nicht vorhanden
                        $id = Uuid::randomHex();
                        $this->propertyGroupOptionRepository->create([
                            [
                                'id' => $id,
                                'groupId' => $groupId,
                                'name' => $name
                            ]
                        ], $context);
                        $this->propertyOptionIds[$type][$value] = $id;
                    }
                }
            }
        }
    }

    public function importProducts(int $count = 100, int $offset = 0): void
    {
        // Disable Indexing während des Imports
        $context = Context::createDefaultContext();
        $context->addState(EntityIndexerRegistry::DISABLE_INDEXING);
        $products = [];
        $batchSize = 5000;

        $this->loadCategories($context);
        $this->loadAttributes();
        // Lade oder erstelle Property-Optionen einmalig
        $this->loadOrCreatePropertyOptions($context);

        $count = max(1, min(40000, $count));
        // Zufälliger Prefix für diese Import-Session
        $importPrefix = substr(md5(uniqid()), 0, 6);


        for ($i = 1; $i <= $count; $i++) {
            $currentNumber = $i + $offset; // Verwendung des Offsets
            $productId = Uuid::randomHex();

            $width = mt_rand(1, 10);
            $height = mt_rand(1, 10);
            $length = mt_rand(1, 10);

            // Neue eindeutige Produktnummer
            $productNumber = sprintf('PROD-%s-%d-%d-%d-%06d',
                $importPrefix,
                $width,
                $height,
                $length,
                $currentNumber
            );

            // Korrekte Preisberechnung mit 19% MwSt
            $netPrice = $this->dataGenerator->getRandomPrice();
            $grossPrice = $netPrice * 1.19; // Netto + 19% MwSt

            $products[] = [
                'id' => $productId,
                'productNumber' => $productNumber, // Neue eindeutige Nummer,
                'stock' => 100,
                'translations' => $this->getProductTranslations($width, $height, $length),
                'price' => [[
                    'currencyId' => strtolower('B7D2554B0CE847CD82F3AC9BD1C0DFCA'),
                    'net' => (string)$netPrice,
                    'gross' => (string)$grossPrice,
                    'linked' => true
                ]],
                'tax' => ['name' => '19%', 'taxRate' => 19],
                'categories' => $this->getRandomCategoryIds(),

                'properties' => [
                    ['id' => $this->propertyOptionIds['width'][$width]],
                    ['id' => $this->propertyOptionIds['height'][$height]],
                    ['id' => $this->propertyOptionIds['length'][$length]]
                ],
                'active' => true,
                'visibilities' => [
                    [
                        //'salesChannelId' => strtolower('98432DEF39FC4624B33213A56B8C944D'), // Default Storefront
                        'salesChannelId' => strtolower('0196cd8ed19871fa846d4bfba2298364'),
                        'visibility' => 30 // Fully visible
                    ]
                ]
            ];

            if (count($products) >= $batchSize) {
                $this->productRepository->create($products, $context);
                $products = [];

                // Speicher freigeben
                gc_collect_cycles();

                // Fortschritt anzeigen
                echo sprintf("\rImportiere Produkt %d von %d (%d%%) [Offset: %d]",
                    $i,
                    $count,
                    round(($i/$count) * 100),
                    $offset
                );


            }
        }

        if (!empty($products)) {
            $this->productRepository->create($products, $context);
        }
    }


    private function loadAttributes(): void
    {
        // Prüfe ob die Attribute bereits existieren
        $this->attributeIds = $this->attributeService->getAttributeIds();

        if (empty($this->attributeIds) ||
            !isset($this->attributeIds['width']) ||
            !isset($this->attributeIds['height']) ||
            !isset($this->attributeIds['length'])) {
            throw new \RuntimeException('Required attributes (width, height, length) not found. Please create attributes first using the import:attributes command.');
        }
    }

    private function getRandomCategoryIds(): array
    {
        // Wähle 1-3 zufällige Kategorien aus
        $numCategories = mt_rand(1, 1);
        $selectedCategories = array_rand(array_flip($this->categoryIds), $numCategories);

        if (!is_array($selectedCategories)) {
            $selectedCategories = [$selectedCategories];
        }

        return array_map(function($categoryId) {
            return ['id' => $categoryId];
        }, $selectedCategories);
    }

    private function loadCategories(Context $context): void
    {
        $criteria = new Criteria();
        $categories = $this->categoryRepository->search($criteria, $context);

        if ($categories->getTotal() === 0) {
            throw new \RuntimeException('No categories found. Please create categories first using the import:categories command.');
        }

        foreach ($categories->getElements() as $category) {
            $this->categoryIds[] = $category->getId();
        }
    }

    private function getProductTranslations(int $width, int $height, int $length): array
    {
        $translations = [];
        $supportedLocales = ['de-DE', 'en-GB', 'fr-FR', 'nl-NL', 'it-IT', 'es-ES'];

        foreach ($supportedLocales as $locale) {
            $translations[$locale] = [
                'name' => $this->dataGenerator->generateProductName($width, $height, $length, $locale),
                'description' => $this->dataGenerator->generateProductDescription($locale),
                'metaTitle' => $this->dataGenerator->generateMetaTitle(
                    $this->dataGenerator->generateProductName($width, $height, $length, $locale),
                    $locale
                ),
                'metaDescription' => $this->dataGenerator->generateMetaDescription($locale)
            ];
        }

        return $translations;
    }
}

/src/custom/plugins/NasadImportProducts/Core/Service/AttributeService.php

<?php declare(strict_types=1);

namespace NasadImportProducts\Core\Service;

use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\Framework\Uuid\Uuid;

class AttributeService
{
    public function __construct(
        private readonly EntityRepository $propertyGroupRepository,
        private readonly EntityRepository $propertyGroupOptionRepository
    ) {
    }

    public function getAttributeIds(): array
    {
        $context = Context::createDefaultContext();

        $widthCriteria = new Criteria();
        $widthCriteria->addFilter(new EqualsFilter('name', 'Breite'));
        $widthGroup = $this->propertyGroupRepository->search($widthCriteria, $context)->first();

        $heightCriteria = new Criteria();
        $heightCriteria->addFilter(new EqualsFilter('name', 'Hoehe'));
        $heightGroup = $this->propertyGroupRepository->search($heightCriteria, $context)->first();

        $lengthCriteria = new Criteria();
        $lengthCriteria->addFilter(new EqualsFilter('name', 'Laenge'));
        $lengthGroup = $this->propertyGroupRepository->search($lengthCriteria, $context)->first();

        if (!$widthGroup || !$heightGroup || !$lengthGroup) {
            throw new \RuntimeException('Required property groups not found. Please run import:attributes command first.');
        }

        return [
            'width' => $widthGroup->getId(),
            'height' => $heightGroup->getId(),
            'length' => $lengthGroup->getId(),
        ];
    }

    public function createAttributes(): void
    {
        $context = Context::createDefaultContext();

        $widthId = Uuid::randomHex();
        $heightId = Uuid::randomHex();
        $lengthId = Uuid::randomHex();

        // Erstelle die Basisgruppen
        $groups = [
            [
                'id' => $widthId,
                'name' => 'Breite',
                'displayType' => 'text',
                'sortingType' => 'numeric',
                'filterable' => true,
            ],
            [
                'id' => $heightId,
                'name' => 'Hoehe',
                'displayType' => 'text',
                'sortingType' => 'numeric',
                'filterable' => true,
            ],
            [
                'id' => $lengthId,
                'name' => 'Laenge',
                'displayType' => 'text',
                'sortingType' => 'numeric',
                'filterable' => true,
            ],
        ];

        // Erstelle die Gruppen
        $this->propertyGroupRepository->create($groups, $context);

        // Erstelle die Optionen für jede Gruppe
        $options = [];
        $groupIds = [$widthId, $heightId, $lengthId];

        foreach ($groupIds as $groupId) {
            for ($i = 1; $i <= 10; $i++) {
                $options[] = [
                    'id' => Uuid::randomHex(),
                    'groupId' => $groupId,
                    'name' => $i . ' cm',
                    'position' => $i,
                ];
            }
        }

        // Speichere die Optionen
        if (!empty($options)) {
            $this->propertyGroupOptionRepository->create($options, $context);
        }
    }

    public function deleteAttributes(): void
    {
        $context = Context::createDefaultContext();
        $attributeNames = ['Breite', 'Hoehe', 'Laenge'];

        foreach ($attributeNames as $name) {
            $criteria = new Criteria();
            $criteria->addFilter(new EqualsFilter('name', $name));

            $attribute = $this->propertyGroupRepository->search($criteria, $context)->first();

            if ($attribute) {
                $this->propertyGroupRepository->delete([['id' => $attribute->getId()]], $context);
            }
        }
    }
}

/src/custom/plugins/NasadImportProducts/Core/Service/DummyDataGenerator.php

<?php declare(strict_types=1);

namespace NasadImportProducts\Core\Service;

class DummyDataGenerator
{
    private const array LANGUAGES = ['de-DE', 'en-GB', 'fr-FR', 'nl-NL', 'it-IT', 'es-ES'];

    public function generateCategoryName(int $number, string $locale): string
    {
        $prefixes = [
            'de-DE' => 'Test',
            'en-GB' => 'Test',
            'fr-FR' => 'Test',
            'nl-NL' => 'Test',
            'it-IT' => 'Test',
            'es-ES' => 'Test'
        ];
        return $prefixes[$locale] . ' ' . $number;
    }

    public function generateCategoryDescription(string $locale): string
    {
        $descriptions = [
            'de-DE' => 'Testbeschreibung',
            'en-GB' => 'Description',
            'fr-FR' => 'Description',
            'nl-NL' => 'Beschrijving',
            'it-IT' => 'Descrizione',
            'es-ES' => 'Descripcion'
        ];

        return $descriptions[$locale];
    }

    public function generateProductName(int $width, int $height, int $length, string $locale): string
    {
        $prefixes = [
            'de-DE' => 'Produkt',
            'en-GB' => 'Product',
            'fr-FR' => 'Produit',
            'nl-NL' => 'Product',
            'it-IT' => 'Prodotto',
            'es-ES' => 'Producto'
        ];

        return sprintf('%s %dx%dx%d',
            $prefixes[$locale],
            $width,  // Direkt die Werte verwenden
            $height, // ohne Modifikation
            $length
        );
    }

    public function generateProductDescription(string $locale): string
    {
        $descriptions = [
            'de-DE' => 'Test',
            'en-GB' => 'Test',
            'fr-FR' => 'Test',
            'nl-NL' => 'Test',
            'it-IT' => 'Test',
            'es-ES' => 'Test'
        ];

        return $descriptions[$locale];
    }

    public function generateMetaTitle(string $name, string $locale): string
    {
        return $name . ' | ' . $this->getStoreName($locale);
    }

    public function generateMetaDescription(string $locale): string
    {
        $descriptions = [
            'de-DE' => 'Test',
            'en-GB' => 'Test',
            'fr-FR' => 'Test',
            'nl-NL' => 'Test',
            'it-IT' => 'Test',
            'es-ES' => 'Test'
        ];

        return $descriptions[$locale];
    }

    private function getStoreName(string $locale): string
    {
        $names = [
            'de-DE' => 'Shop',
            'en-GB' => 'Shop',
            'fr-FR' => 'Shop',
            'nl-NL' => 'Shop',
            'it-IT' => 'Shop',
            'es-ES' => 'Shop'
        ];

        return $names[$locale];
    }

    public function getRandomPrice(): string
    {
        return number_format(mt_rand(1000, 99900) / 100, 2, '.', '');

    }
}

und die Daten so erzeugt:

rm -rf var/cache/*
php bin/console cache:clear
php bin/console cache:warmup

# Neue Daten importieren
bin/console nasad:import:attributes
sleep 5
bin/console nasad:import:categories --count=300

Zuerst alle Importe ausführen:

bin/console nasad:import:products --count=250000 --offset=0 &
bin/console nasad:import:products --count=250000 --offset=250000 &
bin/console nasad:import:products --count=250000 --offset=500000 &
bin/console nasad:import:products --count=250000 --offset=750000 &

Warten bis alle Import-Prozesse abgeschlossen sind
Dann die Indizierung in die Queue stellen:

bin/console dal:refresh:index --use-queue

die Queue-Worker starten:

bin/console messenger:consume async -vv --limit=1000 &
bin/console messenger:consume async -vv --limit=1000 &
bin/console messenger:consume async -vv --limit=1000 &

Ja, sobald sich der Shopware Context verändert, wird kein Cache mehr verwendet. Das trifft bspw. zu, wenn ein Artikel im Warenkorb ist oder ein Kunde sich eingeloggt hat.

Hier Performance Tweaks | Shopware Documentation wird beschrieben, wie man das umgehen kann, wenn dies im spezifisches Projekt nicht notwendig ist. Der Warenkorb wird per XHR dynamisch nachgeladen.

Ab Shopware 6.7 wird ESI für einzelne Bereiche genutzt.

Die Anzahl der Produkte sind in der Regel selten das Problem. Erst die Verknüpfung mit Options, Properties, Rules, etc. treibt manche Queries in die Höhe.

Nutze den Symfony Profiler im dev Modus um herauszufinden, wo das Bottelneck liegt.

Hallo,

vielen Dank für deine Vorschläge.

Ja, ich benutze den Symfony Profiler – genau deshalb habe ich nach dem Cache gefragt, weil die Ursache irgendwo dort liegen muss.
Laut Query Metrics habe ich 16 Datenbankabfragen mit einer **Gesamtlaufzeit von 1,57 ms:

Es liegt also nicht an der Datenbank.

Performance metrics

kernel.controller: 6107.1 ms / 4136 MiB
ContextResolverListener: 6106.8 ms / 4136 MiB
sales-channel-context: 6106.5 ms / 4136 MiB

Alle Rules habe ich gelöscht.

Ist die Verarbeitungsreihenfolge richtig von mir?
Request → PHP Bootstrap → Kernel → Context Aufbau → Controller → HTTP Cache

Noch was. Im Adminbereich habe ich sehr gute Ladezeiten.

Der Sales-Chanel-Context sollte eigentlich nicht so komplex zu berechnen sein. Der scheint aber das Problem zu sein.

Würde an deiner Stelle versuchen entweder die SQL Queries zu analysieren oder die PHP Class zu finden und die PHP memory usage Funktion an diversen Stellen einzubauen und im error log ausgeben.

So solltest du herausfinden welcher Part so viel RAM benötigt.

CustomFields nicht vergessen, sind auch ein Killer