跳到主要内容

计时器

DeepSeek V3 中英对照 Timers

计时器(Timers)旨在测量短时延及其事件发生的频率。所有 Timer 的实现至少会报告总时间和事件计数作为独立的时间序列,但根据后端的支持情况,还可以报告其他时间序列(如最大值、百分位数、直方图等)。虽然你可以将计时器用于其他用途,但请注意不支持负值,并且记录许多较长的持续时间可能会导致总时间溢出,最大值为 Long.MAX_VALUE 纳秒(约 292.3 年)。

例如,考虑一个显示典型 Web 服务器请求延迟的图表。服务器预计能够快速响应许多请求,因此计时器每秒会更新多次。

计时器的基础单位因指标后端而异,这是有充分理由的。Micrometer 在这方面明确保持中立。然而,由于可能存在的混淆,Micrometer 在与 Timer 实现交互时需要指定一个 TimeUnit。Micrometer 了解每个实现的偏好,并根据实现以适当的基础单位发布你的计时数据。以下代码片段展示了 Timer 接口的一部分:

public interface Timer extends Meter {
...
void record(long amount, TimeUnit unit);
void record(Duration duration);
double totalTime(TimeUnit unit);
}
java

该接口包含一个流畅的计时器构建器:

Timer timer = Timer
.builder("my.timer")
.description("a description of what this timer does") // optional
.tags("region", "test") // optional
.register(registry);
java
备注

基本的 Timer 实现(如 CumulativeTimerStepTimer)的最大统计值是时间窗口最大值(TimeWindowMax)。这意味着它的值是在一个时间窗口内的最大值。如果在时间窗口长度内没有记录新的值,最大值会在新时间窗口开始时重置为 0。时间窗口的大小直到值完全过期为止是 DistributionStatisticConfig 中的 expiry 乘以 bufferLengthexpiry 默认值为仪表注册表的步长,除非显式设置为不同的值,而 bufferLength 默认为 3。时间窗口最大值用于在资源压力较大触发延迟后捕获后续间隔内的最大延迟,并防止指标被发布。百分位数也是时间窗口百分位数(TimeWindowPercentileHistogram)。直方图桶通常表现得像计数器,因此根据后端的不同,它们可以作为累积值报告(例如在 Prometheus 的情况下),或者作为计数器在推送间隔内增加的速率报告。

记录代码块

Timer 接口提供了几个方便的快捷方法用于内联记录时间,包括以下内容:

timer.record(() -> dontCareAboutReturnValue());
timer.recordCallable(() -> returnValue());

Runnable r = timer.wrap(() -> dontCareAboutReturnValue()); 1
Callable c = timer.wrap(() -> returnValue());
java
  • 包装 RunnableCallable 并返回其经过增强的版本以供后续使用。

备注

Timer 实际上是一种特殊的分布摘要(Distribution Summary),它知道如何将持续时间缩放到每个监控系统的基本时间单位,并且具有自动确定的基本单位。在需要测量时间的任何情况下,都应使用 Timer 而不是 DistributionSummary

Timer.Sample 中存储开始状态

你也可以将开始状态存储在一个稍后可以停止的样本实例中。该样本基于注册表的时钟记录一个开始时间。在启动样本后,执行要计时的代码,并通过在样本上调用 stop(Timer) 来完成操作:

Timer.Sample sample = Timer.start(registry);

// do stuff
Response response = ...

sample.stop(registry.timer("my.timer", "response", response.status()));
java

请注意,我们直到停止采样时才会决定将样本累积到哪个计时器。这使我们能够根据我们所计时的操作的结束状态动态确定某些标签。

@Timed 注解

micrometer-core 模块包含一个 @Timed 注解,框架可以使用该注解为特定类型的方法(例如处理 Web 请求端点的方法)或更一般地为所有方法添加计时支持。

此外,micrometer-core 中还包含了一个正在孵化中的 AspectJ 切面。你可以通过编译时/加载时的 AspectJ 织入,或者通过框架提供的功能(如 Spring AOP)以其他方式解释 AspectJ 切面并代理目标方法,来在你的应用中使用它。以下是一个 Spring AOP 配置的示例:

@Configuration
public class TimedConfiguration {
@Bean
public TimedAspect timedAspect(MeterRegistry registry) {
return new TimedAspect(registry);
}
}
java

应用 TimedAspect 使得 @Timed 可以在 AspectJ 代理实例中的任何任意方法上使用,如下例所示:

