Elasticsearch Rollup 汇算索引实战:监控指标多粒度预聚合查询,附 painless 时间窗取每日最新值

监控平台的性能指标写入 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 会返回这个汇算索引支持哪些字段、哪些聚合、什么粒度------查询报错时先看它。

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_index terms 聚合直接以 _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 实战:日志大屏的总数、成功率与分组统计。

八、踩坑点汇总

  1. _rollup_search 只认 Job 声明过的东西 。字段、terms 维度、metrics 类型(min/max/sum/avg)都必须在 Job 配置里出现过,查询里多写一个没配置的聚合直接报错。拿不准就 GET <rollup_index>/_rollup_search/capabilities 对一遍。
  2. 查询粒度必须能被 Job 粒度整除 。Job 配的是 1h,查询可以 1h/2h/1d,不能 30m------明细已经在小时粒度上被合并,更细的粒度物理上不存在。
  3. time_zone 必须显式指定 。Job 与查询两侧都要 Asia/Shanghai,否则天界按 UTC 切,曲线每天 08:00 断档,极难排查。
  4. terms 聚合的 missing 桶 。可空维度字段(如实例路径)不加 missing 参数,null 文档会无声丢失,报表总数对不上。
  5. 三层 terms 的 size 逐层都要给。默认 10 的桶上限在监控场景必炸,999 起步;桶数真超了要考虑拆查询。
  6. script 查询先过滤后执行 。painless 的时间窗脚本放在 bool.must 里与 terms 过滤并列,靠前面的 terms 把候选集压小;如果 terms 选择性差(比如只按指标过滤、全集群对象都进来),脚本查询会显著变慢,这时应改回按天循环 range。
  7. _index 元字段可直接聚合,按天分桶、按天计数都不需要额外字段,很多场景能省掉一次 cardinality。
  8. 版本预期:Rollup 在 8.x 后期版本已被官方标记为 deprecated,时序数据流(TSDS)的 downsampling 是新版本上的替代方向;但 7.x 存量集群与传统索引场景下 Rollup 依然可用,且"预聚合 + 透明查询"的思路是通用的。

九、结语

一句话总结:短期明细直接查原始索引(三层 terms 下钻 + missing 兜底),长期趋势走 Rollup 汇算索引(_rollup_search 透明合并),跨天时间窗用 painless 从 _index 反解日期,计数类排查用 terms 的 doc_count。四板斧组合下来,监控指标的查询基本不再需要碰全量明细。

本系列前几篇:

你的监控平台历史趋势查询是每次现场聚合,还是已经上了预聚合?如果还在现场聚合,单个趋势查询最长要跑多久?评论区聊聊。

相关推荐
ha_lydms34 分钟前
Xml Functions(XML 函数)
xml·大数据·hadoop·spark·数据处理·dataworks·maxcompute
科创致远34 分钟前
ESOP 电子作业指导书车间无纸化实战评测
大数据·数据库·人工智能·制造
silvamux_35 分钟前
品牌事实资产建模教程:把口述材料变成可校验的故事输入
大数据·人工智能
xiaomuzuirenzhen39 分钟前
2027车企数字化岗位项目准备:从SQL数据建模到库存分析看板
大数据·数据库·sql
Z21-X44 分钟前
云原生运维学习笔记:存储、网络与操作系统——云计算的底层基石
运维·笔记·学习·云原生
科研邪修法1 小时前
文献怎么检索?UPDF论文搜索和文献筛选方法
搜索引擎
爱吃火鸡面呀1 小时前
Spark RDD 创建与转换算子详解
大数据·分布式·spark
ha_lydms1 小时前
Vector Functions(向量函数)
大数据·hadoop·spark·数据处理·dataworks·maxcompute·数据中台
ha_lydms1 小时前
阿里云大数据组件
大数据·hadoop·阿里云·spark·云计算·数据处理·dataworks