最近线上的项目使用了 spring-actuator
做度量统计收集,使用 Prometheus
进行数据收集, Grafana
进行数据展示,用于监控生成环境机器的性能指标和业务数据指标。一般,我们叫这样的操作为"埋点"。 SpringBoot
中的依赖 spring-actuator
中集成的度量统计API使用的框架是 Micrometer
,官网是 micrometer.io
。在实践中发现了业务开发者滥用了 Micrometer
的度量类型 Counter
,导致无论什么情况下都只使用计数统计的功能。这篇文章就是基于 Micrometer
分析其他的度量类型API的作用和适用场景。全文接近3W字,内容比较干,希望能够耐心阅读,有所收获。
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
在 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();
Micrometer
中, Meter
的命名约定使用英文逗号(dot,也就是".")分隔单词。但是对于不同的监控系统,对命名的规约可能并不相同,如果命名规约不一致,在做监控系统迁移或者切换的时候,可能会对新的系统造成破坏。 Micrometer
中使用英文逗号分隔单词的命名规则,再通过底层的命名转换接口 NamingConvention
进行转换,最终可以适配不同的监控系统,同时可以消除监控系统不允许的特殊字符的名称和标记等。开发者也可以覆盖 NamingConvention
实现自定义的命名转换规则: registry.config().namingConvention(myCustomNamingConvention);
。在 Micrometer
中,对一些主流的监控系统或者存储系统的命名规则提供了默认的转换方式,例如当我们使用下面的命名时候:
MeterRegistry registry = ... registry.timer("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")
这样,当我们选择命名为"database.calls"的计数器,我们可以进一步选择分组"db"或者"users"分别统计不同分组对总调用数的贡献或者组成。一个反例如下:
MeterRegistry registry = ... registry.counter("calls", "class", "database", "db", "users"); registry.counter("calls", "class", "http", "uri", "/api/users");
通过命名"calls"得到的计数器,由于标签混乱,数据是基本无法分组统计分析,这个时候可以认为得到的时间序列的统计数据是没有意义的。可以定义全局的Tag,也就是全局的Tag定义之后,会附加到所有的使用到的Meter上(只要是使用同一个MeterRegistry),全局的Tag可以这样定义:
MeterRegistry registry = ... registry.config().commonTags("stack", "prod", "region", "us-east-1"); // 和上面的意义是一样的 registry.config().commonTags(Arrays.asList(Tag.of("stack", "prod"), Tag.of("region", "us-east-1")));
像上面这样子使用,就能通过主机,实例,区域,堆栈等操作环境进行多维度深入分析。
还有两点点需要注意:
Tag
的值必须 不为NULL 。Micrometer
中, Tag
必须成对出现,也就是 Tag
必须设置为 偶数个 ,实际上它们以Key=Value的形式存在,具体可以看 io.micrometer.core.instrument.Tag
接口:public interface Tag extends Comparable{ String getKey(); String getValue(); static Tag of(String key, String value) { return new ImmutableTag(key, value); } default int compareTo(Tag o) { return this.getKey().compareTo(o.getKey()); } }
当然,有些时候,我们需要过滤一些必要的标签或者名称进行统计,或者为Meter的名称添加白名单,这个时候可以使用 MeterFilter
。 MeterFilter
本身提供一些列的静态方法,多个 MeterFilter
可以叠加或者组成链实现用户最终的过滤策略。例如:
MeterRegistry registry = ... registry.config() .meterFilter(MeterFilter.ignoreTags("http")) .meterFilter(MeterFilter.denyNameStartsWith("jvm"));
表示忽略"http"标签,拒绝名称以"jvm"字符串开头的 Meter
。更多用法可以参详一下 MeterFilter
这个类。
Meter
的命名和 Meter
的 Tag
相互结合,以命名为轴心,以 Tag
为多维度要素,可以使度量数据的维度更加丰富,便于统计和分析。
前面提到Meter主要包括: Timer
, Counter
, Gauge
, DistributionSummary
, LongTaskTimer
, FunctionCounter
, FunctionTimer
和 TimeGauge
。下面逐一分析它们的作用和个人理解的实际使用场景(应该说是生产环境)。
Counter
是一种比较简单的 Meter
,它是一种单值的度量类型,或者说是一个单值计数器。 Counter
接口允许使用者使用一个固定值(必须为正数)进行计数。准确来说: Counter
就是一个增量为正数的单值计数器。这个举个很简单的使用例子:
MeterRegistry meterRegistry = new SimpleMeterRegistry(); Counter counter = meterRegistry.counter("http.request", "createOrder", "/order/create"); counter.increment(); System.out.println(counter.measure()); // [Measurement{statistic='COUNT', value=1.0}]
使用场景:
Counter
的作用是记录XXX的总量或者计数值,适用于一些增长类型的统计,例如下单、支付次数、 HTTP
请求总量记录等等,通过 Tag
可以区分不同的场景,对于下单,可以使用不同的 Tag
标记不同的业务来源或者是按日期划分,对于 HTTP
请求总量记录,可以使用 Tag
区分不同的 URL
。用下单业务举个例子:
//实体 @Data public class Order { private String orderId; private Integer amount; private String channel; private LocalDateTime createTime; } public class CounterMain { private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); static { Metrics.addRegistry(new SimpleMeterRegistry()); } public static void main(String[] args) throws Exception { Order order1 = new Order(); order1.setOrderId("ORDER_ID_1"); order1.setAmount(100); order1.setChannel("CHANNEL_A"); order1.setCreateTime(LocalDateTime.now()); createOrder(order1); Order order2 = new Order(); order2.setOrderId("ORDER_ID_2"); order2.setAmount(200); order2.setChannel("CHANNEL_B"); order2.setCreateTime(LocalDateTime.now()); createOrder(order2); Search.in(Metrics.globalRegistry).meters().forEach(each -> { StringBuilder builder = new StringBuilder(); builder.append("name:") .append(each.getId().getName()) .append(",tags:") .append(each.getId().getTags()) .append(",type:").append(each.getId().getType()) .append(",value:").append(each.measure()); System.out.println(builder.toString()); }); } private static void createOrder(Order order) { //忽略订单入库等操作 Metrics.counter("order.create", "channel", order.getChannel(), "createTime", FORMATTER.format(order.getCreateTime())).increment(); } }
控制台输出:
name:order.create,tags:[tag(channel=CHANNEL_A), tag(createTime=2018-11-10)],type:COUNTER,value:[Measurement{statistic='COUNT', value=1.0}] name:order.create,tags:[tag(channel=CHANNEL_B), tag(createTime=2018-11-10)],type:COUNTER,value:[Measurement{statistic='COUNT', value=1.0}]
上面的例子是使用全局静态方法工厂类 Metrics
去构造 Counter
实例,实际上, io.micrometer.core.instrument.Counter
接口提供了一个内部建造器类 Counter.Builder
去实例化 Counter
, Counter.Builder
的使用方式如下:
public class CounterBuilderMain { public static void main(String[] args) throws Exception{ Counter counter = Counter.builder("name") //名称 .baseUnit("unit") //基础单位 .description("desc") //描述 .tag("tagKey", "tagValue") //标签 .register(new SimpleMeterRegistry());//绑定的MeterRegistry counter.increment(); } }
FunctionCounter
是 Counter
的特化类型,它把计数器数值增加的动作抽象成接口类型 ToDoubleFunction
,这个接口JDK1.8中对于 Function
的特化类型接口。 FunctionCounter
的使用场景和 Counter
是一致的,这里介绍一下它的用法:
public class FunctionCounterMain { public static void main(String[] args) throws Exception { MeterRegistry registry = new SimpleMeterRegistry(); AtomicInteger n = new AtomicInteger(0); //这里ToDoubleFunction匿名实现其实可以使用Lambda表达式简化为AtomicInteger::get FunctionCounter.builder("functionCounter", n, new ToDoubleFunction() { @Override public double applyAsDouble(AtomicInteger value) { return value.get(); } }).baseUnit("function") .description("functionCounter") .tag("createOrder", "CHANNEL-A") .register(registry); //下面模拟三次计数 n.incrementAndGet(); n.incrementAndGet(); n.incrementAndGet(); } }
FunctionCounter
使用的一个明显的好处是,我们不需要感知 FunctionCounter
实例的存在,实际上我们只需要操作作为 FunctionCounter
实例构建元素之一的 AtomicInteger
实例即可,这种接口的设计方式在很多主流框架里面可以看到。
Timer
(计时器)适用于记录耗时比较短的事件的执行时间,通过时间分布展示事件的序列和发生频率。所有的 Timer
的实现至少记录了发生的事件的数量和这些事件的总耗时,从而生成一个时间序列。 Timer
的基本单位基于服务端的指标而定,但是实际上我们不需要过于关注 Timer
的基本单位,因为 Micrometer
在存储生成的时间序列的时候会自动选择适当的基本单位。 Timer
接口提供的常用方法如下:
public interface Timer extends Meter { ... void record(long var1, TimeUnit var3); default void record(Duration duration) { this.record(duration.toNanos(), TimeUnit.NANOSECONDS); }T record(Supplier var1); T recordCallable(Callable var1) throws Exception; void record(Runnable var1); default Runnable wrap(Runnable f) { return () -> { this.record(f); }; } default Callable wrap(Callable f) { return () -> { return this.recordCallable(f); }; } long count(); double totalTime(TimeUnit var1); default double mean(TimeUnit unit) { return this.count() == 0L ? 0.0D : this.totalTime(unit) / (double)this.count(); } double max(TimeUnit var1); ... }
实际上,比较常用和方便的方法是几个函数式接口入参的方法:
Timer timer = ... timer.record(() -> dontCareAboutReturnValue()); timer.recordCallable(() -> returnValue()); Runnable r = timer.wrap(() -> dontCareAboutReturnValue()); Callable c = timer.wrap(() -> returnValue());
使用场景:
根据个人经验和实践,总结如下:
这里举个实际的例子,要对系统做一个功能,记录指定方法的执行时间,还是用下单方法做例子:
public class TimerMain { private static final Random R = new Random(); static { Metrics.addRegistry(new SimpleMeterRegistry()); } public static void main(String[] args) throws Exception { Order order1 = new Order(); order1.setOrderId("ORDER_ID_1"); order1.setAmount(100); order1.setChannel("CHANNEL_A"); order1.setCreateTime(LocalDateTime.now()); Timer timer = Metrics.timer("timer", "createOrder", "cost"); timer.record(() -> createOrder(order1)); } private static void createOrder(Order order) { try { TimeUnit.SECONDS.sleep(R.nextInt(5)); //模拟方法耗时 } catch (InterruptedException e) { //no-op } } }
在实际生产环境中,可以通过 spring-aop
把记录方法耗时的逻辑抽象到一个切面中,这样就能减少不必要的冗余的模板代码。上面的例子是通过Mertics构造Timer实例,实际上也可以使用Builder构造:
MeterRegistry registry = ... Timer timer = Timer .builder("my.timer") .description("a description of what this timer does") // 可选 .tags("region", "test") // 可选 .register(registry);
另外, Timer
的使用还可以基于它的内部类 Timer.Sample
,通过start和stop两个方法记录两者之间的逻辑的执行耗时。例如:
Timer.Sample sample = Timer.start(registry); // 这里做业务逻辑 Response response = ... sample.stop(registry.timer("my.timer", "response", response.status()));
FunctionTimer
是 Timer
的特化类型,它主要提供两个单调递增的函数(其实并不是单调递增,只是在使用中一般需要随着时间最少保持不变或者说不减少):一个用于计数的函数和一个用于记录总调用耗时的函数,它的建造器的入参如下:
public interface FunctionTimer extends Meter { staticBuilder builder(String name, T obj, ToLongFunction countFunction, ToDoubleFunction totalTimeFunction, TimeUnit totalTimeFunctionUnit) { return new Builder<>(name, obj, countFunction, totalTimeFunction, totalTimeFunctionUnit); } ... }
官方文档中的例子如下:
IMap, ?> cache = ...; // 假设使用了Hazelcast缓存 registry.more().timer("cache.gets.latency", Tags.of("name", cache.getName()), cache, c -> c.getLocalMapStats().getGetOperationCount(), //实际上就是cache的一个方法,记录缓存生命周期初始化的增量(个数) c -> c.getLocalMapStats().getTotalGetLatency(), // Get操作的延迟时间总量,可以理解为耗时 TimeUnit.NANOSECONDS );
按照个人理解, ToDoubleFunction
用于统计事件个数, ToDoubleFunction
用于记录执行总时间,实际上两个函数都只是 Function
函数的变体,还有一个比较重要的是总时间的单位totalTimeFunctionUnit。简单的使用方式如下:
public class FunctionTimerMain { public static void main(String[] args) throws Exception { //这个是为了满足参数,暂时不需要理会 Object holder = new Object(); AtomicLong totalTimeNanos = new AtomicLong(0); AtomicLong totalCount = new AtomicLong(0); FunctionTimer.builder("functionTimer", holder, p -> totalCount.get(), p -> totalTimeNanos.get(), TimeUnit.NANOSECONDS) .register(new SimpleMeterRegistry()); totalTimeNanos.addAndGet(10000000); totalCount.incrementAndGet(); } }
LongTaskTimer
是 Timer
的特化类型,主要用于记录长时间执行的任务的持续时间,在任务完成之前,被监测的事件或者任务仍然处于运行状态,任务完成的时候,任务执行的总耗时才会被记录下来。 LongTaskTimer
适合用于长时间持续运行的事件耗时的记录,例如相对耗时的定时任务。在 Spring(Boot)
应用中,可以简单地使用 @Scheduled
和 @Timed
注解,基于 spring-aop
完成定时调度任务的总耗时记录:
@Timed(value = "aws.scrape", longTask = true) @Scheduled(fixedDelay = 360000) void scrapeResources() { //这里做相对耗时的业务逻辑 }
当然,在非 Spring
体系中也能方便地使用 LongTaskTimer
:
public class LongTaskTimerMain { public static void main(String[] args) throws Exception{ MeterRegistry meterRegistry = new SimpleMeterRegistry(); LongTaskTimer longTaskTimer = meterRegistry.more().longTaskTimer("longTaskTimer"); longTaskTimer.record(() -> { //这里编写Task的逻辑 }); //或者这样 Metrics.more().longTaskTimer("longTaskTimer").record(()-> { //这里编写Task的逻辑 }); } }
Gauge
(仪表)是获取当前度量记录值的句柄,也就是它表示一个可以任意上下浮动的单数值度量 Meter
。 Gauge
通常用于变动的测量值,测量值用 ToDoubleFunction
参数的返回值设置,如当前的内存使用情况,同时也可以测量上下移动的"计数",比如队列中的消息数量。官网文档中提到 Gauge
的典型使用场景是用于测量集合或映射的大小或运行状态中的线程数。一般情况下, Gauge
适合用于监测有自然上界的事件或者任务,而 Counter
一般使用于无自然上界的事件或者任务的监测,所以像 HTTP
请求总量计数应该使用 Counter
而非 Gauge
。 MeterRegistry
中提供了一些便于构建用于观察数值、函数、集合和映射的Gauge相关的方法:
Listlist = registry.gauge("listGauge", Collections.emptyList(), new ArrayList<>(), List::size); List list2 = registry.gaugeCollectionSize("listSize2", Tags.empty(), new ArrayList<>()); Map map = registry.gaugeMapSize("mapGauge", Tags.empty(), new HashMap<>());
上面的三个方法通过 MeterRegistry
构建 Gauge
并且返回了集合或者映射实例,使用这些集合或者映射实例就能在其size变化过程中记录这个变更值。更重要的优点是,我们不需要感知 Gauge
接口的存在,只需要像平时一样使用集合或者映射实例就可以了。此外, Gauge
还支持 java.lang.Number
的子类, java.util.concurrent.atomic
包中的 AtomicInteger
和 AtomicLong
,还有 Guava
提供的 AtomicDouble
:
AtomicInteger n = registry.gauge("numberGauge", new AtomicInteger(0)); n.set(1); n.set(2);
除了使用 MeterRegistry
创建 Gauge
之外,还可以使用建造器流式创建:
//一般我们不需要操作Gauge实例 Gauge gauge = Gauge .builder("gauge", myObj, myObj::gaugeValue) .description("a description of what this gauge does") // 可选 .tags("region", "test") // 可选 .register(registry);
使用场景:
根据个人经验和实践,总结如下:
举个相对实际的例子,假设我们需要对登录后的用户发送一条短信或者推送,做法是消息先投放到一个阻塞队列,再由一个线程消费消息进行其他操作:
public class GaugeMain { private static final MeterRegistry MR = new SimpleMeterRegistry(); private static final BlockingQueueQUEUE = new ArrayBlockingQueue<>(500); private static BlockingQueue REAL_QUEUE; static { REAL_QUEUE = MR.gauge("messageGauge", QUEUE, Collection::size); } public static void main(String[] args) throws Exception { consume(); Message message = new Message(); message.setUserId(1L); message.setContent("content"); REAL_QUEUE.put(message); } private static void consume() throws Exception { new Thread(() -> { while (true) { try { Message message = REAL_QUEUE.take(); //handle message System.out.println(message); } catch (InterruptedException e) { //no-op } } }).start(); } }
上面的例子代码写得比较糟糕,只为了演示相关使用方式,切勿用于生产环境。
TimeGauge
是 Gauge
的特化类型,相比 Gauge
,它的构建器中多了一个 TimeUnit
类型的参数,用于指定 ToDoubleFunction
入参的基础时间单位。这里简单举个使用例子:
public class TimeGaugeMain { private static final SimpleMeterRegistry R = new SimpleMeterRegistry(); public static void main(String[] args) throws Exception { AtomicInteger count = new AtomicInteger(); TimeGauge.BuildertimeGauge = TimeGauge.builder("timeGauge", count, TimeUnit.SECONDS, AtomicInteger::get); timeGauge.register(R); count.addAndGet(10086); print(); count.set(1); print(); } private static void print() throws Exception { Search.in(R).meters().forEach(each -> { StringBuilder builder = new StringBuilder(); builder.append("name:") .append(each.getId().getName()) .append(",tags:") .append(each.getId().getTags()) .append(",type:").append(each.getId().getType()) .append(",value:").append(each.measure()); System.out.println(builder.toString()); }); } } //输出 name:timeGauge,tags:[],type:GAUGE,value:[Measurement{statistic='VALUE', value=10086.0}] name:timeGauge,tags:[],type:GAUGE,value:[Measurement{statistic='VALUE', value=1.0}]
Summary
(摘要)主要用于跟踪事件的分布,在 Micrometer
中,对应的类是 DistributionSummary
(分布式摘要)。它的使用方式和 Timer
十分相似,但是它的记录值并不依赖于时间单位。常见的使用场景:使用 DistributionSummary
测量命中服务器的请求的有效负载大小。使用 MeterRegistry
创建 DistributionSummary
实例如下:
DistributionSummary summary = registry.summary("response.size");
通过建造器流式创建如下:
DistributionSummary summary = DistributionSummary .builder("response.size") .description("a description of what this summary does") // 可选 .baseUnit("bytes") // 可选 .tags("region", "test") // 可选 .scale(100) // 可选 .register(registry);
使用场景:
根据个人经验和实践,总结如下:
举个相对具体的例子:
public class DistributionSummaryMain { private static final DistributionSummary DS = DistributionSummary.builder("cacheHitPercent") .register(new SimpleMeterRegistry()); private static final LoadingCacheCACHE = CacheBuilder.newBuilder() .maximumSize(1000) .recordStats() .expireAfterWrite(60, TimeUnit.SECONDS) .build(new CacheLoader () { @Override public String load(String s) throws Exception { return selectFromDatabase(); } }); public static void main(String[] args) throws Exception { String key = "doge"; String value = CACHE.get(key); record(); } private static void record() throws Exception { CacheStats stats = CACHE.stats(); BigDecimal hitCount = new BigDecimal(stats.hitCount()); BigDecimal requestCount = new BigDecimal(stats.requestCount()); DS.record(hitCount.divide(requestCount, 2, BigDecimal.ROUND_HALF_DOWN).doubleValue()); } }
集成了 Micrometer
框架的 JVM
应用使用到 Micrometer
的 API
收集的度量数据位于内存之中,因此,需要额外的存储系统去存储这些度量数据,需要有监控系统负责统一收集和处理这些数据,还需要有一些UI工具去展示数据, 一般情况下大佬或者老板只喜欢看炫酷的仪表盘或者动画 。常见的存储系统就是时序数据库,主流的有 Influx
、 Datadog
等。比较主流的监控系统(主要是用于数据收集和处理)就是 Prometheus
(一般叫普罗米修斯,下面就这样叫吧)。而展示的UI目前相对用得比较多的就是 Grafana
。另外, Prometheus
已经内置了一个时序数据库的实现,因此,在做一套相对完善的度量数据监控的系统只需要依赖目标 JVM
应用, Prometheus
组件和 Grafana
组件即可。下面花一点时间从零开始搭建一个这样的系统,之前写的一篇文章基于 Windows
系统,操作可能跟生产环境不够接近,这次使用 CentOS7
。
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
作为示例。依赖:
org.springframework.boot spring-boot-dependencies 2.1.0.RELEASE pom import org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-actuator org.springframework.boot spring-boot-starter-aop org.projectlombok lombok 1.16.22 io.micrometer micrometer-registry-prometheus 1.1.0
接着编写一个下单接口和一个消息发送模块,模拟用户下单之后向用户发送消息:
//实体 @Data public class Message { private String orderId; private Long userId; private String content; } @Data public class Order { private String orderId; private Long userId; private Integer amount; private LocalDateTime createTime; } //控制器和服务类 @RestController public class OrderController { @Autowired private OrderService orderService; @PostMapping(value = "/order") public ResponseEntitycreateOrder(@RequestBody Order order) { return ResponseEntity.ok(orderService.createOrder(order)); } } @Slf4j @Service public class OrderService { private static final Random R = new Random(); @Autowired private MessageService messageService; public Boolean createOrder(Order order) { //模拟下单 try { int ms = R.nextInt(50) + 50; TimeUnit.MILLISECONDS.sleep(ms); log.info("保存订单模拟耗时{}毫秒...", ms); } catch (Exception e) { //no-op } //记录下单总数 Metrics.counter("order.count", "order.channel", order.getChannel()).increment(); //发送消息 Message message = new Message(); message.setContent("模拟短信..."); message.setOrderId(order.getOrderId()); message.setUserId(order.getUserId()); messageService.sendMessage(message); return true; } } @Slf4j @Service public class MessageService implements InitializingBean { private static final BlockingQueue QUEUE = new ArrayBlockingQueue<>(500); private static BlockingQueue REAL_QUEUE; private static final Executor EXECUTOR = Executors.newSingleThreadExecutor(); private static final Random R = new Random(); static { REAL_QUEUE = Metrics.gauge("message.gauge", Tags.of("message.gauge", "message.queue.size"), QUEUE, Collection::size); } public void sendMessage(Message message) { try { REAL_QUEUE.put(message); } catch (InterruptedException e) { //no-op } } @Override public void afterPropertiesSet() throws Exception { EXECUTOR.execute(() -> { while (true) { try { Message message = REAL_QUEUE.take(); log.info("模拟发送短信,orderId:{},userId:{},内容:{},耗时:{}毫秒", message.getOrderId(), message.getUserId(), message.getContent(), R.nextInt(50)); } catch (Exception e) { throw new IllegalStateException(e); } } }); } } //切面类 @Component @Aspect public class TimerAspect { @Around(value = "execution(* club.throwable.smp.service.*Service.*(..))") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { Signature signature = joinPoint.getSignature(); MethodSignature methodSignature = (MethodSignature) signature; Method method = methodSignature.getMethod(); Timer timer = Metrics.timer("method.cost.time", "method.name", method.getName()); ThrowableHolder holder = new ThrowableHolder(); Object result = timer.recordCallable(() -> { try { return joinPoint.proceed(); } catch (Throwable e) { holder.throwable = e; } return null; }); if (null != holder.throwable) { throw holder.throwable; } return result; } private class ThrowableHolder { Throwable throwable; } }
yaml的配置如下:
server: port: 9091 management: server: port: 10091 endpoints: web: exposure: include: '*' base-path: /management
注意多看 spring官方文档 关于 Actuator
的详细描述,在 SpringBoot2.x
之后,配置Web端点暴露的权限控制和 SpringBoot1.x
有很大的不同。总结一下就是:除了 shutdown
端点之外,其他端点默认都是开启支持的( 这里仅仅是开启支持,并不是暴露为Web端点,端点必须暴露为Web端点才能被访问 ),禁用或者开启端点支持的配置方式如下:
management.endpoint.${端点ID}.enabled=true/false
可以查看 actuator-api文档 查看所有支持的端点的特性,这个是2.1.0.RELEASE版本的官方文档,不知道日后链接会不会挂掉。端点只开启支持,但是不暴露为Web端点,是无法通过 http://{host}:{management.port}/{management.endpoints.web.base-path}/{endpointId}
访问的。暴露监控端点为Web端点的配置是:
management.endpoints.web.exposure.include=info,health management.endpoints.web.exposure.exclude=prometheus
management.endpoints.web.exposure.include
用于指定暴露为Web端点的监控端点,指定多个的时候用英文逗号分隔。 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服务端通过此端口收集数据)。
Prometheus 目前的最新版本是2.5,鉴于笔者当前没深入玩过 Docker
,这里还是直接下载它的压缩包解压安装。
wget https://github.com/prometheus/prometheus/releases/download/v2.5.0/prometheus-2.5.0.linux-amd64.tar.gz tar xvfz prometheus-*.tar.gz cd prometheus-*
先编辑解压出来的目录下的 Prometheus
配置文件 prometheus.yml
,主要修改 scrape_configs
节点的属性:
scrape_configs: # The job name is added as a label `job=` to any timeseries scraped from this config. - job_name: 'prometheus' # metrics_path defaults to '/metrics' # scheme defaults to 'http'. # 这里配置需要拉取度量信息的URL路径,这里选择应用程序的prometheus端点 metrics_path: /management/prometheus static_configs: # 这里配置host和port - targets: ['localhost:10091']
配置拉取度量数据的路径为 localhost:10091/management/metrics
,此前记得把前一节提到的应用在虚拟机中启动。接着启动 Prometheus
应用:
# 可选参数 --storage.tsdb.path=存储数据的路径,默认路径为./data ./prometheus --config.file=prometheus.yml
Prometheus
引用的默认启动端口是9090,启动成功后,日志如下:
此时,访问 http://${虚拟机host}:9090/targets
就能看到当前 Prometheus
中执行的 Job
:
访问 http://${虚拟机host}:9090/graph
可以查找到我们定义的度量 Meter
和 spring-boot-starter-actuator
中已经定义好的一些关于JVM或者 Tomcat
的度量 Meter
。我们先对应用的 /order
接口进行调用,然后查看一下监控前面在应用中定义的 order_count_total
和 method_cost_time_seconds_sum
:
可以看到, Meter
的信息已经被收集和展示,但是显然不够详细和炫酷,这个时候就需要使用Grafana的UI做一下点缀。
Grafana
的安装过程如下:
wget https://s3-us-west-2.amazonaws.com/grafana-releases/release/grafana-5.3.4-1.x86_64.rpm sudo yum localinstall grafana-5.3.4-1.x86_64.rpm
安装完成后,通过命令 service grafana-server start
启动即可,默认的启动端口为3000,通过 http://${host}:3000
访问即可。初始的账号密码都为admin,权限是管理员权限。接着需要在 Home
面板添加一个数据源,目的是对接 Prometheus
服务端从而可以拉取它里面的度量数据。数据源添加面板如下:
其实就是指向Prometheus服务端的端口就可以了。接下来可以天马行空地添加需要的面板,就下单数量统计的指标,可以添加一个 Graph
的面板:
配置面板的时候,需要在基础(General)中指定Title:
接着比较重要的是Metrics的配置,需要指定数据源和Prometheus的查询语句:
最好参考一下 Prometheus
的官方文档,稍微学习一下它的查询语言 PromQL
的使用方式,一个面板可以支持多个 PromQL
查询。前面提到的两项是基本配置,其他配置项一般是图表展示的辅助或者预警等辅助功能,这里先不展开,可以去 Grafana
的官网挖掘一下使用方式。然后我们再调用一下下单接口,过一段时间,图表的数据就会自动更新和展示:
接着添加一下项目中使用的Timer的Meter,便于监控方法的执行时间,完成之后大致如下:
上面的面板虽然设计相当粗糙,但是基本功能已经实现。设计面板并不是一件容易的事,如果有需要可以从 Github
中搜索一下 grafana dashboard
关键字找现成的开源配置使用或者二次加工后使用。
常言道:工欲善其事,必先利其器。 Micrometer
是 JVM
应用的一款相当优异的度量框架,它提供基于 Tag
和丰富的度量类型和 API
便于多维度地进行不同角度度量数据的统计,可以方便地接入 Prometheus
进行数据收集,使用 Grafana
的面板进行炫酷的展示,提供了天然的 spring-boot
体系支持。但是,在实际的业务代码中,度量类型 Counter
经常被滥用,一旦工具被不加思考地滥用,就反而会成为混乱或者毒瘤。因此,这篇文章就是对 Micrometer
中的各种 Meter
的使用场景基于个人的理解做了调研和分析,后面还会有系列的文章分享一下这套方案在实战中的经验和踩坑经历。