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 的核心思路:同一层级的所有字段,批量一起执行。
执行流程变成:
- 解析根字段
shop,拿到结果。 - 把
shop下所有子字段(products)收集到同一批次,一次性触发 resolver。 products返回后,把所有product.node收集到下一批次。- 对这 100 个
product.node,同时收集它们的所有直接子字段(variants、images),按类型分组,批量触发。
关键收益:同一类型的 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 代码里,而在执行引擎的遍历方式上。