
监控平台的性能指标写入 Elasticsearch 之后,最高频的一类需求是"拉长时间看趋势":某个数据库实例过去一个月每小时的平均连接数、某台主机近两周每天的最大 CPU 使用率、某次巡检周期内每天固定时刻的内存快照。如果这些统计直接在按天滚动的原始指标索引上做 date_histogram 聚合,等于每次查询都要把上亿条明细重新扫一遍------查的时间范围越长,集群越吃力,曲线还越慢。
Elasticsearch 对这个场景有两条成熟路子:一条是 Rollup 汇算索引 ------后台任务把明细数据按配置持续预聚合成粗粒度索引,查询时用 _rollup_search 端点透明改写,历史趋势查询的成本直接从"扫全量明细"降到"扫预聚合结果";另一条是查询侧技巧------date math 索引表达式锁定当天索引、painless 脚本从索引名反解日期按天开时间窗。
本文把两条路上的 8 个 DSL 全部过一遍,全部来自 7.x 生产环境的真实查询(索引名、字段名、ID 已脱敏替换,可直接套用)。这也是本 ES 实战系列的续篇,前几篇见文末链接。
一、先说清楚数据模型
指标数据按"对象类型 + 数值类型 + 日期"滚动建索引,命名形如:
| 索引模式 | 说明 |
|---|---|
metric-host-long-20250906 |
主机指标,long 型(计数类:连接数、进程数) |
metric-host-double-20250906 |
主机指标,double 型(率值类:CPU/内存使用率) |
metric-db-long-20250906 |
数据库指标,long 型 |
metric-db-double-20250906 |
数据库指标,double 型 |
long 与 double 分开建索引,是为了避免 value 字段的 mapping 冲突------计数类与率值类指标在同一个集群里各自落位,聚合时也能按类型路由。文档结构非常扁平:
| 字段 | 类型 | 含义 |
|---|---|---|
date |
date | 指标采集时间 |
timestamp |
date | 采集时间戳(取最新值时排序用) |
host_id.keyword |
keyword | 监控对象 ID(配置库里的主机/实例标识) |
metric_name.keyword |
keyword | 指标名,如 HOST_CPU_USEDRATE |
dimension.keyword |
keyword | 维度实例,如表空间文件路径,可为 null |
value |
long / double | 指标值 |
原始索引保留 30 天,30 天以上的趋势查询全部走汇算索引------这就是下面 Rollup 登场的背景。
二、Rollup 汇算索引:把"每次重算"变成"一次算好"
Rollup 的思路一句话讲完:用一个后台 Job,按你声明的分组(时间直方图 + terms 维度)和指标(min/max/sum/avg),把原始索引持续聚合写入一个专用的汇算索引。之后趋势查询不再碰明细:
json
PUT _rollup/job/rollup-metric-hour
{
"index_pattern": "metric-*-long-*",
"rollup_index": "rollup-metric-hour",
"cron": "*/30 * * * * ?",
"page_size": 1000,
"groups": {
"date_histogram": {
"field": "date",
"fixed_interval": "1h",
"time_zone": "Asia/Shanghai"
},
"terms": {
"fields": ["host_id.keyword", "metric_name.keyword", "dimension.keyword"]
}
},
"metrics": [
{
"field": "value",
"metrics": ["min", "max", "sum", "avg"]
}
]
}
几个关键点:
index_pattern匹配原始索引,rollup_index是聚合结果落地的目标索引(一个 Job 一个,不要带通配符);cron是 Quartz 表达式,*/30 * * * * ?即每 30 分钟增量聚合一次;groups.terms里声明的字段,决定了汇算索引里能按什么维度分组查询------没在 Job 里声明的字段,_rollup_search查不到;metrics里把 min/max/sum/avg 配全,avg 并不单独存储,而是拆成 sum 与 count 在查询时合成,所以成本极低,建议一次配齐。
配套管理命令:
json
GET _rollup/job/rollup-metric-hour
POST _rollup/job/rollup-metric-hour/_stop
GET rollup-metric-hour/_rollup_search/capabilities
capabilities 会返回这个汇算索引支持哪些字段、哪些聚合、什么粒度------查询报错时先看它。
三、_rollup_search 实战:小时与天两种粒度
3.1 单指标的小时级趋势
"某个数据库实例,过去一段时间每小时的最大/最小/平均连接数"------汇算索引上的标准查询:
json
GET rollup-metric-hour/_rollup_search
{
"size": 0,
"query": {
"bool": {
"must": [
{ "term": { "host_id.keyword": { "value": "f3a1c2d4e5b6478890abcdef12345601" } } },
{ "term": { "metric_name.keyword": { "value": "DB_CONNECTION_COUNT" } } }
]
}
},
"aggs": {
"per_hour": {
"date_histogram": {
"field": "date",
"time_zone": "Asia/Shanghai",
"fixed_interval": "1h"
},
"aggs": {
"avg_val": { "avg": { "field": "value" } },
"max_val": { "max": { "field": "value" } },
"min_val": { "min": { "field": "value" } }
}
}
}
}
注意端点是 _rollup_search 而不是 _search。它会同时查询汇算索引与 Job 配置里仍在保留期内的原始索引,把两边结果透明合并 ------也就是说原始数据滚动删除后,趋势曲线不会断。这种"按天滚动建索引 + 明细只留短期"的存储策略,和笔者在Elasticsearch 日增 30TB 日志架构实战里介绍的日志索引设计是同一套思路,指标与日志两个场景通用。
3.2 按指标分组的天级趋势
天级汇算索引(fixed_interval 配成 1d 的 Job 产物)上,先按指标名分桶、再按天出 min/max/avg,一次拿到"CPU + 内存"两条曲线的数据:
json
GET rollup-metric-day/_rollup_search
{
"size": 0,
"query": {
"bool": {
"must": [
{ "term": { "host_id.keyword": { "value": "f3a1c2d4e5b6478890abcdef12345602" } } },
{
"terms": {
"metric_name.keyword": ["HOST_MEMORY_USEDRATE", "HOST_CPU_USEDRATE"]
}
}
]
}
},
"aggs": {
"metrics": {
"terms": { "field": "metric_name.keyword" },
"aggs": {
"per_day": {
"date_histogram": {
"field": "date",
"time_zone": "Asia/Shanghai",
"fixed_interval": "1d"
},
"aggs": {
"avg_val": { "avg": { "field": "value" } },
"max_val": { "max": { "field": "value" } },
"min_val": { "min": { "field": "value" } }
}
}
}
}
}
}
terms 嵌套 date_histogram 是报表类需求的最常见骨架:外层分组、内层时间轴,返回结构可以直接喂给前端图表。
四、当天累计值:date math 索引表达式
不碰汇算索引,也有轻量技巧。"当天某指标的累计值"(sum 适合计数类指标),可以把"今天"直接写进索引名------用 date math 索引表达式,避免在代码里拼日期字符串:
json
GET %3Cmetric-*-long-%7Bnow%2Fd%7ByyyyMMdd%7D%7D%3E/_search
{
"size": 0,
"query": {
"bool": {
"must": [
{ "term": { "host_id.keyword": { "value": "f3a1c2d4e5b6478890abcdef12345603" } } },
{ "term": { "metric_name.keyword": { "value": "DB_SESSION_COUNT" } } },
{ "term": { "dimension.keyword": { "value": "/opt/db/data/DEMO/TBS_DATA01.DBF" } } }
]
}
},
"aggs": {
"value_total": { "sum": { "field": "value" } }
}
}
那串 %3C...%3E 是 URL 编码,还原后是:
<metric-*-long-{now/d{yyyyMMdd}}>
{now/d{yyyyMMdd}} 在查询时刻解析为当天日期、按 yyyyMMdd 格式化后拼进索引名(/d 取整到天,格式不写则默认输出 2025.09.06 这种带点格式,匹配不到我们的索引命名)。date math 默认按 UTC 计算,东八区零点前后查询时注意边界,必要时换成 {now+8h/d{yyyyMMdd}}。在 Kibana Dev Tools 里写 URL 编码形式,程序里(REST 客户端)直接传未编码形式即可。dimension 这里是单维度过滤------同一个指标如果关联多个表空间文件,各自一条曲线。
五、原始索引三层下钻:对象 → 指标 → 维度
明细保留期内(30 天)的查询直接打原始索引。典型报表是"一批对象 × 一批指标 × 每个维度实例"的 min/max/avg 三层 terms 下钻:
json
GET metric-db-double-20250906/_search
{
"size": 0,
"query": {
"bool": {
"must": [
{ "terms": { "host_id.keyword": ["f3a1c2d4e5b6478890abcdef12345604"] } },
{
"terms": {
"metric_name.keyword": [
"DB_TABLESPACE_TABLESIZE",
"DB_INSTANCE_DATASIZE"
]
}
},
{ "range": { "date": { "gte": 1757110800000, "lte": 1757154000000 } } }
]
}
},
"aggs": {
"object_agg": {
"terms": { "field": "host_id.keyword", "size": 999 },
"aggs": {
"metric_agg": {
"terms": { "field": "metric_name.keyword", "size": 999 },
"aggs": {
"dimension_agg": {
"terms": { "field": "dimension.keyword", "size": 999, "missing": "N/A" },
"aggs": {
"max_val": { "max": { "field": "value" } },
"min_val": { "min": { "field": "value" } },
"avg_val": { "avg": { "field": "value" } }
}
}
}
}
}
}
}
}
两个细节:dimension 为 null 的文档默认会从 terms 聚合里整个消失 ,加 missing: "N/A" 才会落进一个名为 N/A 的桶;size: 999 是桶上限保险丝,对象/指标/维度三层都要设,否则默认 10 个桶会悄悄截断报表。
六、painless 时间窗:每天 05:00--06:00 的最新一条
来一个更刁钻的需求:日报快照------每个对象、每个指标,在每天凌晨 05:00 到 06:00 之间的最后一条记录(这段时间业务低峰,取到的值最稳定,适合当基线)。
难点在于"每天":跨两天的数据分属两个索引,没法用一条 range 表达"9 月 6 日的 05--06 点、9 月 7 日的 05--06 点"。笨办法是按天循环发 N 次查询;聪明的办法是 painless 脚本从 _index 索引名里把日期反解出来,在脚本内按每条文档自己的日期构造时间窗:
json
GET metric-*-20250906,metric-*-20250907/_search
{
"size": 0,
"query": {
"bool": {
"must": [
{
"terms": {
"metric_name.keyword": ["HOST_CPU_USEDRATE", "HOST_MEMORY_USEDRATE"]
}
},
{
"terms": {
"host_id.keyword": [
"f3a1c2d4e5b6478890abcdef12345601",
"f3a1c2d4e5b6478890abcdef12345602"
]
}
},
{
"script": {
"script": """
String dayStr = doc['_index'].value.splitOnToken('-')[3];
String beginStr = dayStr + ' ' + "05:00";
String endStr = dayStr + ' ' + "06:00";
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd HH:mm");
long begin = sdf.parse(beginStr).getTime();
long end = sdf.parse(endStr).getTime();
long ts = doc['date'].value.toInstant().toEpochMilli();
return ts >= begin && ts <= end;
"""
}
}
]
}
},
"aggs": {
"by_index": {
"terms": { "field": "_index", "size": 999 },
"aggs": {
"by_object": {
"terms": { "field": "host_id.keyword", "size": 999 },
"aggs": {
"by_metric": {
"terms": { "field": "metric_name.keyword", "size": 999 },
"aggs": {
"latest": {
"top_hits": {
"sort": [{ "timestamp": { "order": "desc" } }],
"size": 1
}
}
}
}
}
}
}
}
}
}
拆解一下:
doc['_index'].value.splitOnToken('-')[3]------索引名metric-host-long-20250906按-切开后第 4 段就是20250906,于是每条文档都能算出"自己那天的 05:00--06:00";- 外层
by_indexterms 聚合直接以_index元字段分桶(它可聚合),天然按天分组; - 每组内
top_hits按timestamp倒序取size: 1,即时间窗内最新一条 ------这是"取每组最新值"的标准解法,比max聚合强在能拿回整条文档而不是单个值; - 窗口过滤用
date(指标时间),排序用timestamp,两个字段语义分开。
性能提示:script 查询是逐文档执行的,前面两个 terms 过滤先把候选集压到两个对象 × 两个指标,脚本只在很小的集合上跑,实测毫秒级返回。各类 DSL 在亿级索引上的真实延迟对比,可以参考Elasticsearch 查询性能实测:9000 万日志数据 9 类 DSL 延迟对比。
七、一条 DSL 找出"哪天的数据最多"
排查数据接入是否断流/重推时,有个反直觉的小技巧:terms 聚合的 doc_count 本身就是计数器 。按指标分组、按 _index 取 size: 1 的桶,一步拿到"每个指标记录数最多的是哪天、有多少条":
json
GET metric-*-20250911,metric-*-20250912/_search
{
"size": 0,
"query": {
"terms": {
"metric_name.keyword": [
"HOST_CPU_USEDRATE",
"HOST_MEMORY_USEDRATE",
"HOST_FILESYSTEM_USEDRATE",
"HOST_SWAP_USEDRATE",
"HOST_CPUSTATUS_IOWAIT",
"DB_CONNECTION_COUNT",
"DB_TABLESPACE_TABLESIZE",
"DB_INSTANCE_DATASIZE"
]
}
},
"aggs": {
"by_metric": {
"terms": { "field": "metric_name.keyword", "size": 999 },
"aggs": {
"top_index": {
"terms": { "field": "_index", "size": 1 }
}
}
}
}
}
内层 top_index 桶的 doc_count 就是该索引(该天)的文档数,size: 1 自动取最大的。想看"每天各多少条"就把 size 放开。零 script、零二次查询。更多 terms 嵌套、bucket_script 比率计算这类聚合技法,见Elasticsearch 聚合 DSL 实战:日志大屏的总数、成功率与分组统计。
八、踩坑点汇总
_rollup_search只认 Job 声明过的东西 。字段、terms 维度、metrics 类型(min/max/sum/avg)都必须在 Job 配置里出现过,查询里多写一个没配置的聚合直接报错。拿不准就GET <rollup_index>/_rollup_search/capabilities对一遍。- 查询粒度必须能被 Job 粒度整除 。Job 配的是
1h,查询可以1h/2h/1d,不能30m------明细已经在小时粒度上被合并,更细的粒度物理上不存在。 - time_zone 必须显式指定 。Job 与查询两侧都要
Asia/Shanghai,否则天界按 UTC 切,曲线每天 08:00 断档,极难排查。 - terms 聚合的 missing 桶 。可空维度字段(如实例路径)不加
missing参数,null 文档会无声丢失,报表总数对不上。 - 三层 terms 的 size 逐层都要给。默认 10 的桶上限在监控场景必炸,999 起步;桶数真超了要考虑拆查询。
- script 查询先过滤后执行 。painless 的时间窗脚本放在
bool.must里与 terms 过滤并列,靠前面的 terms 把候选集压小;如果 terms 选择性差(比如只按指标过滤、全集群对象都进来),脚本查询会显著变慢,这时应改回按天循环 range。 _index元字段可直接聚合,按天分桶、按天计数都不需要额外字段,很多场景能省掉一次 cardinality。- 版本预期:Rollup 在 8.x 后期版本已被官方标记为 deprecated,时序数据流(TSDS)的 downsampling 是新版本上的替代方向;但 7.x 存量集群与传统索引场景下 Rollup 依然可用,且"预聚合 + 透明查询"的思路是通用的。
九、结语
一句话总结:短期明细直接查原始索引(三层 terms 下钻 + missing 兜底),长期趋势走 Rollup 汇算索引(_rollup_search 透明合并),跨天时间窗用 painless 从 _index 反解日期,计数类排查用 terms 的 doc_count。四板斧组合下来,监控指标的查询基本不再需要碰全量明细。
本系列前几篇:
- Elasticsearch 日志检索 DSL 实战:时间范围查询、字段去重、分钟级统计与最新日志获取
- Elasticsearch 聚合 DSL 实战:日志大屏的总数、成功率与分组统计(bucket_script 比率计算)
- Elasticsearch 日增 30TB 日志架构实战:可变索引粒度、分片规划与检索调优
- Elasticsearch 查询性能实测:9000 万日志数据 9 类 DSL 延迟对比
你的监控平台历史趋势查询是每次现场聚合,还是已经上了预聚合?如果还在现场聚合,单个趋势查询最长要跑多久?评论区聊聊。