如何使用leangen/graphql-spqr解决GraphQL的N+1问题并批量调用price方法
这个N+1问题确实是GraphQL开发里的典型痛点——当你查询getAllBooks返回10本书后,price方法会被挨个调用10次,要是真实场景里每次都调用外部HTTP服务,性能损耗会非常大。针对你用的leangen/graphql-spqr,DataLoader确实是标准且有效的解决方案,下面我一步步给你讲具体怎么实现:
第一步:引入DataLoader依赖
首先得确保你的项目里有graphql-java-dataloader的依赖,比如Maven的pom.xml里加上:
<dependency> <groupId>com.graphql-java</groupId> <artifactId>graphql-java-dataloader</artifactId> <version>你的对应兼容版本</version> </dependency>
版本可以根据你用的graphql-java和SPQR版本来匹配,选兼容的最新稳定版即可。
第二步:创建DataLoader配置类,注册批量加载器
我们需要定义一个DataLoaderRegistry的Bean,里面注册专门用来批量加载书籍价格的DataLoader。这个DataLoader的核心是实现批量加载逻辑——一次性接收多个书籍ID,调用外部服务批量获取价格,再把结果按顺序映射回去。
示例代码如下:
@Component public class BookDataLoaderConfig { // 假设这是你封装的调用外部HTTP服务的类,负责批量获取价格 private final PriceRemoteService priceRemoteService; public BookDataLoaderConfig(PriceRemoteService priceRemoteService) { this.priceRemoteService = priceRemoteService; } @Bean public DataLoaderRegistry dataLoaderRegistry() { DataLoaderRegistry registry = new DataLoaderRegistry(); // 创建价格批量加载器:输入是书籍ID列表,输出是对应价格的CompletableFuture DataLoader<Long, Long> priceDataLoader = DataLoader.newDataLoader(bookIds -> { // 调用外部服务批量获取价格,返回<书籍ID, 价格>的Map Map<Long, Long> bookPriceMap = priceRemoteService.batchGetPrices(bookIds); // 按照输入的书籍ID顺序返回价格列表,保证顺序一致(GraphQL依赖这个顺序匹配结果) return CompletableFuture.completedFuture( bookIds.stream() .map(bookId -> bookPriceMap.getOrDefault(bookId, 0L)) // 找不到的话返回默认值0 .collect(Collectors.toList()) ); }); // 给这个DataLoader起个名字,后面要通过名字从Registry里获取 registry.register("bookPriceLoader", priceDataLoader); return registry; } }
第三步:修改price方法,用DataLoader异步批量加载
原来的price方法是同步返回价格,现在要改成从DataLoader里异步获取,这样GraphQL会自动收集所有的加载请求,批量触发调用。
修改后的代码:
@GraphQLQuery public CompletableFuture<Long> price( @GraphQLContext Book book, @GraphQLRootContext DataLoaderRegistry dataLoaderRegistry) { // 从上下文里拿到我们注册的价格加载器 DataLoader<Long, Long> priceLoader = dataLoaderRegistry.getDataLoader("bookPriceLoader"); // 传入当前书籍的ID,异步加载价格 return priceLoader.load(book.getId()); }
第四步:确保DataLoaderRegistry被传入GraphQL上下文
最后要保证在执行GraphQL查询的时候,DataLoaderRegistry能被放到查询上下文中,这样price方法才能拿到它。
如果是用Spring Boot整合SPQR,你可以在处理请求的Controller里把Registry传入:
@RestController @RequestMapping("/graphql") public class GraphQLController { private final GraphQL graphQL; private final DataLoaderRegistry dataLoaderRegistry; public GraphQLController(GraphQL graphQL, DataLoaderRegistry dataLoaderRegistry) { this.graphQL = graphQL; this.dataLoaderRegistry = dataLoaderRegistry; } @PostMapping public ResponseEntity<Object> executeGraphql( @RequestBody GraphQLRequest request) { ExecutionInput executionInput = ExecutionInput.newExecutionInput() .query(request.getQuery()) .operationName(request.getOperationName()) .variables(request.getVariables()) .context(dataLoaderRegistry) // 把DataLoaderRegistry放到上下文里 .build(); ExecutionResult result = graphQL.execute(executionInput); return ResponseEntity.ok(result.toSpecification()); } }
原理说明
当你查询getAllBooks返回10本Book后,GraphQL会遍历每一本Book,调用price方法。但此时price方法只是向DataLoader提交了一个加载请求,并没有立即执行外部调用。当所有的price请求都被收集完成后,DataLoader会一次性把所有书籍ID传给批量加载逻辑,只调用一次外部HTTP服务,拿到所有价格后再分别返回给对应的Book,这样就把原来的1+10次调用变成了1+1次,完美解决N+1问题。
额外注意点
- 书籍ID的唯一性:必须保证Book的ID是唯一标识,这样才能正确把批量获取的价格和对应的Book匹配上。
- 外部服务的批量支持:如果你的外部HTTP服务原本不支持批量查询,可能需要自己封装批量调用的逻辑(比如控制并发数的并行调用),或者联系服务端修改接口支持批量。
- 缓存控制:DataLoader默认会缓存加载结果,如果同一个Book多次被查询,不会重复调用外部服务。如果不需要缓存,可以在创建DataLoader时设置
DataLoader.newDataLoader(..., DataLoaderOptions.newOptions().setCacheEnabled(false))。
备注:内容来源于stack exchange,提问作者Amit Jolly

