0Pricing
Spring Boot 4 Complete Guide · 课时

使用批量加载器解决 N+1 问题

使用 @BatchMapping 和 DataLoader 风格的批量解析,消除 N+1 问题。

使用批量加载器解决 N+1 问题 是 CoddyKit 上的免费 Spring Boot 4 Complete Guide 课时。 这是第 3 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 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 N authors
  • N extra queries to fetch each author's books

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 @SchemaMapping resolvers fire one query per parent: N+1.
  • @BatchMapping receives a List of parents and returns a Map<Parent, Value> or a position-aligned List, collapsing it to 2 queries.
  • The core mechanic is a single findByAuthorIdIn(ids) bulk query plus Collectors.groupingBy.
  • For full control, register a DataLoader via BatchLoaderRegistry and return a CompletableFuture from your resolver.
  • Never loop and query per parent inside the batch method, or you reintroduce N+1.

常见问题解答

「使用批量加载器解决 N+1 问题」课时是免费的吗?

是的 — 「使用批量加载器解决 N+1 问题」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Spring Boot 4 Complete Guide 课程的其余内容,请升级到 CoddyKit PRO。 Spring Boot 4 Complete Guide 课程共包含 4 节课。

「使用批量加载器解决 N+1 问题」这节课中我会学到什么?

使用 @BatchMapping 和 DataLoader 风格的批量解析,消除 N+1 问题。 你通过在浏览器中直接运行的动手代码来练习 Spring Boot 4 Complete Guide,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Spring Boot 4 Complete Guide 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Spring Boot 4 Complete Guide 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 3 节课,共 4 节。

「使用批量加载器解决 N+1 问题」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Spring Boot 4 Complete Guide 课中编写并运行代码吗?

能。每节 Spring Boot 4 Complete Guide 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 模式优先设计与类型映射
  2. 数据获取器与参数绑定
  3. 使用批量加载器解决 N+1 问题
  4. 订阅、错误与模式安全
← 返回 Spring Boot 4 Complete Guide