GraphQL 服务端实践

GraphQL 介绍

GraphQL 是一种用于 API 的查询语言和运行时,它允许客户端以高度灵活的方式请求所需数据。它于2015年由Facebook开源,并迅速成为现代应用开发中流行的数据查询语言。GraphQL 的主要特点包括:

  1. 强类型系统:GraphQL 定义了一套类型系统,每个 API 都基于这些类型来定义。这意味着每个查询都会被精确地验证,并且只返回符合该类型的数据。这种类型系统确保了数据的一致性和可预测性。
  2. 单一端点:与 REST API 不同,GraphQL 只使用一个端点来处理所有查询。客户端可以通过这个端点发送复杂的查询,请求他们需要的具体数据。
  3. 单个请求获取所有所需资源:在传统的 REST API 中,获取多个资源通常需要多个请求。GraphQL 允许通过一个请求同时获取多个资源,这减少了网络传输开销,提高了应用性能。
  4. 内省系统:GraphQL 支持内省查询(Introspection),这意味着客户端可以查询 GraphQL 服务了解其支持的查询、类型和字段。这使得 API 的探索和自动化文档生成变得更加容易。

除此之外,GraphQL 还具有以下优势:

  • 灵活的查询语言:客户端可以精确指定他们需要的数据结构,避免了过度获取或欠缺必要数据的问题。
  • 易于演进:由于客户端定义他们需要的数据,后端可以添加新的字段和类型而不影响现有的查询。
  • 强大的开发者工具:社区提供了大量的工具,如 GraphiQL,用于测试和优化 GraphQL 查询。
  • 有效的数据获取:通过减少请求次数和传输的数据量,GraphQL 可以更有效地使用网络资源,特别是在移动环境中。

总的来说,GraphQL 提供了一种更高效、灵活且强类型的方式来设计和使用 Web API,它正在逐渐成为现代应用开发的标准选择。

GraphQL 访问日志

GraphQL 的灵活性和其单一端点特性确实为日志记录带来了特定挑战。在 REST API 中,由于每个端点通常对应一个具体的资源或操作,这使得日志记录过程相对直观。然而,GraphQL 的设计允许单个请求包含多种不同的查询和突变,从而增加了日志记录的复杂性。

以传统 REST 接口为例,我们通常能够通过路由和方法来明确区分不同的请求。但在 GraphQL 中,由于其使用单一端点——通常是 /graphql,就无法直接通过路由区分请求的具体内容。

操作名称和签名

为了克服这一难题,我们可以采用基于客户端发送的操作名称来区分不同请求的策略。然而这种方法也存在一个问题:客户端可能会发送具有相同操作名称的不同请求,这导致了数据查询的混淆。

为了解决这个问题,一种有效的方法是将每个 GraphQL 查询字符串进行哈希处理,从而生成一个独特的操作签名。但普通哈希处理不能满足这个场景,例如:

query GetPostDetails($postId: String!) {
  post(id: $postId) {
    author
    content
  }
}

query GetPostDetails($postId: String!) {
  post(id: $postId) {
    content # 不同的字段排序
    author
  }
}

query GetPostDetails($postId: String!) {
  post(id: $postId) {
    writer: author # 字段别名
    content
  }
}

尽管存在一些小差异(包括注释),但所有这些操作在GraphQL 服务器上执行都是相同的。因此在统计时应将它们视为同一种请求。所以我们需要一种签名算法为这些操作生产相同的签名。

操作签名算法

一个操作签名算法基本上首先需要先把操作查询用以下方法格式化去充。

  • 转换行内参数值:根据类型转换参数值,如布尔和枚举值保持不变,Int 和 Float 替换为 0,字符串、列表和对象替换为空值。
  • 移除多余字符:去除操作定义中的注释、冗余空白等不必要字符。如果文档包含多个操作或未使用的片段,这些也会被移除。
  • 重排序定义:保留的片段定义按字母顺序排列,出现在执行操作定义之前。字段选择也按字母顺序排列,移除所有字段别名。具体实现参考 Apollo Server 源码,接下来一起来看一个签名例子。

格式化前的查询:

