面向 P6-P7+ 工程师 框架训练型 · 长文 约 9,500–10,500 字 信息截止 2026-08

ES 检索性能调优实战:
从深翻超时到聚合提速的四条优化轴

这不是又一份「把 max_result_window 调大」的清单,而是给已经上线 Elasticsearch 集群的工程师: 当深度分页失败、聚合 P99 飙升时,按语句、索引、冷热分层、聚合四条轴定位瓶颈,用可复现的改写与 profile 验证把慢查询变成可治理问题。

主线风格:框架训练 + 问题现场 版本假设:Elasticsearch 8.x 证据等级:官方文档优先,标注推断与生产经验
问题现场 · 复合场景

1扩容没用:深翻超时与聚合卡死为什么同时出现

复合场景 · 综合电商检索与 BI 看板共库的典型故障,非指代具体单一事件 TYPICAL SCENARIO

某订单检索平台接入 Elasticsearch 8.x 后,运营后台「导出第 5000 页订单明细」接口频繁超时; 同一集群上,BI 看板「按省份、品类、渠道三维聚合」在晚高峰 P99 超过 30 秒,协调节点 CPU 打满, 部分 Data 节点出现 GC 抖动。值班同学第一反应是加机器——扩容后深度分页依旧失败,聚合只快了不到两成。

复盘抽出两条慢路径,恰好对应本篇入口:

  • 搜索型路径:列表 API 使用 from=49900&size=100sort 仅按 created_at,同一毫秒大量订单导致排序不稳定与重复/遗漏。
  • 分析型路径:聚合 DSL 对百万级基数的 sku_idterms size=50000,并扫描 orders-* 全年索引。

架构评审会上有人问:「是不是分片太少?」「要不要把 max_result_window 调到十万?」 这两个问题本身就说明瓶颈被误诊成了容量问题,而不是查询路径与索引设计问题。

这是基于常见检索与分析共库模式的复合场景,不代表某一家公司的真实事件。 它暴露的核心判断失误是:把「集群慢」等同于「机器不够」。搜索型请求常卡在协调节点排序与深翻, 分析型请求常卡在分片本地 bucket 构建与 reduce——二者都不是单纯堆硬件能解的。

把现场再拆一层,会发现两条慢路径其实在争用同一套共享资源,这正是「复合」二字的含义。 深翻请求让协调节点在短时间内堆出大量排序候选;高基数聚合让同一批协调节点在 reduce 阶段再次吃满堆与 CPU。 值班看到的「扩容后只快了两成」,往往是因为新增 Data 节点缓解了分片本地扫描,却没有拆开协调节点上的归并瓶颈, 也没有缩短被扫描的索引时间窗。换言之:硬件扩容解决的是容量下界,而本场景的主矛盾是查询形态与索引边界错误

从机制上推导,深翻超时可以写成一条近似成本链:每个参与查询的分片至少向协调节点返回 from + size 条候选的排序键与文档标识;协调节点再做全局堆排序并丢弃前 from 条。 当分片数为 S、页深为 from 时,协调侧候选规模近似按 S × (from + size) 增长—— 这不是「再加两台机器就能线性摊薄」的问题,因为归并发生在单次请求的协调路径上。 聚合卡死则对应另一条成本链:每个分片本地按 sku_id 建 bucket,再把大量部分结果发回协调节点做全局合并; 当 cardinality 接近数万且同时扫全年索引时,网络传输的中间结果与堆上的 hash 表会同时膨胀, 最终触发 circuit breaker 或把 P99 推到不可用区间。两条链路独立成立,又在晚高峰叠加,才会出现「列表与看板一起挂」的表象。

复盘时还应记录三条容易被忽略的现场证据:第一,慢日志里列表接口的 took 主要耗在协调阶段,而分片 query 时间并不夸张;第二,看板聚合的 profile 显示 terms 的 collect 与 reduce 占比极高, 且命中索引集合远超业务真正需要的近三十天;第三,同一毫秒大量订单导致仅按 created_at 排序时, 翻页出现重复与遗漏,产品侧被迫「多翻几页核对」,进一步放大深翻流量。这三条证据分别指向语句层、分层层与排序键设计, 而不是「分片太少」这一单一假设。

本文假设读者已掌握系列《ES 核心底层原理》中的倒排、分片路由与节点角色;不再重复「ES 是什么」, 而聚焦已上线集群上的性能排障。结构按框架训练展开:入口用复合场景锁定两类故障, 解释层用四条优化轴推进,验证层给出 DSL 与 mapping 改写对照,沉淀层给出可直接贴进值班手册的性能排障 SOP。 版本假设为 Elasticsearch 8.x,与 7.x 的关键差异(如 PIT 优先于 scroll)会在对应位置标注。

体系总览 · 调优框架

2四条优化轴:先分清搜索型与分析型瓶颈

