梳理 ES 中常见的高风险查询类型及其危害机制,给出具体的识别标准和规避方案,帮助在设计阶段就避免引发集群不可用的查询写法。
ES 系列:ES 简介 · ES 查询最佳实践 · ES 集群与分片配置最佳实践 · ES 的查询过程分析
目录
| 章节 | 说明 |
|---|---|
| 高风险查询全景 | 8 类高风险查询的强制拦截阈值 |
| 深翻页(from+size) | 内存炸弹,引发 Full GC |
| 模糊查询(wildcard/regexp/fuzzy) | DFA 状态爆炸,CPU 打满 |
| 超大 terms 查询 | 查询体过大,解析耗时高 |
| 超大 should 子句 | 查询复杂度爆炸 |
| 超深嵌套聚合 | IO 和 CPU 同步飙升 |
| 全量扫描查询 | match_all + range 大范围扫描 |
| script 查询 | 逐文档执行,性能极差 |
| nested / parent-child | 内置 join,慢 10-100 倍 |
高风险查询全景
| 风险类型 | 强制拦截阈值 | 危害 |
|---|---|---|
from + size 过大 |
from + size > 10000 |
Full GC,OOM |
terms 参数数量过多 |
单个 terms 数量 > 1024 | 查询解析慢,内存消耗大 |
should 子句过多 |
子句数量 > 1024 | 查询复杂度 O(n²) 膨胀 |
regexp 状态机过大 |
max_determinized_states > 10000 |
CPU 被 DFA 构建打满 |
regexp 表达式过长 |
正则表达式 > 200 字符 | 同上 |
wildcard 字符串过长 |
通配符字符串 > 200 字符 | CPU 被 DFA 构建打满 |
wildcard 全通配 |
通配符字符串全为 * |
直接拦截,等价于全量扫描 |
terms 聚合嵌套过深 |
嵌套深度 > 3 层 | CPU + IO 同步飙升 |
terms 聚合 size 过大 |
size > 65536 或 shard_size > 65536 |
内存堆积 |
深翻页(from+size)
危害原理
查询:from=9000, size=100
每个分片需要返回前 9100 条 → 5 个分片返回 45500 条
协调节点排序汇总后取 100 条
→ 99.8% 的数据是无效传输
→ 分片越多、from 越大,内存消耗呈线性增长
→ 引发 Full GC,甚至 OOM
规避方案
// ✅ 方案1:search_after(实时翻页,推荐)
{
"size": 20,
"sort": [{ "createTime": "desc" }, { "_id": "asc" }],
"search_after": [1704067200000, "last_doc_id"]
}
// ✅ 方案2:scroll(批量导出,非实时)
POST /my_index/_search?scroll=1m
{
"size": 1000,
"query": { "match_all": {} }
}
// 后续翻页
POST /_search/scroll
{
"scroll": "1m",
"scroll_id": "DXF1ZXJ5..."
}
| 方案 | 适用场景 | 注意事项 |
|---|---|---|
from + size |
前几页(from < 1000) | 绝对不超过 10000 |
search_after |
实时逐页翻页(不支持跳页) | 必须有稳定的排序字段 |
scroll |
全量数据导出/迁移 | 有状态,占用资源,不适合实时查询 |
模糊查询(wildcard/regexp/fuzzy)
危害原理
wildcard、regexp、fuzzy 底层都需要构建有穷自动机(DFA):
查询词越长 → DFA 状态数越多 → CPU 计算量指数级增长
前后通配符 "*词*" → 需要遍历所有可能的状态 → 直接打满 CPU
严格限制
| 限制 | 说明 |
|---|---|
不得使用前后通配 *词* |
最危险,直接拦截 |
| 通配符字符串长度 ≤ 200 字符 | 超过则拦截 |
| 正则表达式长度 ≤ 200 字符 | 超过则拦截 |
max_determinized_states |
正则状态机状态数 ≤ 10000 |
替代方案
// ❌ 危险
{ "wildcard": { "name": { "value": "*手机壳*" }}}
{ "regexp": { "name": { "value": "手机.*壳" }}}
// ✅ 推荐:分词查询(大多数场景能满足需求,性能好 1 倍+)
{ "match": { "name": "手机壳" }}
// ✅ ES 7.9+ 的 wildcard 字段类型(专门优化,性能更好)
// 在 mapping 中定义
{
"mappings": {
"properties": {
"name_wildcard": { "type": "wildcard" }
}
}
}
// 查询
{ "wildcard": { "name_wildcard": { "value": "*手机*" }}}
超大 terms 查询
规范
- 单个
terms查询的值列表 不超过 100 个(推荐上限) - 硬拦截阈值:1024 个
// ❌ 超大 terms(可能导致查询解析超时、内存消耗大)
{
"terms": {
"skuId": [100001, 100002, ..., 100500] // 500 个
}
}
// ✅ 改造思路:
// 1. 分批查询(每批 ≤ 100),并发执行后合并结果
// 2. 考虑是否可以改用范围查询
// 3. 使用 bitset filter 或其他方案
子查询数量
bool查询的should子句数量 不超过 1024 个bool查询总子句数(must + should + filter)数量同样不能过多
超深嵌套聚合
危害
聚合嵌套每增加一层,需要遍历的数据量成倍增加:
1 层:遍历所有文档,按 date 分桶
2 层:每个 date 桶内,再按 supplierId 分桶
3 层:每个 supplierId 桶内,再按 skuId 分桶
→ 总计算量 = 日期数 × 供应商数 × SKU 数
规范
- 聚合嵌套深度 不超过 3 层
terms聚合的size不超过 65536terms聚合的shard_size不超过 65536
// ❌ 超深嵌套(4层:日期 × 供应商 × SKU × 指标)
{
"size": 0,
"aggs": {
"by_date": {
"date_histogram": { "field": "createTime", "calendar_interval": "day" },
"aggs": {
"by_supplier": {
"terms": { "field": "supplierId", "size": 100 },
"aggs": {
"by_sku": {
"terms": { "field": "skuId", "size": 100 },
"aggs": {
"total_sales": { "sum": { "field": "amount" } }
}
}
}
}
}
}
}
}
// 计算量 = 天数 × 供应商数 × SKU 数,轻松达到千万级桶
替代方案
// ✅ 方案1:拆分为多个独立查询并发执行,各自只有 1-2 层
// 查询1:按日期统计总销售额
{
"size": 0,
"aggs": {
"by_date": {
"date_histogram": { "field": "createTime", "calendar_interval": "day" },
"aggs": { "total_sales": { "sum": { "field": "amount" } } }
}
}
}
// 查询2:按供应商统计总销售额(并发执行,不嵌套)
{
"size": 0,
"aggs": {
"by_supplier": {
"terms": { "field": "supplierId", "size": 100 },
"aggs": { "total_sales": { "sum": { "field": "amount" } } }
}
}
}
// ✅ 方案2:composite 聚合分页(多维度组合 + 翻页,避免一次性拉全量桶)
{
"size": 0,
"aggs": {
"my_buckets": {
"composite": {
"size": 100,
"sources": [
{ "date": { "date_histogram": { "field": "createTime", "calendar_interval": "day" } } },
{ "supplier": { "terms": { "field": "supplierId" } } }
],
"after": { "date": 1704067200000, "supplier": "S_100" } // 翻页游标
},
"aggs": { "total_sales": { "sum": { "field": "amount" } } }
}
}
}
| 方案 | 适用场景 | 优点 |
|---|---|---|
| 拆分多个独立聚合 | 各维度结果互相独立 | 简单,并发执行快 |
composite 分页 |
需要多维度组合结果 | 内存可控,支持翻页 |
| 写入时预计算 | 查询频繁、维度固定 | 查询极快,零聚合开销 |
composite 聚合的分页原理
composite 不是一次性把所有桶装进内存,而是用游标逐页扫描:
所有 sources 按自然顺序排好序(日期升序、terms 字典序)
第1次请求(无 after):
→ 返回排序后的第 1~100 个组合桶
→ 响应中携带 after_key: { date: xxx, supplier: "S_100" }
第2次请求(带 after_key):
→ ES 找到 after_key 位置,从第 101 个桶开始读
→ 返回第 101~200 个组合桶
→ 携带新的 after_key
... 直到响应桶数 < size,代表已到末尾
每次只处理 size 个桶,处理完即丢弃,下一页重新加载,内存始终可控。
与 terms 聚合的核心区别:
terms 聚合 |
composite 聚合 |
|
|---|---|---|
| 目的 | 取 Top N(按文档数排序) | 遍历所有组合(按 key 顺序) |
| 分页 | 不支持 | 支持,via after_key |
| 内存 | 全量装入内存 | 每次只装 size 个桶 |
| 适合场景 | "最热门的 10 个分类" | "导出所有日期×供应商的销售额" |
composite不支持按桶文档数排序,如果需要 Top N 排序结果,仍须用terms聚合(控制好层数和 size)。
全量扫描查询
典型危险查询
// ❌ match_all + 排序(扫描所有文档)
{
"query": { "match_all": {} },
"sort": [{ "createTime": "desc" }]
}
// ❌ 超大范围 range(扫描行数 > 100000 行)
{
"range": {
"targetId": {
"from": 1646319600000,
"to": 9223372036854776000 // 几乎无上界
}
}
}
规范
- 禁止无条件
match_all查询(除非明确知道数据量极小) range查询的扫描行数 不超过 100000 行- 必须有足够的过滤条件缩小扫描范围
script 查询
危害
script 查询(Painless)在查询阶段对每一个候选文档执行脚本:
普通 term 查询:查倒排索引 → O(1) 定位
script 查询:逐文档遍历执行脚本 → O(n) 计算
替代方案
// ❌ 查询时计算折后价
{
"query": {
"script": {
"script": "doc['price'].value * 0.9 > params.threshold",
"params": { "threshold": 50 }
}
}
}
// ✅ 写入时预计算好 discount_price 字段
{ "range": { "discount_price": { "gt": 50 }}}
根本思路:把计算从查询时移到写入时,用存储空间换查询性能。
nested / parent-child
性能对比
| 关联方式 | 相对性能 | 适用场景 |
|---|---|---|
| 扁平化(应用层 join) | 1x(最快) | 推荐,数据量允许时优先 |
nested 类型 |
~1/10(慢 10 倍以上) | 数组对象需要独立查询时 |
parent-child join |
~1/100(慢 100 倍以上) | 父子关系频繁更新时 |
规避策略
// ❌ nested:每个 nested 对象是独立的隐藏文档
{
"mappings": {
"properties": {
"comments": { "type": "nested" }
}
}
}
// ✅ 数据打平:将 nested 字段展开
{
"comment_authors": ["alice", "bob"],
"comment_contents": ["good", "excellent"]
}
// 代价:丢失了字段间的对应关系,需要业务侧接受这个限制
参考资料
评论 (0)
发表评论