前言
好久没输出了,最近比较忙,一周把一个季度的东西干完才有机会输出。
如果编码速度非常快(质量也非常高),超出一个极限(超出其他所有职能部门的速度),那么不可能有人会占用你的时间。
就像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信息
不满足上述条件,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方法:
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;