Skip to content

GraphQL 与 REST API 架构选型权衡

GraphQL 与 REST API 架构选型权衡

在设计前后端交互的 API 接口体系时,RESTful 长期占据着统治地位。然而,随着前端业务界面的组件化与多端异构化(Web 宽屏、移动端 App、小程序、智能手表),REST API 的固定数据返回结构暴露出严重的效率痛点。

由 Facebook 开源的 GraphQL 带来了“按需声明式数据获取”的新范式。但与此同时,GraphQL 也引入了缓存困难、查询复杂度不可控以及经典的 N+1 数据库并发查询灾难


一、REST 与 GraphQL 核心架构特性全景对比

架构特性 传统 RESTful API 现代化 GraphQL API
端点设计 多个离散的资源 URI(如 /api/users/1, /api/orders 单一 HTTP POST 入口(如 /graphql
数据获取形态 服务端决定返回字段,易出现 过度获取 (Over-fetching)不足获取 (Under-fetching) **客户端按需精确声明**所需的字段集合,零冗余带宽消耗
类型契约体系 依赖外部 OpenAPI / Swagger 文档,容易与实现漂移 强类型 Schema 定义语言 (SDL),原生内置强校验与自动补全
HTTP 缓存 完美契合 HTTP 协议原生缓存(Cache-Control, ETag, CDN 缓存) 由于全是 POST 请求,难以利用基础 HTTP/CDN 缓存(需专有持久化查询)
安全与限流 易于按 API 路由 URL 进行 IP/QPS 精确限流 客户端可构造极深嵌套的恶意查询,需**按查询复杂度 (Query Complexity)** 计费防御
graph TD
    subgraph REST_Multi_Round [REST: 客户端多次往返 (3 次 HTTP 请求)]
        Client1[客户端] -->|1. GET /api/users/10| S1[返回用户基础信息]
        Client1 -->|2. GET /api/users/10/posts| S2[返回用户文章列表]
        Client1 -->|3. GET /api/posts/99/comments| S3[返回文章评论]
    end

    subgraph GraphQL_Single_Round [GraphQL: 单次请求精确聚合]
        Client2[客户端] -->|POST /graphql (携带包含 user/posts/comments 的 query)| GQL[GraphQL 聚合解析网关]
        GQL --> UnifiedData[一次性返回按需定制的精准 JSON 树]
    end

二、GraphQL 核心命门:N+1 查询灾难与 DataLoader 破解

如果直接朴素地编写 GraphQL 解析器(Resolver),当查询“20 篇文章及其各自作者”时: - 主查询执行 1 次:SELECT * FROM posts LIMIT 20; - 子字段解析器针对每篇文章单独执行 1 次:SELECT * FROM users WHERE id = ?;(执行 20 次!) - 合计执行 \(1 + 20 = 21\) 次数据库查询

基于 DataLoader 的批量合并与缓存实战

DataLoader 通过利用 Node.js 事件循环的 process.nextTick,在单次微任务周期内将多个离散的 ID 收集聚合成单次批量 IN (?, ?, ...) 查询:

// services/user.dataloader.ts
import DataLoader from 'dataloader';
import { db } from '@/lib/db';

export interface User {
  id: string;
  name: string;
  avatar: string;
}

// 创建 DataLoader 实例:接收批量 ID 数组,返回等长且对应顺序的结果数组
export function createUserDataLoader() {
  return new DataLoader<string, User>(async (userIds: readonly string[]) => {
    console.log(`🔥 DataLoader 批量合并查询: ${userIds.length} 个用户 ID`);

    // 仅执行 1 次批量 SQL 查询: SELECT * FROM users WHERE id IN (...)
    const users = await db.user.findMany({
      where: {
        id: { in: [...userIds] },
      },
    });

    // 映射回与入参 ID 顺序严格对应的数组
    const userMap = new Map(users.map((u) => [u.id, u]));
    return userIds.map((id) => userMap.get(id) || new Error(`User not found: ${id}`));
  });
}

在 GraphQL 解析器中使用 DataLoader:

// schema/resolvers.ts
export const resolvers = {
  Query: {
    posts: async (_parent: any, _args: any, { db }: any) => {
      return db.post.findMany({ take: 20 });
    },
  },
  Post: {
    // 解析每篇文章的 author 关联字段
    author: async (post: any, _args: any, { userDataLoader }: any) => {
      // 触发 DataLoader 收集,而非立即打数据库
      return userDataLoader.load(post.authorId);
    },
  },
};

三、生产安全防线:查询复杂度与深度限制

为了防止恶意用户构造诸如 user { posts { author { posts { author ... } } } } 的无限递归 DoS 攻击,必须在网关层挂载复杂度分析插件:

// server/security.ts
import { createComplexityLimitRule } from 'graphql-validation-complexity';
import depthLimit from 'graphql-depth-limit';

export const validationRules = [
  // 1. 限制查询嵌套最大深度不超过 5 层
  depthLimit(5),

  // 2. 限制单次查询总计算权重不超过 1000 点
  createComplexityLimitRule(1000, {
    onCost: (cost: number) => console.log('Query Cost:', cost),
    formatErrorMessage: (cost: number) => `查询复杂度超出安全阈值: 评估消耗 ${cost} 点`,
  }),
];

四、最终选型指南

  1. 果断选择 RESTful 的场景
  2. 绝大部分标准 CRUD 内部后台管理系统;
  3. 依赖边缘 CDN 进行大规模公共数据静态缓存(如天气预报、公共新闻);
  4. 面向第三方开发者公开开放的标准 OpenAPI 生态。
  5. 果断选择 GraphQL 的场景
  6. 多端差异巨大(移动端需要极度精简字段、PC 端需要富数据);
  7. 微服务底层作为 BFF (Backend For Frontend) 聚合层,将数十个底层 RPC 聚合为单一视图;
  8. 页面具有复杂的网状图谱关系(如社交关系、电商复杂品类与 SKU 关联)。