# 操作定义需要移动到所有片段定义之后
query GetUser {
  # 用空字符串替换字符串参数值
  user(id: "hello") {
    ...NameParts # 展开片段需要移动到在单个字段之后
    timezone # 按照字母顺序 timeout 需要移动到 name 之后
    aliased: name # 移除别名
  }
}

# 删除多余字符包括注释
fragment NameParts on User {
  firstname
  lastname
}

格式化后的查询:

fragment NameParts on User {
  firstname
  lastname
}
query GetUser {
  user(id: "") {
    name
    timezone
    ...NameParts
  }
}

最后我们再将格式化后查询通过哈希算法得到最终的操作签名。

通过结合使用操作名称和操作签名,我们就能更准确地区分和记录每一个独特的请求。

💡注意需要在日志库以外的保存 hash 对应的完整的 query,不然的话请求是区分出来了,但是完全不知道用户请求了什么。

GraphQL 分页

偏移量分页和游标分页

  • 偏移量分页:这是最常见的分页方式,通过指定偏移量(即,跳过的记录数量)和所需的记录数量来获取数据。然而,这种方式在处理大数据集时会有性能问题,并且在数据更新频繁的情况下可能会导致重复或遗漏数据。
  • 游标分页:这种方式提供了更高效和稳定的解决方案,每次分页请求都会返回一个游标,指向下一页的起始位置。这种方式在处理大数据集和实时更新数据时表现良好,但实现起来可能更复杂。

Relay 游标分页规范

在处理大量数据和实时更新的应用场景中,我们通常采用 Relay 游标分页规范(基于连接的分页规范)。这种规范使用一个游标来指示当前页的位置,每次请求时客户端只需要提供上一次请求返回的游标即可。在 GraphQL 中,这种规范通常以以下形式实现:

{
  products(first: 10, after: "opaqueCursor") {
    edges {
      cursor
      node {
        id
        name
      }
    }
    pageInfo {
      endCursor
      hasNextPage
    }
  }
}

其中,first 参数用于指定需要获取的记录数量,after 参数用于提供上一页的游标,edges 用于返回数据记录和对应的游标,pageInfo 用于提供是否存在下一页等额外信息。

这种方式在处理大数据集和实时更新数据时表现良好,但实现起来可能更复杂。

快速构建游标分页

为了解决游标分页的实现复杂性,我们在 Nest.js 框架上开发了一个工具库,用于快速生成类型及封装复杂的游标分页实现。

  1. 使用 ConnectionBuilder 快速生成 ConnectionConnectionArgsEdgeOrderOrderField 对象类型。
  2. 在Resolver(解决器)中使用 ConnectionService 服务获取数据。
  3. 自动生成的 GraphQL Schema。

GraphQL 速率限制

在开发和维护我们的 GraphQL API 时,我们面临着一个重要挑战:如何有效地管理和限制 API 请求,以保护我们的后端服务免受恶意或过度的查询影响。

因为它与传统的REST API在请求处理和速率限制方面有着根本的不同。传统的 REST API 限速方式通常是基于请求的,即允许客户端每秒发出多个请求。如果再 GraphQL API 采用相同方案会带来几个问题:

  1. REST API 单个端点的开销都是一样,而 GraphQL 较为灵活,客户端不同的查询语句开销天差地别。如果出现一个恶意请求很容易就把服务器打满。
  2. 与单纯获取数据的端点相比更新和删除操作对服务产生更多负载,但它们在基于请求响应的模型里是按一样的开销进行计算的。

在这方面,我们参考了 Shopify 在 GraphQL 速率限制方面的实践和方案,根据我们自身的业务需求和代码框架进行了定制化的实现。

漏桶算法

首先我们先将固定限制的速率限制器换成了漏桶算法,漏桶算法是一种有效的流量控制机制,它通过以下方式工作:

  1. 桶的比喻:想象每个用户都拥有一个代表其容量的“桶”。例如,一个桶能够容纳最多60个“弹珠”。
  2. 弹珠的移除机制:为了保持桶内总有可用空间,系统会每秒从桶中移除一个弹珠(前提是桶内有弹珠)。
  3. API 请求与弹珠:用户每发送一个 API 请求,就相当于向自己的桶中加入了一个弹珠。
  4. 桶满时的处理:桶一旦满载,用户将收到错误提示,并且只能在桶中出现空间后才能继续发送请求。

