Produkty Poradenství O nás Blog Kontakt English
arrow_back Zpět na blog

Řešení N+1 problému pomocí Data Loaderů a Field Resolverů

Řešení N+1 problému pomocí Data Loaderů a Field Resolverů

Druhý díl ze 7 v sérii „Production GraphQL with Netflix DGS"


GraphQL dává klientům možnost požádat přesně o ta data, která potřebují. To je jeho síla — a zároveň jeho past. Bez pečlivého návrhu backendu může jediný dotaz vyvolat stovky databázových volání. Tento článek ukazuje, jak tomu DGS data loadery a field resolvery zabraňují.

N+1 problém vizuálně

Představte si jednoduchý dotaz, který načítá objednávky s jejich produkty:

GRAPHQL
query {
    orders(pageNumber: 0, pageSize: 20) {
        items {
            id
            status
            product {    # ← This field is the problem
                name
                price
            }
        }
    }
}

Bez optimalizace se na backendu stane následující:

graph TD Q["Client Query"] --> O["1 query → fetch 20 orders"] O --> P1["query → product for order #1"] O --> P2["query → product for order #2"] O --> P3["query → product for order #3"] O --> PD["..."] O --> P20["query → product for order #20"] style Q fill:#4a9eff,stroke:#2171c7,color:#fff style O fill:#00bfa5,stroke:#00897b,color:#fff style P1 fill:#ff7043,stroke:#e64a19,color:#fff style P2 fill:#ff7043,stroke:#e64a19,color:#fff style P3 fill:#ff7043,stroke:#e64a19,color:#fff style PD fill:#ff7043,stroke:#e64a19,color:#fff style P20 fill:#ff7043,stroke:#e64a19,color:#fff

21 databázových dotazů na jeden GraphQL request.

Toto je N+1 problém: 1 dotaz na rodičovský seznam plus N dotazů pro každý podřízený záznam. Při 20 položkách to ještě jde. Při 200 položkách se 3 vnořenými poli se dostáváte na 601 dotazů. Váš databázový tým z toho nadšený nebude.

Field Resolvery: Lazy loading s @DgsData

Prvním dílem skládanky je field resolver. Místo toho, aby se při načtení rodičovského objektu eager-loadovaly všechny fieldy, DGS umožňuje resolvovat pole na vyžádání — jen když si je klient skutečně vyžádá.

Java
@DgsComponent
@RequiredArgsConstructor
public class OrderProductResolver {

    private final ProductService productService;

    @DgsData(parentType = "Order", field = "product")
    public Mono<Product> resolveProduct(DgsDataFetchingEnvironment dfe) {
        // DGS gives us the parent object
        Order order = dfe.getSource();
        String productId = order.getProductId();

        if (productId == null) {
            return Mono.empty();
        }

        return productService.findById(productId);
    }
}

Klíčové koncepty:

  • @DgsData(parentType = "Order", field = "product") — říká DGS: „když se někdo zeptá na Order.product, zavolej tuto metodu."
  • DgsDataFetchingEnvironment — poskytuje přístup k rodičovskému objektu (dfe.getSource()), argumentům aktuálního pole a — což je klíčové — k data loaderům.
  • Lazy evaluation — pokud klient pole product nepožaduje, tato metoda se nikdy nespustí. Žádné zbytečné dotazy.

Tím se řeší problém „zbytečné práce", ale ne N+1 problém. Každá objednávka stále spouští vlastní dotaz na produkt. A právě zde přicházejí na řadu data loadery.

Data Loadery: Batchování s MappedBatchLoader

Data loader shromažďuje jednotlivé požadavky na načtení dat v rámci jednoho GraphQL execution a spojí je do jednoho dávkového dotazu. Místo 20 individuálních volání findById dostanete jedno volání findByIds.

DGS podporuje dva typy:

  • BatchLoader<K, V> — vrací List<V> ve stejném pořadí jako vstupní klíče
  • MappedBatchLoader<K, V> — vrací Map<K, V>, kde klíče odpovídají vstupu

MappedBatchLoader je téměř vždy lepší volba. Elegantně zvládá chybějící záznamy (klíč prostě v mapě není) a nevyžaduje udržování pořadí mezi vstupem a výstupem. Zde je reálný vzor:

Java
@DgsDataLoader(name = "products")
@RequiredArgsConstructor
public class ProductDataLoader implements MappedBatchLoader<String, Product> {

    private final ProductRepository productRepository;

    @Override
    public CompletionStage<Map<String, Product>> load(Set<String> productIds) {
        return productRepository.findByIds(new ArrayList<>(productIds))
                .collectList()
                .map(products -> products.stream()
                        .collect(Collectors.toMap(Product::getId, Function.identity())))
                .toFuture();
    }
}

