likes
comments
collection
share

Micrometer源码分析

作者站长头像
站长
· 阅读数 14

前言

好久没输出了,最近比较忙,一周把一个季度的东西干完才有机会输出。

如果编码速度非常快(质量也非常高),超出一个极限(超出其他所有职能部门的速度),那么不可能有人会占用你的时间。

就像Integer.MAX_VALUE+1,超出极限了就是负数。

本章基于springboot3.0.7分析micrometer1.10.7相关源码。

本章不会过多关注:

  • prometheus客户端如何使用
  • PromQL
  • springboot3放弃sleuth,转而使用micrometer-tracing
  • push模式收集metrics

本章仅限于分析micrometer:

  • 以micrometer接入prometheus的实现为抓手,看看如何建立一套metrics模型
  • pull模式收集metrics
  • micrometer做业务埋点,api如何使用更合理

从SpringBoot入手

其实一般情况下,我都不太会看和Spring集成相关的东西,除了一些脱离Spring无法(或不会去选择)单独使用的框架,比如Seata。

这里看一下Micrometer有哪些核心的Bean,好找入口分析。

一般情况下没必要看Spring部分,无非是怎么去new一个对象,无论你用什么看起来高大上的方法,本质就是这个。

PrometheusMetricsExportAutoConfiguration

Properties配置类PrometheusConfig(micrometer提供):

Micrometer源码分析

PrometheusMeterRegistry(micrometer提供):

核心类,MeterRegistry实现,保存所有Meter,micrometer与prometheus集成

Micrometer源码分析

CollectorRegistry(prometheus提供):

prometheus收集metrics的api

Micrometer源码分析

DefaultExemplarSampler(prometheus提供):

prometheus对于Exemplar的支持,metrics关联trace

Micrometer源码分析

PrometheusScrapeEndpoint(springboot提供):

暴露/actuator/prometheus端点给prometheus拉metrics

Micrometer源码分析

MetricsAutoConfiguration

Clock(micrometer提供):时钟,用于获取时间,就是System.currentTimeMillis。

Micrometer源码分析

MeterRegistryPostProcessor(springboot提供):有两个作用

1)对于MeterRegistry类型的Bean做一些全局配置处理,比如加入全局MeterFilter,做Meter过滤;

2)对于MeterBinder类型的Bean,主动触发bind方法注册meter到MeterRegistry;

Micrometer源码分析

PropertiesMeterFilter(springboot提供):

基于management.metrics配置项的MeterFilter;

Micrometer源码分析

Mircometer模型

Meter

Meter聚合了一组度量(Measurement),用Id代表这组度量。

每次measure方法返回都是相同数量、相同顺序的一组瞬时value。

Micrometer源码分析

Id

Id主要由三部分组成:name-meter名,tags-meter标签集合,type-meter的类型。

Id#hashCode:但是Id的唯一性,仅由name和tag决定。(equals代码不贴了,太长)

Micrometer源码分析

Micrometer源码分析

Tag

Tags由多个Tag组成;而Tag由一对kv组成。

当kv相同时,Tag相同;当所有Tag相同,Tags相同。

Micrometer源码分析

Tag#hasCode:

Micrometer源码分析

Measurement

度量包含两个属性:

  • DoubleSupplier:一个返回double的supplier函数;
  • Statistic:枚举,对于一个度量的描述;

Micrometer源码分析

Micrometer源码分析

转换Prometheus格式

比如jvm内存使用情况,暴露给prometheus的数据格式如下:

Micrometer源码分析

  • jvm_memory_used是Id.name,bytes是单位;
  • {application="sb3-app",area="nonheap",id="Compressed Class Space",}是Tags;
  • 这里一行是一个Measurement度量,这个Meter只有一个度量;

比如某个端点的http请求,暴露给prometheus的数据格式如下:

http_server_requests_seconds_count{application="sb3-app",error="none",exception="none",method="GET",outcome="SUCCESS",status="200",uri="/metric",} 2.0
http_server_requests_seconds_sum{application="sb3-app",error="none",exception="none",method="GET",outcome="SUCCESS",status="200",uri="/metric",} 0.006369008
http_server_requests_seconds_max{application="sb3-app",error="none",exception="none",method="GET",outcome="SUCCESS",status="200",uri="/metric",} 0.004348599
  • http_server_requests是Id.name,seconds是单位;
  • {...uri="/metric",}是Tags;
  • 一个端点的http请求(一个Id)有3个Measurement度量,count是请求总数,sum是请求总时长,max是请求最大时长;