排障时不要并行改十处参数。先确认现象类型:单条 DSL 慢、某接口慢,还是全集群慢? 全集群慢优先查 health、relocation、磁盘水位,而不是改 DSL。确认是读路径问题后, 再按「语句 → 索引 → 分层 → 聚合」顺序推进,每一步用监控或 _profile 验证后再进入下一步。

图 1 · 检索性能调优四条轴与入口现象
自制示意图
入口现象 深度分页 from/size 超时 / rejected 协调节点排序与堆压力 · 搜索型路径 聚合 P99 飙升 / circuit breaker 分片 bucket + 协调 reduce · 分析型路径 四条优化轴(建议顺序) 1 语句层 filter 化 search_after + PIT _source / track_total_hits 2 索引层 keyword / text 选型 减少 nested 分片大小合理 3 分层层 ILM rollover hot / warm / cold 读别名约束时间 4 聚合层 composite cardinality / Transform 缩索引范围 沉淀:性能排障 SOP + 前后 profile 对比 单变量验证 · 禁止「先 forcemerge 再调 DSL」的无效组合拳 四轴都排查仍不达标,再进入协调节点独立、读写分离、扩容 hot——那是容量规划与稳定性治理的交界
读图方式:深翻优先打语句轴,高基数聚合优先打聚合轴,但索引 mapping 错误与未分层的巨型索引会同时放大两条路径。 排障顺序是为了避免「一上来就 forcemerge」这类无效操作。
优化轴典型信号首选手段
语句协调节点 search 线程池 queue 高filter 化、search_after、裁剪 _source
索引profile 中 terms/ordinals 构建慢keyword mapping、减少 nested
分层单索引百 GB、recovery 慢、扫全年ILM rollover、hot/warm 读别名
聚合协调节点 heap 高、breaker 触发composite、cardinality、Transform

四条轴不是并列的「技巧清单」,而是一条可证伪的排障流水线。 先用现象分类(搜索型 / 分析型 / 全集群)决定入口;再用监控确认是否存在 relocation、磁盘水位、写入尖峰等「假性能问题」; 最后才进入语句与聚合改写。很多团队在复合场景下同时改 DSL、调 forcemerge、扩容三件事,结果无法归因—— 框架训练的价值,就是强迫你每次只推进一个轴,并用 profile 或慢日志证明该轴确实贡献了延迟下降。

机制上还可以这样理解四轴的耦合:语句层决定「单次请求做多少无用功」;索引层决定「字段能否走廉价路径」; 分层层决定「请求会扇出到多少分片与多大时间窗」;聚合层决定「中间结果在协调节点上膨胀到什么程度」。 复合场景之所以难治,是因为列表深翻与看板聚合同时踩中了语句与聚合两条轴,又被未分层的全年索引放大。 因此后文每一章都会先给出机制推导,再给改写样例,避免只背结论不理解因果。

核心机制 · 语句层

3查询语句优化:能 filter 就不算分,能 PIT 就不深翻

同样返回 100 条文档,语句写法不同,协调节点与分片上的工作量可能差一个数量级。 语句层有三个不变量:能 filter 就不 score能 point-in-time 就不深翻 from/size能命中缓存就不重复算

3.1 Query 上下文 vs Filter 上下文

Boolean 查询中,子句处于 query 上下文时会计算相关性得分并参与算分路径;filter 上下文只做匹配判断, 结果可进入基于 bitset 的查询缓存,且不计分。纯过滤条件(时间范围、状态枚举、租户 ID)应放入 filter,把算力留给真正需要排序的字段。

进一步推导执行路径:当 statuscreated_at 被放进 must 时,分片不仅要判断文档是否匹配, 还要为这些「本不该贡献相关性」的条件计算得分分量,并写入评分结构;即使最终排序字段是 created_at, 额外的算分与 norms 访问仍可能造成可观的 CPU 浪费。改入 filter 后,引擎可以用 bitset 做集合交, 并在 segment 未变更时复用缓存结果——这就是「能 filter 就不算分」的机制依据,而不是风格偏好。 需要注意:对 UUID、订单号这类几乎每次取值都不同的高基数字段做 filter,缓存命中率通常很低, 此时收益主要来自「不计分」,而不是「命中 Node Query Cache」。排障时不要把「已经用了 filter」等同于「一定很快」。

图 2 · Filter 与 Query 执行成本差异
自制示意图
同一 bool 查询在分片上的两条路径 Query 上下文(must 全塞算分) 倒排链合并 BM25 / norms / freq 写入评分结构 缓存友好度低 适合:全文相关性排序 Filter 上下文(结构化条件) 产出匹配 bitset 集合 intersect 不计分 可进 Node Query Cache 适合:状态 / 时间 / 租户过滤
机制要点:重复的 filter 条件在 segment generation 未变时可复用 bitset;高基数字段上的 filter 几乎无法命中缓存。
// 优化前:全部走 query,每个条件都算分
GET orders/_search
{
  "query": {
    "bool": {
      "must": [
        { "term": { "status": "PAID" } },
        { "range": { "created_at": { "gte": "2026-01-01" } } },
        { "match": { "buyer_name": "张三" } }
      ]
    }
  }
}

