SpringBoot结合prometheus自定义埋点方式
作者:小豹子的技术笔记
快速入门
spring-actuator做度量统计收集,使用Prometheus(普罗米修斯)进行数据收集,Grafana(增强ui)进行数据展示,用于监控生成环境机器的性能指标和业务数据指标。
一般,我们叫这样的操作为”埋点”。SpringBoot中的依赖spring-actuator中集成的度量统计API使用的框架是Micrometer,官网是https://Micrometer.io。
Micrometer提供的度量类库
Meter是指一组用于收集应用中的度量数据的接口,Meter单词可以翻译为”米”或者”千分尺”,但是显然听起来都不是很合理,因此下文直接叫Meter,理解它为度量接口即可。
Meter是由MeterRegistry创建和保存的,可以理解MeterRegistry是Meter的工厂和缓存中心,一般而言每个JVM应用在使用Micrometer的时候必须创建一个MeterRegistry的具体实现。
Micrometer中,Meter的具体类型包括:Timer,Counter,Gauge,DistributionSummary,LongTaskTimer,FunctionCounter,FunctionTimer和TimeGauge。
下面分节详细介绍这些类型的使用方法和实战使用场景。而一个Meter具体类型需要通过名字和Tag(这里指的是Micrometer提供的Tag接口)作为它的唯一标识,这样做的好处是可以使用名字进行标记,通过不同的Tag去区分多种维度进行数据统计。
MeterRegistry
MeterRegistry在Micrometer是一个抽象类,主要实现包括:
SimpleMeterRegistry
:每个Meter的最新数据可以收集到SimpleMeterRegistry实例中,但是这些数据不会发布到其他系统,也就是数据是位于应用的内存中的。CompositeMeterRegistry
:多个MeterRegistry聚合,内部维护了一个MeterRegistry的列表。- 全局的
MeterRegistry
:工厂类io.micrometer.core.instrument.Metrics中持有一个静态final的CompositeMeterRegistry实例globalRegistry。
当然,使用者也可以自行继承MeterRegistry去实现自定义的MeterRegistry。
SimpleMeterRegistry适合做调试的时候使用,它的简单使用方式如下:
MeterRegistry registry = new SimpleMeterRegistry(); Counter counter = registry.counter("counter"); counter.increment();
CompositeMeterRegistry实例初始化的时候,内部持有的MeterRegistry列表是空的,如果此时用它新增一个Meter实例,Meter实例的操作是无效的
CompositeMeterRegistry composite = new CompositeMeterRegistry(); Counter compositeCounter = composite.counter("counter"); compositeCounter.increment(); // <- 实际上这一步操作是无效的,但是不会报错 SimpleMeterRegistry simple = new SimpleMeterRegistry(); composite.add(simple); // <- 向CompositeMeterRegistry实例中添加SimpleMeterRegistry实例 compositeCounter.increment(); // <-计数成功
全局的MeterRegistry的使用方式更加简单便捷,因为一切只需要操作工厂类Metrics的静态方法:
Metrics.addRegistry(new SimpleMeterRegistry()); Counter counter = Metrics.counter("counter", "tag-1", "tag-2"); counter.increment();
Tag与Meter的命名
Micrometer中,Meter的命名约定使用英文逗号(dot,也就是”.”)分隔单词。但是对于不同的监控系统,对命名的规约可能并不相同,如果命名规约不一致,在做监控系统迁移或者切换的时候,可能会对新的系统造成破坏。
Micrometer中使用英文逗号分隔单词的命名规则,再通过底层的命名转换接口NamingConvention进行转换,最终可以适配不同的监控系统,同时可以消除监控系统不允许的特殊字符的名称和标记等。开发者也可以覆盖NamingConvention实现自定义的命名转换规则:
registry.config().namingConvention(myCustomNamingConvention);
在Micrometer中,对一些主流的监控系统或者存储系统的命名规则提供了默认的转换方式,例如当我们使用下面的命名时候:
MeterRegistry registry = ... registry.timer("http.server.requests");
对于不同的监控系统或者存储系统,命名会自动转换如下:
- Prometheus - http_server_requests_duration_seconds。
- Atlas - httpServerRequests。
- Graphite - http.server.requests。
- InfluxDB - http_server_requests。
其实NamingConvention已经提供了5种默认的转换规则:dot、snakeCase、camelCase、upperCamelCase和slashes。
另外:
Tag(标签)是Micrometer的一个重要的功能,严格来说,一个度量框架只有实现了标签的功能,才能真正地多维度进行度量数据收集。
Tag的命名一般需要是有意义的,所谓有意义就是可以根据Tag的命名可以推断出它指向的数据到底代表什么维度或者什么类型的度量指标。
假设我们需要监控数据库的调用和Http请求调用统计,一般推荐的做法是:
MeterRegistry registry = ... registry.counter("database.calls", "db", "users") registry.counter("http.requests", "uri", "/api/users")
有两点需要注意:
1、Tag的值必须不为null。
2、Micrometer中,Tag必须成对出现,也就是Tag必须设置为偶数个,实际上它们以Key=Value
的形式存在,具体可以看io.micrometer.core.instrument.Tag
接口。
Meter的命名和Meter的Tag相互结合,以命名为轴心,以Tag为多维度要素,可以使度量数据的维度更加丰富,便于统计和分析。
Meters
前面提到Meter主要包括:Timer,Counter,Gauge,DistributionSummary,LongTaskTimer,FunctionCounter,FunctionTimer和TimeGauge。
下面逐一分析它们的作用和个人理解的实际使用场景(应该说是生产环境)。
Counter
Counter是一种比较简单的Meter,它是一种单值的度量类型,或者说是一个单值计数器。Counter接口允许使用者使用一个固定值(必须为正数)进行计数。
准确来说:Counter就是一个增量为正数的单值计数器。
使用场景:
Counter的作用是记录XXX的总量或者计数值,适用于一些增长类型的统计,例如下单、支付次数、Http请求总量记录等等,通过Tag可以区分不同的场景,对于下单,可以使用不同的Tag标记不同的业务来源或者是按日期划分,对于Http请求总量记录,可以使用Tag区分不同的URL。【增长无上限】
Timer
Timer(计时器)适用于记录耗时比较短的事件的执行时间,通过时间分布展示事件的序列和发生频率。所有的Timer的实现至少记录了发生的事件的数量和这些事件的总耗时,从而生成一个时间序列。
Timer的基本单位基于服务端的指标而定,但是实际上我们不需要过于关注Timer的基本单位,因为Micrometer在存储生成的时间序列的时候会自动选择适当的基本单位。
比较常用和方便的方法是几个函数式接口入参的方法:
Timer timer = ... timer.record(() -> dontCareAboutReturnValue()); timer.recordCallable(() -> returnValue()); Runnable r = timer.wrap(() -> dontCareAboutReturnValue()); Callable c = timer.wrap(() -> returnValue());
使用场景:
根据个人经验和实践,总结如下:
- 记录指定方法的执行时间用于展示。
- 记录一些任务的执行时间,从而确定某些数据来源的速率,例如消息队列消息的消费速率等。
这里举个实际的例子,要对系统做一个功能,记录指定方法的执行时间,还是用下单方法做例子:
Metrics.addRegistry(new SimpleMeterRegistry()); Timer timer = Metrics.timer("timer", "createOrder", "cost"); timer.record(() -> { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println("下单成功了。。。。。"); } );
在实际生产环境中,可以通过spring-aop把记录方法耗时的逻辑抽象到一个切面中,这样就能减少不必要的冗余的模板代码。
另外,Timer的使用还可以基于它的内部类Timer.Sample,通过start和stop两个方法记录两者之间的逻辑的执行耗时。例如:
SimpleMeterRegistry registry = new SimpleMeterRegistry(); Timer.Sample sample = Timer.start(registry); // 业务逻辑处理 sample.stop(registry.timer("my.timer", "response", "200"));
Gauge
Gauge(仪表)是获取当前度量记录值的句柄,也就是它表示一个可以任意上下浮动的单数值度量Meter。Gauge通常用于变动的测量值,测量值用ToDoubleFunction参数的返回值设置,如当前的内存使用情况,同时也可以测量上下移动的”计数”,比如队列中的消息数量。
官网文档中提到Gauge的典型使用场景是用于测量集合或映射的大小或运行状态中的线程数。Gauge一般用于监测有自然上界的事件或者任务,而Counter一般使用于无自然上界的事件或者任务的监测,所以像Http请求总量计数应该使用Counter而非Gauge。
使用场景:
根据个人经验和实践,总结如下:
- 有自然(物理)上界的浮动值的监测,例如物理内存、集合、映射、数值等。
- 有逻辑上界的浮动值的监测,例如积压的消息、(线程池中)积压的任务等,其实本质也是集合或者映射的监测。
DistributionSummary
Summary(摘要)主要用于跟踪事件的分布,在Micrometer中,对应的类是DistributionSummary(分发摘要)。它的使用方式和Timer十分相似,但是它的记录值并不依赖于时间单位。
使用场景:
根据个人经验和实践,总结如下:
1、不依赖于时间单位的记录值的测量,例如服务器有效负载值,缓存的命中率等。
直方图和百分数配置
直方图和百分数配置适用于Summary和Timer,这部分相对复杂,等研究透了再补充。
Histogram类型总共上报5种数据:count、sum,最大值、最小值、bucket
平均耗时可以通过 sum/count来计算
所以,用Histogram类型,可以一个指标多个维度来使用。
完整的类型介绍:Metric Types | client_java
基于SpirngBoot、Prometheus、Grafana集成
集成了Micrometer框架的JVM应用使用到Micrometer的API收集的度量数据位于内存之中,因此,需要额外的存储系统去存储这些度量数据,需要有监控系统负责统一收集和处理这些数据,还需要有一些UI工具去展示数据,一般大佬只喜欢看炫酷的图表或者动画。
常见的存储系统就是时序数据库,主流的有Influx、Datadog等。比较主流的监控系统(主要是用于数据收集和处理)就是Prometheus(一般叫普罗米修斯,下面就这样叫吧)。而展示的UI目前相对用得比较多的就是Grafana。
另外,Prometheus已经内置了一个时序数据库的实现,因此,在做一套相对完善的度量数据监控的系统只需要依赖目标JVM应用,Prometheus组件和Grafana组件即可。
SpirngBoot中使用Micrometer
SpringBoot中的spring-boot-starter-actuator依赖已经集成了对Micrometer的支持,其中的metrics端点的很多功能就是通过Micrometer实现的,prometheus端点默认也是开启支持的,实际上actuator依赖的spring-boot-actuator-autoconfigure中集成了对很多框架的开箱即用的API。
其中prometheus包中集成了对Prometheus的支持,使得使用了actuator可以轻易地让项目暴露出prometheus端点,作为Prometheus收集数据的客户端,Prometheus(服务端软件)可以通过此端点收集应用中Micrometer的度量数据。
我们先引入spring-boot-starter-actuator和spring-boot-starter-web,实现一个Counter和Timer作为示例。依赖:
<!-- Micrometer core dependecy --> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-core</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency>
注意多看spring官方文档关于Actuator的详细描述,在SpringBoot-2.x之后,配置Web端点暴露的权限控制和1.x有很大的不同。
总结一下就是:除了shutdown端点之外,其他端点默认都是开启支持的这里仅仅是开启支持,并不是暴露为Web端点,端点必须暴露为Web端点才能被访问,禁用或者开启端点支持的配置方式如下:
management.endpoint.${端点ID}.enabled=true/false可以查
暴露监控端点为Web端点的配置是:
management.endpoints.web.exposure.include=info,health,metrics,prometheus
management.endpoints.web.exposure.exclude
用于指定不暴露为Web端点的监控端点,指定多个的时候用英文逗号分隔
management.endpoints.web.exposure.include
默认指定的只有info和health两个端点,我们可以直接指定暴露所有的端点:management.endpoints.web.exposure.include=*
,如果采用YAML配置,记得要加单引号’‘。
暴露所有Web监控端点是一件比较危险的事情,如果需要在生产环境这样做,请务必先确认http://{host}:{management.port}
不能通过公网访问(也就是监控端点访问的端口只能通过内网访问,这样可以方便后面说到的Prometheus服务端通过此端口收集数据)。
由于引入了springboot-actuator依赖,会在spring容器启动时自动注入prometheus Registry实例
这样就可以在spring boot框架中直接使用上报API了,如
Metrics.counter("order.count", "order.channel", "huawei").increment(); Timer timer = Metrics.timer("method.cost.time", "method.name", "hello"); timer.record(3, TimeUnit.SECONDS);
最好参考一下Prometheus的官方文档,稍微学习一下它的查询语言PromQL的使用方式,一个面板可以支持多个PromQL查询。
提供一个例子:
public final class MeterRegistryCenter { private static final Logger logger = LoggerFactory.getLogger(MeterRegistryCenter.class); private static CompositeMeterRegistry METER_REGISTRY = null; static { try { METER_REGISTRY = Metrics.globalRegistry; } catch (Throwable t) { logger.warn("Metrics init failed :", t); } } public static Counter counter(String name, Iterable<Tag> tags) { if (METER_REGISTRY != null) { return METER_REGISTRY.counter(name, tags); } return null; } public static Counter counter(String name, String... tags) { if (METER_REGISTRY != null) { return METER_REGISTRY.counter(name, tags); } return null; } public static <T extends Number> T gauge(String name, Iterable<Tag> tags, T number) { if (METER_REGISTRY != null) { return METER_REGISTRY.gauge(name, tags, number); } return null; } public static Timer timer(String name, Iterable<Tag> tags) { if (METER_REGISTRY != null) { return METER_REGISTRY.timer(name, tags); } return null; } public static Timer timer(String name, String... tags) { if (METER_REGISTRY != null) { return METER_REGISTRY.timer(name, tags); } return null; } public static DistributionSummary summary(String name, Iterable<Tag> tags) { if (METER_REGISTRY != null) { return METER_REGISTRY.summary(name, tags); } return null; } public static DistributionSummary summary(String name, String... tags) { if (METER_REGISTRY != null) { return METER_REGISTRY.summary(name, tags); } return null; } }
public final class MetricsMonitor { private static final Logger logger = LoggerFactory.getLogger(MetricsMonitor.class); private static final String DEFAULT_APP_ID = "unknown"; private static final String DEFAULT_SDK_LANG = "unknown"; private static final String DEFAULT_IDC = "unknown"; private static final String DEFAULT_URL_PATH = "unknown"; private static final String APOLLO_QUERY_CONFIG_SOURCE_METER = "apollo_query_config_source"; private static final String APOLLO_URL_REQUEST_METER = "apollo_url_request"; public static Counter getQueryConfigSourceCounter(String appId, String sdkLang, String idc) { if (StringUtils.isBlank(appId)) { appId = DEFAULT_APP_ID; } if (StringUtils.isBlank(sdkLang)) { sdkLang = DEFAULT_SDK_LANG; } if (StringUtils.isBlank(idc)) { idc = DEFAULT_IDC; } return ApolloMeterRegistryCenter.counter(APOLLO_QUERY_CONFIG_SOURCE_METER, "appId", appId, "sdkLang", sdkLang, "idc", idc); } public static Counter getUrlRequestCounter(String url) { if (StringUtils.isBlank(url)) { url = DEFAULT_URL_PATH; } return ApolloMeterRegistryCenter.counter(APOLLO_URL_REQUEST_METER, "url", url); } public static void recordQueryConfigSource(String appId, String sdkLang, String idc) { try { Counter counter = getQueryConfigSourceCounter(appId, sdkLang, idc); if (!Objects.isNull(counter)) { counter.increment(); } } catch (Throwable t) { logger.warn("recordQueryConfigSource failed,msg: " + t.getMessage()); } } public static void recordUrlRequest(String url) { try { Counter counter = getUrlRequestCounter(url); if (!Objects.isNull(counter)) { counter.increment(); } } catch (Throwable t) { logger.warn("recordUrlRequest failed,msg: " + t.getMessage()); } } }
使用push Gateway方式上报数据
Prometheus是一款开源的监控和报警系统,而Pushgateway是Prometheus的一个组件,用于接收短期的指标数据。
在某些情况下,我们可能需要通过Java代码将指标数据推送到Prometheus Pushgateway中。
github: GitHub - prometheus/client_java: Prometheus instrumentation library for JVM applications
各种模式的接入方式文档地址:Quickstart | client_java
一个例子:
1、maven引入依赖
<dependency> <groupId>io.prometheus</groupId> <artifactId>prometheus-metrics-core</artifactId> <version>1.3.0</version> </dependency> <dependency> <groupId>io.prometheus</groupId> <artifactId>prometheus-metrics-exporter-pushgateway</artifactId> <version>1.3.0</version> </dependency>
2、编写service
import io.prometheus.metrics.core.metrics.Counter; import io.prometheus.metrics.exporter.pushgateway.PushGateway; import io.prometheus.metrics.model.registry.PrometheusRegistry; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.Objects; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @Slf4j @Service public class MeterService implements InitializingBean { @Value("${metrics.pushgateway.address:127.0.0.1:9091}") private String prometheusPushGateWayAddress; private static final String PROMETHEUS_JOB_NAME = "test_metrics"; private PushGateway prometheusPushGateWay; private final ScheduledExecutorService reportSchedule = Executors.newSingleThreadScheduledExecutor(); private Counter testCounter; @Override public void afterPropertiesSet() throws Exception { // 1.定义push Gateway PrometheusRegistry registry = new PrometheusRegistry(); prometheusPushGateWay = PushGateway.builder() .address(prometheusPushGateWayAddress) .job(PROMETHEUS_JOB_NAME) .registry(registry) .build(); // 2.定义各种业务指标 testCounter = Counter.builder().name("test_01_metrics_total").labelNames("region", "code").register(registry); // 3.定期推送数据到prometheus server reportSchedule.scheduleAtFixedRate(() -> { try { if (!Objects.isNull(prometheusPushGateWay)) { prometheusPushGateWay.pushAdd(); log.info("push data success"); } } catch (Exception e) { log.error("pushGateway push failed!", e); } }, 60, 60, TimeUnit.SECONDS); } public void report(String region, String code) { testCounter.labelValues(region, code).inc(); } }
3、在业务埋点调用即可。数据将会上报到prometheus,然后通过配置grafana报表即可。
特别注意:使用push Gateway方式上报数据,tag一定要加上机器的IP,否则服务端区分不出是哪个机器的上报,导致数据不准。
维度问题,注意不要发散,如果发散的影响有:
- 导致promethues服务端的cpu、mem等都会有一定的压力
- grafana查询会变慢,导致有时候结果查询不出来
- 程序调用pushAdd函数也有出现超时(数据量太多)
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。