GraphQL 过滤、搜索与聚合:查询数据的工程化

GraphQL 过滤、搜索与聚合建模实战:过滤器参数化设计(FilterInput/等值/范围/成员)、排序与分页的工程约定、全文搜索的 GraphQL 建模(Elasticsearch/搜索聚合)、统计与聚合查询设计(Aggregation 类型/分组统计)、过滤与授权的组合(行级过滤不泄露)、多字段搜索与 faceted 搜索、性能(过滤下推/索引)以及常见过滤器设计反模式。

查询能力的工程化,是 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」更多文章

  1. GraphQL 多态类型设计:Interface 与 Union 深度实践
  2. GraphQL 可观测性与链路追踪:从 resolver 指标到全链路
  3. GraphQL 文件上传与流式传输:multipart 到流式响应