// 优化后:结构化条件进 filter,仅搜索字段走 must
GET orders/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "status": "PAID" } },
        { "range": { "created_at": { "gte": "2026-01-01" } } }
      ],
      "must": [
        { "match": { "buyer_name": "张三" } }
      ]
    }
  }
}
已核验事实

官方文档将 filter 上下文描述为「yes/no」匹配且不计分,并指出 filter 结果适合缓存; query 上下文则计算相关性得分。改写前后应用 profile: true 对比各阶段耗时, 重点看 rewrite 与 collector。 来源:Query and filter context

3.2 深度分页:from/size 的线性恶化与 PIT + search_after

from + size 的工作原理是:每个分片返回 from + size 条结果给协调节点, 协调节点全局排序后丢弃前 from 条。当 from=50000, size=100 时, 每个分片可能传输 50100 条文档标识与排序值——深度越大,堆与网络开销线性恶化。 默认 index.max_result_window 为 10000,超过会直接拒绝:这是保护机制,不是「可调大就没事」。

把机制再推进一步:深翻的浪费不只发生在「传输多余候选」,还发生在「每一次翻页都几乎从零重算」。 用户从第 100 页点到第 101 页时,from/size 并不会复用上一页已经排序好的边界,而是再次要求各分片交出更深窗口的候选。 search_after 的关键差异在于:客户端携带上一页最后一条的排序元组,分片可以在有序倒排/doc_values 扫描中直接定位到「下一窗」, 协调节点只需合并约 size 量级的结果。PIT(point-in-time)则固定一次一致的索引视图,避免翻页过程中写入导致的漏读或重复, 并在 8.x 成为官方推荐的深翻/批拉组合。复合场景里「导出第 5000 页」若改成「异步任务按 PIT 连续批拉」, 产品体验从「卡死的跳页」变成「可追踪的导出任务」,技术成本也从协调节点尖刺变为可限流的后台吞吐。

排序键稳定性是 search_after 能正确工作的前提。仅按 created_at 排序时,同一毫秒内的大量订单会共享相同排序值, 翻页边界会在相等键上抖动,表现为重复行或跳行。追加 _shard_doc(或业务主键)作为 tie-breaker, 才能保证「下一页」语义在分布式环境下可复现。这一点常被当成边角细节,却是复合场景列表故障的直接触发器之一。

图 3 · from/size 深翻 vs search_after 成本模型
自制示意图
from/size 深翻 search_after + PIT 分片各返回 from+size 条 协调节点全局排序 丢弃前 from 条 成本随页深线性上升 默认窗口 10000 调大窗口只绕过拒绝 不消除协调节点开销 PIT 固定一致索引视图 携带上一页 sort 元组 分片只取下一页窗口 成本与页深基本无关 sort 需唯一 tie-breaker 如追加 _shard_doc 导出全量也走批拉而非跳页
产品约束:若必须「随机跳到第 N 页」,应评估是否真应由 ES 承担——导出全量更适合 PIT 批拉或离线仓。
来源:Elastic 官方文档 Paginate search results(search_after / PIT)
POST /orders/_pit?keep_alive=5m

POST /_search
{
  "size": 100,
  "sort": [{ "created_at": "desc" }, { "_shard_doc": "desc" }],
  "pit": { "id": "YOUR_PIT_ID", "keep_alive": "5m" },
  "query": { "match_all": {} }
}

POST /_search
{
  "size": 100,
  "sort": [{ "created_at": "desc" }, { "_shard_doc": "desc" }],
  "search_after": [1704067200000, 3847291],
  "pit": { "id": "YOUR_PIT_ID", "keep_alive": "5m" }
}
常见陷阱

index.max_result_window 调到 100000 只能绕过拒绝,不能消除协调节点排序开销。 运营「跳转到第 N 页」应在产品层改为「仅下一页 / 异步导出 CSV」,技术层用 search_after; 否则扩容也无济于事。Scroll 在 8.x 仍可用于批处理,但官方更推荐 PIT + search_after, 且长时间 scroll 占用旧 segment 视图可能拖慢 merge。

3.3 缓存、裁剪与慢日志联用

与读相关的两层缓存(机制共识,参数因版本略有差异): Node Query Cache缓存 filter bitset; Shard Request Cache缓存 size=0 且不跟踪 total hits 的聚合/查询结果,适合 Dashboard 固定条件统计。 列表页用 _source 裁剪展示列;Dashboard 若不需要精确总数,可设 track_total_hits: false 或较低上限。慢日志定位「哪条语句」, _profile 定位「慢在 query / fetch / agg / collector 哪一段」。

