%title缩略图

不要让指标白白浪费:使用 ES|QL TS command 来查询它们

2026年5月24日   |   by mebius

作者:来自 ElasticFelix Barnsteiner

%title插图%num

重新校准你对 time series 查询的心智模型:了解为什么 FROM 会在 metrics 上产生不准确结果,TS 如何修复这一点,以及何时使用各个 command。

开始动手实践 Elasticsearch:深入查看 Elasticsearch Labs 仓库中的示例 notebooks,启动免费云试用,或立即在本地机器上尝试 Elastic。


如果你使用 ES|QL 来处理日志和 traces,那么 FROM 可能已经非常熟悉,但在 metrics 上它可能会返回数值错误的结果。类似 FROM metrics-* | STATS SUM(request_count) 这样的查询,会把所有主机上每个 sample 的累计计数值相加。结果会不断增长,但它既不是 rate,也不是 count,更不是任何有意义的指标。TS 通过先将数据点按时间序列分组来解决这个问题,然后提供 RATEAVG_OVER_TIMELAST_OVER_TIME 等函数,在每条时间序列内部进行计算。

想了解 metrics analytics 在 ES|QL 和 Discover 中的整体视角,可以参考 Elastic Observability 中的《轻松探索和分析指标》。本文重点讲机制层面。

下面是五点心智模型:

  • FROM 将每条文档视为独立行。这对事件数据是正确的,但 metrics 聚合通常需要知道每一行属于哪条时间序列。
  • TS 增加了时间序列上下文:它在任何聚合发生之前,先按 time series 对数据点进行分组和聚合,并支持 RATEAVG_OVER_TIMELAST_OVER_TIME 等函数。
  • 一个 TS | STATS 查询通常包含两层聚合。内层会在每条 time series 内对样本进行归约;外层再对这些每序列的结果进行分组和合并。
  • 默认的内层聚合是 LAST_OVER_TIME,因此 TS | STATS AVG(cpu_usage)FROM metrics | STATS AVG(cpu_usage) 可能会返回不同结果。
  • 使用 TS 来查询 time series data stream (TSDS),使用 FROM 来查询事件数据和原始文档检索。

什么是 time series(时间序列)?

时间序列是一个由(时间戳,值)组成的序列,这些数据点由指标名称以及一组唯一的维度值共同标识。

例如,来自数据中心 dc1 中主机 h1 每 30 秒上报一次的 request_count,就是一条时间序列。同一个指标在 dc1 中的主机 h2 上,则是另一条不同的时间序列。

在 time series data stream 中,每条 metric 文档都会携带一个内部的 _tsid 字段,用于唯一标识一条时间序列。共享同一个 _tsid 的样本属于同一条时间序列,并且会按时间戳排序、顺序存储。

这种存储布局使得按时间序列进行高效聚合成为可能。这也解释了为什么 TS 只能用于 time series data streams。其他索引模式没有“时间序列”的概念,因此 TS 所依赖的 per-series 操作无法关联到对应的标识。FROM 不支持这些操作,这也是下一节要解释的内容。

为什么 FROM 会在 metrics 上 “留下分析空间(leaves metrics on the table)”

考虑一个名为 request_count 的计数器指标,它每 30 秒从三个主机采集一次。

计数器是一种累计型指标:每个样本表示自进程开始以来的累计总数。对于 request_count 来说,数值 1,000 的含义是 “这个 time series 到目前为止已经观测到 1,000 次请求”,而不是 “自上一个样本以来发生了 1,000 次请求”。当进程重启时,计数器会重置为 0,因此在 1,004 之后突然出现 4 的样本是一次新的计数起点,而不是负流量。ES|QL 的 RATE 函数会在 time series 内计算每秒变化率,并且能平滑处理重置情况。

现在你想计算所有主机的总请求率,并按 5 分钟分桶。

如果你习惯在 event 数据上写 ES|QL,你可能会这样开始查询:

FROM metrics-*
| WHERE TRANGE(1h)
| STATS SUM(request_count) BY BUCKET(@timestamp, 5m)

