ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

一个 GraphQL 查询打出 1900 条 SQL:N+1 之外,REST 与 GraphQL 的 5 笔真实账

一个 GraphQL 查询打出 1900 条 SQL:N+1 之外,REST 与 GraphQL 的 5 笔真实账

title: 一个 GraphQL 查询打出 1900 条 SQL:N+1 之外,REST 与 GraphQL 的 5 笔真实账
tags: [API设计, GraphQL, REST, DataLoader, 接口治理]
category: 后端


DBA 在群里发了张截图,说慢查询日志里同一条 SQL 在两秒内出现了 1900 多次,问是不是有人写了循环调用。

那是我们刚上 GraphQL 的第三周。前端同学写了个查询:取商品列表 20 条,每条商品带上店铺信息,每个店铺带上店主信息,每个店主带上他的等级配置。四层嵌套,20 × 1 × 1 × 1 看起来很少,但每一层都是独立的 resolver,每个 resolver 对每个父节点都单独发一次查询——20 个商品触发 20 次店铺查询,20 个店铺触发 20 次店主查询,加上评价列表那一层每个商品取 5 条评价再各自取评价者信息,就滚到了四位数。

这是 GraphQL 最著名的 N+1 问题,网上文章都写过,但真正踩到的时候你才会发现:它不是一个"注意一下就能避免"的坑,而是 GraphQL 架构层面的固有特性,必须用专门的机制去解决。

这篇把我们在 REST 和 GraphQL 之间来回折腾两年的账算清楚。

先说我们为什么会去碰 GraphQL

不是为了赶时髦,是被逼的。

我们的商品详情页要展示 11 个模块,最初的做法是 11 个 REST 接口。App 端每次进详情页发 11 个请求,弱网环境下首屏要 3 秒以上。前端要求合并接口,于是后端做了个"聚合接口"——一个/api/product/detail返回所有 11 个模块的数据。

聚合接口用了半年,出现了新问题:不同端需要的字段不一样。App 详情页要全部 11 个模块,小程序只要 6 个,PC 端要 9 个但其中"店铺推荐"模块的数据结构和 App 不一样。我们的聚合接口开始长出参数:?modules=base,price,stock,shop,然后是?version=2,然后是/api/v2/product/detail-for-miniapp

到第 8 个月的时候,这个接口的响应体定义有 340 行,Controller 里有 6 个 if 分支判断调用方。这时候 GraphQL 的"按需取字段"就显得很有吸引力。

账一:N+1 问题,DataLoader 是必需品不是可选项

上线两周就撞上了开头那个 1900 条 SQL。解决方案是 DataLoader——它的原理是把同一轮执行中的多个查询请求攒起来,批量执行一次。