这种模型有效地确保了频繁请求的客户端在必要时仍有足够的“空间”进行突发请求。例如,某应用平均每秒发送 20 个请求,但偶尔需要一次性发送 30 个请求,此时漏桶算法允许这种情况发生而不会触发速率限制。这样既确保了系统运行的稳定性,又提供了一定程度的灵活性。

根据类型和数量计算查询开销

然后服务器在执行 GraphQL 查询之前对查询语法 AST 静态分析。通过识别查询中使用的每种类型来计算其成本。

  1. Object:一点。 Object 是查询基本单位,对象通常表示单个服务器端操作,例如数据库查询或对内部服务的请求。
  2. Scalar 和 Enum:零点 标量和枚举是 Object 本身的一部分,在 Object 里我们已经算过开销,这里的 Scalar 和 Enum 其实就是 Object 上的某个字段。一个 Object 上多返回几个字段消耗是比较少的。
  3. Connection: 两点 + (返回的对象数量 * 对象开销) GraphQL 的 Connection 表示的是一对多的关系。Shopify 用的也是 Relay 分页标准,所以就会有 edges、pageInfo、cursor 等字段。cursor 和 pageInfo 不需要计算成本,因为他们是通过查询结果再生成出来的。
  4. Interface 和 Union:一点 Interface 和 Union 和 Object 类似,本质上是能返回不同类型的 Object,所以是算一点。
  5. Mutation:十点 Mutation 指的是那些有副作用的请求,即该请求会影响数据库中的数据或索引,甚至可能触发队列任务。这种请求要比一般的查询请求消耗更多资源,所以算十点。

自定义开销

如果全部按照以上方案有时就过于死板了,可以通过装饰器在 ObjectType 或者 Resolver 的字段上面自定义开销。

@ObjectType()
export class Product {
	@Field({ complexity: 10 })
  image: string;
}

在响应中获取查询开销

不需要用户计算查询成本,我们在 API 响应包含一个扩展对象,其中包含查询成本。

query {
  shop {
    id
    name
    timezoneOffsetMinutes
    customerAccounts
  }
}
{
  "data": {
    "shop": {
      "id": "gid://shopify/Shop/91615055400",
      "name": "My Shop",
      "timezoneOffsetMinutes": -420,
      "customerAccounts": "DISABLED"
    }
  },
  "extensions": {
    "cost": {
      "requestedQueryCost": 1,
      "actualQueryCost": 1,
      "throttleStatus": {
        "maximumAvailable": 1000.0,
        "currentlyAvailable": 999,
        "restoreRate": 50.0
      }
    }
  }
}

预计开销和实际开销

您是否注意到上述查询中存在两种不同类型的成本?

  • 在执行查询之前使用静态分析计算请求的查询成本。
  • 实际查询成本是在我们执行查询时计算的。

有时,实际成本低于要求的成本。当您在连接中查询特定数量的记录但返回的记录较少时,通常会发生这种情况。好消息是,请求费用与实际费用之间的任何差额都会退还给 API 客户端。

在此示例中,我们查询库存较低的前五个产品。只有一种产品与此查询匹配,因此即使请求的成本为 7,您也只需支付按实际成本计算的 4 点费用:

query {
  products(first: 5, query: "inventory_total:<5") {
    edges {
      node {
        title
      }
    }
  }
}
{
  "data": {
    "products": {
      "edges": [
        {
          "node": {
            "title": "Low inventory product"
          }
        }
      ]
    }
  },
  "extensions": {
    "cost": {
      "requestedQueryCost": 7,
      "actualQueryCost": 3,
      "throttleStatus": {
        "maximumAvailable": 1000.0,
        "currentlyAvailable": 997,
        "restoreRate": 50.0
      }
    }
  }
}

评估计算查询开销的有效性

在 Shopify 的数据分析,查询复杂度与执行时间呈线性相关:

通过对 GraphQL 查询的复杂度计算进行限流,相比 REST 又具备了更优的灵活性,这种速率限制模式会鼓励用户只请求需要的那些数据,使服务器的负载也更加可预期。