最开始生成的图看起来是合理的:一条随时间上升的曲线。但 y 轴上的数值,其实是把每个时间桶里所有累计计数器值加总后的结果。每个主机都会在每个采样点贡献自己的运行总数,并在每个 bucket 中重复计入。由于查询对这些累计值使用了 SUM,结果既不是 rate,也不是某个时间桶内的请求数,即使应用已经停止接收请求,它仍然会持续增长。

request_count 是单调递增计数器,因此它的原始值表示 “这个主机历史上累计发生了多少请求”,而不是 “这个时间桶内发生了多少请求”。正确的计算方式是:“先在每个主机的 time series 内计算每秒增长量,然后再跨主机求和”。但 FROM 无法直接表达这种操作。它可以按字段分组行,但没有“同一条时间序列随时间变化”的概念,也无法在每条 time series 内计算 counter 的变化。它也不能使用类似 RATE(request_count, 5m) 这样的滑动时间窗口函数,我们稍后会再讨论这一点。

TS 正是为了解决这个问题而引入的,它提供了一种更简洁的语法来表达时间序列聚合:

TS metrics-*
| WHERE TRANGE(1h)
| STATS SUM(RATE(request_count)) BY TBUCKET(5m)

RATE(request_count) 在每条 time series 内运行,生成一个按秒计算的增长率,并且能正确处理 counter reset(重置)。然后 SUM 再把各个主机的 rate 加总起来。

两阶段聚合:inner 和 outer

每一个 TS | STATS 查询都包含两个明确的聚合阶段。

我们用一个更具体的查询来说明:计算每个 data center 的请求速率。

TS metrics-*
| WHERE TRANGE(1h)
| STATS SUM(RATE(request_count)) BY datacenter, TBUCKET(5m)

下方的图展示了 TS 如何执行这个查询。它首先在每条 time series 内部对样本进行归约,然后再按 data center 和 time bucket 对这些每条 time series 的结果进行分组与合并,最终生成每个时间桶、每个数据中心的结果。

%title插图%num

这些阶段分别是:

  • 内部阶段(time series 内部)

    • 在每一条 time series 内分别执行。它会在每个时间桶内,把多个(timestamp,value)数据点压缩成每条 time series 在该 bucket 的单一值,方法是应用内部聚合函数,例如上面例子中的 RATE。相关函数包括:RATEAVG_OVER_TIMEMAX_OVER_TIMELAST_OVER_TIMESTDDEV_OVER_TIME 等等。完整列表可以参考 time series aggregation functions 页面。
  • 外部阶段(跨 time series 的 “分组” 阶段)

    • 把每条 time series 的结果再聚合成每个分组、每个时间桶的单一值。使用的是标准 ES|QL 聚合函数,例如 SUMAVGMAXMIN、percentiles 等。

SUM(RATE(request_count)) BY datacenter, TBUCKET(5m) 中:

  • RATE(request_count) 是内部聚合,它在每条 time series 上运行。
  • SUM(...) 是外部聚合,它在同一个 datacenter 和时间桶内合并多条 time series。
  • TBUCKET(5m) 定义时间桶边界(等价于 BUCKET(@timestamp, 5m))。

外部聚合是可选的。如果你只需要每条 time series 的结果,可以直接使用 time series 聚合函数:

TS metrics-*
| WHERE TRANGE(1h)
| STATS request_rate = RATE(request_count) BY TBUCKET(5m)

这个查询会保留每条 time series 在每个 bucket 内的 per-series rate,而不是再用 SUMAVG 或其他聚合把不同 time series 混合在一起。

默认的内部聚合:LAST_OVER_TIME

TS 必须先在每条 time series 内部对原始样本进行归约,然后才能执行外部聚合。这意味着在 TS | STATS 聚合中,每一个 metric 字段都需要一个内部聚合,即使查询里没有显式写出来。

