Chapter 06

Resolver 设计与 DataLoader

解析 GraphQL 执行机制,用 DataLoader 彻底解决 N+1 问题

Resolver 函数签名

每个 GraphQL 字段都有一个对应的 Resolver 函数。Resolver 接受四个参数:

// (parent, args, context, info) => value | Promise<value>
type Resolver = (
  parent: T,      // 父字段解析的返回值
  args: Args,     // 字段的参数(如 user(id: "1") 中的 {id: "1"})
  context: Context, // 跨 Resolver 共享的数据(DB连接、用户信息、DataLoader等)
  info: GraphQLResolveInfo // 查询的元信息(字段名、路径、Schema等)
) => T | Promise<T>;

Resolver 链(Resolver Chain)

const resolvers = {
  // 根查询 Resolver
  Query: {
    users: async (_, __, { db }) => {
      return db.users.findMany();
      // 返回 User 对象数组,parent = 每个 User 对象
    },
  },

  // User 类型的字段 Resolver
  User: {
    // parent 是上面返回的 User 对象
    fullName: (parent) => `${parent.firstName} ${parent.lastName}`,

    // 关联字段:从 parent.id 加载帖子
    posts: async (parent, { limit = 10 }, { db }) => {
      return db.posts.findMany({
        where: { authorId: parent.id },
        take: limit,
      });
    },
  },
};

N+1 问题根因

假设查询 10 个用户的帖子:

query {
  users {      # 查询1次:SELECT * FROM users  (返回10条)
    name
    posts {    # 查询10次:SELECT * FROM posts WHERE authorId = ?
      title
    }
  }
}

这会触发 1 + 10 = 11 次数据库查询——这就是 N+1 问题。当用户数量增大时,数据库查询会线性增长。

DataLoader:批处理解决方案

DataLoader 工作原理

DataLoader 使用批处理(batching)缓存(caching)两个机制:

import DataLoader from 'dataloader';

// 创建 DataLoader:批处理函数接受 keys 数组,返回对应 values 数组
function createUserLoader(db: PrismaClient) {
  return new DataLoader<string, User>(
    async (userIds: ReadonlyArray) => {
      // 批量查询:1次查询获取所有用户
      const users = await db.users.findMany({
        where: { id: { in: [...userIds] } },
      });

      // 按 key 顺序返回(DataLoader 要求结果顺序与 keys 顺序一致)
      const userMap = new Map(users.map(u => [u.id, u]));
      return userIds.map(id => userMap.get(id) ?? new Error(`User ${id} not found`));
    }
  );
}

在 Context 中注入 DataLoader

// Context 创建(每个请求一个新的 DataLoader 实例)
const server = new ApolloServer({ typeDefs, resolvers });

await startStandaloneServer(server, {
  context: async ({ req }) => ({
    db,
    user: await getUserFromToken(req.headers.authorization),
    // 每个请求创建新的 DataLoader(避免请求间缓存泄漏)
    loaders: {
      user: createUserLoader(db),
      posts: createPostsByAuthorLoader(db),
    },
  }),
});

使用 DataLoader

const resolvers = {
  Post: {
    // 不再直接查数据库,而是使用 DataLoader
    author: (parent, _, { loaders }) => {
      return loaders.user.load(parent.authorId);
      // DataLoader 会在同一 tick 收集所有 authorId,批量查询
    },
  },
};

// 效果:原来 N+1 次查询,现在变成 2 次查询!
// 查询1:SELECT * FROM posts WHERE ...(获取帖子)
// 查询2:SELECT * FROM users WHERE id IN ('u1','u2','u3',...)(批量获取作者)

按关联字段批量加载

// 按作者 ID 批量加载帖子(一对多关系)
function createPostsByAuthorLoader(db: PrismaClient) {
  return new DataLoader<string, Post[]>(
    async (authorIds) => {
      const posts = await db.posts.findMany({
        where: { authorId: { in: [...authorIds] } },
      });

      // 按 authorId 分组
      const grouped = new Map<string, Post[]>();
      for (const post of posts) {
        const arr = grouped.get(post.authorId) ?? [];
        arr.push(post);
        grouped.set(post.authorId, arr);
      }
      return authorIds.map(id => grouped.get(id) ?? []);
    }
  );
}
DataLoader 的缓存范围

DataLoader 的缓存是请求级别的——在同一个 GraphQL 请求中,相同 key 只查询一次。请求结束后,整个 DataLoader 实例(包括缓存)被丢弃。这就是为什么要在 context 函数中每次请求创建新的 DataLoader,而不是全局单例。

Resolver 执行机制详解

默认字段 Resolver
如果不为某个字段提供 Resolver,GraphQL 会使用默认 Resolver:直接返回 parent[fieldName]。例如 User 类型的 name 字段,如果父对象有 name 属性,则无需显式写 Resolver。
Resolver 链执行顺序
GraphQL 引擎自顶向下执行:先执行根 Query Resolver 获取父对象,再并行执行所有子字段的 Resolver(同级字段并发,不同层级顺序执行)。这意味着同一层级的多个 Resolver 可能同时触发 DataLoader 批处理。
info 参数的用途
第四个参数 info: GraphQLResolveInfo 包含查询 AST(fieldNodes)、路径(path)、返回类型(returnType)等元信息。可用于字段级日志、按需加载(只有当客户端请求了某字段才触发查询)、复杂度计算等高级场景。
异步 Resolver 与错误处理
Resolver 可以返回 Promise,GraphQL 会自动等待解析完成。若 Resolver 抛出错误,该字段在响应中值为 null,错误信息出现在顶层 errors 数组中——GraphQL 的部分成功设计让一个字段失败不影响整个请求。

按需查询优化(info 参数应用)

import { GraphQLResolveInfo } from 'graphql';

// 工具函数:检查客户端是否请求了某个字段
function hasField(info: GraphQLResolveInfo, fieldName: string): boolean {
  return info.fieldNodes[0].selectionSet?.selections.some(
    (s) => s.kind === 'Field' && s.name.value === fieldName
  ) ?? false;
}

const resolvers = {
  Query: {
    user: async (_, { id }, { db }, info) => {
      // 只有当客户端请求了 posts 字段时,才 include posts
      // 避免无谓的 JOIN 查询
      const includePosts = hasField(info, 'posts');

      return db.users.findUnique({
        where: { id },
        include: { posts: includePosts },
      });
    },
  },
};

DataLoader 进阶:缓存失效与强制刷新

// DataLoader 支持手动清除缓存,适合 Mutation 后刷新数据
const resolvers = {
  Mutation: {
    updateUser: async (_, { id, data }, { db, loaders }) => {
      const updated = await db.users.update({ where: { id }, data });

      // 清除该用户的缓存,后续 load 会重新查询
      loaders.user.clear(id);

      // 或者主动填充新数据到缓存,避免额外查询
      loaders.user.prime(id, updated);

      return updated;
    },
  },
};
DataLoader 常见误区

本章小结

本章核心要点