机制推导上,track_total_hits 的默认精确计数要求引擎在匹配阶段尽量弄清「一共有多少命中」, 对宽过滤条件的列表接口可能成为隐藏成本;若产品只展示「约有很多结果」或分页并不依赖精确总数, 关闭或设上限往往比再加缓存更直接。fetch 阶段则与返回字段体积强相关:把大文本、嵌套 JSON、图片元数据一并打进 _source,会让协调节点与客户端带宽同时变差——profile 里常见「query 不慢、fetch 很慢」的形态。 慢日志与 profile 应联用:前者回答「是谁、何时、哪条语句」,后者回答「慢在哪一段」;只看其一容易误判。

生产经验

协调节点慢而分片 profile 不慢,优先查 fetch 阶段文档体积与 _source 是否过大; 分片 query 阶段慢则回到 filter/mapping。上线前对核心 DSL 过五条检查: 结构化条件是否在 filter;分页是否 search_after;sort 是否含唯一 tie-breaker; 列表是否裁剪 _source;聚合是否与明细查询拆分。

来源:Elastic 官方文档 Query and filter context;缓存与请求结果缓存行为以 8.x 当前文档为准。
核心机制 · 索引层

4索引设计优化:类型选对、结构扁平、分片大小合理

语句优化遇到天花板时,根因往往在 mapping:字段类型决定是否走倒排、是否被分词、是否占用 doc_values。 复合场景中的聚合超时,常见根因之一是 brand_name 被建成纯 text,报表对 brand_name.keyword 做高基数 terms——若 mapping 未预留子字段,只能 runtime 或 reindex,代价极高。

4.1 keyword vs text 与 multi-field

text 分词建倒排,适合全文检索;keyword 整值索引,适合精确匹配、聚合、排序。 需要「既可搜又可聚合」时用 multi-field。ES 8.x 仍可能对 string 动态映射为 text + keyword 子字段 (ignore_above: 256),超长字符串 keyword 子字段会被截断不参与聚合——订单备注类长文本不应作为聚合维度。 生产更推荐显式 composable template,而非依赖 dynamic 自动推断。

PUT orders
{
  "mappings": {
    "properties": {
      "product_name": {
        "type": "text",
        "fields": {
          "raw": { "type": "keyword", "ignore_above": 256 }
        }
      },
      "status": { "type": "keyword" },
      "amount": { "type": "scaled_float", "scaling_factor": 100 },
      "created_at": { "type": "date" }
    }
  }
}
来源:Elastic 官方文档 Field data typesTune for search speed

4.2 doc_values、norms、nested 与分片体量

聚合与排序依赖 doc_values(列式存储)。text 默认不启用 doc_values,不能直接聚合。 仅展示、从不搜索或聚合的大文本可 index: falsedoc_values: false,只留 _source。 不做相关度排序的 keyword 可 norms: false 降体积。 nested 与 parent/child 引入额外 join:能 denormalize 就 denormalize; 仅当必须独立检索子对象并保持绑定关系时才用 nested。

机制推导:terms 聚合在 keyword 上通常依托 doc_values 的有序字典(ordinals)快速建 bucket; 若字段实际是 text,引擎要么无法直接聚合,要么被迫走更昂贵的路径(例如 fielddata,若被错误开启)。 这也是复合场景中「品牌维度突然变慢」的常见根因——动态 mapping 看起来「有 .keyword」,但 ignore_above、分析器差异或误用主字段,都会让报表落到错误执行路径。 nested 的代价则来自「子文档独立索引 + 父子绑定」:每次涉及 nested 的查询/聚合都要额外处理连接语义, 在高并发列表与深层报表并存时,会把本可扁平完成的扫描变成反复的 join 式遍历。能在写入时冗余关键维度字段, 通常比在查询时用 nested 恢复关系更划算。

分片过多则查询 fan-out 与聚合 merge 开销上升;分片过大则 recovery 与 heap 压力高。 经验规则(作者经验总结,非官方硬性指标):搜索型索引单分片约 10~50 GB,日志型约 30~50 GB。 主分片数创建后不可随意变少,规划应留余量。写入尖峰导致 segment 激增时,同样 DSL 在 merge 前后延迟不同—— 读写争用是读慢的上游原因之一,与稳定性治理衔接。对复合场景而言,分片规划还要回答一个产品问题: 列表与看板是否应共用同一套超宽 mapping?若报表字段极多而列表只需窄投影,长期更稳妥的做法是 检索索引与汇总索引分离,而不是在一个巨型 mapping 上同时满足两种负载。

待验证推断

将高基数品牌字段从「纯 text + 动态 .keyword」改为显式 keyword 主字段后, 同一聚合在作者经验总结中常见从数十秒降至秒级。该数字非基准测试,须在你的数据规模与硬件上 profile 复测后再作为团队基线。

核心机制 · 分层层

5冷热数据分层:用 ILM 把查询范围从「全年」切回「近线」

当索引体量持续增长,「全放 SSD」成本与「全放 HDD」延迟都不可接受。 Index Lifecycle Management(ILM)结合冷热节点,把最近写入与高频查询留在 hot, 只读历史沉到 warm/cold,并在 delete 阶段清理过期数据。对复合场景里「扫 orders-* 全年」的聚合, 分层与读别名往往比再调大 terms size 更有效。