考虑一个名为 cpu_usage 的指标,它是一个 gauge:用于记录某个时间点的值,可以上下波动。比如一个样本值 0.42 表示 “该主机在这个时刻 CPU 使用率为 42%”。对于 gauge 来说,一个时间桶内 “最合理的值” 就是最新的样本。

这正是 ES|QL 自动帮你补上的部分。如果你写:

TS metrics | STATS AVG(cpu_usage) BY host.name, TBUCKET(5m)

那么隐式的内部聚合就是 LAST_OVER_TIME(cpu_usage),整个查询等价于:

TS metrics
| WHERE TRANGE(1h)
| STATS AVG(LAST_OVER_TIME(cpu_usage)) BY host.name, TBUCKET(5m)

对于每一条 time series,LAST_OVER_TIME 会在每个 bucket 中选取最新的一条样本。然后 AVG 再在不同 time series 之间做平均。

这也是为什么看起来相同的查询,在 FROMTS 上可能会返回不同结果。FROM 会对每一条 document 直接做平均,而 TS 是先在每条 time series 内把数据归约成一个 butgcodecket 值,再进行聚合。如果你的主机上报频率不同,这种差异会被放大。

例如,在一个 5 分钟的 bucket 中,一个主机每秒上报一次(300 条 document),而另一个主机每两分钟上报一次(2~3 条 document)。使用 FROM | STATS AVG(cpu_usage) 时,上报频率高的主机会对平均值产生更大影响;而在 TS 中,每条 time series 先被压缩成一个 bucket 值,因此外层平均时每个主机只贡献一个值。

如果你想要的是 “bucket 内的平均值”,而不是 “最新值”,可以显式指定内部聚合:

TS metrics-*
| WHERE TRANGE(1h)
| STATS AVG(AVG_OVER_TIME(cpu_usage)) BY host.name, TBUCKET(5m)

AVG_OVER_TIME 会在每条 time series 内对所有 CPU 使用率样本求平均。然后外层的 AVG 再对这些每条 time series 的结果在相同 host 之间求平均。这样得到的结果是:先在 time series 内做 “按采样点加权” 的平均,再在 time series 之间做“等权”的平均。适用于你关心 bucket 内整体行为,而不仅仅是最终状态的情况。

峰值和谷值也是同样的规则。对于 CPU 峰值图表,应使用 MAX(MAX_OVER_TIME(cpu_usage)),而不是简单的 MAX(cpu_usage)。内层 MAX_OVER_TIME 先在每条 time series 内找到峰值,外层 MAX 再在所有匹配的 time series 中选出最大值。

计数器的情况则相反。它的样本值本身是累计总量,因此单独看最新值通常没有意义。对于 counter,几乎总是应该使用 RATE(计算每秒增长率)或 INCREASE(计算 bucket 内总增量)作为内部聚合。而退回默认的 LAST_OVER_TIME,就会得到最新的累计值,这正是前面 FROM 查询踩到的陷阱。

关键是:内部函数必须有意识地选择,外部函数反而是更简单的一层。

什么时候用 TS,什么时候用 FROM

一个实用的经验法则:

  • 当你在 time series data stream 上做指标聚合时,使用 TS。它是为这种数据设计的查询入口,会默认应用 per-series 语义。
  • 当你处理事件数据(logs、traces、audit records、transactions)时,使用 FROM。每一行都是独立事件,不存在 time series 上下文。

FROM 在 TSDS 索引上仍然可以工作,有时也有用,例如你只想查看原始 metric 文档而不做 time series 聚合。但对于 dashboards、alerting 以及任何图表分析场景,TS 才是正确的默认选择。

如果你需要先发现有哪些 metrics 或 time series 存在,可以在 TS 之后、STATS 之前使用 METRICS_INFtgcodeOTS_INFO。更多内容可参考《ES|QL METRICS_INFO 和 TS_INFO:对 time series 数据进行目录化分析》的深入讲解。

使用 ES|QL 对 TS 结果进行后处理

