Micrometer源码分析

2023年 7月 14日 133.3k 0

前言

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

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

就像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提供):

PrometheusMeterRegistry(micrometer提供):

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

CollectorRegistry(prometheus提供):

prometheus收集metrics的api

DefaultExemplarSampler(prometheus提供):

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

PrometheusScrapeEndpoint(springboot提供):

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

MetricsAutoConfiguration

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

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

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

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

PropertiesMeterFilter(springboot提供):

基于management.metrics配置项的MeterFilter;

Mircometer模型

Meter

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

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

Id

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

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

Tag

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

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

Tag#hasCode:

Measurement

度量包含两个属性:

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

转换Prometheus格式

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

  • 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。

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

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

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

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

MeterBinder

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

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

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

几种Meter

Counter

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

micrometer对于Prometheus的实现是PrometheusCounter。

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

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

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

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

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

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

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

PrometheusCounter#updateExemplar:

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

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

DefaultExemplarSampler#doSample:

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

  • 如果trace要采集(isSampled=true)
  • 距离上次采样时间超过一定时间(minRetentionIntervalMs=7109ms)
  • 不满足上述条件,PrometheusCounter中的Exemplar不会更新。

    Gauge

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

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

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

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

    Timer

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

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

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

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

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

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

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

    PrometheusTimer#recordNonNegative:

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

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

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

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

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

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

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

    TimeWindowMax#rotate:第一块滚动逻辑

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

    TimeWindowMax#rotate:第二块滚动逻辑

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

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

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

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

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

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

    LongTaskTimer

    LongTaskTimer和Timer完全不同。

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

    LongTaskTimer也支持Histogram。

    举个例子:

    @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。

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

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

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

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

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

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

    DistributionSummary

    在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对应的值;

    HistogramSnapshot

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

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

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

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

    PrometheusTimer#takeSnapshot:

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

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

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

    父类AbstractTimer的Histogram走TimeWindowPercentileHistogram。

    自己的Histogram走PrometheusHistogram。

    PrometheusHistogram底层是micrometer的TimeWindowFixedBoundaryHistogram。

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

    Histogram

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

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

    AbstractTimeWindowHistogram

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

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

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

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

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

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

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

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

    TimeWindowPercentileHistogram

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

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

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

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

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

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

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

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

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

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

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

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

    TimeWindowFixedBoundaryHistogram

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

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

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

    从TimeWindowFixedBoundaryHistogram的泛型参数来看:

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

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

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

    TimeWindowFixedBoundaryHistogram记录采样值:

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

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

    FixedBoundaryHistogram#record方法:

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

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

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

    PrometheusHistogram

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

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

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

    关于业务埋点

    如何定义业务Meter

    1、固定tag的Meter

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

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

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

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

    @Component
    public class OrderMetrics implements MeterBinder {
    
        private static final Map 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)不可能启动阶段穷举。

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

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

    开箱即用的Meter

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

    ThreadPoolTaskExecutor/ThreadPoolTaskScheduler

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

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

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

    DefaultMeterObservationHandler

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

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

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

    总结

    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;

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论