MeterRegistry

一个抽象类,内存中存储Meter。

Micrometer源码分析

MeterRegistry提供一些方便使用的注册Meter方法,比如注册一个Counter类型的meter。

Micrometer源码分析

为此,子类需要实现一些构建Meter的方法,比如构建一个Counter。

Micrometer源码分析

MeterRegistry提供注册Meter的骨架方法getOrCreateMeter流程如下:

  • MeterFilter#accept过滤Meter.Id
  • MeterFilter#configure根据Meter.Id二次处理DistributionStatisticConfig
  • builder#apply执行用户回调构建Meter
  • 同义meter注册
  • meter注册Listener通知
  • 注册mter

Micrometer源码分析

MeterBinder

除了直接调用MeterRegistry可以注册Meter之外。

部分Metrics会实现MeterBinder接口,将Meter注册到MeterRegistry。

Micrometer源码分析

在SpringBoot中,MeterRegistryPostProcessor会来触发所有MeterBinder的注册动作。

几种Meter

Counter

Counter只会返回一个double类型的度量,是一个只增不减的计数器。

Micrometer源码分析

micrometer对于Prometheus的实现是PrometheusCounter

PrometheusCounter除了一个DoubleAddr计数器之外,还有一个Exemplar

Micrometer源码分析

每次更新计数器,还会更新Exemplar。

Micrometer源码分析

这个Exemplar的作用就是关联metric和trace,见OpenMetrics

在客户端侧,如果加入tracing相关组件,就能使用Exemplar,见SpringBoot+micrometer-tracing

在prometheus侧,需要开启exemplar-storage特性,见Prometheus+exemplars-storage

在grafana侧,可以通过metric拿到Exemplar,而Exemplar中包含trace_id,方便定位问题。

Micrometer源码分析

需要注意的是,目前Exemplar仅支持prometheus,所以这两个模型都是prometheus客户端提供的。

Micrometer源码分析

PrometheusCounter#updateExemplar:

每次increment都会调用CounterExemplarSampler采集一个Exemplar,替换Counter中的Exemplar。

所以promethues配置scrape_interval=15s抓取一次,那么只会抓到最近一次Counter记录到的trace。

Micrometer源码分析

DefaultExemplarSampler#doSample:

当满足下面两个条件的情况下,才会采集新的Exemplar,Exemplar包含当前线程中的trace信息

  1. 如果trace要采集(isSampled=true)
  2. 距离上次采样时间超过一定时间(minRetentionIntervalMs=7109ms)

不满足上述条件,PrometheusCounter中的Exemplar不会更新。

Micrometer源码分析

Gauge

Gauge只会返回一个double类型的度量,可增可减。

Micrometer源码分析

micrometer对于Prometheus没有特殊实现,使用DefaultGauge

  • value:一个Function函数,获取double类型度量,不像Counter一般自己就能维护一个计数器;
  • ref:作为Function入参对象,一个弱引用,一般来说ref引用的对象是一个长生命周期对象;

注意Gauge无法提供Exemplar,即无法关联trace。

Micrometer源码分析

Timer

Timer用于跟踪大量短时间运行事件的计时器,一般这些事件在一分钟以内,比如http请求。

Micrometer源码分析

Timer有三个度量:count-总数、totalTime-总耗时、max-最长耗时。

Micrometer源码分析

Timer用于统计事件时长,埋点方式有多种。

比如,在事件开始需要Timer#start拿到一个Timer.Sample。

Micrometer源码分析

在事件结束需要Timer.Sample#stop,调用Timer实例的record方法记录事件耗时。

Micrometer源码分析

又比如,Timer#record直接记录耗时,需要子类实现。

Micrometer源码分析

AbstractTimer实现了record逻辑,忽略Histogram和IntervalEstimator,子类需要实现recordNonNegative。

Micrometer源码分析

PrometheusTimer#recordNonNegative:

三个度量计算,重点关注一下TimeWindowMax。

注意,单纯PrometheusTimer并不支持Exemplar,Exemplar依附于Histogram使用。

Micrometer源码分析

Micrometer源码分析

TimeWindowMax属性如下,主要是通过一个环形数组,每个数组元素存储1分钟内的最大时长。

Micrometer源码分析

TimeWindowMax的滚动间隔时长和环形数组容量,都取自于DistributionStatisticConfig。

Micrometer源码分析

在记录最值的时候,先滚动,然后用采样值更新所有桶中的最值。

Micrometer源码分析

滚动方法如下,早期版本(1.6.11之前)有bug(issue=2647),可能无法采集到正确的最值。

TimeWindowMax#rotate:超过一分钟未滚动,才进行滚动。

Micrometer源码分析

TimeWindowMax#rotate:第一块滚动逻辑

这个是issue=2647新增的逻辑,如果超过3分钟未滚动,清空所有桶,并更新上次滚动时间。

Micrometer源码分析

TimeWindowMax#rotate:第二块滚动逻辑

早期版本只有这一块滚动逻辑,问题在于timer长期未被调用,lastRotateTimestampMillis上次滚动时间无法正确更新。

Micrometer源码分析

TimeWindowMax#poll:获取最值的方法,先尝试滚动,然后获取当前桶中的最值。

注:滚动和获取最值做了同步synchronized。

Micrometer源码分析

以官方测试用例为例,逻辑如下。

需要理解的是,TimeWindowMax最值并不是1分钟内的最值,是3分钟内的最值。

Micrometer源码分析

官方称为:基于可配置环形缓冲区的分布衰减最大值。

Micrometer源码分析

LongTaskTimer

Micrometer源码分析

LongTaskTimer和Timer完全不同。

LongTaskTimer用于度量正在执行的任务数量这些任务已经执行的时长,重点就是in-flight。

LongTaskTimer也支持Histogram。

Micrometer源码分析

举个例子:

@RestController
public class OrderMetrics implements MeterBinder {

    private LongTaskTimer longTaskTimer;

    @Override
    public void bindTo(MeterRegistry meterRegistry) {
        longTaskTimer = LongTaskTimer.builder("order.long.task")
            .publishPercentileHistogram(true)
            .publishPercentiles(0.5, 0.95)
            .minimumExpectedValue(Duration.ofSeconds(10))
            .maximumExpectedValue(Duration.ofSeconds(20))
            .register(meterRegistry);
    }

    @GetMapping("/order/longtask")
    public String longtask() {
        EXECUTOR.submit(() -> {
            LongTaskTimer.Sample sample = longTaskTimer.start();
            int time = RANDOM.nextInt(10, 20);
            try {
                TimeUnit.SECONDS.sleep(time);
            } catch (InterruptedException e) {
                sample.stop();
                return;
            }
            sample.stop();
        });
        return "success";
    }
}

虽然历史曾经有过LongTaskTimer采集数据(从histogram数据可以看到)。

但是active_count、duration_sum、max都为0,因为LongTaskTimer的度量仅针对in-flight数据,当所有任务执行完毕,这些度量都会变成0。

Micrometer源码分析

Prometheus接入的实现是CumulativeHistogramLongTaskTimer,只有Histogram相关的takeSnapshot方法有点特殊,逻辑都走DefaultLongTaskTimer

Histogram部分放在后面统一看,这里Histogram的特点和其他Prometheus接入的实现类似,桶计数要使用累积数量。

Micrometer源码分析

DefaultLongTaskTimer#start:创建Sample实例,放入activeTasks集合存储。

Micrometer源码分析

DefaultLongTaskTimer.SampleImpl#stop:从activeTasks集合移除Sample。

Micrometer源码分析

获取度量,duration统计所有正在执行任务的累计时长,activeTasks统计正在执行的任务数量。

Micrometer源码分析

max统计此时执行最长的时长。

Micrometer源码分析

DistributionSummary

Micrometer源码分析

在micrometer侧,DistributionSummary和Timer几乎没有区别,仅仅是单位上的区别。

Timer是时间,而DistributionSummary是任意值。