Pojďme si to rozebrat:

  1. @DgsDataLoader(name = "products") — registruje tento loader v DGS pod názvem "products". Field resolvery na tento název odkazují, když chtějí loader použít.
  2. MappedBatchLoader<String, Product> — typ klíče je String (ID produktu), typ hodnoty je Product.
  3. load(Set<String> productIds) — DGS tuto metodu zavolá jednou za celý GraphQL execution, se všemi ID produktů sesbíranými ze všech field resolverů, které tento loader použily.
  4. Vrací CompletionStage<Map<String, Product>> — mapa přiřazuje každému ID jeho načtený produkt. Pokud produkt nebyl nalezen, prostě v mapě chybí (klient dostane null pro dané pole).

Volání .toFuture() převádí reaktivní Mono na CompletionStage, který DataLoader API očekává. Toto je standardní most mezi reaktivním vnitřkem a kontraktem DataLoaderu.

Propojení dohromady

Nyní field resolver místo přímého volání služby použije data loader:

Java
@DgsData(parentType = "Order", field = "product")
public Mono<Product> resolveProduct(DgsDataFetchingEnvironment dfe) {
    Order order = dfe.getSource();
    String productId = order.getProductId();

    if (productId == null || productId.isBlank()) {
        return Mono.empty();
    }

    // Get the data loader by its registered name
    DataLoader<String, Product> productLoader = dfe.getDataLoader("products");

    // Queue this ID for batching — doesn't execute immediately
    return Mono.fromCompletionStage(productLoader.load(productId));
}

Kouzlo se odehrává v productLoader.load(productId). Tato metoda nespustí dotaz okamžitě. Místo toho zařadí ID produktu do fronty. Poté, co jsou zavolány všechny field resolvery pro aktuální úroveň execution, DGS odešle nahromaděná ID do metody ProductDataLoader.load() v jedné dávce.

Výsledek:

graph TD Q["Client Query"] --> O["1 query → fetch 20 orders"] O --> DL["DataLoader collects all 20 product IDs"] DL --> B["1 query → batch load all products"] style Q fill:#4a9eff,stroke:#2171c7,color:#fff style O fill:#00bfa5,stroke:#00897b,color:#fff style DL fill:#7c4dff,stroke:#5e35b1,color:#fff style B fill:#00bfa5,stroke:#00897b,color:#fff

2 databázové dotazy celkem — bez ohledu na velikost stránky.

Proč MappedBatchLoader místo BatchLoader

BatchLoader<K, V> vyžaduje, abyste vrátili výsledky přesně ve stejném pořadí jako vstupní klíče. Tím vzniká subtilní, ale nebezpečný kontrakt:

Java
// BatchLoader — must maintain order, handle missing entries with nulls
@Override
public CompletionStage<List<Product>> load(List<String> keys) {
    return productRepository.findByIds(keys)
            .collectList()
            .map(products -> {
                // Must return products in the SAME ORDER as keys
                // Must include null for missing products
                Map<String, Product> byId = products.stream()
                        .collect(Collectors.toMap(Product::getId, Function.identity()));
                return keys.stream()
                        .map(byId::get)  // null if not found
                        .toList();
            })
            .toFuture();
}

MappedBatchLoader tomuto zcela předchází. Vraťte mapu a framework se postará o přiřazení:

Java
// MappedBatchLoader — just return what you found
@Override
public CompletionStage<Map<String, Product>> load(Set<String> keys) {
    return productRepository.findByIds(new ArrayList<>(keys))
            .collectList()
            .map(products -> products.stream()
                    .collect(Collectors.toMap(Product::getId, Function.identity())))
            .toFuture();
}

Méně kódu, žádné chyby s řazením, žádné doplňování null hodnot. V praxi je MappedBatchLoader správná volba pro téměř každý use case.

Stránkování: vzor *Page

GraphQL nabízí dva styly stránkování: Relay-style cursor-based stránkování (pomocí Connections a Edges) a offset-based stránkování. Oba jsou validní; správná volba závisí na vašich datech:

Offset-basedCursor-based
Ideální proAdministrátorské tabulky, výsledky vyhledáváníInfinite scroll, real-time feedy
Složitost na straně klientaNízká — stačí předat page/sizeVyšší — musíte sledovat kurzory
Podpora „skok na stránku 5"AnoNe
Zvládání vkládání/mazáníMůže přeskočit nebo zduplikovat položkyStabilní — kurzor je záložka

Pro většinu CRUD aplikací s vyhledáváním a řazením je offset-based stránkování jednodušší a dostačující. Zde je vzor:

Schema

GRAPHQL
type ProductPage {
    items: [Product]
    totalPages: Int
    currentPage: Int
    totalElements: Int
}

type Query {
    products(
        searchText: String,
        category: ProductCategory,
        pageNumber: Int!,
        pageSize: Int!,
        sortBy: String,
        sortOrder: SortOrder
    ): ProductPage
}

