ES 系列 #07:ES 高风险查询识别与规避

发布于 2026-05-26 10:25 👁 7 次阅读
#性能#elasticsearch#best-practice#query#risk

梳理 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 > 65536shard_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(可能导致查询解析超时、内存消耗大)
{
  "terms": {
    "skuId": [100001, 100002, ..., 100500]  // 500 个
  }
}

// ✅ 改造思路:
// 1. 分批查询(每批 ≤ 100),并发执行后合并结果
// 2. 考虑是否可以改用范围查询
// 3. 使用 bitset filter 或其他方案

子查询数量


超深嵌套聚合

危害

聚合嵌套每增加一层,需要遍历的数据量成倍增加:

1 层:遍历所有文档,按 date 分桶
2 层:每个 date 桶内,再按 supplierId 分桶
3 层:每个 supplierId 桶内,再按 skuId 分桶
→ 总计算量 = 日期数 × 供应商数 × SKU 数

规范

// ❌ 超深嵌套(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  // 几乎无上界
    }
  }
}

规范


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)

暂无评论,来留下第一条吧。

发表评论