@Service
public class ExampleService {

@Timed
public void sync() {
// @Timed will record the execution time of this method,
// from the start and until it exits normally or exceptionally.
...
}

@Async
@Timed
public CompletableFuture<?> async() {
// @Timed will record the execution time of this method,
// from the start and until the returned CompletableFuture
// completes normally or exceptionally.
return CompletableFuture.supplyAsync(...);
}

}
java
备注

TimedAspect 不支持带有 @Timed 的元注解。

方法参数上的 @MeterTag

为了支持在方法参数上使用 @MeterTag 注解,你需要配置 @TimedAspect 以添加 MeterTagAnnotationHandler

ValueResolver valueResolver = parameter -> "Value from myCustomTagValueResolver [" + parameter + "]";

// Example of a ValueExpressionResolver that uses Spring Expression Language
ValueExpressionResolver valueExpressionResolver = new SpelValueExpressionResolver();

// Setting the handler on the aspect
timedAspect.setMeterTagAnnotationHandler(
new MeterTagAnnotationHandler(aClass -> valueResolver, aClass -> valueExpressionResolver));
java

假设我们有以下接口。

interface MeterTagClassInterface {

@Timed
void getAnnotationForTagValueResolver(@MeterTag(key = "test", resolver = ValueResolver.class) String test);

@Timed
void getAnnotationForTagValueExpression(
@MeterTag(key = "test", expression = "'hello' + ' characters'") String test);

@Timed
void getAnnotationForArgumentToString(@MeterTag("test") Long param);

@Timed
void getMultipleAnnotationsForTagValueExpression(
@MeterTag(key = "value1", expression = "'value1: ' + value1") @MeterTag(key = "value2",
expression = "'value2: ' + value2") DataHolder param);

}
java

当其实现被调用时使用不同的参数(请记住,实现也需要用 @Timed 注解进行标注),将会创建以下计时器:

// Example for returning <toString()> on the parameter
service.getAnnotationForArgumentToString(15L);

assertThat(registry.get("method.timed").tag("test", "15").timer().count()).isEqualTo(1);

// Example for calling the provided <ValueResolver> on the parameter
service.getAnnotationForTagValueResolver("foo");

assertThat(registry.get("method.timed")
.tag("test", "Value from myCustomTagValueResolver [foo]")
.timer()
.count()).isEqualTo(1);

// Example for calling the provided <ValueExpressionResolver>
service.getAnnotationForTagValueExpression("15L");

assertThat(registry.get("method.timed").tag("test", "hello characters").timer().count()).isEqualTo(1);

// Example for using multiple @MeterTag annotations on the same parameter
// @MeterTags({ @MeterTag(...), @MeterTag(...) }) can be also used
service.getMultipleAnnotationsForTagValueExpression(new DataHolder("zxe", "qwe"));

assertThat(
registry.get("method.timed").tag("value1", "value1: zxe").tag("value2", "value2: qwe").timer().count())
.isEqualTo(1);
java

函数追踪计时器

Micrometer 还提供了一种较少使用的计时器模式,该模式跟踪两个单调递增的函数(即随时间保持不变或增加但永不减少的函数):计数函数和总时间函数。一些监控系统,如 Prometheus,会将计数器的累积值(在这种情况下适用于计数和总时间函数)推送到后端,而其他系统则会发布计数器在推送间隔内的递增速率。通过采用这种模式,您可以让 Micrometer 针对您的监控系统的实现选择是否对计时器进行速率归一化,从而确保您的计时器在不同类型的监控系统之间保持可移植性。

IMap<?, ?> cache = ...; // suppose we have a Hazelcast cache
registry.more().timer("cache.gets.latency", Tags.of("name", cache.getName()), cache,
c -> c.getLocalMapStats().getGetOperationCount(), 1
c -> c.getLocalMapStats().getTotalGetLatency(),
TimeUnit.NANOSECONDS 2
);
java
  • getGetOperationCount() 是一个单调递增的函数,从它生命周期的开始,每次缓存获取都会增加。

  • 这表示 getTotalGetLatency() 所代表的时间单位。每个注册表实现都会指定其预期的时间基本单位,报告的总时间将按此值进行缩放。

函数跟踪计时器与监控系统的速率归一化功能(无论是查询语言的产物还是数据推送到系统的方式)相结合,为函数本身的累积价值增加了一层丰富性。你可以推理吞吐量和延迟的速率,判断该速率是否在可接受的范围内,是否随时间增加或减少,等等。

