查询能力的工程化,是 API 从"能查"到"好用"的分水岭。GraphQL 里"查一组数据"不只是 list 一个字段——客户端要过滤(只看某种状态)、搜索(关键词)、排序、分页、还要看聚合(总共有多少)。这些能力如果设计得好,客户端一次请求拿到完整结果;设计得烂,就是"一次 list 全查回来,客户端在内存里过滤"——既慢又泄数据。
本文给出过滤、搜索、排序分页与聚合的 GraphQL 工程化建模:参数设计、schema 表达、性能下推、以及授权与过滤的组合。
一、过滤器(Filter)参数化设计
1.1 从裸参数到 FilterInput
# 反模式: 为每个过滤条件加一个参数
# listOrders(status: String, channel: String, since: Date, ...)
# 问题: 参数爆炸、难扩展、命名乱
# 正模式: 结构化 FilterInput
# listOrders(filter: OrderFilter, pagination: PaginationInput, sort: SortInput)
input OrderFilter {
status: OrderStatus
statusIn: [OrderStatus!]
channel: String
createdAt: DateRange # 范围过滤
amount: NumericRange
keyword: String # 搜索(可选)
}
input DateRange { from: DateTime, to: DateTime }
input NumericRange { min: Float, max: Float }
1.2 过滤操作符的统一
# 过滤操作符设计(两种风格)
# 风格一: 字段内嵌操作符(GraphQL 规范建议)
# input FilterField { eq: String, contains: String, in: [String] }
# 风格二: 专门字段(dateRange/min/max 等语义化命名,可读性好)
# 取舍
# 风格一通用但复杂(嵌套深);风格二简单直接(字段即语义)
# 工程建议: 混合——常见精确/范围用风格二,灵活搜索用风格一/搜索
1.3 过滤的默认语义
# 约定
# 1) 过滤参数可空 = 不过滤(返回全量,配合分页)
# 2) 多条件组合 = AND(除非显式支持 OR)
# 3) 空结果返回空数组,不报错(过滤无匹配是正常结果)
# 4) 非法过滤值(枚举不存在)→ 校验错误 400,别静默忽略
# 5) 过滤键白名单: 别让客户端用任意字段过滤(索引/授权可控)
二、排序与分页的工程约定
2.1 排序设计
# 排序约定
# 1) 允许排序的字段白名单(sortKey enum,别让任意字段排)
# 2) SortInput { field: SortField!, direction: SortDirection }
# 3) 默认排序稳定(id 或主键),避免分页漂移
# 4) 多级排序: [SortInput!](按多个键)
# 5) 敏感字段不允许排序(防探测/泄露模式)
2.2 游标分页(推荐)
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
}
type OrderEdge { cursor: String!, node: Order! }
type PageInfo { hasNextPage: Boolean!, endCursor: String }
input PaginationInput { first: Int, after: String, last: Int, before: String }
# 游标分页要点(参考 Relay 连接规范)
# 1) 游标: base64 编码的"排序键 + 定位"(稳定、不受新增影响)
# 2) first/after 正向, last/before 反向
# 3) hasNextPage 决定"加载更多"
# 4) 别用 OFFSET: 数据变动时翻页错位/重复
# 5) 与过滤/排序组合: 游标基于"排序键"构建,过滤在游标外
2.3 深度分页的注意
# 深游标分页(百万级)
# 1) 游标查询走索引(按排序键 + 游标定位)
# 2) 别 SELECT * 再分页,投影下推
# 3) 大 offset 场景用"where 游标键 > x"替代 OFFSET
# 4) 数据一致性: 分页期间数据变化导致游标失效 → 容忍或重查
三、全文搜索的 GraphQL 建模
3.1 搜索字段的两种形态
# 形态一: 轻量搜索(LIKE/ILike)
# keyword: String → SQL LIKE '%kw%'
# 适用: 小型数据集、字段少
# 形态二: 全文搜索(Elasticsearch/外部检索引擎)
# query: String, filters, highlight, sort
# 适用: 大规模、相关性排序、faceted 搜索
# 决策: 数据量/相关性需求决定;别一上来就上 ES
3.2 搜索参数的 schema
input SearchQuery {
query: String!
filters: [FilterClause!] # 结构化过滤(见上文)
page: PaginationInput
sort: [SortInput!]
}
# 搜索返回连接(connection 风格,兼容现有消费)
type Query {
searchProducts(query: SearchQuery): ProductConnection!
}
3.3 搜索聚合与高亮
# 搜索结果的工程扩展
# 1) 高亮片段: highlight 字段(返回命中上下文)
# 2) 相关性: _score 排序(搜索专用排序键)
# 3) 聚合(facets): 见下节
# 4) 搜索建议/纠错: 单独端点或字段
# 注意: 搜索参数要防注入/超时/上限(maxHits)
四、聚合查询设计(Aggregation)
4.1 聚合与列表的关系
# 聚合 = "统计结果",不是"数据明细"
# 两种建模
# 1) 独立聚合字段: stats: OrderStats { total, avg, byStatus }
# 2) 连接带聚合: OrderConnection.stats(一次请求列表+统计)
# 实践: 列表页顶部"共 N 条、各状态分布" → 连接内嵌聚合最省一次往返
4.2 聚合类型的建模
# 订单列表 + 统计一次拿到
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
stats: OrderAggs @include(if: $withStats) # 可选聚合
}
type OrderAggs {
totalCount: Int!
totalAmount: Float!
byStatus: [StatusBucket!]!
}
type StatusBucket { status: OrderStatus!, count: Int!, amount: Float! }
# 聚合设计要点
# 1) 聚合作用域: 与列表同 filter(客户端所见一致)
# 2) 聚合代价: 全量统计(不走分页)——用 @include 控制按需
# 3) 大聚合: 走物化/预聚合,别每请求全量算
# 4) 聚合与行级授权: 统计也要过授权(别让越权者看到计数)
4.3 Faceted 搜索的聚合
# faceted 搜索 = 结果 + 各维度计数(电商筛选)
# 建模: SearchConnection { facets: [Facet!] }
# type Facet { field: FacetField!, buckets: [Bucket!]! }
# 实现: ES aggregation 或 SQL GROUP BY
# 注意: facet 计数基于"当前过滤后结果集",别混用全量
五、过滤与授权的组合
5.1 过滤不能泄露无权数据
# 危险: 过滤参数让客户端探测无权数据
# 例: listOrders(filter: {ownerId: 他人}) → 泄露他人订单
# 防护
# 1) 过滤键白名单(只允许可公开过滤的字段)
# 2) 行级授权与过滤合并: 服务端 WHERE = 客户端 filter AND 授权范围
# 3) 敏感字段不进过滤键(ownerId 等由上下文决定)
# 4) 聚合计数同样过授权(别让计数泄露规模)
5.2 服务端强制授权范围
// 客户端过滤 + 服务端授权范围(AND 合并)
const orders = await db.orders.find({
where: {
...clientFilter, // 客户端想要的
ownerId: ctx.user.id, // 服务端强制的(不可覆盖)
// scope: ctx.user.scope
},
});
// 铁律: 授权过滤在服务端拼接,客户端 filter 永远叠加其上
5.3 搜索的安全边界
# 搜索端点风险
# 1) 搜索结果泄露: 搜索也要行级授权(不只列表)
# 2) 搜索注入: 关键词转义/参数化(别拼接)
# 3) 资源耗尽: 大 OR 查询/通配搜索打爆 → 限长 + 超时 + 上限
# 4) 枚举探测: 搜索结果差异泄露"存在性" → 敏感数据不提供搜索
六、性能:过滤下推与索引
6.1 过滤下推原则
# 让过滤在数据源执行,别在应用层
# 1) 过滤器 → SQL WHERE(下推)
# 2) 搜索 → 检索引擎(下推)
# 3) 排序 → SQL ORDER BY(下推,走索引)
# 4) 聚合 → SQL GROUP BY / ES aggregation(下推)
# 反模式: list 全量回来内存过滤/排序 —— 慢 + 泄 + 内存炸
# 检查: trace 里"list 行数远大于返回行数"就是没下推
6.2 索引配合
# 过滤/排序字段要有索引
# 1) 高选择性过滤字段建索引
# 2) 排序 + 过滤组合建复合索引(排序列在前)
# 3) 范围过滤(时间/数值)配合索引 + 分区
# 4) 大表: 分区 + 过滤键对齐(分区裁剪)
# 5) 搜索字段: ES/倒排索引(别用 LIKE 扫全表)
6.3 聚合的性能
# 聚合是性能重灾区
# 1) 全量统计 → 物化/预聚合表(离线算好)
# 2) 高并发统计 → 缓存/聚合结果缓存
# 3) 大表 GROUP BY → 分区 + 预聚合
# 4) 列表 + 统计同请求 → 统计走物化,列表走明细,别混算
七、常见反模式
- 过滤不做、全查回来:客户端内存过滤,慢 + 泄数据 + 无法分页。
- 过滤参数爆炸:十个独立参数替代 FilterInput,维护灾难。
- 任意字段过滤:客户端用任意字段过滤,索引失效 + 授权绕过。
- OFFSET 分页:数据变动时翻页错位。用游标。
- 搜索裸 LIKE:大表全扫。数据量上来后换检索引擎。
- 聚合不授权:计数泄露数据规模。
- 排序键不可控:任意字段排序打爆索引/暴露模式。
Q1: FilterInput 和每个参数一个字段,怎么权衡?
推荐 FilterInput:结构化、可扩展、schema 自文档化。字段语义化命名(createdAt/statusIn/amount)比操作符嵌套更易读。灵活搜索场景再叠加操作符风格。
Q2: 搜索一定需要 Elasticsearch 吗?
不一定。小数据量 + 简单需求用 LIKE/ILIKE 足够;数据量上来了(万级以上)、要相关性排序、faceted 搜索时才上检索引擎。核心判断:相关性排序和复杂聚合是否需要。
Q3: 列表页的"总数"怎么算不卡?
统计走物化/预聚合(离线算好计数),列表走明细分页,两个查询分开算。别在列表查询里 COUNT 全表。用连接内嵌 stats + @include 按需控制。
Q4: 过滤会不会泄露无权数据?
会,如果过滤键不受控。防护:过滤键白名单 + 服务端强制授权范围(clientFilter AND serverScope)+ 敏感字段不进过滤键。铁律是"授权过滤服务端拼接,客户端 filter 只叠加"。
Q5: 分页 + 过滤 + 排序一起用,游标怎么算?
游标编码的是"排序键值 + 定位标识"。过滤在游标查询外层(WHERE 先过滤再按游标定位),排序决定游标键。三者组合时保证"排序键唯一稳定"(加主键后缀)防漂移。
一句话总结
GraphQL 数据查询的工程化,是把过滤、排序、分页、搜索、聚合统一成结构化的 schema 契约:FilterInput 表达过滤条件、连接规范承载分页、搜索与聚合字段各司其职,再叠加"过滤下推到数据源 + 索引配合 + 授权范围强制拼接"三条工程铁律——让客户端一次请求拿到精确、安全、高性能的结果集,而不是把整个数据集搬回客户端自己处理。
相关阅读
- GraphQL 游标分页 — 连接规范与深分页
- GraphQL Schema 设计进阶 — 输入类型与列表建模
- GraphQL 安全与防护 — 过滤/搜索的授权边界
- GraphQL Resolver 性能与 N+1 根治 — 过滤下推与批量
- GraphQL 认证与授权 — 行级授权与过滤组合
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。