A distribution summary tracks the distribution of events. It is similar to a timer structurally, but records values that do not represent a unit of time. For example, you could use a distribution summary to measure the payload sizes of requests hitting a server.

Histogram

Histogram不属于Meter,依附于Timer和DistributionSummary。

案例

默认情况下,普通的Timer不会开启Histogram,案例通过Timer统计订单详情的P50和P95。

注:使用micrometer提供的TimedAspect加Timed注解也可以,api更灵活。

@RestController
public class OrderMetrics implements MeterBinder {
    private Timer orderDetailTimer;
    @Override
    public void bindTo(MeterRegistry meterRegistry) {
         orderDetailTimer = Timer.builder("order.detail")
                .publishPercentileHistogram(true)
                .publishPercentiles(0.5, 0.95)
                // 默认1ms-30s
                .minimumExpectedValue(Duration.ofMillis(1))
                .maximumExpectedValue(Duration.ofSeconds(5))
                .register(meterRegistry);
    }
    public void orderDetailRecord(long time, TimeUnit unit) {
        orderDetailTimer.record(time, unit);
    }

    @GetMapping("/order/detail")
    public String detail() {
        log.info("订单详情开始"); // for trace
        int time = RANDOM.nextInt(1, 2000);
        this.orderDetailRecord(time, TimeUnit.MILLISECONDS);
        log.info("订单详情结束"); // for trace
        return "success";
    }
}

暴露给prometheus的数据格式如下:

  • order_detail_seconds_bucket,代表1ms到5s每个桶中的请求数量,这个数量是依次累计的,第10个桶包含前1-9个桶中所有请求数量的总和,支持Exemplar;
  • order_detail_seconds{quantile},代表P50和P95对应的值;

Micrometer源码分析

HistogramSnapshot

对于部分Meter,比如Timer和Summary,支持Histogram,会实现takeSnapshot方法。

takeSnapshot返回一个Histogram的快照,不属于Measurement度量。

Micrometer源码分析

HistogramSnapshot除了传统的count、total、max这种度量之外,还有两个数组:

  • percentileValues:分位数对应值,比如案例P50对应值在percentileValues[0]的位置,P95对应值在percentileValues[1]的位置,即案例order_detail_seconds{quantile}
  • histogramCounts:每个桶中的数量,即案例order_detail_seconds_bucket

Micrometer源码分析

PrometheusTimer#takeSnapshot

对于PrometheusTimer来说,桶计数需要走自己的histogram来获取,而分位数值走父类的histogram获取

至于为什么需要这么复杂分两个Histogram来获取数据,是因为Prometheus对于桶计数需要完整的数据,而不是滚动的Histogram。

言外之意,桶计数需要从进程启动到进程停止,完整生命周期内累计的数据;而分位数值是滚动数据。

Micrometer源码分析

父类AbstractTimer的Histogram走TimeWindowPercentileHistogram

Micrometer源码分析

自己的Histogram走PrometheusHistogram

Micrometer源码分析

PrometheusHistogram底层是micrometer的TimeWindowFixedBoundaryHistogram

Micrometer源码分析

整个Histogram涉及到的继承关系如下,一点一点来看吧。

Micrometer源码分析

Histogram

Histogram自身具备takeSnapshot能力,所以一般Timer和Summary都会委派底层Histogram生成快照。

除此以外,record方法记录埋点值。

Micrometer源码分析

AbstractTimeWindowHistogram

和TimeWindowMax类似,通过一个环形数组存储埋点值。

只不过数组元素是泛型T,即AbstractTimeWindowHistogram可以理解为存储了x个时间窗口内的T

相较于TimeWindowMax多了一些Histogram专属的属性,泛型U是一个累积Histogram

这个累计Histogram大概是啥意思,后面再看。

Micrometer源码分析

记录采集值和TimeWindowMax逻辑类似,recordLong记录bucket需要子类实现。

Micrometer源码分析

AbstractTimeWindowHistogram#takeSnapshot:获取HistogramSnapshot的骨架方法。

Micrometer源码分析

子类需要实现三个抽象方法用于获取HistogramSnapshot:

  • accumulate:累计Histogram,后面再看;
  • valueAtPercentile:获取分位数对应的值,比如P95对应2秒;
  • countAtValue:获取某个桶中的采样总数,比如传入1秒,得到0.5-1秒总共有5个请求;