注意

Micrometer 无法保证计数和总时间函数的单调性。通过使用此签名,您是基于对它们定义的了解来断言它们的单调性。

FunctionTimer 接口本身也提供了一个流畅的构建器,用于函数计时器,提供了对较少使用的选项的访问,例如基本单位和描述。你可以通过调用 register(MeterRegistry) 来注册计时器,作为其构建的最后一步:

IMap<?, ?> cache = ...

FunctionTimer.builder("cache.gets.latency", cache,
c -> c.getLocalMapStats().getGetOperationCount(),
c -> c.getLocalMapStats().getTotalGetLatency(),
TimeUnit.NANOSECONDS)
.tags("name", cache.getName())
.description("Cache gets")
.register(registry);
java

暂停检测

Micrometer 使用 LatencyUtils 包来补偿协调遗漏——由于系统和虚拟机暂停导致的额外延迟,这些延迟会使您的延迟统计数据向下倾斜。分布统计信息,如百分位数和 SLO 计数,受到暂停检测器实现的影响,该实现会在此处和彼处添加额外的延迟以补偿暂停。

Micrometer 支持两种暂停检测器实现:基于时钟漂移的检测器和无操作(no-op)检测器。在 Micrometer 1.0.10/1.1.4/1.2.0 之前,默认配置了基于时钟漂移的检测器,以便无需进一步配置即可报告尽可能准确的指标。自 1.0.10/1.1.4/1.2.0 版本起,默认配置了无操作检测器,但可以通过以下示例配置时钟漂移检测器。

基于时钟漂移的检测器具有可配置的睡眠间隔和暂停阈值。CPU 消耗与 sleepInterval 成反比,暂停检测精度也是如此。对于这两个值,100ms 是一个合理的默认值,可以在消耗可忽略的 CPU 时间的同时,提供对长时间暂停事件的良好检测。

你可以按以下方式自定义暂停检测器:

registry.config().pauseDetector(new ClockDriftPauseDetector(sleepInterval, pauseThreshold));
registry.config().pauseDetector(new NoPauseDetector());
java

在未来,我们可能会提供更多的检测器实现。在某些情况下,一些停顿可能能够从 GC 日志中推断出来,例如,不需要持续的 CPU 负载,无论多么微小。此外,未来的 JDK 可能会直接提供对停顿事件的访问。

内存占用估计

计时器是最消耗内存的仪表,其总内存占用会因所选选项的不同而有很大差异。以下内存消耗表基于各种功能的使用情况。这些数据假设没有标签且环形缓冲区长度为 3。添加标签和增加缓冲区长度都会在一定程度上增加总内存占用。总存储量也可能会因注册表实现的不同而有所变化。

  • R = 环形缓冲区长度。我们在所有示例中假设默认值为 3。R 通过 Timer.Builder#distributionStatisticBufferLength 设置。

  • B = 总直方图桶数。可以是 SLO 边界或百分位直方图桶数。默认情况下,计时器被限制在最小预期值 1ms 和最大预期值 30 秒之间,适用于百分位直方图时,产生 66 个桶。

  • I = 用于暂停补偿的间隔估计器。1.7 kb。

  • M = 时间衰减最大值。104 字节。

  • Fb = 固定边界直方图。8b * B * R。

  • Pp = 百分位精度。默认情况下为 1。通常在 [0, 3] 范围内。Pp 通过 Timer.Builder#percentilePrecision 设置。

  • Hdr(Pp) = 高动态范围直方图。

    • 当 Pp = 0 时:1.9kb * R + 0.8kb

    • 当 Pp = 1 时:3.8kb * R + 1.1kb

    • 当 Pp = 2 时:18.2kb * R + 4.7kb

    • 当 Pp = 3 时:66kb * R + 33kb

暂停检测客户端百分位数直方图和/或 SLOs公式示例
I + M~1.8kb
I + M + Fb对于默认的百分位数直方图,~7.7kb
I + M + Hdr(Pp)对于默认情况下添加 0.95 百分位数,~14.3kb
M~0.1kb
M + Fb对于默认的百分位数直方图,~6kb
M + Hdr(Pp)对于默认情况下添加 0.95 百分位数,~12.6kb
备注

对于 Prometheus 而言,R 的值始终为 1,无论你如何通过 Timer.Builder 进行配置。这种特殊情况的存在是因为 Prometheus 期望接收从不回滚的累积直方图数据。