# Phỏng vấn Spring GraphQL: Resolver, DataLoader và Giải pháp cho Vấn đề N+1 > Chuẩn bị cho phỏng vấn Spring GraphQL với hướng dẫn đầy đủ này. Resolver, DataLoader, xử lý vấn đề N+1, mutation và các thực hành tốt nhất cho câu hỏi kỹ thuật. - Published: 2026-03-25 - Updated: 2026-05-04 - Author: SharpSkill - Tags: spring graphql, dataloaders, n+1 problem, interview, graphql java - Reading time: 14 min --- Spring for GraphQL đơn giản hóa việc tích hợp GraphQL trong các ứng dụng Spring Boot. Công nghệ này đã trở nên thiết yếu cho các API hiện đại, và các câu hỏi phỏng vấn về chủ đề này ngày càng phổ biến. Hướng dẫn này bao gồm các khái niệm cốt lõi: resolver, DataLoader, vấn đề N+1 và các pattern nâng cao. > **Trọng tâm phỏng vấn** > > Nhà tuyển dụng đặc biệt kiểm tra hiểu biết về vấn đề N+1 và cách sử dụng DataLoader. Hai chủ đề này chiếm 60% câu hỏi trong các buổi phỏng vấn Spring GraphQL. ## Spring for GraphQL là gì? Spring for GraphQL là người kế nhiệm chính thức của GraphQL Java Spring, được tích hợp tự nhiên vào framework Spring từ phiên bản 2.7. Sự tích hợp này mang lại nhiều lợi thế: hỗ trợ annotation Spring, tích hợp với Spring Security và sử dụng minh bạch WebFlux hoặc MVC. Cấu hình cơ bản chỉ yêu cầu phụ thuộc `spring-boot-starter-graphql` và một schema GraphQL. ```java // build.gradle.kts dependencies { // Spring GraphQL starter với Spring MVC implementation("org.springframework.boot:spring-boot-starter-graphql") implementation("org.springframework.boot:spring-boot-starter-web") // Cho các test GraphQL testImplementation("org.springframework.graphql:spring-graphql-test") } ``` Schema GraphQL nằm trong `src/main/resources/graphql/` với phần mở rộng `.graphqls`. ```graphql # schema.graphqls type Query { # Lấy bài viết theo định danh article(id: ID!): Article # Danh sách bài viết phân trang articles(page: Int = 0, size: Int = 10): [Article!]! } type Article { id: ID! title: String! content: String! # Quan hệ với tác giả author: Author! # Danh sách bình luận comments: [Comment!]! createdAt: String! } type Author { id: ID! name: String! email: String! # Bài viết do tác giả này viết articles: [Article!]! } type Comment { id: ID! content: String! author: Author! createdAt: String! } ``` Kiến trúc này khai báo các kiểu và mối quan hệ của chúng. GraphQL tự động tạo tài liệu và xác thực các truy vấn ở phía máy chủ. ## Resolver Spring GraphQL hoạt động như thế nào? Resolver là trái tim của việc thực thi GraphQL. Mỗi trường schema có thể có một resolver riêng. Spring sử dụng annotation `@QueryMapping` cho các truy vấn gốc và `@SchemaMapping` cho các quan hệ. ```java // ArticleController.java @Controller public class ArticleController { private final ArticleRepository articleRepository; private final AuthorRepository authorRepository; public ArticleController(ArticleRepository articleRepository, AuthorRepository authorRepository) { this.articleRepository = articleRepository; this.authorRepository = authorRepository; } // Resolver cho Query.article(id) @QueryMapping public Article article(@Argument Long id) { return articleRepository.findById(id) .orElseThrow(() -> new ArticleNotFoundException(id)); } // Resolver cho Query.articles(page, size) @QueryMapping public List
articles(@Argument int page, @Argument int size) { Pageable pageable = PageRequest.of(page, size); return articleRepository.findAll(pageable).getContent(); } // Resolver cho Article.author - được gọi cho mỗi bài viết @SchemaMapping(typeName = "Article", field = "author") public Author author(Article article) { return authorRepository.findById(article.getAuthorId()) .orElseThrow(() -> new AuthorNotFoundException(article.getAuthorId())); } } ``` Resolver `author` được thực thi cho mỗi bài viết được trả về. Kiến trúc linh hoạt này tải dữ liệu theo yêu cầu nhưng giới thiệu vấn đề N+1. > **Bẫy phỏng vấn cổ điển** > > Ứng viên triển khai resolver `@SchemaMapping` mà không đề cập đến vấn đề N+1 thể hiện sự hiểu biết không đầy đủ về GraphQL. Nhà tuyển dụng luôn mong đợi phân tích này. ## Vấn đề N+1 trong GraphQL là gì? Vấn đề N+1 xảy ra khi một truy vấn GraphQL kích hoạt N truy vấn bổ sung để tải các quan hệ. Pattern phá hoại này xảy ra một cách có hệ thống với các resolver cơ bản. Xem xét một truy vấn lấy 50 bài viết với tác giả của chúng: ```graphql # Truy vấn GraphQL query { articles(size: 50) { id title author { name } } } ``` Với resolver trước đó, truy vấn này thực thi: - 1 truy vấn cho 50 bài viết - 50 truy vấn để tải từng tác giả riêng lẻ ```sql -- Truy vấn 1: lấy bài viết SELECT * FROM articles LIMIT 50 -- Truy vấn 2-51: từng tác giả riêng lẻ SELECT * FROM authors WHERE id = 1 SELECT * FROM authors WHERE id = 2 SELECT * FROM authors WHERE id = 3 -- ... 47 truy vấn bổ sung ``` Việc nhân số truy vấn này làm giảm hiệu suất đáng kể. Một endpoint phản hồi trong 50ms có thể nhảy lên 2 giây với N+1. ## DataLoader hoạt động như thế nào? DataLoader nhóm các truy vấn riêng lẻ thành các yêu cầu hàng loạt. Thay vì tải từng tác giả riêng biệt, DataLoader thu thập tất cả các ID được yêu cầu và thực hiện một truy vấn duy nhất. ```java // AuthorBatchLoader.java @Component public class AuthorBatchLoader { private final AuthorRepository authorRepository; public AuthorBatchLoader(AuthorRepository authorRepository) { this.authorRepository = authorRepository; } // Phương thức tải hàng loạt public Mono> loadAuthors(Set authorIds) { // Truy vấn duy nhất cho tất cả tác giả List authors = authorRepository.findAllById(authorIds); // Chuyển đổi thành Map để tra cứu nhanh Map authorMap = authors.stream() .collect(Collectors.toMap(Author::getId, Function.identity())); return Mono.just(authorMap); } } ``` Việc đăng ký DataLoader được thực hiện thông qua `BatchLoaderRegistry` trong một cấu hình chuyên dụng. ```java // GraphQLConfig.java @Configuration public class GraphQLConfig { @Bean public BatchLoaderRegistry batchLoaderRegistry( AuthorBatchLoader authorBatchLoader) { return registry -> { // Đăng ký DataLoader cho tác giả registry.forTypePair(Long.class, Author.class) .registerMappedBatchLoader((authorIds, env) -> authorBatchLoader.loadAuthors(authorIds)); }; } } ``` Resolver được sửa đổi giờ đây sử dụng DataLoader thay vì truy cập trực tiếp: ```java // ArticleController.java @Controller public class ArticleController { private final ArticleRepository articleRepository; public ArticleController(ArticleRepository articleRepository) { this.articleRepository = articleRepository; } @QueryMapping public List
articles(@Argument int page, @Argument int size) { Pageable pageable = PageRequest.of(page, size); return articleRepository.findAll(pageable).getContent(); } // Resolver được tối ưu với DataLoader @SchemaMapping(typeName = "Article", field = "author") public CompletableFuture author( Article article, DataLoader authorDataLoader) { // DataLoader tự động nhóm các cuộc gọi return authorDataLoader.load(article.getAuthorId()); } } ``` Với cách tiếp cận này, cùng truy vấn cho 50 bài viết chỉ thực thi 2 truy vấn SQL: ```sql -- Truy vấn 1: lấy bài viết SELECT * FROM articles LIMIT 50 -- Truy vấn 2: tất cả tác giả cùng một lúc SELECT * FROM authors WHERE id IN (1, 2, 3, 4, 5, ..., 50) ``` ## Cách triển khai DataLoader với context? DataLoader nâng cao đôi khi yêu cầu context bổ sung, chẳng hạn như định danh tenant cho ứng dụng đa tenant hoặc bộ lọc bảo mật. ```java // SecuredAuthorBatchLoader.java @Component public class SecuredAuthorBatchLoader { private final AuthorRepository authorRepository; private final SecurityContextHolder securityContext; public SecuredAuthorBatchLoader(AuthorRepository authorRepository, SecurityContextHolder securityContext) { this.authorRepository = authorRepository; this.securityContext = securityContext; } public Mono> loadAuthorsForTenant( Set authorIds, BatchLoaderEnvironment env) { // Lấy context GraphQL GraphQLContext context = env.getContext(); String tenantId = context.get("tenantId"); // Truy vấn được lọc theo tenant List authors = authorRepository .findAllByIdInAndTenantId(authorIds, tenantId); Map authorMap = authors.stream() .collect(Collectors.toMap(Author::getId, Function.identity())); return Mono.just(authorMap); } } ``` Cấu hình context được thực hiện thông qua interceptor WebGraphQL: ```java // TenantInterceptor.java @Component public class TenantInterceptor implements WebGraphQlInterceptor { @Override public Mono intercept( WebGraphQlRequest request, Chain chain) { // Trích xuất tenant từ headers String tenantId = request.getHeaders() .getFirst("X-Tenant-ID"); // Thêm vào context GraphQL request.configureExecutionInput((input, builder) -> builder.graphQLContext(ctx -> ctx.put("tenantId", tenantId) ).build() ); return chain.next(request); } } ``` ## Sự khác biệt giữa @QueryMapping và @SchemaMapping là gì? Câu hỏi phỏng vấn cổ điển này xác minh sự hiểu biết về phân cấp resolver. | Annotation | Sử dụng | Tương đương | |------------|---------|-------------| | `@QueryMapping` | Trường gốc của kiểu Query | `@SchemaMapping(typeName = "Query")` | | `@MutationMapping` | Trường gốc của kiểu Mutation | `@SchemaMapping(typeName = "Mutation")` | | `@SubscriptionMapping` | Subscription thời gian thực | `@SchemaMapping(typeName = "Subscription")` | | `@SchemaMapping` | Tất cả trường của bất kỳ kiểu nào | Dạng tổng quát | ```java // EquivalenceDemo.java @Controller public class EquivalenceDemo { // Hai khai báo này tương đương @QueryMapping public Article article(@Argument Long id) { return findArticle(id); } @SchemaMapping(typeName = "Query", field = "article") public Article articleEquivalent(@Argument Long id) { return findArticle(id); } // Đối với các trường lồng nhau, chỉ @SchemaMapping hoạt động @SchemaMapping(typeName = "Article", field = "author") public Author author(Article article) { return findAuthor(article.getAuthorId()); } // Cú pháp thay thế với kiểu làm tham số @SchemaMapping public Author author(Article article) { // Spring suy luận typeName = "Article" từ tham số return findAuthor(article.getAuthorId()); } } ``` ## Cách quản lý mutation với xác thực? Mutation GraphQL sửa đổi dữ liệu. Spring GraphQL tích hợp với Bean Validation để xác thực đầu vào. ```graphql # schema.graphqls type Mutation { createArticle(input: CreateArticleInput!): Article! updateArticle(id: ID!, input: UpdateArticleInput!): Article! deleteArticle(id: ID!): Boolean! } input CreateArticleInput { title: String! content: String! authorId: ID! } input UpdateArticleInput { title: String content: String } ``` Controller sử dụng `@Valid` để kích hoạt xác thực: ```java // ArticleMutationController.java @Controller public class ArticleMutationController { private final ArticleService articleService; public ArticleMutationController(ArticleService articleService) { this.articleService = articleService; } @MutationMapping public Article createArticle(@Argument @Valid CreateArticleInput input) { // Xác thực được thực hiện tự động return articleService.create(input); } @MutationMapping public Article updateArticle(@Argument Long id, @Argument @Valid UpdateArticleInput input) { return articleService.update(id, input); } @MutationMapping public boolean deleteArticle(@Argument Long id) { articleService.delete(id); return true; } } ``` ```java // CreateArticleInput.java public record CreateArticleInput( @NotBlank(message = "Tiêu đề là bắt buộc") @Size(min = 5, max = 200, message = "Tiêu đề phải từ 5 đến 200 ký tự") String title, @NotBlank(message = "Nội dung là bắt buộc") @Size(min = 100, message = "Nội dung phải có ít nhất 100 ký tự") String content, @NotNull(message = "Tác giả là bắt buộc") Long authorId ) {} ``` > **Xử lý lỗi** > > Lỗi xác thực tự động trả về một lỗi GraphQL có cấu trúc với đường dẫn của trường không hợp lệ. Spring GraphQL định dạng các lỗi này theo đặc tả GraphQL. ## Cách tối ưu hóa truy vấn với @BatchMapping? Annotation `@BatchMapping` đơn giản hóa việc tạo DataLoader trực tiếp trong các controller. Cách tiếp cận này tránh cấu hình BatchLoaderRegistry rõ ràng. ```java // OptimizedArticleController.java @Controller public class OptimizedArticleController { private final ArticleRepository articleRepository; private final AuthorRepository authorRepository; private final CommentRepository commentRepository; public OptimizedArticleController(ArticleRepository articleRepository, AuthorRepository authorRepository, CommentRepository commentRepository) { this.articleRepository = articleRepository; this.authorRepository = authorRepository; this.commentRepository = commentRepository; } @QueryMapping public List
articles(@Argument int page, @Argument int size) { return articleRepository.findAll(PageRequest.of(page, size)) .getContent(); } // BatchMapping cho tác giả - xử lý tất cả bài viết cùng một lúc @BatchMapping public Map author(List
articles) { // Thu thập ID tác giả duy nhất Set authorIds = articles.stream() .map(Article::getAuthorId) .collect(Collectors.toSet()); // Truy vấn duy nhất cho tất cả tác giả Map authorsById = authorRepository .findAllById(authorIds) .stream() .collect(Collectors.toMap(Author::getId, Function.identity())); // Liên kết bài viết -> tác giả return articles.stream() .collect(Collectors.toMap( Function.identity(), article -> authorsById.get(article.getAuthorId()) )); } // BatchMapping cho bình luận @BatchMapping public Map> comments(List
articles) { List articleIds = articles.stream() .map(Article::getId) .toList(); // Lấy hàng loạt bình luận List allComments = commentRepository .findByArticleIdIn(articleIds); // Nhóm theo bài viết Map> commentsByArticle = allComments.stream() .collect(Collectors.groupingBy(Comment::getArticleId)); return articles.stream() .collect(Collectors.toMap( Function.identity(), article -> commentsByArticle .getOrDefault(article.getId(), List.of()) )); } } ``` `@BatchMapping` cung cấp cú pháp ngắn gọn hơn so với cấu hình DataLoader thủ công trong khi vẫn đảm bảo các đảm bảo hiệu suất tương tự. ## Cách kiểm tra Resolver GraphQL? Spring GraphQL cung cấp `GraphQlTester` để viết các test biểu cảm và dễ đọc. ```java // ArticleControllerTest.java @SpringBootTest @AutoConfigureGraphQlTester class ArticleControllerTest { @Autowired private GraphQlTester graphQlTester; @Test void shouldReturnArticleById() { // Given: tài liệu GraphQL String query = """ query { article(id: 1) { id title author { name } } } """; // When & Then graphQlTester.document(query) .execute() .path("article.id").entity(String.class).isEqualTo("1") .path("article.title").entity(String.class).isNotEmpty() .path("article.author.name").entity(String.class).isNotEmpty(); } @Test void shouldCreateArticle() { String mutation = """ mutation { createArticle(input: { title: "Tiêu đề mới", content: "Nội dung kiểm tra đủ dài để đáp ứng các ràng buộc xác thực", authorId: 1 }) { id title } } """; graphQlTester.document(mutation) .execute() .path("createArticle.id").entity(String.class).isNotEmpty() .path("createArticle.title").entity(String.class) .isEqualTo("Tiêu đề mới"); } @Test void shouldReturnErrorForInvalidInput() { String mutation = """ mutation { createArticle(input: { title: "", content: "ngắn", authorId: 1 }) { id } } """; graphQlTester.document(mutation) .execute() .errors() .expect(error -> error.getMessage() .contains("Tiêu đề là bắt buộc")); } @Test void shouldHandleVariables() { String query = """ query GetArticle($id: ID!) { article(id: $id) { id title } } """; graphQlTester.document(query) .variable("id", 42) .execute() .path("article").valueIsNull(); } } ``` ## Cách quản lý subscription thời gian thực? Subscription cho phép đẩy dữ liệu thời gian thực đến các client thông qua WebSocket. ```graphql # schema.graphqls type Subscription { articleCreated: Article! commentAdded(articleId: ID!): Comment! } ``` ```java // SubscriptionController.java @Controller public class SubscriptionController { private final Sinks.Many
articleSink = Sinks.many().multicast().onBackpressureBuffer(); private final Sinks.Many commentSink = Sinks.many().multicast().onBackpressureBuffer(); @SubscriptionMapping public Flux
articleCreated() { return articleSink.asFlux(); } @SubscriptionMapping public Flux commentAdded(@Argument Long articleId) { return commentSink.asFlux() .filter(comment -> comment.getArticleId().equals(articleId)); } // Phương thức được gọi khi tạo bài viết public void publishArticle(Article article) { articleSink.tryEmitNext(article); } // Phương thức được gọi khi thêm bình luận public void publishComment(Comment comment) { commentSink.tryEmitNext(comment); } } ``` Cấu hình WebSocket yêu cầu một phụ thuộc bổ sung: ```java // build.gradle.kts dependencies { implementation("org.springframework.boot:spring-boot-starter-websocket") } ``` ```java // WebSocketConfig.java @Configuration public class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } } ``` ## Các thực hành tốt nhất về bảo mật GraphQL là gì? Bảo mật GraphQL yêu cầu nhiều lớp bảo vệ chống lại các cuộc tấn công từ chối dịch vụ và truy cập trái phép. ```java // SecurityConfig.java @Configuration @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http .authorizeHttpRequests(auth -> auth // Endpoint GraphQL được bảo vệ .requestMatchers("/graphql").authenticated() // GraphiQL chỉ có thể truy cập trong dev .requestMatchers("/graphiql").permitAll() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .build(); } } ``` ```java // SecuredArticleController.java @Controller public class SecuredArticleController { private final ArticleRepository articleRepository; public SecuredArticleController(ArticleRepository articleRepository) { this.articleRepository = articleRepository; } // Truy cập bị giới hạn cho người dùng đã xác thực @QueryMapping @PreAuthorize("isAuthenticated()") public List
articles(@Argument int page, @Argument int size) { return articleRepository.findAll(PageRequest.of(page, size)) .getContent(); } // Chỉ quản trị viên mới có thể xóa @MutationMapping @PreAuthorize("hasRole('ADMIN')") public boolean deleteArticle(@Argument Long id) { articleRepository.deleteById(id); return true; } // Kiểm tra chi tiết: chỉ tác giả mới có thể sửa đổi @MutationMapping @PreAuthorize("@articleSecurity.isAuthor(#id, authentication)") public Article updateArticle(@Argument Long id, @Argument UpdateArticleInput input) { return articleRepository.findById(id) .map(article -> { article.setTitle(input.title()); article.setContent(input.content()); return articleRepository.save(article); }) .orElseThrow(); } } ``` Bảo vệ chống lại các truy vấn phức tạp: ```yaml # application.yml spring: graphql: schema: introspection: # Tắt introspection trong môi trường production enabled: false ``` ```java // QueryComplexityInstrumentation.java @Component public class QueryComplexityInstrumentation extends SimplePerformantInstrumentation { private static final int MAX_COMPLEXITY = 100; @Override public InstrumentationState createState(InstrumentationCreateStateParameters parameters) { return new ComplexityState(); } @Override public InstrumentationContext beginExecution( InstrumentationExecutionParameters parameters, InstrumentationState state) { int complexity = calculateComplexity(parameters.getDocument()); if (complexity > MAX_COMPLEXITY) { throw new GraphQLException( "Truy vấn quá phức tạp: " + complexity + " (max: " + MAX_COMPLEXITY + ")" ); } return super.beginExecution(parameters, state); } } ``` ## Kết luận Spring for GraphQL cung cấp tích hợp GraphQL thanh lịch vào hệ sinh thái Spring. Việc thành thạo resolver, DataLoader và quản lý vấn đề N+1 tạo nên nền tảng kiến thức được mong đợi trong các buổi phỏng vấn. **Danh sách kiểm tra phỏng vấn Spring GraphQL:** - ✅ Giải thích vai trò của các annotation `@QueryMapping`, `@MutationMapping` và `@SchemaMapping` - ✅ Mô tả vấn đề N+1 và tác động của nó đối với hiệu suất GraphQL - ✅ Triển khai DataLoader với `@BatchMapping` hoặc BatchLoaderRegistry - ✅ Xác thực đầu vào mutation với Bean Validation - ✅ Bảo mật resolver với `@PreAuthorize` và Spring Security - ✅ Kiểm tra resolver với GraphQlTester - ✅ Cấu hình subscription cho các tính năng thời gian thực - ✅ Bảo vệ API khỏi các truy vấn quá phức tạp --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/vi/blog/spring-boot/spring-graphql-interview-resolvers-dataloaders-n1