Micrometer源码分析

TimeWindowPercentileHistogram

在PrometheusTimer中,仅用于获取分位数值,比如P95=2秒。

DoubleRecorder即泛型T,是底层环形数组中的元素,用于记录每个时间窗口内的采样值;

DoubleHistogram即泛型U,是底层定义的累计Histogram,当takeSnapshot会将当前窗口的采样值T累计到U;

这两个类都不是micrometer自己的,是HdrHistogram提供的,没有其他任何依赖,当jdk用即可。

Micrometer源码分析

在记录采样值时,调用HdrHistogram记录。

Micrometer源码分析

在创建snapshot时,将当前窗口的DoubleRecorder灌入DoubleHistogram。

Micrometer源码分析

因为Prometheus只用这里的分位数值,所以只用看valueAtPercentile,调用DoubleHistogram#getValueAtPercentile这个api即可。

Micrometer源码分析

所以按照这个逻辑,是否能采集到分位数值其实和采集频率有关。

如果在一个时间窗口(1分钟)内不来采集,那么就不会调用累积Histogram方法,那么这个时间窗口的数据就会随着底层环形数组滚动而丢失。

此外,HdrHistogram统计Pxx总归是有误差的,不会把所有采样值都放到内存中。

Micrometer源码分析

可以通过调整DistributionStatisticConfig#percentilePrecision来提升精度,但是耗费内存会更多。

默认精度是1,具体逻辑我也不深究了。

Micrometer源码分析

TimeWindowFixedBoundaryHistogram

在Prometheus场景下,TimeWindowFixedBoundaryHistogram仅用于统计每个桶中的采样数量

特点是PrometheusHistogram在构造父类时,写死了两个参数:

  • expiry:1825天,意味着底层的环形数组永远不滚动;
  • bufferLength:1,意味着底层的环形数组只有一个元素;

Micrometer源码分析

从TimeWindowFixedBoundaryHistogram的泛型参数来看:

  • T:底层环形数组元素,是内部类TimeWindowFixedBoundaryHistogram.FixedBoundaryHistogram;
  • U:Void,所以不支持累计Histogram;

Micrometer源码分析

从成员变量来看,主要是有一组buckets。

比如案例会划分为57个bucket,每个bucket的元素是一个时间值,从1毫秒到5秒。

Micrometer源码分析

TimeWindowFixedBoundaryHistogram记录采样值:

Micrometer源码分析

TimeWindowFixedBoundaryHistogram获取桶内的采样数量,走cumulativeBucketCounts=true:

Micrometer源码分析

FixedBoundaryHistogram维护了一个和bucket长度一致的原子长整型数组values。

Micrometer源码分析

FixedBoundaryHistogram#record方法:

  1. 从bucket数组中,找到最大的小于等于采样值的bucket下标(算法题二分查找变形);
  2. values对应下标元素自增,即统计采样值到某个bucket中;

Micrometer源码分析

FixedBoundaryHistogram#countAtValueCumulative:

获取桶内数量的时候,Prometheus需要把该桶之前的数据累加。

即0-1秒有1个请求,1-2秒有1个请求,入参2秒获取的数据实际是2个,和案例中一致。

Micrometer源码分析

PrometheusHistogram

PrometheusHistogram维护了一组和父类一样的buckets数组,一组死值。

特点在于PrometheusHistogram支持记录每个bucket对应的Exemplar。

Micrometer源码分析

每次采集数据,找到采样值所属bucket(二分),并记录Exemplar,逻辑和PrometheusCounter类似。

Micrometer源码分析

关于业务埋点

如何定义业务Meter

1、固定tag的Meter

其实大部分Meter的tag都是可穷举可预知的。

为了减少内存和cpu的浪费,不必在运行时去注册Meter到MeterRegistry,虽然MeterRegistry#getOrCreateMeter支持判断同一个Meter仅注册一次。

比如:Jvm内存分堆和非堆,那么使用MeterBinder在ioc容器启动阶段就可以注册Meter了。

Micrometer源码分析

比如我要统计不同订单的下单数量的走势,根据订单类型枚举OrderType直接在ioc容器启动阶段注册Counter即可,运行时只需要调用increment api,而不需要再去走getOrCreateMeter。

