Loslegen
Javaneer
Zurück zur Stufe
Modul 11·GraphQL mit Spring

Das N+1-Problem & Batching

Die feldweise Auflösung von GraphQL macht N+1-Abfragen gefährlich leicht; @BatchMapping und DataLoader fassen sie zu einer effizienten Abfrage zusammen.

15 Min. LesezeitExperte

Deutsche Übersetzung in Arbeit

Diese Lektion ist noch nicht ins Deutsche übersetzt und wird daher auf Englisch angezeigt. Der Rest der Seite ist vollständig lokalisiert.

Auf dieser Seite

Per-field resolution, the source of GraphQL's flexibility, is also the source of its most notorious performance trap: the N+1 problem. A single innocent-looking client query can fan out into hundreds of database queries on the server. This lesson shows exactly how it happens and the two Spring tools - @BatchMapping and DataLoader - that collapse the fan-out back into one efficient fetch.

How one query becomes N+1

Recall that a nested field resolver runs once per parent object. Now a client asks for a list of books with each author:

query { books { title author { name } } }

The engine resolves it like this:

books()              → 1 query: SELECT * FROM books           (returns 100 books)
author(book #1)      → 1 query: SELECT * FROM authors WHERE id = ?
author(book #2)      → 1 query: SELECT * FROM authors WHERE id = ?
...
author(book #100)    → 1 query: SELECT * FROM authors WHERE id = ?

That's 1 + 100 = 101 queries for one client request - the "N+1". With nesting (books → authors → their other books) it compounds into thousands. The client wrote one clean query; the server quietly melted.

It's invisible in development

With 5 books in your test database, N+1 is 6 queries - fast, unnoticed. In production with 500 books and a loaded database, it's 501 queries and a timeout. Like most batch and data problems, N+1 hides until real volume exposes it. Watch your SQL logs (or use Hibernate's statistics) when a resolver returns a list.

Fix 1: @BatchMapping

Spring's simplest fix: instead of a resolver that fetches one author for one book, write a @BatchMapping that fetches all authors for all books in the chunk, in one call. Spring collects the parent books, invokes your method once with the whole list, and you return a map from book to author:

    @BatchMapping                                   // resolves Book.author for ALL books at once
    Map<Book, Author> author(List<Book> books) {
        Set<Long> authorIds = books.stream()
            .map(Book::authorId).collect(toSet());
        Map<Long, Author> byId = authorService.findAllByIds(authorIds)   // ONE query
            .stream().collect(toMap(Author::id, a -> a));
        return books.stream()
            .collect(toMap(b -> b, b -> byId.get(b.authorId())));
    }

Now the query is 2 queries total: one for the books, one for all their authors (WHERE id IN (...)). The method name author still maps to Book.author - you've only changed how it's fetched, from per-item to batched. This is the go-to fix in Spring for GraphQL.

Fix 2: DataLoader

DataLoader is the more general mechanism (from the GraphQL spec) that @BatchMapping builds on. It defers each field request, collects all the keys requested during one tick of query execution, then calls a single batch-load function - and it caches within the request so the same author fetched twice is loaded once. You register a batch loader and reference it in a resolver:

    @SchemaMapping
    CompletableFuture<Author> author(Book book, DataLoader<Long, Author> loader) {
        return loader.load(book.authorId());   // deferred; batched behind the scenes
    }

Use @BatchMapping for the common case (it's terser); reach for an explicit DataLoader when you need its per-request caching or want to share one loader across several fields.

A runner making one trip to the stockroom

The N+1 resolver is a shop assistant who, handed a list of 100 orders, walks to the stockroom and back once per order - 100 exhausting round-trips. @BatchMapping and DataLoader turn them into a smart runner who collects all 100 slips, walks to the stockroom once, grabs everything in a single armful (SELECT ... WHERE id IN (...)), and hands each order its item. Same result, one trip instead of a hundred - and DataLoader even remembers items already fetched so it never grabs the same one twice.

Diagnose and fix

A BookVault GraphQL query { members { name currentLoans { book { title } } } } returns 200 members and is timing out. The SQL log shows 400+ queries. Explain the query count, then describe how you'd fix it with batching.

Why does a GraphQL query for a list of books with their authors cause an N+1 problem?

Key takeaways

  • Per-field resolution runs a nested resolver once per parent, so a list query becomes 1 + N database queries - the N+1 problem.
  • N+1 is invisible on tiny test data and causes timeouts at production volume; watch SQL logs when a resolver returns a list.
  • @BatchMapping replaces a per-item resolver with one that receives the whole list of parents and returns a Map, fetching all children in one query.
  • DataLoader is the general spec mechanism @BatchMapping builds on: it defers and batches keys per execution tick and caches within the request.
  • Any nested resolver that returns data for a list of parents is a latent N+1 - batch it with @BatchMapping or a DataLoader.
War diese Lektion hilfreich?
Diese Seite auf GitHub bearbeiten