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.
/**
* 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));
}<?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.