GraphQL 的 N + 1 问题

在 GraphQL 中,N + 1 问题是一个常见的性能瓶颈,尤其是在处理具有复杂关系的数据时。这个问题发生在当一个 GraphQL 查询需要从多个关联的资源中获取数据时,而后端为每个单独的资源执行单独的数据库查询。这样做导致大量冗余和效率低下的数据库操作,尤其是在处理有大量重复关联数据的查询时更为明显。

例如,考虑一个简单的场景,你的 GraphQL 服务提供一个查询接口,返回一个帖子列表,每个帖子都包含作者的信息。如果不加优化,每获取一个帖子的作者信息,就可能会触发一个独立的数据库查询。这就是 N + 1 问题的典型示例。在这个例子中,如果请求的是 10 个帖子的信息,理论上至少需要执行 11 次数据库查询(1次获取帖子列表,加上每个帖子的作者信息各 1 次)。

query {
  posts(first: 10) {
    id
    title
    author {
      name
    }
  }
}

# SELECT * FROM post;
# [
#   { post_id: 1, author_id: 1 },
#   { post_id: 2, author_id: 1 },
#   { post_id: 3, author_id: 2 }
# ]
# SELECT * FROM user WHERE id = 1;
# SELECT * FROM user WHERE id = 1;
# SELECT * FROM user WHERE id = 2;
# ...

使用 DataLoader 解决 N + 1 问题

为了解决这个问题,可以采用 DataLoader 这个工具。DataLoader 是一个为 GraphQL 设计的工具库,它通过批处理和缓存机制来优化数据加载过程,从而减少不必要的数据库访问。

  1. 批处理(Batching):DataLoader 会暂存所有单个请求周期内的资源请求,然后将这些请求合并为一个批量请求。这样做的好处是显而易见的:减少了数据库查询的次数。回到前面的例子,DataLoader可以将对同一作者的多次查询合并为一次查询,大大减少数据库操作。
  2. 缓存(Caching):DataLoader 还提供了一个简单的缓存机制。当一个资源被请求并返回后,它的结果会被缓存。如果在同一个请求周期内有对同一资源的后续请求,DataLoader 会直接从缓存中提供数据,而不是再次查询数据库。这进一步减少了不必要的数据库访问。

使用 DataLoader 不仅可以显著提高数据获取的效率,还可以减少数据库的负载,特别是在面对大规模、高并发的应用场景时,这种优化尤为重要。然而,值得注意的是,DataLoader 的缓存机制并不适用于所有情况,特别是在数据频繁变化的场景下,需要谨慎使用以避免数据过时的问题。

DataLoader 实现原理

在 JavaScript 中,DataLoader 利用 Promise 和事件循环(Event Loop)来实现其批处理功能。下面详细解释这个过程:

  1. Promise
  2. 收集和批处理请求
  3. 事件循环和微任务
  4. 执行批处理操作
  5. 优化性能

这种结合 Promise 和事件循环的方法使 DataLoader 能够高效地合并和批处理数据请求,同时保持非阻塞性能和对异步操作的优雅处理。

总结

在本文中,我们深入探讨了在生产环境中使用GraphQL的一些关键实践,包括如何有效地处理日志,如何解决 N + 1 问题,以及如何实施有效的速率限制。这些实践可以帮助我们更好地管理和优化GraphQL服务,保证其稳定性和性能。

下期分享预告(GraphQL 客户端实践)

在我们的下一期分享中,我们将深入探讨 Apollo Client 的使用以及 GraphQL Codegen。我们将从实践的角度,详细介绍这些工具的使用方法以及它们在生产环境中的应用。

  • Apollo Client:Apollo 是一个非常强大的 GraphQL 客户端,我们将介绍如何使用 Apollo Client 来管理我们的数据和状态。
  • GraphQL 编辑器插件:将介绍如何在 VSCode 使用 GraphQL 编辑器插件来提高代码的编写效率和准确性。
  • GraphQL Codegen:最后,我们将介绍 GraphQL Codegen,这是一个用于生成类型安全的 GraphQL 查询的工具。我们将演示如何使用这个工具来提高我们代码的质量和安全性。

敬请期待!