抑制「n+1 问题」
让我们了解 Gato GraphQL 如何通过架构设计从根本上完全避免「n+1 问题」。
什么是「n+1 问题」
「n+1 问题」的基本含义是:针对数据库执行的 query 数量可能与图中节点数量一样多。
这意味着什么?让我们通过一个例子来说明:假设我们想获取一份导演列表,并通过以下 query 获取每位导演的电影:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
}
}
}
}为了高效处理,我们期望只需执行 2 次数据库 query 来获取数据:第 1 次获取导演数据,第 2 次获取所有导演的所有电影数据。
然而,为了满足这个 query,GraphQL 需要对数据库执行「n+1」次 query:首先执行 1 次以获取 N 位导演(本例中为 10 位)的列表,然后对每位导演分别执行 1 次 query 来获取其电影列表。在本例中,我们必须执行 1+10=11 次 query。
这个问题的根源在于 GraphQL resolver 每次只处理 1 个对象,而不是同时处理同类型的所有对象。在本例中,处理(作为根类型的)Query 类型对象的 resolver 会被调用一次以获取所有 Director 对象的列表,然后处理 Director 类型的 resolver 会对每个 Director 对象各调用一次,以获取其电影列表。
换句话说:GraphQL resolver 只见树木,不见森林。
这个问题实际上比最初看起来更严重,因为图中节点数量随图的层级数呈指数增长。因此,「n+1」这个名称仅适用于深度为 2 层的图。对于深度为 3 层的图,应该称之为「N2+n+1 问题」!以此类推……
例如,继续上面的例子,让我们在 query 中也添加每部电影的演员列表,如下所示:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
actors(first: 10) {
name
}
}
}
}
}这样,针对数据库执行的 query 为:首先执行 1 次以获取 10 位导演的列表,然后对每位导演各执行 1 次 query 以获取其电影列表,最后对每位导演的每部电影各执行 1 次 query 以获取演员列表。总计 1+10+100=111 次 query。
意识到这种行为后,「n+1 问题」很容易被视为 GraphQL 最大的性能障碍:如果不加以控制,查询几层深的图可能会变得极其缓慢,从而使 GraphQL 几乎失去实用价值。
「n+1 问题」的通用解决方案
「n+1 问题」的标准解决方案最初由工具库 DataLoader 提供。其策略非常简单:将 query 各部分的解析推迟到后期阶段,在该阶段可以通过单次 query 将同类型的所有对象一并解析。这种策略称为「批处理」,能有效解决「n+1」问题。
此外,DataLoader 会在获取对象后对其进行缓存,因此如果后续 query 需要加载已加载过的对象,可以跳过执行并直接从缓存中获取。这种策略称为「缓存」,主要是在「批处理」基础上的优化。
「批处理/延迟」解决方案的问题
从技术角度来说,「批处理」或「延迟」策略本身没有任何问题:它就是有效的。
(从现在起,我们仅将该策略称为「延迟」。)
然而问题在于,这种策略是事后补救:开发者可能先实现服务器,然后注意到解析 query 速度很慢,才决定引入延迟机制。因此,实现 resolver 可能涉及一些额外步骤,给开发过程增加了摩擦。此外,由于开发者必须了解「延迟」机制的工作原理,这使其实现比原本应有的更加复杂。
这个问题不在于策略本身,而在于 GraphQL 服务器将此功能作为附加组件提供,尽管没有它,query 可能慢到使 GraphQL 几乎失去实用价值。
因此,解决方案是明确的:「延迟」策略不应是附加组件,而应内置于 GraphQL 服务器本身。不应有「普通」和「延迟」两种 query 执行策略,而应只有一种:「延迟」。GraphQL 服务器必须执行「延迟」机制,即使开发者以「普通」方式实现 resolver(换言之,额外的复杂性由 GraphQL 服务器承担,而非开发者)。
这正是 Gato GraphQL 所做的。
使「延迟」成为 GraphQL 服务器执行的唯一策略
大多数 GraphQL 服务器的问题在于:解析对象类型(object、union 和 interface)为对象的责任,是由 resolver 在处理父节点时自行承担的(例如:films => directors),而不是将此任务委托给数据加载引擎。
Gato GraphQL 将这一责任从 resolver 转移到服务器的数据加载引擎,具体方式如下:
- resolver 在解析父节点与子节点之间的关系时,返回 ID 而不是对象
- 给定某类型的 ID 列表,
DataLoader实体从该类型中获取对应的对象 - 服务器的数据加载引擎充当这两部分之间的粘合剂:它首先从 resolver 获取对象 ID,然后在即将执行该关系的嵌套 query 之前(此时已积累了需要为该特定类型解析的所有 ID),通过
DataLoader获取这些 ID 对应的对象(从而可以高效地将所有 ID 包含在单次 query 中)。
这种方法可以概括为:「处理 ID,而非对象」。
让我们使用前面的例子来直观展示这种新方法。以下 query 获取导演列表及其电影:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
}
}
}
}请注意从每位导演获取的 2 个字段 name 和 films,以及它们目前的不同之处:
字段 name 是标量类型。由于可以预期 Director 类型的对象包含名为 name 的 string 类型属性(其中存储导演姓名),因此可以立即解析。因此,一旦获取了 Director 对象,无需执行额外 query 来解析此属性。
而字段 films 是对象类型的列表。通常无法立即解析,因为它引用了一个 Film 类型对象的列表,这些对象仍需通过 1 次或多次额外 query 从数据库中获取。因此,开发者需要为其实现「延迟」机制。
现在,让我们考虑不同的行为,并将字段 films 解析为 ID 列表(而非对象列表)。因为可以预期 Director 对象包含名为 filmIDs 的属性,其中存储所有电影的 ID(假设 ID 表示为字符串,类型为 array of string),那么该字段也可以立即解析,无需实现「延迟」机制。
最后,除了 ID 之外,resolver 还必须提供一项额外信息:期望对象的类型(在本例中可能是 [(Film, 2), (Film, 5), (Film, 9)])。不过,此信息是内部信息,传递给引擎,无需在 query 响应中输出。
在代码中实现适配后的方案
让我们看看 Gato GraphQL 如何在 PHP 代码中实现这种方案。以下代码演示了不同的 resolver(为了清晰起见,以下所有代码均经过编辑)。
FieldResolvers
FieldResolvers 接收特定类型的对象,并解析其字段。对于关系,还必须指明其解析到的对象类型。这是其接口契约:
interface FieldResolverInterface
{
public function resolveValue($object, string $field, array $args = []);
public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string;
}其实现如下所示:
class PostFieldResolver implements FieldResolverInterface
{
public function resolveValue($object, string $field, array $args = [])
{
$post = $object;
switch ($field) {
case 'title':
return $post->title;
case 'author':
return $post->authorID; // This is an ID, not an object!
}
return null;
}
public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string
{
switch ($field) {
case 'author':
return UserTypeResolver::class;
}
return null;
}
}请注意,通过移除处理 promise/延迟对象的逻辑,解析字段 author 的代码变得多么简洁明了。
TypeResolvers
TypeResolvers 是处理特定类型的对象:它们知道类型名称以及加载该类型对象的 TypeDataLoader 等信息。
数据加载引擎在解析字段时,会从特定的 TypeResolver 类获取 ID。然后,在获取这些 ID 对应的对象时,数据加载引擎会询问 TypeResolver 应使用哪个 TypeDataLoader 对象来加载这些对象。
其接口契约定义如下:
interface TypeResolverInterface
{
public function getTypeName(): string;
public function getTypeDataLoaderClass(): string;
}在本例中,UserTypeResolver 类定义了 User 类型的数据必须通过 UserTypeDataLoader 类来加载:
class UserTypeResolver implements TypeResolverInterface
{
public function getTypeName(): string
{
return 'User';
}
public function getTypeDataLoaderClass(): string
{
return UserTypeDataLoader::class;
}
}TypeDataLoaders
TypeDataLoaders 接收特定类型的 ID 列表,并返回该类型对应的对象。这是其接口契约:
interface TypeDataLoaderInterface
{
public function getObjects(array $ids): array;
}获取用户的方式如下:
class UserTypeDataLoader implements TypeDataLoaderInterface
{
public function getObjects(array $ids): array
{
$userAPI = UserAPIFacade::getInstance();
return $userAPI->getUsers($ids);
}
}执行一个(超级)大型 query
让我们验证这种策略是否有效。前往 Gato GraphQL 的 GraphiQL 客户端,执行以下 query。该 query 涉及深度为 10 层的图(posts => author => posts => tags => posts => comments => author => posts => comments => author),如果存在「n+1 问题」将无法在合理时间内解析。
query {
posts(pagination:{ limit:10 }) {
excerpt
title
url
author {
name
url
posts(pagination:{ limit:10 }) {
title
tags(pagination:{ limit:10 }) {
slug
url
posts(pagination:{ limit:10 }) {
title
comments(pagination:{ limit:10 }) {
content
date
author {
name
posts(pagination:{ limit:10 }) {
title
url
comments(pagination:{ limit:10 }) {
content
date
author {
name
username
url
}
}
}
}
}
}
}
}
}
}
}向下滚动结果,我们将看到响应有多庞大,涉及多少实体,检索了多少层级,而这一切都被迅速执行,毫无障碍。