从机制上理解分层收益:聚合与搜索的成本不仅取决于单分片扫描速度,还取决于参与查询的分片总数。 当读别名绑定全年索引时,协调节点必须向每一个分片发出请求并等待汇总;即使每个分片只慢一点点,扇出规模也会把 P99 抬高。 ILM rollover 把时间切成可管理的索引单元后,业务可以把「默认查询近三十天」落成别名约束,而不是依赖每个调用方自觉加 range。冷层 searchable snapshot 进一步把主体数据放进对象存储,本地只保留必要元数据与缓存—— 因此「偶发极慢」往往不是 bug,而是缓存未命中时的预期延迟;若把这种延迟当成实时看板 SLA,就会在复合场景里反复误报故障。

图 4 · ILM Hot-Warm-Cold-Delete 与查询 SLA
自制示意图
HOT SSD · 写入 近 7 天查询 rollover 切分 priority 高 WARM 只读缩容 可选 forcemerge 近线报表 副本可减 COLD searchable snapshot 低频审计 对象存储为主 缓存未命中慢 DELETE 保留期结束 清理过期索引 与法务保留对齐 避免误扫已删别名 BI 默认只查 hot/warm 读别名;跨 cold 查询必须单独 SLA,否则「时快时慢」会被当成故障
权衡本质:用查询延迟换存储成本。运营后台默认 30 天、法务审计 1 年——后者走 cold 并接受更长延迟。
来源:Elastic 官方文档 ILM: Manage the index lifecycle

5.1 Rollover、别名与 forcemerge 边界

时序/日志类索引不应单索引无限增长。rollover 常用条件: max_primary_shard_size(如 50gb)、max_age(如 7d)、max_docs。 写入别名(如 orders-write)保证零停机切换;读别名绑定 hot+warm,避免扫已进入 delete 的索引。 8.x data stream 进一步封装该模式,分层思想一致。

落地复合场景时,建议显式区分三类读路径的 SLA:运营列表默认只打 hot(或 hot+近期 warm); 近线 BI 允许 hot+warm;法务/审计才允许进入 cold,并在产品文案中写明「可能较慢」。 若三条路径共用一个 orders-* 通配,再精巧的聚合改写也会被扇出规模击穿。 forcemerge 则应视为「只读索引上的可选压缩动作」,而不是性能万能药:它降低 segment 数可能改善部分查询, 但会在合并窗口制造 IO 尖刺,并可能削弱按 segment 并行的弹性。复合故障排查中, 「先 forcemerge 再观察聚合」常常让因果混乱——应先缩别名时间窗与改 composite,再评估是否值得 merge。

PUT _ilm/policy/orders-policy
{
  "policy": {
    "phases": {
      "hot": {
        "actions": {
          "rollover": {
            "max_primary_shard_size": "50gb",
            "max_age": "7d"
          },
          "set_priority": { "priority": 100 }
        }
      },
      "warm": {
        "min_age": "7d",
        "actions": {
          "allocate": { "number_of_replicas": 1 },
          "forcemerge": { "max_num_segments": 1 }
        }
      },
      "cold": {
        "min_age": "30d",
        "actions": {
          "searchable_snapshot": { "snapshot_repository": "found-snapshots" }
        }
      },
      "delete": {
        "min_age": "365d",
        "actions": { "delete": {} }
      }
    }
  }
}
常见陷阱

warm 阶段 forcemerge 到 1 个 segment 会长时间占用 IO,且合并后失去部分按 segment 并行搜索的弹性; 官方对 forcemerge 持谨慎态度,仅在明确只读且有收益时使用。ILM 迁移与 nightly rebuild 冲突可触发 relocation 风暴——应错开维护窗口并控制并发 recoveries。具体 tier 命名与托管集群预设可能不同, 落地前以 _cat/nodeattrs_ilm/explain 为准。

核心机制 · 聚合层

6聚合查询提速:降基数、分批、预聚合,而不是调大 size

聚合是 CPU 与堆内存密集型路径:每个分片本地构建 bucket,协调节点再 reduce。 晚高峰超时常见根因是高基数 terms、嵌套聚合过深、跨过多分片/索引。

把复合场景里的「按省份、品类、渠道三维聚合」放到这条机制上审视:每一层 terms 都会放大下一层的 bucket 乘积; 若再对百万级 sku_id 直接做大 size terms,协调节点需要合并的不是「业务关心的几十个维度」, 而是「全部分片贡献的海量部分桶」。circuit breaker 触发时,调大限制只是把 OOM 风险延后; 正确的推导结论是——要么降低 cardinality(换维度、预聚合、近似算法),要么把遍历改成可分页的 composite, 要么把固定报表搬到 Transform 汇总索引。三者可以组合,但不应与「继续调大 size」混为一谈。

