处理 N+1 问题
使用 dataloader 模式增强子图性能
GraphQL 开发者经常遇到返回列表的操作中的 "N+1 查询问题"。考虑以下TopReviews
查询:
query TopReviews {topReviews(first: 10) {idratingproduct {nameimageUrl}}}
在一个单体 GraphQL 服务器 中,执行引擎会采取以下步骤:
- 解析
Query.topReviews
字段,它返回一组Review
。 - 对于每个
Review
,解析Review.product
字段。
如果 Query.topReviews
返回十个评论,则执行引擎会解析 Review.product
字段 十次。如果 Reviews.product
字段 对单个 Product
进行数据库或 REST 查询,则对数据源的调用有十次是独立的。这是不理想的,原因如下:
- 使用单次查询获取所有产品更高效 —— 举例来说,
SELECT * FROM products WHERE id IN (<product ids>)
。 - 如果任何评论引用了相同的产品,则获取已检索数据的资源会被浪费。
联邦图中的N+1问题
考虑相同的 TopReviews
操作,它以Reviews和Products子图中定义的Product类型作为 实体。
type Query {topReviews(first: Int): [Review]}type Review {id: IDrating: Intproduct: Product}type Product @key(fields: "id") {id: ID}
type Product @key(fields: "id") {id: ID!name: StringimageUrl: String}
大多数 子图实现使用引用解析器返回对应键的实体对象。虽然这种模式很简单,但当客户端操作请求许多实体的 字段时,可能会降低性能。回顾一下topReviews
查询,现在在联邦图的上下文中:
query TopReviews {topReviews(first: 10) { # Defined in Reviews subgraphidratingproduct { # ⚠️ NOT defined in Reviews subgraphnameimageUrl}}}
路由器执行两个查询:
- 从评论子图中获取除
Product.name
和Product.imageURL
之外的所有 字段。 - 从产品子图中获取每个产品的name和imageURL。
在产品子图中,Product的引用解析器不接受键的列表,而是一个单一的键。因此,子图库为每个键调用一次引用解析器:
// Products subgraphconst resolvers = {Product: {__resolveReference(productRepresentation) {return fetchProductByID(productRepresentation.id);}},// ...other resolvers...}
一个简单的fetchProductByID
函数在每次调用时可能会进行数据库调用。如果您需要解决Product.name
对于N
个不同的产品,这将导致N
次数据库调用。这些调用是在除了Review子图进行获取初始评论列表和每个产品的id
的调用之外进行的。这个问题可能会导致性能问题,甚至可能使拒绝服务攻击成为可能。
查询规划以处理N+1查询
默认情况下,router"的查询规划器处理类似Product
类型实体的N+1查询。查询规划的处理TopReviews
操作如下:
- 首先,router使用根字段
Query.topReviews
从Reviews子图中获取Review
列表。 - 接下来,router提取
Product
实体引用并将它们批量提取到Products子图的的Query._entities
根字段。 - 在router获取到
Product
实体后,它通过Flatten
步聚将它们合并到Review
列表中。
大多数subgraph实现(包括@apollo/subgraph
)不会直接编写Query._entities
解析器。相反,它们使用引用解析器API来解析单个实体引用:
const resolvers = {Product: {__resolveReference(productRepresentation) {return fetchProductByID(productRepresentation.id);},},};
这个API的动机与子图规范的一个微妙而关键方面有关子图规范:解析的实体的顺序必须与给定的实体引用的顺序相匹配。如果实体以错误的顺序返回,则这些字段将与错误的实体合并,导致结果不正确。为了避免这个问题,大多数子图库都会为您处理实体顺序。
因为顺序很重要,它重新引入了N+1查询问题:在上面的例子中fetchProductByID
为每个实体引用调用了一次。
dataloader模式解决方案
解决N+1问题的解决方案——无论是联邦图还是单一架构图——是dataloader模式。例如,在一个Apollo Server实现中,使用dataloader可能看起来像这样:
const resolvers = {Product: {__resolveReference(product, context) {return context.dataloaders.products(product.id);},},};
使用dataloader,当查询计划器调用带有一批Product实体的Products子图时,路由器将向Products数据源发送单个分批请求。
几乎每个GraphQL服务器库都提供了dataloader实现,Apollo建议在每个resolver中使用它,即使是那些不为实体或不需要返回列表的。