1. GraphQL 与 REST 的取舍
一句话总结: GraphQL 把「取什么数据」的决定权交给客户端,适合多端聚合与快速演进的前端;但它把缓存、限流、鉴权的复杂度从网络层搬到了应用层。
REST 的问题是响应形状由服务端固定:移动端只想要订单列表的三个字段,却拿到了包含明细、地址、日志的完整对象;另一个页面需要订单加用户加物流,只能发三次请求再在前端拼装。GraphQL 用一个端点、一份强类型 Schema 解决这两个问题:客户端声明它要的字段,服务端按需返回。
代价同样明确:HTTP 层的缓存(CDN、代理)对单一 POST 端点几乎失效,必须靠持久化查询与应用层缓存补回;限流不能按路径做,要按查询复杂度做;鉴权从「这个端点谁能访问」变成「这个字段谁能访问」。
| 维度 | REST | GraphQL |
|---|---|---|
| 响应形状 | 服务端固定 | 客户端声明 |
| 请求次数 | 多资源需多次 | 一次拿全 |
| HTTP 缓存 | 天然支持 | 基本失效 |
| 版本管理 | 路径版本 | Schema 演进 + 弃用 |
| 限流 | 按端点 | 按查询复杂度 |
| 鉴权粒度 | 端点级 | 字段级 |
| 学习曲线 | 低 | 中高 |
| 工具链 | 成熟 | 成熟但更复杂 |
// 一次查询拿到订单、下单用户与商品名,服务端只查必要字段
// query {
// order(id: 42) {
// id
// total
// customer { name }
// items { product { name } quantity }
// }
// }
避坑: 不要因为「GraphQL 是新技术」就把它套在所有场景上。文件上传、简单 CRUD、需要 CDN 缓存的公开只读接口,REST 往往更简单更便宜。GraphQL 真正的主场是「多个客户端对同一份数据有不同视图需求」,比如后台管理系统、移动端与 Web 端共享同一份 API。
2. Schema 设计
一句话总结: Schema 是服务端与客户端之间的契约,应该按「领域对象」建模而不是按「数据库表」建模,并把可空性与分页约定在一开始就定死。
Schema 设计的第一原则是面向能力而非表结构。数据库里的 orders 表有 20 列,不代表 GraphQL 里就该暴露 20 个字段——暴露的是客户端真正需要的领域概念。第二原则是可空性从宽:把一个字段声明为非空,日后想改成可空是破坏性变更,反过来则安全。
// 用 C# 类型定义 Object Type,字段与领域模型对齐
public sealed class OrderType : ObjectType<Order>
{
protected override void Configure(IObjectTypeDescriptor<Order> descriptor)
{
descriptor.Field(o => o.Id).Type<NonNullType<IdType>>();
descriptor.Field(o => o.Total).Type<NonNullType<DecimalType>>();
descriptor.Field(o => o.Status).Type<NonNullType<EnumType<OrderStatus>>>();
// 计算字段:不落在数据库列上,由 Resolver 按需计算
descriptor.Field("itemCount")
.Type<NonNullType<IntType>>()
.Resolve(ctx => ctx.Parent<Order>().Items.Count);
// 明确标记弃用,客户端工具会给出提示
descriptor.Field(o => o.LegacyCode)
.Deprecated("改用 id 字段");
}
}
// 分页:用 Relay 风格的 Connection,把游标分页约定固化进 Schema
public sealed class Query
{
[UsePaging(MaxPageSize = 100, DefaultPageSize = 20)]
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<Order> GetOrders([Service] AppDbContext db) => db.Orders;
}
| 设计决策 | 推荐做法 | 反例 |
|---|---|---|
| 可空性 | 默认可空,确认后才标非空 | 全部标非空 |
| 分页 | Relay Connection + 游标 | 偏移量分页 |
| 命名 | 与领域一致(customer) | 与表名一致(tbl_cust) |
| 枚举 | GraphQL 枚举,不用魔法数字 | 返回 int 状态码 |
| 错误 | 顶层 errors + 字段级 null | 把错误塞进 data |
| 弃用 | @deprecated 指令 | 直接删字段 |
避坑: 把数据库实体直接当 GraphQL 类型暴露,会让 Schema 随数据库迁移而漂移——加一列就多一个字段,改列名就是破坏性变更。正确做法是定义独立的 GraphQL 类型并在 Resolver 里做映射。另外,
UseProjection能让 EF Core 只 SELECT 客户端要的列,但一旦 Resolver 里出现ToList()或自定义映射,投影优化就会失效。
3. Resolver 与依赖注入
一句话总结: Resolver 是字段的取值函数,应保持「薄」——只做取数与映射,业务逻辑放在注入的领域服务里。
HotChocolate 的 Resolver 有两种写法:基于约定的属性/方法名(GetXxx),或显式 .Resolve()。前者简洁,后者可控。无论哪种,依赖都通过 [Service] 特性或构造函数注入获取,作用域默认是每个请求一个 DI Scope。
// 显式 Resolver:参数绑定清晰,便于加缓存与日志
public sealed class OrderResolvers
{
public Task<Customer?> GetCustomerAsync(
[Parent] Order order,
[Service] ICustomerService customers,
CancellationToken ct)
=> customers.FindAsync(order.CustomerId, ct);
public async Task<IReadOnlyList<OrderEvent>> GetTimelineAsync(
[Parent] Order order,
[Service] IOrderEventStore events,
[Service] IMemoryCache cache,
CancellationToken ct)
{
var key = $"order:{order.Id}:timeline";
return await cache.GetOrCreateAsync(key, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
return await events.ListAsync(order.Id, ct);
})!;
}
}
// 注册:把 Query 与 Resolver 都挂上,并开启全局能力
builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddType<OrderType>()
.AddTypeExtension<OrderResolvers>()
.AddFiltering()
.AddSorting()
.AddProjections()
.AddInMemorySubscriptions()
.ModifyRequestOptions(o => o.IncludeExceptionDetails = builder.Environment.IsDevelopment());
| Resolver 写法 | 适用 | 可测试性 |
|---|---|---|
| 约定方法(GetXxx) | 简单字段 | 中 |
| 显式 Resolve 表达式 | 需要精细控制 | 中 |
| 类型扩展类 | 大量字段分组 | 高 |
| 中间件管线 | 横切逻辑 | 高 |
避坑: Resolver 里不要做写操作(Mutation 除外)——Query 字段可能被并发执行,且客户端可以任意组合字段,把副作用放在 Query 里会让执行顺序变得不可预测。另一个坑是把
DbContext注册为单例或跨请求复用:GraphQL 请求内多个 Resolver 会并发执行,DbContext不是线程安全的,必须按请求作用域注入,且用IDbContextFactory处理并发场景。
4. DataLoader 批处理
一句话总结: N+1 是 GraphQL 最经典的性能陷阱,DataLoader 把同一轮执行中分散的按 ID 查询合并成一次批量查询,从根本上消解它。
考虑查询「10 个订单,每个订单的客户名」。若 GetCustomerAsync 逐个查库,就是 1 + 10 = 11 次查询。客户端再嵌套一层商品,就是 1 + 10 + 100 次。DataLoader 的核心思路是在同一执行轮次内收集所有 ID,合并成一次 WHERE id IN (...)。
// 批量数据加载器:一次查询取回全部客户
public sealed class CustomerBatchDataLoader(
IDbContextFactory<AppDbContext> factory,
IBatchScheduler scheduler) : BatchDataLoader<int, Customer>(scheduler)
{
protected override async Task<IReadOnlyDictionary<int, Customer>> LoadBatchAsync(
IReadOnlyList<int> keys, CancellationToken ct)
{
await using var db = await factory.CreateDbContextAsync(ct);
return await db.Customers
.Where(c => keys.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, ct);
}
}
// Resolver 改用 DataLoader,签名从「单个 ID」变成「一批 ID」
public sealed class OrderResolvers
{
public Task<Customer?> GetCustomerAsync(
[Parent] Order order,
CustomerBatchDataLoader loader,
CancellationToken ct)
=> loader.LoadAsync(order.CustomerId, ct); // 自动合并同轮次的请求
}
// 分组加载器:当批量查询的 key 不是主键,而是外键时使用
public sealed class ItemsByOrderIdDataLoader(
IDbContextFactory<AppDbContext> factory,
IBatchScheduler scheduler)
: GroupedDataLoader<int, OrderItem>(scheduler)
{
protected override async Task<ILookup<int, OrderItem>> LoadGroupedBatchAsync(
IReadOnlyList<int> keys, CancellationToken ct)
{
await using var db = await factory.CreateDbContextAsync(ct);
var items = await db.OrderItems.Where(i => keys.Contains(i.OrderId)).ToListAsync(ct);
return items.ToLookup(i => i.OrderId);
}
}
| 场景 | 加载器类型 | 合并方式 |
|---|---|---|
| 按主键批量取 | BatchDataLoader | WHERE id IN (...) |
| 按外键取子集合 | GroupedDataLoader | ToLookup |
| 需要缓存跨请求 | CacheDataLoader | 内存缓存 |
| 数据源不支持批量 | 自定义 + 缓存 | 并发 + 去重 |
避坑: DataLoader 只能合并同一批次(同一个执行轮次)内的请求,如果一个 Resolver 在返回前
await了别的远程调用,后续字段就落到了下一轮,合并会失效。排查时打开 HotChocolate 的执行诊断,观察是否出现「批次数量异常增长」。另一个坑是 DataLoader 默认按请求作用域缓存,跨请求共享会读到过期数据。
5. 订阅与实时
一句话总结: 订阅把 GraphQL 从请求响应模型扩展到推送模型,服务端发布事件、客户端订阅字段,底层通常走 WebSocket 或 SSE。
订阅在 Schema 上表现为 Subscription 类型,字段返回一个 IAsyncEnumerable<T>。HotChocolate 负责把事件流推给订阅的客户端,传输层可以是 WebSocket(graphql-transport-ws 协议)或 SSE。
// 订阅类型:返回异步流,事件到达即推送
public sealed class Subscription
{
[Subscribe]
[Topic($"{{{nameof(orderId)}}}")]
public OrderStatusChanged OnOrderStatusChanged(
int orderId,
[EventMessage] OrderStatusChanged message) => message;
}
// Mutation 中发布事件,主题按订单 ID 区分
public sealed class Mutation
{
public async Task<Order> UpdateStatusAsync(
int id,
OrderStatus status,
[Service] IOrderService orders,
[Service] ITopicEventSender sender,
CancellationToken ct)
{
var order = await orders.UpdateStatusAsync(id, status, ct);
await sender.SendAsync($"onOrderStatusChanged:{id}",
new OrderStatusChanged(id, status), ct);
return order;
}
}
// 生产环境用 Redis 做事件总线,让多实例部署也能正确推送
builder.Services
.AddGraphQLServer()
.AddSubscriptionType<Subscription>()
.AddRedisSubscriptions(_ => ConnectionMultiplexer.Connect("redis:6379"));
// 客户端订阅示例:
// subscription {
// onOrderStatusChanged(orderId: 42) { orderId status }
// }
| 传输方式 | 协议 | 断线重连 | 适用 |
|---|---|---|---|
| WebSocket | graphql-transport-ws | 客户端负责 | 双向、低延迟 |
| SSE | text/event-stream | 浏览器自动 | 只读推送 |
| 轮询 | HTTP | 无 | 兜底方案 |
避坑: 订阅最容易忽略的是鉴权——WebSocket 建立连接时鉴权一次,但订阅本身也要校验「这个用户是否有权订阅这个订单」。另外,内存订阅(
AddInMemorySubscriptions)只在单实例下正确,多实例部署必须换成 Redis 或消息队列作为事件总线,否则事件只会推给发布者所在的那台机器。
6. 性能与安全防护
一句话总结: GraphQL 开放了查询自由度,就必须用深度限制、复杂度分析与持久化查询把这份自由关进笼子,否则一次恶意查询就能拖垮数据库。
风险有三类:深度炸弹(深层嵌套导致指数级查询)、广度炸弹(大量字段与别名导致重复执行)、内省泄露(生产环境暴露完整 Schema 结构)。
// 防护配置:深度限制 + 复杂度分析 + 生产关闭内省
builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.ModifyRequestOptions(o => o.ExecutionTimeout = TimeSpan.FromSeconds(10))
.AddMaxExecutionDepthRule(maxAllowedDepth: 12)
.AddCostAnalyzer(options => options.DefaultCost = 1) // 字段级成本
.DisableIntrospection(!builder.Environment.IsDevelopment())
.AddPagingArguments();
// 字段级成本:列表字段按分页上限计费,防止一次拉取百万行
public sealed class OrderType : ObjectType<Order>
{
protected override void Configure(IObjectTypeDescriptor<Order> descriptor)
{
descriptor.Field(o => o.Items)
.Cost(10) // 列表字段成本更高
.UsePaging(MaxPageSize = 50);
}
}
| 防护手段 | 拦住的问题 | 配置位置 |
|---|---|---|
| 深度限制 | 深层嵌套炸弹 | MaxExecutionDepthRule |
| 复杂度限制 | 广度炸弹 | CostAnalyzer |
| 执行超时 | 慢查询 | ExecutionTimeout |
| 持久化查询 | 任意查询与注入 | AllowOnlyPersistedQueries |
| 关闭内省 | Schema 泄露 | DisableIntrospection |
| 分页上限 | 全表拉取 | MaxPageSize |
避坑: 复杂度限制的阈值如果设得太紧,会误伤正常客户端;太松则形同虚设。稳妥做法是先采集一段时间的真实查询复杂度分布,取 P99 的 2~3 倍作为初始阈值,再根据误报调整。另外,GraphQL 的 POST 请求默认不带
Content-Type校验时,某些代理会把它当表单处理,要显式要求application/json。
7. 生产实践与版本演进
一句话总结: GraphQL 没有「版本号」,演进靠的是「只加不删」与
@deprecated指令,配合字段级遥测判断何时可以真正删除。
REST 用 /v2/ 表达不兼容变更,GraphQL 的哲学是持续演进:新增字段永远安全,删除或改类型则通过弃用流程。关键是能回答「还有谁在查这个字段」——需要字段级使用遥测。
// 字段级遥测:记录每个字段的调用次数与调用方
public sealed class FieldUsageDiagnosticListener : IDiagnosticEventListener
{
private readonly Meter _meter = new("graphql.usage");
public override void ExecuteOperation(IRequestContext context)
{
// 从 context.Operation 遍历 SelectionSet,上报字段路径
foreach (var path in context.Operation.GetFieldPaths())
{
_meter.CreateCounter<long>("graphql.field.calls")
.Add(1, new KeyValuePair<string, object?>("field", path));
}
}
}
# 部署侧:单一端点 + 持久化查询白名单,兼顾缓存与安全
apiVersion: apps/v1
kind: Deployment
metadata:
name: graphql-gateway
spec:
template:
spec:
containers:
- name: api
image: contoso/graphql:1.4.0
env:
- name: GraphQL__PersistedQueriesOnly
value: "true"
- name: GraphQL__MaxDepth
value: "12"
| 演进动作 | 是否安全 | 做法 |
|---|---|---|
| 新增字段 | 安全 | 直接加 |
| 新增可选参数 | 安全 | 直接加 |
| 新增枚举值 | 安全 | 客户端需处理未知值 |
| 改字段类型 | 破坏性 | 新增字段 + 弃用旧字段 |
| 删字段 | 破坏性 | 弃用 → 遥测确认无调用 → 删 |
| 收紧可空性 | 破坏性 | 不可行,保持可空 |
避坑: 客户端如果对枚举做了穷举
switch,新增枚举值会让它走到默认分支甚至抛异常。要么在契约里约定「必须处理未知枚举值」,要么把枚举字段改为字符串。另一个坑是弃用后忘记真正删除——@deprecated只是提示,字段依然占用维护成本与认知负担,应该像特性开关一样有清理工单。
8. 总结
| 环节 | 要点 |
|---|---|
| 取舍 | 多端视图差异大时选 GraphQL,只读缓存型接口选 REST |
| Schema | 按领域建模、可空性从宽、分页用 Relay Connection |
| Resolver | 保持薄,只取数与映射,业务逻辑注入领域服务 |
| 批处理 | DataLoader 合并同轮次请求,消除 N+1 |
| 订阅 | 异步流 + 事件总线,多实例必须用 Redis 分发 |
| 防护 | 深度限制、复杂度分析、超时、持久化查询、关闭内省 |
| 演进 | 只加不删,弃用靠遥测确认,删除要开工单 |
GraphQL 把 API 的控制权交给了客户端,因此服务端必须用更严格的护栏把这份自由约束在可控范围内:Schema 定义边界,DataLoader 守住性能,复杂度分析挡住滥用,字段遥测支撑演进。做对了这些,GraphQL 能让前端迭代速度显著提升;做漏了任何一环,它就会变成一次查询打穿数据库的事故来源。下一篇我们把视角从「单个服务的接口」抬升到「多个服务之间的事务」——当一次业务操作要跨越订单、库存、支付三个服务时,如何保证最终一致。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。