第一tgcodeSTATS 命令是 time series 处理与普通 ES|QL 处理之间的分界点。在第一个 STATS 之前,TS 需要保持数据按 _tsid 分组,因此不允许使用会改变行顺序或数据形状的命令。在第一个 STATS 之后,输出就变成了标准的 ES|QL 表格。你可以对其进行排序、限制结果、关联 lookup 数据、做 enrich,或者计算派生列。

例如,下面这个查询会先计算每个主机在每个 bucket 的平均 CPU,然后找出每个主机在所有 bucket 中的最大平均值,并返回比例:

TS metrics-*
| WHERE TRANGE(1h)
| STATS avg_cpu = AVG(AVG_OVER_TIME(cpu_usage)) BY host.name, time_bucket = TBUCKET(5m)
| INLINE STATS max_avg_cpu = MAX(avg_cpu) BY host.name
| EVAL cpu_ratio = avg_cpu / max_avg_cpu
| KEEP host.name, time_bucket, cpu_ratio
| SORT host.name, time_bucket DESC

内层聚合的滑动窗口

时间序列聚合函数支持第二个参数:用于内层阶段的窗口大小。

TS metrics-*
| WHERE TRANGE(1h)
| STATS AVG(RATE(app.requests, 5m)) BY TBUCKET(1m)

这会在一个 5 分钟滑动窗口上计算 rate,但每 1 分钟输出一个值。当你希望在较细的 bucket 粒度下得到更平滑的图表时,这非常有用。

这个 window 是 ES|QL 对 PromQL range vector selector 的对应物:RATE(app.requests, 5m) 的作用等价于 rate(app_requests[5m])

需要注意的几个坑

TS 中有一些行为可能看起来出乎意料,尤其是从基于事件的 FROM 心智模型切换过来时。这些都不是 bug,而是 per-series 模型的直接结果。下面是需要注意的点。

COUNT(*) 会被拒绝
比如你想统计每个 service 在每个 bucket 收集了多少样本。在 FROM 的思维里会写 COUNT(*),但 TS 会直接拒绝:在按 time series 分组之后,已经不存在“普通行”的概念,因此 row count 没有明确语义。你需要明确你想统计的是什么:

  • 每个 service 的样本数量:
    STATS samples = SUM(COUNT_OVER_TIME(cpu_usage)) BY service.name, TBUCKET(5m)
    内层 COUNT_OVER_TIME 统计每条 time series 的样本数;外层 SUM 再跨 time series 相加。

  • 每个 service 报告的不同主机数:
    STATS hosts = COUNT_DISTINCT(host.name) BY service.name, TBUCKET(5m)
    这是跨 time series 的唯一值计数。

STATS 之前不能 sort、limit、lookup join 或 enrich
TS metrics | SORT @timestamp | STATS ... 会失败。因为必须先按 _tsid 完成分组,否则数据语义不成立。如果需要缩小范围,应使用 WHERE。在第一个 STATS 之后,结果变回标准 ES|QL,就可以继续做任何 pipeline 操作,如前文所述。

Gauge 和 counter 的映射问题
time series 函数对字段 mapping 中的 metric 类型是敏感的。RATE 只适用于 counter;*_OVER_TIME 用于 gauge。如果手动构建 TSDS mapping,需要特别注意这一点。

这一点对 Prometheus 用户尤其容易造成困扰。Prometheus 的 metric type 元数据在进入 Elasticsearch 时可能不可用,因此经常需要依赖命名规则(如 _total 表示 counter)进行推断。这些 heuristic 并不完美,如果 metric 被错误分类,就会被函数拒绝。更底层的机制(包括 Prometheus Remote Write 如何映射到 TSDS)可以参考《Prometheus Remote Write 在 Elasticsearch 中的摄取工作原理》。

更灵活的转换函数(gauge↔counter)已经在规划中,用于在查询时修复这类问题。

Kibana 图表在缩小时变空
Kibana 中,TBUCKET 会跟随时间选择器变化。缩小时间范围会减小 bucket size。当 bucket 小于采集间隔时,一些 bucket 会没有数据点,导致 RATE 等函数返回 null,图表就会 “空白”。Elastic 正在考虑改进,比如:bucket 太小时提示警告、设置最小 bucket、或自动扩大 window/bucket 来避免空图。