图 5 · 协调节点聚合 Reduce 路径
自制示意图
Client 协调节点 分发 + reduce merge heap / breaker 风险点 分片 1 本地 agg 分片 2 本地 agg 分片 N 本地 agg 压力放大器 terms size 过大 跨过多索引分片 分片数与 bucket cardinality 共同决定协调节点 CPU/堆峰值
处置原则:调大 circuit breaker 只是延后爆炸;正确做法是降低 cardinality、预裁剪 filter、拆分多次较小聚合或预聚合。

6.1 composite、cardinality 与 Transform

termssize 默认 10,调到数千会线性增加协调节点 merge 成本。 「导出所有 distinct 值」应使用 composite 分页,而非一次性 size=50000。 Dashboard 的 UV 量级通常可用 HyperLogLog++ 近似(cardinality + precision_threshold); 财务对账必须精确时,改用 Transform 或离线仓。固定维度、固定窗口的报表优先 Transform 写入汇总索引, 用分钟级延迟换查询稳定性。新建项目优先 Transform,而非老式 Rollup。

机制差异可以概括成三句话。第一,terms 大 size 追求「一次拿齐」,代价是协调节点必须持有并合并接近全量的桶集合。 第二,composite 追求「稳定批拉」,每次只推进一页 bucket,内存峰值可控,适合导出与后台重建。 第三,Transform 追求「查询时不再现场聚合」,把计算前移到持续任务,看板只扫小索引——这与列表侧的 PIT 批拉是同一思想: 把尖刺型同步成本改成可调度的异步成本。复合场景同时命中深翻与高基数聚合时,两端都做「同步改异步」往往比单端极致调参更有效。

// 优化前
GET orders/_search
{
  "size": 0,
  "aggs": {
    "by_sku": { "terms": { "field": "sku_id", "size": 50000 } }
  }
}

// 优化后:composite 分批
GET orders/_search
{
  "size": 0,
  "aggs": {
    "sku_page": {
      "composite": {
        "size": 1000,
        "sources": [{ "sku": { "terms": { "field": "sku_id" } } }]
      }
    }
  }
}
来源:Elastic 官方文档 Composite aggregationCardinality aggregation

6.2 缩索引范围、路由与 runtime 边界

跨 90 天索引做全局聚合时,协调节点需询问每个索引的每个分片。用别名限制时间范围, 避免 orders-* 扫全年;写入 _routing 使同一买家落固定分片可减少特定维度 fan-out(需读写同时改造)。 8.x runtime field 适合临时实验,生产高频聚合维度不应依赖它——无法在 index 阶段构建 ordinals,通常显著慢于 keyword doc_values。 多层 terms 应在每层加 filter 预裁剪,或用 filters 聚合按已知枚举拆分。

回到复合场景的可验证改写顺序:先把看板默认索引别名从全年通配改为近线别名,观察扇出分片数是否下降; 再把 sku_id 的巨型 terms 改为 composite 分批或下沉 Transform;最后检查维度字段是否为真正的 keyword doc_values。 每一步都应保留 profile 片段:看 aggs 耗时、协调节点 heap 曲线、以及 breaker 是否仍触发。 若缩范围后仍然慢,才进入路由改造或独立 coordinating 节点——那是容量与拓扑问题,不应与 DSL 问题一次性打包处理。

已核验事实

Composite aggregation 设计为可分页遍历所有 bucket,官方明确推荐用于需要大量 bucket 的场景, 以替代把 terms size 调得极大。Cardinality 默认基于 HyperLogLog++ 近似算法。 来源见上节链接

边界判断 · 适合与不适合

7适合继续在 ES 上调优,与该迁出检索路径的场景

不是所有「慢」都值得在 Elasticsearch 内部极致优化。先判断问题是否属于检索/分析读路径, 再决定继续调优、改产品交互,还是把部分负载迁到汇总库/离线仓。 边界判断的核心不是「ES 能不能做」,而是「做完之后的边际成本是否仍可接受」。 复合场景之所以反复踩坑,往往是因为运营列表、实时看板、历史审计三类需求被默认塞进同一套检索路径, 再用扩容掩盖产品约束冲突。

适合继续调优

  • 列表翻页可改为 search_after / 异步导出
  • 过滤条件稳定、可 filter 化与缓存
  • 报表维度固定,可 Transform 近实时汇总
  • 热点集中在近线数据,ILM 可切时间范围
  • 映射错误可通过 reindex / 双写切换修复
  • 聚合精度允许近似(UV、漏斗粗估)

不适合硬扛在 ES

  • 必须随机跳到任意深页且要精确总数
  • 财务级精确去重且要求毫秒级全局 UV
  • 跨冷层全历史 ad-hoc 多维钻取当实时看板
  • 把 ES 当主事务库做强一致联机事务
  • 以 ES 替代数仓承担任意 SQL 式自由探索
  • 要求冷热数据同一 SLA、同一刷新频率

7.1 适合继续调优的判定细则

