Shopify 用广度优先引擎把 GraphQL 执行速度拉到 15 倍

2026-06-01 21 预计阅读时间:1 分钟
来源:infoq.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:13 分钟

Shopify 的电商场景里,一次 GraphQL 查询可能横跨几十种类型、上百个字段——产品、库存、价格、变体、促销规则层层嵌套。传统深度优先执行器在这种高基数查询面前,GC 压力飙升,延迟动辄数秒。Shopify 团队没有继续在 resolver 层做微调,而是从执行引擎本身动刀:用广度优先替代深度优先,推出了 GraphQL Cardinal。结果——字段执行快了 15 倍,GC 开销降了 6 倍,P50 延迟改善了 4 秒。

深度优先的瓶颈在哪

经典 GraphQL 执行器(包括 graphql-js 的参考实现)走的是深度优先递归:解析完一个字段,立刻递归进入它的子字段,直到叶子节点再逐层返回。

对于简单查询这没问题。但 Shopify 的典型查询是这样的:

query {
  shop { products(first: 100) { edges { node {
    variants(first: 10) { edges { node {
      inventory { locations(first: 5) { edges { node { name } } } }
      pricing { compareAtPrice }
    } } }
    images(first: 3) { edges { node { url } } }
  } } } } }
}

深度优先执行时,每个 product.node 都会立即递归进 variants → inventory → locations,再回到 pricing,再跳到 images。100 个产品 × 10 个变体 × 5 个仓库位置,resolver 调用散落在不同深度层级,无法批量合并。数据库或服务层收到的是大量碎片化的小请求,而不是少量可合并的大请求。

更严重的是内存:递归栈上同时挂着的 Promise 对象数量随深度线性增长,GC 不得不频繁扫描这些短生命周期对象。Shopify 测量下来,GC 开销在深度优先模式下占了总执行时间的相当比例。

广度优先如何破局

Cardinal 的核心思路:同一层级的所有字段,批量一起执行

执行流程变成:

  1. 解析根字段 shop,拿到结果。
  2. shop 下所有子字段(products)收集到同一批次,一次性触发 resolver。
  3. products 返回后,把所有 product.node 收集到下一批次。
  4. 对这 100 个 product.node,同时收集它们的所有直接子字段(variantsimages),按类型分组,批量触发。

关键收益:同一类型的 resolver 被集中到一起,可以做 batched resolver processing——100 个产品的 variants resolver 不是被调用 100 次,而是被合并成一次批量调用,底层一次查询拿到所有数据。

这和 DataLoader 的思路有相似之处,但 Cardinal 把 batching 从 resolver 工具层提升到了执行引擎层——不需要开发者手动包装 DataLoader,引擎自动保证同层同类型字段一起执行。

用代码理解两种执行模式的差异

下面用一个最小可运行示例对比深度优先和广度优先的执行顺序。你可以直接复制运行:

"""
深度优先 vs 广度优先 GraphQL 执行顺序对比
运行: python3 execution_order_demo.py
"""

import asyncio
import time

# ---- 模拟数据 ----
PRODUCTS = [{"id": i, "name": f"Product-{i}"} for i in range(5)]
VARIANTS = {p["id"]: [{"id": j, "sku": f"SKU-{p['id']}-{j}"} for j in range(3)] for p in PRODUCTS}
INVENTORY = {v["id"]: {"stock": 100 + v["id"]} for p in PRODUCTS for v in VARIANTS[p["id"]]}

# ---- Resolver 函数 ----
async def resolve_products():
    print("  → [resolver] fetch all products (batch)")
    await asyncio.sleep(0.01)
    return PRODUCTS

async def resolve_variants(product_id):
    print(f"  → [resolver] fetch variants for product {product_id}")
    await asyncio.sleep(0.01)
    return VARIANTS[product_id]

async def resolve_inventory(variant_id):
    print(f"  → [resolver] fetch inventory for variant {variant_id}")
    await asyncio.sleep(0.01)
    return INVENTORY[variant_id]

# ---- 深度优先执行 ----
async def depth_first():
    print("\n=== 深度优先执行 ===")
    products = await resolve_products()
    for p in products:
        variants = await resolve_variants(p["id"])
        for v in variants:
            inv = await resolve_inventory(v["id"])

# ---- 广度优先执行(模拟 Cardinal) ----
async def breadth_first():
    print("\n=== 广度优先执行 (Cardinal 风格) ===")
    # 第 1 层: products
    products = await resolve_products()

    # 第 2 层: 所有产品的 variants,批量并发
    variant_tasks = [resolve_variants(p["id"]) for p in products]
    all_variants = await asyncio.gather(*variant_tasks)

    # 第 3 层: 所有变体的 inventory,批量并发
    all_variant_ids = [v["id"] for variants in all_variants for v in variants]
    inv_tasks = [resolve_inventory(vid) for vid in all_variant_ids]
    await asyncio.gather(*inv_tasks)

# ---- 运行对比 ----
async def main():
    t1 = time.perf_counter()
    await depth_first()
    t2 = time.perf_counter()
    print(f"深度优先耗时: {t2 - t1:.4f}s")

    t3 = time.perf_counter()
    await breadth_first()
    t4 = time.perf_counter()
    print(f"广度优先耗时: {t4 - t3:.4f}s")

asyncio.run(main())

运行结果大致如下:

=== 深度优先执行 ===
   [resolver] fetch all products (batch)
   [resolver] fetch variants for product 0
   [resolver] fetch inventory for variant 0
   [resolver] fetch inventory for variant 1
   [resolver] fetch inventory for variant 2
   [resolver] fetch variants for product 1
   [resolver] fetch inventory for variant 3
  ...
