Batch LoaderによるN+1問題の解消
@BatchMappingとDataLoader形式の一括解決を使い、N+1問題を解消します。
「Batch LoaderによるN+1問題の解消」はCoddyKit上の無料Spring Boot 4 Complete Guideレッスンです。 これはレッスン3/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはSpring Boot 4 Complete Guide学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 Spring Boot 4 Complete Guideコースには全4レッスンが含まれています。
このレッスンの一部はまだ翻訳されておらず、英語で表示されています。
What the N+1 Problem Looks Like
In GraphQL, fields are resolved lazily. When a query asks for a list of objects and then a nested field on each one, Spring for GraphQL calls the nested resolver once per parent.
- 1 query to fetch
Nauthors Nextra queries to fetch each author'sbooks
That is N+1 database round-trips. With 100 authors you fire 101 queries. This is the single biggest performance killer in naive GraphQL APIs, and batching is the cure.
The Naive @SchemaMapping Resolver
Here is the per-parent resolver that causes N+1. For every Author in the result set, Spring invokes books separately, each issuing its own SQL query.
It is correct, but it does not scale. Notice there is no batching at all: one author in, one DB call out.
@Controller
public class AuthorController {
private final BookRepository books;
public AuthorController(BookRepository books) {
this.books = books;
}
// Called once PER author -> N+1
@SchemaMapping(typeName = "Author")
public List<Book> books(Author author) {
return books.findByAuthorId(author.id());
}
}The Batching Idea
The fix is to collect all parent keys first, then resolve them in a single batched call.
- Gather the IDs of all 100 authors
- Issue one query:
SELECT * FROM book WHERE author_id IN (...) - Group the results back per author
This turns N+1 into exactly 2 queries. Spring for GraphQL offers two ways to express this: the high-level @BatchMapping annotation, and the lower-level DataLoader API.
@BatchMapping Returning a Map
@BatchMapping is the easiest fix. Instead of a single parent, your method receives a List of parents and returns a Map<Parent, Value> keyed by parent.
Spring collects every Author needed for the field, calls this method once, then distributes each entry to the right place automatically.
@Controller
public class AuthorController {
private final BookRepository books;
public AuthorController(BookRepository books) {
this.books = books;
}
@BatchMapping(typeName = "Author", field = "books")
public Map<Author, List<Book>> books(List<Author> authors) {
Set<Long> ids = authors.stream()
.map(Author::id)
.collect(Collectors.toSet());
Map<Long, List<Book>> byAuthorId = books.findByAuthorIdIn(ids).stream()
.collect(Collectors.groupingBy(Book::authorId));
return authors.stream().collect(Collectors.toMap(
a -> a,
a -> byAuthorId.getOrDefault(a.id(), List.of())
));
}
}@BatchMapping Returning a List
There is a second, even shorter form: return a List that is positionally aligned with the input list. Element i of the result must correspond to author i of the input.
Use the Map form when ordering is awkward, and the List form when you can guarantee one result slot per input in the same order.
@BatchMapping(typeName = "Author", field = "books")
public List<List<Book>> books(List<Author> authors) {
Map<Long, List<Book>> byAuthorId = books.findByAuthorIdIn(
authors.stream().map(Author::id).toList())
.stream()
.collect(Collectors.groupingBy(Book::authorId));
// Same order as the input list
return authors.stream()
.map(a -> byAuthorId.getOrDefault(a.id(), List.of()))
.toList();
}Inferring the Field Name
If you omit the field attribute, Spring derives it from the method name. A method named books on type Author maps to Author.books.
You only need typeName when the controller is not already bound to a type, and field only when the method name differs from the schema field. The example below relies entirely on inference.
@Controller
public class AuthorController {
// typeName "Author" inferred is NOT automatic here, so set it;
// field "books" IS inferred from the method name.
@BatchMapping(typeName = "Author")
public Map<Author, List<Book>> books(List<Author> authors) {
// ... batched lookup ...
return Map.of();
}
}How Map Grouping Works in Plain Java
The heart of every batch loader is the same plain-Java move: take a flat list of children, then groupingBy their foreign key. This snippet has no Spring at all so you can see the mechanic clearly.
Run it: one pass over the books builds a map from author id to that author's books, exactly what the batch resolver returns.
import java.util.*;
import java.util.stream.*;
public class Main {
record Book(long authorId, String title) {}
public static void main(String[] args) {
List<Book> all = List.of(
new Book(1, "Dune"),
new Book(2, "1984"),
new Book(1, "Messiah"),
new Book(3, "It")
);
Map<Long, List<Book>> byAuthor = all.stream()
.collect(Collectors.groupingBy(Book::authorId));
for (long id : List.of(1L, 2L, 3L)) {
List<Book> books = byAuthor.getOrDefault(id, List.of());
System.out.println("author " + id + " -> " + books.size() + " book(s)");
}
}
}The Lower-Level DataLoader
Under the hood @BatchMapping uses a DataLoader from the java-dataloader library. You can register one yourself for full control, for example to share a loader across multiple fields or add per-request caching.
Register it via a BatchLoaderRegistry, which Spring auto-configures and injects.
@Configuration
public class DataLoaderConfig {
public DataLoaderConfig(BatchLoaderRegistry registry, BookRepository books) {
registry.forTypePair(Long.class, List.class)
.registerMappedBatchLoader((authorIds, env) -> {
Map<Long, List<Book>> grouped = books.findByAuthorIdIn(authorIds)
.stream()
.collect(Collectors.groupingBy(Book::authorId));
return Mono.just(authorIds.stream().collect(
Collectors.toMap(id -> id, id -> grouped.getOrDefault(id, List.of()))
));
});
}
}Consuming a DataLoader in a Resolver
Once registered, inject the loader into a resolver with @SchemaMapping. You return a CompletableFuture from load(key). Spring batches every load call made during the request into a single invocation of your batch function.
This is the manual equivalent of @BatchMapping, useful when one loader feeds several fields.
@SchemaMapping(typeName = "Author")
public CompletableFuture<List<Book>> books(
Author author,
DataLoader<Long, List<Book>> loader) {
return loader.load(author.id());
}Don't Re-Introduce N+1 Inside the Batch
A subtle trap: the batch method runs once, but if you loop over the parents and call the repository inside that loop, you have just rebuilt N+1 in disguise.
- Wrong:
authors.forEach(a -> books.findByAuthorId(a.id())) - Right: one
findByAuthorIdIn(allIds)call, then group in memory
The whole point is a single bulk query, so always pass the full set of keys to one repository method.
// ANTI-PATTERN: batched signature, but N queries inside
@BatchMapping(typeName = "Author")
public Map<Author, List<Book>> books(List<Author> authors) {
return authors.stream().collect(Collectors.toMap(
a -> a,
a -> books.findByAuthorId(a.id()) // <-- one query each = N+1 again!
));
}Defining the IN Query
Batching only works if your data layer can fetch many keys at once. With Spring Data JPA you expose a derived query that accepts a collection and translates to a SQL IN clause.
This single repository method is what powers every batch loader above. Keep the collection bounded; extremely large IN lists can be slow, so very big batches may need chunking.
public interface BookRepository extends JpaRepository<Book, Long> {
// SELECT * FROM book WHERE author_id IN (:ids)
List<Book> findByAuthorIdIn(Collection<Long> ids);
}Quick Check
You added @BatchMapping for Author.books, but profiling still shows N+1 queries. Which cause is most likely?
Recap
You eliminated the N+1 problem in Spring for GraphQL:
- Per-parent
@SchemaMappingresolvers fire one query per parent: N+1. @BatchMappingreceives aListof parents and returns aMap<Parent, Value>or a position-alignedList, collapsing it to 2 queries.- The core mechanic is a single
findByAuthorIdIn(ids)bulk query plusCollectors.groupingBy. - For full control, register a
DataLoaderviaBatchLoaderRegistryand return aCompletableFuturefrom your resolver. - Never loop and query per parent inside the batch method, or you reintroduce N+1.
よくある質問
「Batch LoaderによるN+1問題の解消」レッスンは無料ですか?
はい。「Batch LoaderによるN+1問題の解消」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Spring Boot 4 Complete Guideコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Spring Boot 4 Complete Guideコースには全4レッスンが含まれています。
「Batch LoaderによるN+1問題の解消」で何を学びますか?
@BatchMappingとDataLoader形式の一括解決を使い、N+1問題を解消します。 ブラウザで直接実行するハンズオンコードでSpring Boot 4 Complete Guideを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。
Spring Boot 4 Complete Guideを始めるのに経験は必要ですか?
事前経験は必要ありません。CoddyKitのSpring Boot 4 Complete Guideは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン3/4です。
「Batch LoaderによるN+1問題の解消」レッスンにはどのくらい時間がかかりますか?
ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。
このSpring Boot 4 Complete Guideレッスンでコードを書いて実行できますか?
はい。すべてのSpring Boot 4 Complete Guideレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。
このコースのすべてのレッスン
- スキーマファースト設計と型マッピング
- データフェッチャーと引数バインディング
- Batch LoaderによるN+1問題の解消
- サブスクリプション、エラー、スキーマセキュリティ