若产品能接受「只提供下一页 / 上一页」或「异步导出」,深度分页问题几乎总是适合留在 ES 内解决: 技术动作清晰(PIT + search_after + 唯一 sort),收益可被 profile 直接验证,也不强迫集群承担随机跳页的归并成本。 若看板维度在迭代中相对稳定,且刷新频率在分钟级可接受,Transform 或汇总索引通常比现场多层 terms 更稳—— 这时「继续调优」的含义是改架构形态,而不只是改一条 DSL。 过滤条件以枚举、租户、时间为主时,filter 化与请求缓存能显著降低重复计算,属于高置信度的语句层收益。

还有一类适合继续调优的情况:mapping 选错导致的慢。只要业务允许短暂双写或离线 reindex, 把聚合维度纠正为 keyword / 合理 multi-field,往往比长期靠 runtime field「顶着」更便宜。 判定标准是:修复成本一次性,而错误路径会在每次报表刷新时重复付费。复合场景中的品牌字段问题,通常落在这一类。

7.2 不适合硬扛的判定细则

若业务坚持「输入页码直达第 N 页 + 展示精确总数 + 毫秒级响应」,ES 深翻模型从机制上就不匹配: 调大 max_result_window 只会把拒绝变成超时。此类需求应回到产品:缩小可跳页范围、改为筛选定位,或导出到离线仓查询。 财务对账要求精确去重且全局一致时,HyperLogLog 近似不可接受,现场高基数 terms 又无法稳定支撑峰值—— 应迁到具备批量计算与校验能力的数仓链路,ES 最多提供近线预览。

跨冷层的自由探索同样不适合当作实时看板:searchable snapshot 的缓存未命中会带来长尾延迟, 这与「晚高峰也必须三秒出数」的 SLA 冲突。更稳妥的做法是单独提供历史分析入口,并写明延迟预期; 或把高频历史主题预热到 warm / 汇总表。最后,把 ES 当主事务库既不属于性能调优范畴,也会引入一致性与事务边界错误—— 应在架构评审阶段直接否决,而不是进入本篇四条优化轴。

场景继续 / 迁移理由
后台订单列表继续 · search_after搜索型路径,改分页模型即可
固定 KPI 卡片继续 · filter + request cache / Transform条件重复,缓存与预聚合收益高
品牌/品类近线多维报表继续 · keyword + 缩别名 + composite/Transform复合场景主战场,四轴内可闭环
任意跳页导出百万行迁移 · 离线仓 / 异步任务深翻不是 ES 优势路径
全年冷数据自由探索迁移或降级 SLAsearchable snapshot 未命中延迟不可控
财务日终精确对账 UV迁移 · 数仓批处理精确性与峰值稳定性不可兼得于现场聚合
生产实践 · 排障 SOP

8性能排障 SOP:从现象到单变量处置

以下表格面向「深度分页失败 / 聚合超时 / 查询 P99 突增」三类入口,可直接纳入值班手册。 处置时务必单变量验证:一次只改语句、mapping 或 ILM 其中一类。 建议与慢查询日志、APM 中 ES span 关联:SOP 解决「怎么查」,监控解决「谁触发了查」。 复合场景值班时,先用两行分别登记「列表接口」与「看板聚合」的 query hash,避免把两条链路的证据混在同一张工单里, 导致复盘时无法回答「到底是哪条改写产生了收益」。

故障现象 排查工具链 根因定位要点 处置方案 预防机制
深翻页超时或 rejected _search 参数;index.max_result_window;协调节点 JVM/search 统计 from 过大;sort 非 unique search_after + PIT;取消跳页;异步导出 评审禁止大 from;网关限制 from+size
聚合 P99 飙升 profile: true;thread_pool;Hot Threads 高基数 terms;字段为 text;嵌套过深 composite;降 size;修正 mapping;Transform 聚合 review 清单;报表索引分离
全集群查询变慢 _cluster/health;shards;fs/jvm relocation;磁盘水位;heap 压力 限流;调 allocation;扩容 hot;加速 rollover 磁盘水位告警;ILM 常态化(见 T07)
重复 filter 仍慢 query_cache 命中率;慢日志 高基数 filter;缓存被 evict;segment 过多 结构化 filter;只读索引谨慎 merge 避免对 UUID 做高基数 terms
冷数据偶发极慢 _ilm/explain;snapshot 仓库 snapshot 缓存未命中;跨 cold 大扫 默认 hot/warm 别名;预热关键冷数据 BI 强制时间范围;冷层 SLA 对齐
单接口突刺拖垮集群 Slow queries;APM;_tasks 定时任务;新发布 DSL;缓存失效 cancel task;限流;回滚;辅助启用 cache 变更前 shadow profile;核心 API 基线告警
生产经验

复合场景的最终处置对照:深翻改为 PIT + search_after + 唯一 sort 键; 聚合改为 composite 分批 + Transform 汇总索引 + 限制别名时间范围。 两条线并行改造后,作者经验总结中 P99 可从约 30 秒降至数秒内——须在你的集群复测。 复盘时保留改写前后 profile 与慢日志片段,并记录 query hash 与索引 generation。

决策框架 · 收尾

9决策矩阵:检索性能问题该怎么走完整流程