总结

对于指标查询,优先使用 TS,除非你明确需要处理原始文档。然后根据 “每条 time series 内部应该表达什么含义” 来选择内部聚合函数:计数器用 RATE,当前值型 gauge 用 LAST_OVER_TIME,峰值、均值、最小值或分布则用显式的 *_OVER_TIME 函数。

一旦每条 time series 的值定义正确,外层聚合就是更熟悉的一步:把这些 time series 再按你需要的方式分组、汇总,生成图表、告警或表格。

完整参考可以查看 TS command 文档以及 time series aggregation functions 列表。

常见问题

为什么 ES|QL 的 FROM 在 counter 指标上会返回错误结果?

FROM 会把每个 metric 文档当作独立行处理,因此 SUM(request_count) 会把跨主机、跨时间的累计值直接相加,结果会无限增长,并不是 rate 或请求数。应使用 TS 配合 RATE 在每条 time series 内计算每秒变化率,再跨主机求和。

TS 和 FROM 在 ES|QL 中有什么区别?

TS 是 time series data streams(TSDS)的专用入口,会在聚合前按 time series 分组,从而支持 RATEAVG_OVER_TIMELAST_OVER_TIME 等函数。FROM 则是按文档读取,没有 per-series 语义。指标数据用 TS,事件和原始文档分析用 FROM。

为什么 TS metrics | STATS AVG(cpu_usage)FROM metrics | STATS AVG(cpu_usage) 得到的平均值不同?

因为 TS 有隐式的内部聚合(默认 LAST_OVER_TIME),每条 time series 在每个 bucket 只贡献一个值;而 FROM 是对所有 document 做平均,上报频率高的主机会权重更大。TS 是“按 time series 等权”,FROM 是“按文档等权”。

如何在 ES|QL 中计算 counter 的每秒速率?

使用 TS 和 RATE
TS metrics-* | STATS SUM(RATE(request_count)) BY TBUCKET(5m), host.name
RATE 在每条 time series 内计算增长率并处理 reset,外层 SUM 再跨主机汇总。

什么时候需要在 ES|QL metrics 查询里加时间过滤?

在 Kibana 中(Dashboard、Discover 等),时间范围由全局时间选择器控制,无需手写过滤。在 Kibana 之外或 Dev Tools 中,应显式加 WHERE @timestamp ... 或使用 TRANGE(1h) 来限制扫描范围。

TS | STATS 的两个聚合阶段是什么?

内部阶段在每条 time series 内用 RATEAVG_OVER_TIME 等函数把多个样本压缩为一个值;外部阶段再用 SUMAVG 等标准 ES|QL 聚合跨 time series 汇总。正确性取决于内部函数,外部只是组合。

为什么 Kibana 指标图在缩放后会变空?

当 bucket 小于采集间隔时会出现空桶,RATE 返回 null,图表就会消失。需要扩大时间范围或增大 inner window(例如 RATE(request_count, 5m))。

TS 查询里可以在 STATS 前用 SORT、LIMIT 或 LOOKUP JOIN 吗?

不可以。TS 在第一个 STATS 之前必须保持 _tsid 分组状态,因此任何改变行结构或顺序的操作都会被拒绝。应先 WHERE 过滤,再 STATS,之后才能做排序、join 或 enrich。

原文:https://www.elastic.co/search-labs/blog/esql-ts-command-querying-metrics

文章来源于互联网:不要让指标白白浪费:使用 ES|QL TS command 来查询它们

相关推荐: 比 Prometheus 快 30x:我们如何将 Elasticsearch 重建为领先的列式 metrics 存储

作者:来自 ElasticKostas Krikellas,Martijn Van Groningen,Nhat Nguyen及Felix Barnsteiner Elasticsearch 现在以每个数据点 3.75 字节的成本存储 OTel 指标,并且查询…

Tags: ,