
title: 一个 GraphQL 查询打出 1900 条 SQLN1 之外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 最著名的 N1 问题网上文章都写过但真正踩到的时候你才会发现它不是一个注意一下就能避免的坑而是 GraphQL 架构层面的固有特性必须用专门的机制去解决。这篇把我们在 REST 和 GraphQL 之间来回折腾两年的账算清楚。先说我们为什么会去碰 GraphQL不是为了赶时髦是被逼的。我们的商品详情页要展示 11 个模块最初的做法是 11 个 REST 接口。App 端每次进详情页发 11 个请求弱网环境下首屏要 3 秒以上。前端要求合并接口于是后端做了个聚合接口——一个/api/product/detail返回所有 11 个模块的数据。聚合接口用了半年出现了新问题不同端需要的字段不一样。App 详情页要全部 11 个模块小程序只要 6 个PC 端要 9 个但其中店铺推荐模块的数据结构和 App 不一样。我们的聚合接口开始长出参数?modulesbase,price,stock,shop然后是?version2然后是/api/v2/product/detail-for-miniapp。到第 8 个月的时候这个接口的响应体定义有 340 行Controller 里有 6 个 if 分支判断调用方。这时候 GraphQL 的按需取字段就显得很有吸引力。账一N1 问题DataLoader 是必需品不是可选项上线两周就撞上了开头那个 1900 条 SQL。解决方案是 DataLoader——它的原理是把同一轮执行中的多个查询请求攒起来批量执行一次。Component public class ShopDataLoader { Resource private ShopService shopService; public DataLoaderLong, ShopDTO create() { // BatchLoaderFunction 接收的是本轮攒下来的所有 shopId BatchLoaderLong, ShopDTO batchLoader shopIds - CompletableFuture.supplyAsync(() - { // 一次 IN 查询拿全部20 次查询压成 1 次 ListShopDTO shops shopService.listByIds(shopIds); MapLong, 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 DataFetcherCompletableFutureShopDTO shopFetcher() { return env - { Product product env.getSource(); // 从执行上下文里拿 DataLoader注意必须是每次请求一个新实例 // 不能做成单例 Bean —— DataLoader 内部有缓存 // 跨请求复用会导致 A 用户看到 B 用户请求时缓存的数据 DataLoaderLong, ShopDTO loader env.getDataLoader(shopLoader); return loader.load(product.getShopId()); }; }DataLoader不能做成单例这一点官方文档写得不够醒目。它内部有一个CacheMap设计意图是在单次请求内去重。如果做成 Spring 单例 Bean这个缓存会跨请求存活而且永不过期——等于给自己埋了个内存泄漏加数据串号的双重炸弹。我们是在压测时发现堆内存只涨不降才定位到的。加上 DataLoader 之后那个四层嵌套查询从 1900 条 SQL 降到 7 条。账二查询复杂度不受控客户端可以打死你的服务REST 接口的最坏情况是可预测的——/api/products?size100你知道最多返回 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-age300CDN 就帮你挡住了。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?fieldsid,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 个模块都查了一遍。对我们来说这是可以接受的因为查询开销大头在缓存层多查几个模块的边际成本很小。版本管理我们的规则是只有破坏性变更才升版本。加字段不升版删字段不升版先标记Deprecated观察 3 个月调用量改字段类型才升版。两年里我们只升过 1 次大版本。版本放 URL 路径而不是 Header。/api/v2/products比Accept: application/vnd.xxx.v2json好排查得多出问题时看一眼 nginx 日志就知道调的哪个版本。旧版本下线要有数据支撑。我们在网关加了按版本维度的调用量统计某个版本连续 30 天调用量为 0 才允许下线。复盘两年后的真实分布现在我们的系统里两者并存分布是这样场景用什么原因App/小程序商品详情GraphQL字段需求差异大聚合收益明显运营后台报表GraphQL查询组合多变写死接口维护不过来开放平台对外 APIREST第三方接入成本低文档好写内部服务间调用REST Feign契约稳定不需要灵活性支付/下单等写操作REST幂等、审计、限流都更好做几个关键数字详情页首屏请求数从 11 个降到 1 个弱网首屏从 3.1 秒降到 1.4 秒后端 GraphQL 服务的 P99 是 180ms比原来的聚合 REST 接口120ms慢主要开销在查询解析和 DataLoader 的批次等待上GraphQL 相关的线上问题N1、复杂度超限、缓存失效占我们全年故障的 14%。我的取舍判断写操作我不建议用 GraphQL 的 Mutation。下单、支付这类操作需要幂等键、需要审计日志、需要精细的限流这些在 REST 语义下都有成熟做法换到 GraphQL 全部要重新造一遍。而且写操作本身不存在字段按需的需求——你不会想要只执行下单的一部分。对外开放的 API 用 REST。第三方开发者的接入成本是个真实的商业指标。REST 给一份 Swagger 文档就能开始对接GraphQL 要先让人理解 schema、query 语法、变量声明。我们开放平台试过提供 GraphQL 端点六个月里只有 3 个开发者用过。GraphQL 适合一个后端服务多个差异很大的前端这个场景不适合就一个 Web 端。如果你的调用方只有一个那客户端要什么字段是确定的直接写死接口就行引入 GraphQL 的所有成本N1、复杂度限制、缓存重做、监控重做都得不到对应收益。已经在用 REST 且没有明显痛点的别为了架构先进性去改。我们改造的直接触发点是那个 340 行的聚合接口和 6 个 if 分支是真的维护不动了。如果你的聚合接口还只有 3 个调用方、100 行响应体那fields参数加个字段裁剪就够用了。最后留个问题假设你的 GraphQL 服务已经上线现在需要对某个字段做权限控制——普通用户看不到商品的成本价管理员可以看。这个校验放在哪一层是在 resolver 里判断当前用户角色还是在 schema 层面拆成两套类型或者用 Instrumentation 在执行前扫描查询里是否包含敏感字段三种做法在性能、可维护性、遗漏风险上分别怎么样如果字段是嵌套的product.shop.owner.idCard你的方案还成立吗评论区聊聊。