把前面四条轴收成一张可执行的决策流程:先分类现象,再按轴推进,最后决定继续、改产品还是迁出。

图 6 · 检索性能排障决策流
自制示意图
现象:慢 / 超时 / rejected 全集群慢?查 health/磁盘 搜索型:深翻/列表 分析型:聚合/看板 语句 → 索引 → 分层 聚合 → 索引 → 分层 profile 验证 → 决策矩阵:继续 / 改产品 / 迁移 仍不达标 → 协调节点独立 / 读写分离 / 扩容 hot(交界 T05/T07)
使用方式:评审会上先回答「这是搜索型还是分析型」,再打开对应轴的检查清单,避免直接讨论加机器。

9.1 决策矩阵

决策矩阵不是简单的「能不能做」,而是在继续调优 / 改产品交互 / 迁移或降级三条出路之间做显式选择。 评审时建议逐行回答:当前约束落在哪一列?是否有证据(profile、慢日志、扇出分片数)支撑? 若一行需求同时要求「深翻跳页 + 精确总数 + 冷数据实时」,应拆成多行分别决策,避免用一句「再优化一下」掩盖冲突。

条件继续调优改产品交互迁移/降级
深翻需求 可接受仅下一页;用 PIT + search_after;sort 含唯一键 取消跳页;提供筛选定位;改为异步导出任务 百万行离线拉取;仓内分页;对象存储批处理
聚合精度 近似 UV 可接受;cardinality / 预聚合足够 看板降维;降低刷新;分拆多张简单卡 精确对账走数仓;日终批任务出数
时间范围 默认近线别名;ILM 切分后扇出可控 明确冷层 SLA;历史查询单独入口 历史探索换分析引擎或预汇总主题表
mapping 错误 可 reindex / 双写切换;显式 template 固化 临时降级字段;隐藏高代价维度 字段职责迁出 ES;明细与指标分库
共库争用 列表与聚合拆 DSL;请求级限流;错峰 看板降频;导出改异步队列 检索索引与汇总索引物理分离
协调节点归并 缩 size / 缩索引范围后 profile 已改善 禁止高峰重聚合;改为预计算 独立 coordinating;读写分离;扩容 hot

9.2 复合场景对照:如何填完整条决策链

仍以开篇复合场景为例,完整决策链可以写成六步,每一步只允许一个主结论。 第一步:确认不是全集群健康问题(无持续 red/yellow 抖动、无磁盘水位踢除、无大规模 relocation)。 第二步:把现象拆成搜索型(深翻超时)与分析型(聚合 P99),禁止用「集群慢」笼统描述。 第三步:搜索型走语句轴——验证 from/size、sort 唯一性、_source 体积;分析型走聚合轴——验证 terms size、字段类型、索引别名范围。 第四步:若扇出索引包含冷层或全年通配,先做分层轴约束,再谈聚合参数。 第五步:用决策矩阵逐条对照产品约束,明确哪些要改交互(取消跳页)、哪些可继续调优(PIT/composite)、哪些应迁移(财务精确 UV)。 第六步:单变量上线并保留前后证据;两条路径都稳定后,才讨论是否需要独立协调节点或扩容。

这样填写的价值在于:扩容被放到最后而不是最先;产品约束被写成可执行的「改交互」动作,而不是口头上的「用户再忍忍」; 迁移不再等于承认失败,而是承认「该需求的成本曲线不属于 ES 优势区」。复合场景的复盘文档若缺少决策矩阵落点, 下一次故障仍容易重新从「加机器」开始。

生产经验

把决策矩阵贴进架构评审模板时,要求每个慢查询工单至少勾选一行「条件」与一列「出路」, 并附上一条 profile 或慢日志证据。缺少证据的「继续调优」按未完成处理,避免评审变成经验口号会。

9.3 相邻知识地图

  • T05《ES 核心底层原理》:倒排、分片、节点角色与容量估算——调优的前提模型。
  • T07《ES 线上稳定性治理》:脑裂、磁盘水位、rebalance、完整 hot-warm-cold 巡检——性能与稳定性闭环。
  • 官方 Tune for search speed / Paginate / ILM / Aggregations:参数与 API 以当前 8.x 文档为准。
待验证推断

若四条轴与产品约束都对齐后仍不达标,瓶颈常转移到协调节点归并能力或写入/merge 争用, 此时「独立 coordinating 节点 + 限制重聚合并发」往往比继续改 DSL 更有效。 该判断依赖你的 QPS 与聚合深度,落地前用压测与 thread_pool rejected 计数验证。

9.4 一句话收束

检索性能调优的本质不是堆参数,而是把「搜索型」与「分析型」路径拆开,用语句、索引、分层、聚合四条轴 做可验证的单变量改进,并把无法在 ES 内廉价满足的需求推回产品或离线体系。 所有结论用慢日志与 profile 说话,避免凭感觉扩容。 对复合场景尤其如此:先拆路径,再填决策矩阵,最后才谈机器。