@Component
public class OrderMetrics implements MeterBinder {

    private static final Map<OrderType, Counter> ORDER_COUNT = new HashMap<>();

    @Override
    public void bindTo(MeterRegistry meterRegistry) {
        for (OrderType orderType : OrderType.values()) {
            Counter counter = meterRegistry.counter("order.count", "order.type", orderType.getCode());
            ORDER_COUNT.put(orderType, counter);
        }
    }

    public void orderIncr(OrderType orderType) {
        ORDER_COUNT.get(orderType).increment();
    }
}

2、非固定tag的Meter

有些情况,tag纬度很多,且tag不太能穷举,只能在运行阶段注册Meter到MeterRegistry

如DefaultMeterObservationHandler(springboot2不是这个),统计http请求耗时,默认tag纬度很多(uri+status+exception+method+outcome)不可能启动阶段穷举。

Micrometer源码分析

举个不一定很恰当的例子,订单取消订单原因类型,可以无限扩充,仅用于放在看板上分析,不需要每次新增原因类型,就修改后端服务。

Micrometer源码分析

当然了,使用Prometheus采集metrics我们应该避免发生高基数问题。

开箱即用的Meter

SpringBoot提供了很多开箱即用的Meter,举几个早期版本不一定有的例子。

ThreadPoolTaskExecutor/ThreadPoolTaskScheduler

这两个类都是Spring提供的,包装了普通的jdk的线程池,提供了很多在Spring里的增强功能。

对于这两类bean,SpringBoot会暴露线程池的相关meter,对业务也比较有帮助。

Micrometer源码分析

除了executor_completed是Counter之外,其他都是Guage。

Micrometer源码分析

DefaultMeterObservationHandler

在springboot早期版本,使用自己写的WebMvcMetricsFilter来记录http.server.requests指标。

在新版本中,使用micrometer提供了DefaultMeterObservationHandler。

Micrometer源码分析

区别在于DefaultMeterObservationHandler不仅采集普通Timer的,还采集了LongTaskTimer,即可以看到正在处理(in-flight)的请求metrics。

Micrometer源码分析

总结

Mircometer的核心模型就两个:

  • Meter:包含一组瞬时Measurement度量,用一个唯一标识Id(name+Tags)来标识这组度量;
  • MeterRegistry:存储Meter的地方,getOrCreateMeter方法控制同一个Id的Meter只注册一个Meter实例;

注册Meter一般有两种方式:

  • 实现MeterBinder接口,由ioc容器回调bindTo方法,注册Meter;
  • 运行时构造Meter实例,通过MeterRegistry#register API注册Meter;

五种Meter模型:

  • Counter:只包含一个只增不减的double类型度量,支持Exemplar;
  • Gauge:只包含一个可增可减的double类型度量,不支持Exemplar;
  • Timer:用于跟踪大量短时间运行事件的计时器,包含三个度量count总数、totalTime总耗时、max最长耗时。其中max默认统计的是3分钟内的最大值,基于环形数组实现,数组容量bufferLength=3,每个元素代表时长expiry=1分钟。支持Histogram,依托Histogram支持Exemplar;
  • LongTaskTimer:用于度量正在执行(in-flight)的任务,包含active_count正在执行任务数、duration正在执行任务总时长、max正在执行任务的最大时长,支持Histogram,依托Histogram支持Exemplar;
  • DistributionSummary:和Timer的实现几乎一致,区别在于DistributionSummary用于统计非时间的任意值,支持Histogram,依托Histogram支持Exemplar;

关于Histogram:

  • 桶计数:percentileHistogram=true开启桶计数,桶数量由minimumExpectedValue和maximumExpectedValue确定。在Prometheus场景下,桶计数需要累计整个进程生命周期中的所有计数,底层TimeWindowFixedBoundaryHistogram维护了一个永远不滚动的1容量的环形数组;
  • 分位数值:percentiles非空开启分位数值记录,分位数值数量由percentiles确定。收集时会将当前时间窗口内的计数,灌入累积Histogram,底层使用三方工具HdrHistogram实现;
  • 支持Exemplar:对于每个桶,支持记录Exemplar;