Ř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:
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í:
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á.
@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á naOrder.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
productnepož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íčeMappedBatchLoader<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:
@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:
@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.MappedBatchLoader<String, Product>— typ klíče jeString(ID produktu), typ hodnoty jeProduct.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.- 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 dostanenullpro 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:
@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:
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:
// 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í:
// 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-based | Cursor-based | |
|---|---|---|
| Ideální pro | Administrátorské tabulky, výsledky vyhledávání | Infinite scroll, real-time feedy |
| Složitost na straně klienta | Nízká — stačí předat page/size | Vyšší — musíte sledovat kurzory |
| Podpora „skok na stránku 5" | Ano | Ne |
| Zvládání vkládání/mazání | Může přeskočit nebo zduplikovat položky | Stabilní — 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
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
@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ě:
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í:
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
// 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
// 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.


