Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

Das N+1-Problem verstehen und mit Batch-Resolvern lösen

Das N+1-Problem verstehen und mit Batch-Resolvern lösen

~9 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026

Das is_favorite-Feld aus Kapitel 17 funktioniert korrekt - hat aber ein verstecktes Performance-Problem, das bei jeder Liste mit mehreren Veranstaltungen zuschlägt. Dieses Kapitel zeigt das Problem konkret und löst es mit einem Batch-Resolver.

Das Problem sichtbar machen

Eine Liste von 20 Veranstaltungen mit is_favorite im Selection-Set ruft IsFavorite::resolve() zwanzig Mal auf - jeder Aufruf löst intern eine eigene SELECT-Abfrage auf mironsoft_event_customer_favorite aus. Eine einzige GraphQL-Anfrage verursacht damit 21 Datenbankabfragen (eine für die Liste selbst plus 20 für is_favorite) statt der zwei, die eigentlich nötig wären. Genau das ist das klassische N+1-Problem, benannt nach dieser Formel: eine Abfrage für die Liste (1), plus eine pro Listeneintrag (N).

query {
  events(pageSize: 20) {
    items {
      title
      is_favorite   # loest 20x eine eigene DB-Abfrage aus
    }
  }
}

Die Lösung: alle Anfragen eines Durchlaufs bündeln

Ein Batch-Resolver löst nicht einen einzelnen Wert auf, sondern bekommt alle für einen GraphQL-Durchlauf anstehenden Anfragen für dasselbe Feld gebündelt übergeben - hier also alle 20 is_favorite-Aufrufe auf einmal. Damit lässt sich die Datenbankabfrage von 20 Einzelabfragen auf eine einzige WHERE event_id IN (...)-Abfrage reduzieren.

app/code/Mironsoft/Event/Model/ResourceModel/EventFavorite.php (ergänzte Methode)
/**
 * Loads the set of event IDs the given customer has favorited, restricted
 * to the given candidate event IDs - a single batched query.
 *
 * @param int $customerId
 * @param int[] $eventIds
 * @return int[] Event IDs that are favorites
 */
public function getFavoriteEventIds(int $customerId, array $eventIds): array
{
    if ($eventIds === []) {
        return [];
    }

    $connection = $this->resourceConnection->getConnection();
    $select = $connection->select()
        ->from($this->resourceConnection->getTableName(self::TABLE_NAME), ['event_id'])
        ->where('customer_id = ?', $customerId)
        ->where('event_id IN (?)', $eventIds);

    return array_map('intval', $connection->fetchCol($select));
}
app/code/Mironsoft/Event/Model/Resolver/Batch/IsFavorite.php
<?php

declare(strict_types=1);

namespace Mironsoft\Event\Model\Resolver\Batch;

use Magento\Authorization\Model\UserContextInterface;
use Magento\Framework\GraphQl\Query\Resolver\BatchRequestItemInterface;
use Magento\Framework\GraphQl\Query\Resolver\BatchResponse;
use Magento\Framework\GraphQl\Query\Resolver\BatchResolverInterface;
use Mironsoft\Event\Api\Data\EventInterface;
use Mironsoft\Event\Model\ResourceModel\EventFavorite;

/**
 * Batched resolver for the is_favorite field - collapses N per-event lookups
 * into a single query per GraphQL request.
 */
class IsFavorite implements BatchResolverInterface
{
    /**
     * @param EventFavorite $eventFavoriteResource Resource model for the favorites linkage table
     */
    public function __construct(
        private readonly EventFavorite $eventFavoriteResource,
    ) {
    }

    /**
     * Resolves is_favorite for every requested event in a single batch.
     *
     * @param BatchRequestItemInterface[] $requests One entry per Event in the current selection
     * @return BatchResponse
     */
    public function resolve(array $requests): BatchResponse
    {
        $response = new BatchResponse();

        $context = $requests !== [] ? $requests[0]->getContext() : null;
        $isCustomer = $context !== null
            && $context->getUserType() === UserContextInterface::USER_TYPE_CUSTOMER
            && $context->getExtensionAttributes()->getIsCustomer();

        if (!$isCustomer) {
            foreach ($requests as $request) {
                $response->addResponse($request, false);
            }

            return $response;
        }

        $eventIds = [];
        foreach ($requests as $request) {
            /** @var EventInterface|null $event */
            $event = ($request->getValue())['model'] ?? null;
            if ($event !== null && $event->getEventId() !== null) {
                $eventIds[] = $event->getEventId();
            }
        }

        $customerId = (int) $context->getUserId();
        $favoriteEventIds = $this->eventFavoriteResource->getFavoriteEventIds($customerId, $eventIds);

        foreach ($requests as $request) {
            /** @var EventInterface|null $event */
            $event = ($request->getValue())['model'] ?? null;
            $isFavorite = $event !== null && in_array($event->getEventId(), $favoriteEventIds, true);
            $response->addResponse($request, $isFavorite);
        }

        return $response;
    }
}

Statt N einzelnen Aufrufen sammelt resolve() hier alle Anfragen des aktuellen Durchlaufs ($requests), ermittelt in genau einer Datenbankabfrage, welche der enthaltenen event_ids Favoriten sind, und ordnet die Antworten anschließend wieder den einzelnen Requests zu - die Datenbank sieht dabei nur noch eine Abfrage statt zwanzig.

Die Registrierung im Schema ändert sich nicht

extend type Event {
    is_favorite: Boolean!
        @resolver(class: "Mironsoft\\Event\\Model\\Resolver\\Batch\\IsFavorite")
}

Die @resolver-Direktive erkennt automatisch, ob die referenzierte Klasse ResolverInterface oder BatchResolverInterface implementiert - kein zusätzliches Schema-Flag nötig, nur der ausgetauschte Klassenname.

Achtung: Batch-Resolver bündeln Anfragen nur innerhalb eines einzigen GraphQL-Requests - nicht über mehrere HTTP-Requests hinweg. Eine einzelne Anfrage mit 20 Veranstaltungen wird zu einer Datenbankabfrage gebündelt, aber 20 separate HTTP-Requests mit je einer Veranstaltung lösen weiterhin 20 einzelne Datenbankabfragen aus - hier hilft nur Caching (Kapitel 21) oder Response-Batching auf Client-Seite.

Tipp: Nicht jedes N+1-Problem braucht sofort einen Batch-Resolver. Bei Feldern mit wenigen erwarteten Listeneinträgen (z. B. maximal 5) oder seltenen Aufrufen lohnt sich der zusätzliche Code oft nicht - eine gute Faustregel ist, erst bei spürbarer Antwortzeit oder bei absehbar großen Listen (wie der Veranstaltungsliste mit pageSize bis 20+) zu batchen.

Mit dem N+1-Problem gelöst geht es in Kapitel 21 um eine verwandte, aber andere Optimierungsebene: Caching ganzer GraphQL-Antworten.