Если вы использовали GraphQL, вы, вероятно, сталкивались или слышали проблему N + 1.

Что это?

Когда мы выполняем следующий запрос, чтобы получить 100 лучших обзоров и соответствующие имена авторов, мы сначала делаем один вызов, чтобы получить 100 записей обзора из базы данных, а затем для каждого обзора мы делаем еще один вызов базы данных для получения сведений о пользователе. учитывая ID автора.

Запрос

{
  top100Reviews {
    body
    author {
      name
    }
  }
}

Схема

const typeDefs = gql`
  type User {
    id: ID!
    name: String
  }
  type Review {
    id: ID!
    body: String
    author: User
    product: Product
  }
  type Query {
    top100Reviews: [Review]
  }
`;

Резольверы

const resolver = {
  Query: {
    top100Reviews: () => get100Reviews(),
  },
  Review: {
    author: (review) => getUser(review.authorId),
  },
};

Мне нужно сделать много звонков. Кроме того, это большие накладные расходы на сеть.

DataLoader может помочь вам сократить накладные расходы, объединяя каждую отдельную загрузку сведений о пользователях в один вызов. Нам нужно просто предоставить реализацию пакетной функции.

const DataLoader = require('dataloader');
const userLoader = new DataLoader(ids => batchGetUsers(ids));
const resolvers = {
  Query: {
    top100Reviews: () => get100Reviews(),
  },
  Review: {
    author: (review) => userLoader.load(review.authorId),
  },
};

Когда вызывается каждый userLoader.load(review.authorId), он группируется и в конечном итоге вызывает batchGetUsers(). Таким образом, вместо 100 отдельных обращений к базе данных для получения сведений о пользователе то же самое можно сделать одним вызовом.

Какое отношение имеет N + 1 к Федерации Аполлонов?

Планировщик запросов в Apollo частично, но не полностью обрабатывает N + 1 за вас. Используя демонстрацию федерации в качестве примера, у нас есть служба для пользователей, которая имеет дело только с пользователями.

// User service
const typeDefs = gql`
  type User @key(fields: "id") {
    id: ID!
    name: String
    username: String
  }
`;
const resolvers = {
  User: {
    __resolveReference(object) {
      return getUser(object.id);
    }
  }
};

Другая служба проверки занимается только отзывами, хотя внутри Review у нас есть ссылка на User тип, который, как мы знаем, будет обрабатываться пользовательской службой.

// Review service
const typeDefs = gql`
  type Review @key(fields: "id") {
    id: ID!
    body: String
    author: User
    product: Product
  }

  extend type User @key(fields: "id") {
    id: ID! @external
  }
  extend type Product @key(fields: "upc") {
    upc: String! @external
  }
  extend type Query {
    top100Reviews: [Review]
  }
`;
const resolvers = {
  Review: {
    author(review) {
      return { __typename: "User", id: review.authorId };
    }
  },
  // ...
};

На шлюзе эти отдельные схемы объединяются в единую схему, и если мы выполняем тот же запрос, что и раньше, top100Reviews запрос делегируется и разрешается службой проверки, а разрешение author делегируется пользовательский сервис.

{
  top100Reviews {
    body
    author {
      name
    }
  }
}

Сначала мы могли бы подозревать, что при разрешении author будет выполнено несколько обращений к пользовательской службе, но это не так. Мы можем проверить утверждение, зарегистрировав тело входящего запроса в параметре context.

// User service
const server = new ApolloServer({
  schema: buildFederatedSchema([{ typeDefs, resolvers}]),
  context: ({ req }) => {
    console.log(JSON.stringify(req.body, null, 2));
  },
});

Мы видим, что планировщик запросов объединяет все идентификаторы пользователей в единый массив и отправляет массив представлений в службу пользователя, которая отвечает за разрешение ссылки.

{
  "query": "query ($representations: [_Any!]!) {\n  _entities(representations: $representations) {\n    ... on User {\n      name\n    }\n  }\n}",
  "variables": {
    "representations": [
      {
        "__typename": "User",
        "id": "1"
      },
      {
        "__typename": "User",
        "id": "2"
      },
      {
        "__typename": "User",
        "id": "1"
      },
      {
        "__typename": "User",
        "id": "2"
      },
      // ... too long to list them all
    ]
  }
}

Затем служба пользователей сопоставляет каждый отдельный идентификатор и вызывает __resolveReference() для разрешения каждого идентификатора пользователя. Хотя шлюз не выполняет несколько вызовов пользовательской службы, для разрешения ссылок по-прежнему требуется несколько вызовов базы данных из-за getUser().

// User service
const resolvers = {
  User: {
    __resolveReference(object) {
      return getUser(object.id);  // Bad
    }
  }
};

Решение

Мы можем исправить это, используя тот же userLoader.load() вместо getUser(). Загрузчик данных группирует все идентификаторы пользователей и эффективно выполняет один вызов базы данных.

// User service
const resolvers = {
  User: {
    __resolveReference(object) {
      return userLoader.load(object.id);  // Good
    }
  }
};

Резюме

Мы объясняем ловушку запроса N + 1 и способы решения проблемы с помощью загрузчика данных. Мы также обнаруживаем, что Apollo Gateway эффективно обрабатывает делегирование службам путем пакетной обработки ссылок, но наш преобразователь по-прежнему должен явно использовать загрузчик данных, чтобы избежать множественных обращений к базе данных.