Data Fetcher

Java
@DgsQuery
public Mono<ProductPage> products(
        @InputArgument String searchText,
        @InputArgument ProductCategory category,
        @InputArgument Integer pageNumber,
        @InputArgument Integer pageSize,
        @InputArgument String sortBy,
        @InputArgument SortOrder sortOrder) {

    // Default pagination parameters
    int page = pageNumber != null ? pageNumber : 0;
    int size = pageSize != null ? pageSize : 10;
    String sort = sortBy != null ? sortBy : "name";

    return productService.search(searchText, category, page, size, sort, sortOrder);
}

Obalovací typ *Page se opakuje pro každou doménovou entitu (ProductPage, OrderPage, UserPage). Je to záměrné — GraphQL nepodporuje generika, takže každý stránkovaný typ potřebuje vlastní wrapper. Výhodou je, že každý page typ může obsahovat doménově specifická agregační pole (např. ProductPage může zahrnovat averagePrice).

Více Data Loaderů v jednom requestu

Komplexní dotaz může zasáhnout několik data loaderů současně:

GRAPHQL
query {
    orders(pageNumber: 0, pageSize: 20) {
        items {
            id
            status
            product {         # → ProductDataLoader
                name
                price
            }
            customer {        # → CustomerDataLoader
                name
            }
            warehouse {       # → WarehouseDataLoader
                location
            }
        }
    }
}

Každý field resolver zařadí ID do svého příslušného data loaderu. DGS odešle všechny tři dávky poté, co se field resolvery vrátí:

graph TD Q["Client Query"] --> O["1 query → fetch 20 orders"] O --> R1["Field resolver: product"] O --> R2["Field resolver: customer"] O --> R3["Field resolver: warehouse"] R1 --> DL1["ProductDataLoader
1 batch query"] R2 --> DL2["CustomerDataLoader
1 batch query"] R3 --> DL3["WarehouseDataLoader
1 batch query"] style Q fill:#4a9eff,stroke:#2171c7,color:#fff style O fill:#00bfa5,stroke:#00897b,color:#fff style R1 fill:#7c4dff,stroke:#5e35b1,color:#fff style R2 fill:#7c4dff,stroke:#5e35b1,color:#fff style R3 fill:#7c4dff,stroke:#5e35b1,color:#fff style DL1 fill:#00bfa5,stroke:#00897b,color:#fff style DL2 fill:#00bfa5,stroke:#00897b,color:#fff style DL3 fill:#00bfa5,stroke:#00897b,color:#fff

4 dotazy celkem — O(fields), nikoli O(items x fields). Přidání dalších objednávek nezvyšuje počet dotazů.

Složitost je lineární v počtu různých polí, nikoli v počtu položek. Přidání dalších objednávek nezvyšuje počet dotazů.

Časté chyby

1. Přímé volání služby místo použití data loaderu

Java
// WRONG — bypasses batching, causes N+1
@DgsData(parentType = "Order", field = "product")
public Mono<Product> resolveProduct(DgsDataFetchingEnvironment dfe) {
    Order order = dfe.getSource();
    return productService.findById(order.getProductId());  // N queries!
}

// CORRECT — uses data loader for batching
@DgsData(parentType = "Order", field = "product")
public Mono<Product> resolveProduct(DgsDataFetchingEnvironment dfe) {
    Order order = dfe.getSource();
    DataLoader<String, Product> loader = dfe.getDataLoader("products");
    return Mono.fromCompletionStage(loader.load(order.getProductId()));
}

2. Chybějící null check na ID rodiče

Java
// WRONG — NullPointerException when productId is null
DataLoader<String, Product> loader = dfe.getDataLoader("products");
return Mono.fromCompletionStage(loader.load(order.getProductId()));

// CORRECT — guard against null
String productId = order.getProductId();
if (productId == null || productId.isBlank()) {
    return Mono.empty();
}
DataLoader<String, Product> loader = dfe.getDataLoader("products");
return Mono.fromCompletionStage(loader.load(productId));

3. Použití BatchLoader když mohou chybět výsledky

Pokud váš databázový dotaz nevrací výsledek pro každé ID (např. smazané záznamy), BatchLoader špatně přiřadí klíče a hodnoty. Použijte místo něj MappedBatchLoader — chybějící klíče prostě v mapě nejsou.

Co bude dál

Data loadery brání kolapsu výkonnostního základu, ale neřeší další zásadní otázku: kdo smí tyto dotazy volat? Ve třetím díle se budeme věnovat zabezpečení vašeho GraphQL API — autentizaci, autorizaci, sanitizaci chyb a ochraně před zneužívajícími dotazy.


Titulní foto: Aakash Dhage na Unsplash.

Další z blogu