@Component public class ShopDataLoader { @Resource private ShopService shopService; public DataLoader<Long, ShopDTO> create() { // BatchLoaderFunction 接收的是本轮攒下来的所有 shopId BatchLoader<Long, ShopDTO> batchLoader = shopIds -> CompletableFuture.supplyAsync(() -> { // 一次 IN 查询拿全部,20 次查询压成 1 次 List<ShopDTO> shops = shopService.listByIds(shopIds); Map<Long, ShopDTO> map = shops.stream() .collect(Collectors.toMap(ShopDTO::getId, Function.identity())); // 关键:返回的 List 顺序必须和入参 shopIds 严格一致, // 缺失的位置要放 null,否则 DataLoader 会把结果分配给错误的父节点 return shopIds.stream().map(map::get).collect(Collectors.toList()); }); return DataLoaderFactory.newDataLoader(batchLoader); } }

那个"顺序必须一致"的注释是血泪。我们第一版直接return shopService.listByIds(shopIds),而 MyBatis 的IN查询返回顺序是由数据库决定的(通常按主键顺序,但不保证)。结果是商品 A 显示了商品 B 的店铺名。这个 bug 在测试环境没复现,因为测试数据的 ID 恰好是顺序的;上到预发环境,ID 分布一乱就露馅了。

而且这个错误极难通过日志发现——数据是有的,只是错位了,接口返回 200,监控一切正常。是运营看到"某家店铺下面挂了别人的商品"才报上来的。

resolver 里的用法:

public DataFetcher<CompletableFuture<ShopDTO>> shopFetcher() { return env -> { Product product = env.getSource(); // 从执行上下文里拿 DataLoader,注意必须是每次请求一个新实例, // 不能做成单例 Bean —— DataLoader 内部有缓存, // 跨请求复用会导致 A 用户看到 B 用户请求时缓存的数据 DataLoader<Long, ShopDTO> loader = env.getDataLoader("shopLoader"); return loader.load(product.getShopId()); }; }

DataLoader不能做成单例这一点,官方文档写得不够醒目。它内部有一个CacheMap,设计意图是在单次请求内去重。如果做成 Spring 单例 Bean,这个缓存会跨请求存活,而且永不过期——等于给自己埋了个内存泄漏加数据串号的双重炸弹。我们是在压测时发现堆内存只涨不降才定位到的。

加上 DataLoader 之后,那个四层嵌套查询从 1900 条 SQL 降到 7 条。

账二:查询复杂度不受控,客户端可以打死你的服务

REST 接口的最坏情况是可预测的——/api/products?size=100你知道最多返回 100 条。GraphQL 不是,客户端可以写出这样的查询:

{ products(first: 100) { shop { products(first: 100) { shop { products(first: 100) { id } } } } } }

三层嵌套,理论上 100 万个节点。我们内部有人手滑写过类似的查询,把服务打到 OOM。

graphql-java 提供了两个开箱即用的限制器,我们两个都开了:

@Bean public GraphQL graphQL(GraphQLSchema schema) { return GraphQL.newGraphQL(schema) .instrumentation(new ChainedInstrumentation(List.of( // 限制查询深度,超过 8 层直接拒绝 // 8 是我们统计线上真实查询后定的,P99 深度是 5 new MaxQueryDepthInstrumentation(8), // 限制节点复杂度,每个字段计 1 分,带 first 参数的列表按 first 值加权 new MaxQueryComplexityInstrumentation(2000) ))) .build(); }

MaxQueryDepthInstrumentation的值怎么定,我建议先跑一段时间的统计再拍板。我们最初拍了个 5,结果拦掉了一个合法的运营后台查询(它确实需要 6 层)。改成 8 之后到现在没有误伤。

MaxQueryComplexityInstrumentation的 2000 分是这么算的:正常的商品列表查询大约 300-600 分,运营后台的复杂报表查询能到 1400 分,留一倍余量。

这两个限制器是 GraphQL 上生产的前置条件,不加等于把服务的生死交给客户端。REST 世界里你不需要考虑这个问题,因为每个接口的成本是后端定死的。

账三:缓存能力,REST 有天然优势

这一笔账是 GraphQL 最难扳回的。

REST 的 URL 就是缓存键,CDN、Nginx、浏览器都能直接缓存。GET /api/products/10086这个请求,加个Cache-Control: max-age=300,CDN 就帮你挡住了。

GraphQL 全部是 POST 到同一个/graphql端点,请求体不同但 URL 相同,中间层完全无法区分。我们试过三种办法:

方案做法我们的评价
Persisted Query查询语句预注册,客户端只传 hash有效,但要维护一套查询注册流程
GET + 查询串把 query 放 URL 参数走 GETURL 长度限制,复杂查询就超了
应用层缓存在 resolver 内部缓存只能缓存字段级,缓存不住整个响应

我们最后用的是 Persisted Query + 应用层字段缓存的组合。Persisted Query 的落地成本比想象中高:前端构建时提取所有查询语句生成 hash 映射表,后端启动时加载这张表,运行时只接受表里存在的 hash。这套流程一旦建立,好处是顺带解决了账二的复杂度问题(客户端没法发任意查询了),但坏处是前端每改一次查询就要重新发一次后端配置,敏捷性直接被打回 REST 时代。

到这一步我们其实已经在反思:如果最终要限制客户端只能发预定义的查询,那和 REST 的区别到底还剩多少?

账四:错误处理与状态码,GraphQL 把这事复杂化了

REST 的错误语义是清晰的:404 找不到,403 没权限,500 服务器炸了。监控系统按状态码统计错误率,一行配置就搞定。

GraphQL 永远返回 200,错误放在响应体的errors数组里。这带来两个实际问题。

第一,监控要重写。我们原来的接口成功率告警是基于 HTTP 状态码的,GraphQL 上线后这个告警彻底失灵——服务已经在批量报错了,监控面板上成功率还是 100%。我们后来加了个自定义Instrumentation,在执行完成后检查errors是否为空,为空才上报成功。

第二,部分成功怎么算。一个查询取了 5 个字段,其中 1 个失败了,GraphQL 会返回 4 个成功字段 + 1 条错误。这在设计上很优雅(部分降级),但在实践中很麻烦:前端要为每个字段单独处理空值,后端要判断"这算不算一次失败"。我们的规则是:核心字段(价格、库存)失败算整体失败,非核心字段(推荐、评价)失败只记录不告警。这个规则需要在代码里逐字段标注,维护成本不低。

账五:REST 的版本管理,其实没有想象中糟

我们后来回头重新审视 REST,发现之前那个 340 行响应体的问题,根源不是 REST 本身,是我们没做接口治理

同样的问题用 REST 也能解决得不错,关键是三条规则:

@RestController @RequestMapping("/api/products") public class ProductController { /** * 稀疏字段集:客户端用 fields 参数声明需要哪些字段 * GET /api/products/10086?fields=id,title,price,shop.name * 这是 JSON:API 规范里的做法,本质上是 GraphQL 的简化版 */ @GetMapping("/{id}") public ProductVO detail(@PathVariable Long id, @RequestParam(required = false) String fields) { ProductVO vo = productService.detail(id); if (StringUtils.hasText(fields)) { // 用 Jackson 的 FilterProvider 做字段裁剪, // 注意这只减少了传输体积,后端的查询开销并没有减少 return FieldFilter.apply(vo, fields); } return vo; } }

这个fields参数解决了 80% 的"按需取字段"诉求,成本只有一个工具类。它和 GraphQL 的差距在于:它只裁剪了输出,没有裁剪查询。也就是说客户端只要 3 个字段,后端还是把 11 个模块都查了一遍。对我们来说这是可以接受的,因为查询开销大头在缓存层,多查几个模块的边际成本很小。

版本管理我们的规则是:

  1. 只有破坏性变更才升版本。加字段不升版,删字段不升版(先标记@Deprecated观察 3 个月调用量),改字段类型才升版。两年里我们只升过 1 次大版本。
  2. 版本放 URL 路径而不是 Header。/api/v2/productsAccept: application/vnd.xxx.v2+json好排查得多,出问题时看一眼 nginx 日志就知道调的哪个版本。
  3. 旧版本下线要有数据支撑。我们在网关加了按版本维度的调用量统计,某个版本连续 30 天调用量为 0 才允许下线。

复盘:两年后的真实分布

现在我们的系统里两者并存,分布是这样:

场景用什么原因
App/小程序商品详情GraphQL字段需求差异大,聚合收益明显
运营后台报表GraphQL查询组合多变,写死接口维护不过来
开放平台对外 APIREST第三方接入成本低,文档好写
内部服务间调用REST + Feign契约稳定,不需要灵活性
支付/下单等写操作REST幂等、审计、限流都更好做

几个关键数字:详情页首屏请求数从 11 个降到 1 个,弱网首屏从 3.1 秒降到 1.4 秒;后端 GraphQL 服务的 P99 是 180ms,比原来的聚合 REST 接口(120ms)慢,主要开销在查询解析和 DataLoader 的批次等待上;GraphQL 相关的线上问题(N+1、复杂度超限、缓存失效)占我们全年故障的 14%。

我的取舍判断

写操作我不建议用 GraphQL 的 Mutation。下单、支付这类操作需要幂等键、需要审计日志、需要精细的限流,这些在 REST 语义下都有成熟做法,换到 GraphQL 全部要重新造一遍。而且写操作本身不存在"字段按需"的需求——你不会想要"只执行下单的一部分"。

对外开放的 API 用 REST。第三方开发者的接入成本是个真实的商业指标。REST 给一份 Swagger 文档就能开始对接,GraphQL 要先让人理解 schema、query 语法、变量声明。我们开放平台试过提供 GraphQL 端点,六个月里只有 3 个开发者用过。

GraphQL 适合"一个后端服务多个差异很大的前端"这个场景,不适合"就一个 Web 端"。如果你的调用方只有一个,那客户端要什么字段是确定的,直接写死接口就行,引入 GraphQL 的所有成本(N+1、复杂度限制、缓存重做、监控重做)都得不到对应收益。

已经在用 REST 且没有明显痛点的,别为了架构先进性去改。我们改造的直接触发点是那个 340 行的聚合接口和 6 个 if 分支,是真的维护不动了。如果你的聚合接口还只有 3 个调用方、100 行响应体,那fields参数加个字段裁剪就够用了。

最后留个问题

假设你的 GraphQL 服务已经上线,现在需要对某个字段做权限控制——普通用户看不到商品的成本价,管理员可以看。这个校验放在哪一层?是在 resolver 里判断当前用户角色,还是在 schema 层面拆成两套类型,或者用 Instrumentation 在执行前扫描查询里是否包含敏感字段?三种做法在性能、可维护性、遗漏风险上分别怎么样?如果字段是嵌套的(product.shop.owner.idCard),你的方案还成立吗?

评论区聊聊。

返回列表