报表页只加了一个"按城市统计订单额"的柱状图。上线当天,那个数据节点的堆内存从 45 % 涨到 91 %,Full GC 从一天两次变成一小时三次。查字段类型:city 是 text——当初为了支持"城市名模糊搜索"建的,没人注意到它同时也在被聚合。
这是 ES 里最经典的一类事故:检索与聚合用的是两套完全不同的数据结构,而 text 类型只对前者友好,对后者是一场灾难。它不会报错,只会慢慢把堆吃掉。
1. 一套字段,两种加载方式
| 用途 | 数据结构 | 存在哪 | 默认 |
|---|---|---|---|
| 检索(全文匹配) | 倒排索引(term → doc list) | 磁盘段文件 | 开 |
| 排序 / 聚合 | 正排(doc → values) | doc_values:磁盘列存 | 开(keyword/数值) |
| 排序 / 聚合(text) | fielddata(term → doc list 的内存版) | 堆内存 | 关,按需加载 |
第三行是全篇的关键:text 字段做聚合时,ES 必须把倒排索引整个读进堆,这叫 fielddata。它一旦加载就不会自己退回去,除非触发 circuit breaker 或手动 clear cache。
// 同一个聚合,两种字段类型的成本差三个量级
聚合 city(keyword): doc_values 顺序扫描 → 堆占用 ≈ 0,走 page cache
聚合 city(text): fielddata 全量加载 → 堆占用 = 唯一 term 数 × ~数十字节
// 一个 100 万行的索引,城市名若被分词成 3 段,term 基数会翻几倍
2. 为什么 text 是灾难:term 基数是乘出来的
text 会把"北京市朝阳区"切成多个 term。聚合按 term 分桶,不是按原值分桶——于是:
| 字段类型 | 分桶键 | 桶数(10 万条数据) |
|---|---|---|
keyword |
完整值 | 300 个城市 |
text(标准分词) |
分词后的 term | 上千个("北京"+"市"+"朝阳"+"区"…) |
text(ngram 分词) |
字符片段 | 上万到十万级 |
// 同一个 city 字段,两种写法的桶数差一个量级
聚合 "city": 北京 / 北京市 / 朝阳 / 区 → 上千桶
聚合 "city.keyword": 北京市朝阳区 → 300 桶
// 差的不是精度,是堆内存:桶数 × 单桶开销
第三行最致命:ngram 分词器会把每个词拆成 2–3 字的片段,聚合时的桶数直接爆到与文档数同阶。而这一切只表现为"堆涨了",没有任何错误提示。
3. 三个修法,代价不同
| 修法 | 做法 | 代价 | 适用 |
|---|---|---|---|
| 双字段 | city(text,检索)+ city.keyword(keyword,聚合) |
索引体积 +30–60 % | 标准做法 |
| fielddata 限额 | fielddata_frequency_filter 只保留高频 term |
丢失长尾桶 | 应急 |
| 预聚合 | 写入时算好聚合结果,查一个小索引 | 实时性下降 | 报表类 |
第一行的机制来自 fields 多字段(multi-fields):同一个原始值建两份索引,检索走 text、聚合走 keyword。它是最彻底也最常见的修法,代价是存储——所以字段设计时该先问"这个字段会不会被聚合"。
// 一个可用的映射(检索 + 聚合都对)
"city": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"keyword": { "type": "keyword", "ignore_above": 256 } // 聚合与排序走这个
}
}
// 注意:聚合必须写 city.keyword,写 city 就会触发 fielddata
4. fielddata 的断路器:它只是止损
ES 有一个 indices.breaker.fielddata.limit(默认堆的 40 %)。超了会拒绝查询并抛 CircuitBreakingException——这是保护,不是修复。
| 现象 | 含义 |
|---|---|
CircuitBreakingException: fielddata |
加载被拒,查询失败 |
| 堆使用率高但没报错 | 已加载的 fielddata 还没到限额 |
手动 clear cache 后恢复 |
只是把内存还回去,下次查询还会加载 |
// 应急三件套(按顺序:先止损,再定位)
$ curl -XPOST 'localhost:9200/myidx/_cache/clear?fielddata=true' // 1 把内存还回去
$ curl -XPUT 'localhost:9200/_cluster/settings' -H 'Content-Type: application/json' \
-d '{"persistent":{"indices.breaker.fielddata.limit":"25%"}}' // 2 降限额
$ grep -rn "aggs" --include=*.json src/ | grep -v ".keyword" // 3 找出还在用 text 的查询
// 第三条才是修复:改查询或改映射,前两条只是把事故推后
第三行说明:清缓存是止血,不是治病。而如果负载高峰恰好两次加载撞在一起,就会出现"查询时好时坏"的诡异现象。
// 三步定位:谁在加载 fielddata
$ curl -s 'localhost:9200/_nodes/stats/indices/fielddata?human&pretty' | grep -A3 fielddata
$ curl -s 'localhost:9200/myidx/_stats/fielddata?human&pretty' | grep memory_size
$ curl -s 'localhost:9200/myidx/_mapping' | python3 -c 'import sys,json;m=json.load(sys.stdin);[print(k,v) for k,v in list(m.values())[0]["mappings"]["properties"].items() if v.get("type")=="text"]'
// 第三条列出所有 text 字段——它们都是潜在爆点
5. 什么时候不该用 ES 做这个聚合
| 场景 | 为什么不该 | 替代 |
|---|---|---|
| 高基数维度(用户 ID、订单号) | 桶数 = 基数,必然爆 | 预聚合 / ClickHouse |
| 需要精确去重 | cardinality 是近似 |
精确计数走库 |
| 多维交叉报表 | 桶数乘法膨胀 | 数据仓库 |
| 秒级刷新的看板 | 每次查询都要全量扫 | 定时预聚合 |
第一行的判据很硬:桶数 ≈ 维度基数。城市(几百)可以,用户(百万)不行——而"不行"的表现同样是"堆涨了",不是报错。
6. 排错:五种"聚合把节点打爆"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 加一个聚合堆就飙 | text 字段触发了 fielddata | 改用 .keyword |
偶尔 CircuitBreakingException |
多次查询叠加加载 | 改双字段 + 降限额 |
| 聚合结果比预期多很多 | 桶按 term 而非原值 | 同上 |
| 清缓存后很快又涨回来 | 查询逻辑仍在用 text | 改代码,不是清缓存 |
| 单条聚合慢但不报错 | doc_values 磁盘 IO | 加 page cache 或预聚合 |
第四行是最常见的"治标循环":运维清缓存、业务继续用 text 聚合,于是每周处理一次同样的告警。根因在查询语句里,而不在集群。
7. 边界:什么情况"用 text 聚合"是可以的
| 场景 | 可以吗 | 理由 |
|---|---|---|
| 数据量很小(< 1 万条) | 可以 | term 基数天然低 |
| 一次性分析,事后删索引 | 可以 | 短命索引不值得优化 |
| 就是要按"词"统计(词频) | 必须 | 语义上就该按 term 分桶 |
| 高频长期查询 | 不行 | 成本会反复出现 |
第三行容易被误判成"错误用法":做词频分析(比如统计评论里出现最多的词)本来就要用 text,此时 fielddata 是正确选择,只要控制数据量与限额。
判断口径一句话:聚合内存的风险 = 桶数 × 单桶开销,而桶数由"字段是不是被分词"决定——所以判据不是"数据量多大",而是"这个维度会被切成几个 term"。要按原值分组就用 keyword,要按词分组才用 text,两者不能靠调参互换。
8. 自测
- 检索与聚合分别用哪种数据结构?它们存在磁盘还是堆里?
- 为什么
text字段做聚合会触发 fielddata?它加载后会自己释放吗? keyword与text聚合的桶数差异从哪来?ngram为什么会把桶数炸到与文档数同阶?- 双字段方案解决了什么、代价是什么?
- 断路器
CircuitBreakingException是修复还是止损?为什么清缓存解决不了问题? - 什么判据能让你在加聚合前判断"会不会爆"?
9. 一句话总结
聚合的内存风险来自"桶数 × 单桶开销",而桶数由字段是否被分词决定:keyword 走磁盘上的 doc_values(几乎不占堆),text 则要把它整个倒排读进堆(fielddata),且加载后不会自己退回。修法的优先级是双字段(text 检索 + .keyword 聚合)→ 预聚合 → 换存储;fielddata_frequency_filter 与断路器只是止损。判据:按原值分组用 keyword,按词分组才用 text,两者不能靠调参互换。