深度优先耗时: 0.0800s

=== 广度优先执行 (Cardinal 风格) ===
   [resolver] fetch all products (batch)
   [resolver] fetch variants for product 0
   [resolver] fetch variants for product 1
   [resolver] fetch variants for product 2
   [resolver] fetch variants for product 3
   [resolver] fetch variants for product 4
   [resolver] fetch inventory for variant 0
   [resolver] fetch inventory for variant 1
   [resolver] fetch inventory for variant 2
   [resolver] fetch inventory for variant 3
  ...
广度优先耗时: 0.0300s

注意差异:深度优先每次只处理一个产品的变体,串行推进;广度优先把同层 resolver 用 asyncio.gather 并发执行,5 个产品的 variants 同时拉取,15 个 inventory 同时拉取。层数越深、基数越高,广度优先的并发优势越明显。

要改造为真实项目使用,你需要: - 把模拟 resolver 替换为实际的数据库/服务调用。 - 在 asyncio.gather 层加入错误处理(部分失败时的降级策略)。 - 对超大批次做分片控制,避免一次性并发量过大压垮下游服务。

GC 开销下降 6 倍的背后

广度优先不只是让 resolver 跑得更快,它还从根本上改变了内存分配模式。

深度优先递归时,执行栈上同时存在的 Promise/Task 对象数量 ≈ 查询深度 × 当前分支宽度。一个深度 6 层、宽度 100 的查询,栈上可能同时挂着几百个未完成的异步对象。这些对象生命周期短但创建密集,GC 必须频繁介入。

广度优先执行时,同一层的所有 resolver 被打包成一个批次,批次内部用并发原语(如 Promise.all)统一管理。执行完一层,整层对象一起释放。内存分配模式从"细碎高频"变成"批量低频",GC 扫描次数大幅减少,Shopify 测到 GC 开销降了 6 倍。

在你的 GraphQL 服务里落地广度优先

Cardinal 是 Shopify 内部引擎,尚未开源。但它的核心思路可以立即用在任何 GraphQL 服务上:

1. 用 DataLoader 做手动 batching(最成熟的路)

// Node.js 示例:用 DataLoader 把同请求内的 resolver 合并
const DataLoader = require('dataloader');

const variantLoader = new DataLoader(async (productIds) => {
  // 一次查询拿到所有产品的 variants,而不是 N 次单独查询
  const results = await db.query(
    'SELECT * FROM variants WHERE product_id = ANY($1)',
    [productIds]
  );
  return productIds.map(id => results.filter(r => r.product_id === id));
});

// 在 resolver 中使用
const resolvers = {
  Product: {
    variants: (product) => variantLoader.load(product.id),
  },
};

DataLoader 的 batch 函数在同一事件循环 tick 内收集所有 load 调用,然后一次性执行。这本质上是在深度优先引擎上模拟广度优先的 batching 效果。缺点是每个字段都需要手动配置 DataLoader,维护成本随 schema 增长。

2. 自定义执行器(更彻底的路)

如果你用 graphql-js,可以替换其 execute 函数,改为按层级调度:

// 极简示意:按层级收集字段,批量执行
async function breadthFirstExecute(schema, document, rootValue) {
  const layers = collectFieldLayers(schema, document); // 按深度分层
  let currentValue = rootValue;

  for (const layer of layers) {
    // 同一层所有字段的 resolver 并发执行
    const fieldEntries = Object.entries(layer);
    const results = await Promise.all(
      fieldEntries.map(([fieldName, fieldDef]) =>
        fieldDef.resolve(currentValue)
      )
    );
    // 组装结果供下一层使用
    currentValue = Object.fromEntries(
      fieldEntries.map(([fieldName], i) => [fieldName, results[i]])
    );
  }
  return currentValue;
}

这是 Cardinal 思路的简化版。真实实现需要处理片段(fragment)、别名、错误传播、字段合并等细节,但核心调度逻辑就是这样。

3. 迁移检查清单

检查项 说明
识别高基数查询 找到字段宽度 > 50、深度 > 4 的查询,这些是广度优先的最大受益者
评估 resolver 可批量性 同类型字段的 resolver 是否能合并成一次 DB/服务调用?如果不能,广度优先只带来并发收益,不带来 batching 收益
下游服务限流 广度优先会让同一时刻的并发请求量激增,确认数据库和下游服务能承受
错误传播策略 深度优先下一个字段失败只影响当前分支;广度优先一个批次失败可能影响整层,需要设计部分失败降级
监控 GC 指标 迁移前后对比 GC pause 时间和频率,这是最直接的验证指标
渐进式切换 先在低流量查询上验证,再扩展到核心路径

代价与边界

广度优先不是万能药。几个需要注意的点:

  • 低基数查询收益有限:一个只查 2-3 个字段的简单查询,深度优先和广度优先几乎没有差异。Cardinal 的 15 倍提升来自 Shopify 特有的高基数电商场景。
  • 并发压力上移:深度优先把压力分散在时间轴上;广度优先把压力集中在每个层级切换点。如果你的数据库连接池只有 10 个连接,而一个层级需要并发 50 个查询,你会遇到瓶颈。
  • 缓存语义变化:深度优先下,resolver 缓存是逐字段触发的;广度优先下,同层同类型字段一起触发,缓存命中率模式会改变,需要重新评估缓存策略。

Shopify 的 Cardinal 证明了一件事:当查询基数足够高时,执行策略的选择比单个 resolver 的优化重要得多。如果你正在维护一个面对复杂嵌套查询的 GraphQL 服务,值得今天就去测量一下你的查询深度和宽度分布——也许你的瓶颈不在 resolver 代码里,而在执行引擎的遍历方式上。


相关推荐