1. 程式人生 > >3W字乾貨深入分析基於Micrometer和Prometheus實現度量和監控的方案

3W字乾貨深入分析基於Micrometer和Prometheus實現度量和監控的方案

![](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202007/jvm-m-logo.jpg) ## 前提 最近線上的專案使用了`spring-actuator`做度量統計收集,使用`Prometheus`進行資料收集,`Grafana`進行資料展示,用於監控生成環境機器的效能指標和業務資料指標。一般,我們叫這樣的操作為"埋點"。`SpringBoot`中的依賴`spring-actuator`中整合的度量統計API使用的框架是`Micrometer`,官網是`micrometer.io`。在實踐中發現了業務開發者濫用了`Micrometer`的度量型別`Counter`,導致無論什麼情況下都只使用計數統計的功能。這篇文章就是基於`Micrometer`分析其他的度量型別API的作用和適用場景。全文接近3W字,內容比較幹,希望能夠耐心閱讀,有所收穫。 ## 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`是一個抽象類,主要實現包括: - 1、`SimpleMeterRegistry`:每個`Meter`的最新資料可以收集到`SimpleMeterRegistry`例項中,但是這些資料不會發布到其他系統,也就是資料是位於應用的記憶體中的。 - 2、`CompositeMeterRegistry`:多個`MeterRegistry`聚合,內部維護了一個`MeterRegistry`的列表。 - 3、全域性的`MeterRegistry`:工廠類`io.micrometer.core.instrument.Metrics`中持有一個靜態`final`的`CompositeMeterRegistry`例項`globalRegistry`。 當然,使用者也可以自行繼承`MeterRegistry`去實現自定義的`MeterRegistry`。`SimpleMeterRegistry`適合做除錯的時候使用,它的簡單使用方式如下: ```java MeterRegistry registry = new SimpleMeterRegistry(); Counter counter = registry.counter("counter"); counter.increment(); ``` `CompositeMeterRegistry`例項初始化的時候,內部持有的`MeterRegistry`列表是空的,如果此時用它新增一個`Meter`例項,`Meter`例項的操作是無效的: ```java CompositeMeterRegistry composite = new CompositeMeterRegistry(); Counter compositeCounter = composite.counter("counter"); compositeCounter.increment(); // <- 實際上這一步操作是無效的,但是不會報錯 SimpleMeterRegistry simple = new SimpleMeterRegistry(); composite.add(simple); // <- 向CompositeMeterRegistry例項中新增SimpleMeterRegistry例項 compositeCounter.increment(); // <-計數成功 ``` 全域性的`MeterRegistry`的使用方式更加簡單便捷,因為一切只需要操作工廠類`Metrics`的靜態方法: ```java 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`中,對一些主流的監控系統或者儲存系統的命名規則提供了預設的轉換方式,例如當我們使用下面的命名時候: ```java MeterRegistry registry = ... registry.timer("http.server.requests"); ``` 對於不同的監控系統或者儲存系統,命名會自動轉換如下: - 1、Prometheus - http_server_requests_duration_seconds。 - 2、Atlas - httpServerRequests。 - 3、Graphite - http.server.requests。 - 4、InfluxDB - http_server_requests。 其實`NamingConvention`已經提供了5種預設的轉換規則:dot、snakeCase、camelCase、upperCamelCase和slashes。 另外,`Tag`(標籤)是`Micrometer`的一個重要的功能,嚴格來說,一個度量框架只有實現了標籤的功能,才能真正地多維度進行度量資料收集。Tag的命名一般需要是有意義的,所謂有意義就是可以根據`Tag`的命名可以推斷出它指向的資料到底代表什麼維度或者什麼型別的度量指標。假設我們需要監控資料庫的呼叫和Http請求呼叫統計,一般推薦的做法是: ```java MeterRegistry registry = ... registry.counter("database.calls", "db", "users") registry.counter("http.requests", "uri", "/api/users") ``` 這樣,當我們選擇命名為"database.calls"的計數器,我們可以進一步選擇分組"db"或者"users"分別統計不同分組對總呼叫數的貢獻或者組成。一個反例如下: ```java MeterRegistry registry = ... registry.counter("calls", "class", "database", "db", "users"); registry.counter("calls", "class", "http", "uri", "/api/users"); ``` 通過命名"calls"得到的計數器,由於標籤混亂,資料是基本無法分組統計分析,這個時候可以認為得到的時間序列的統計資料是沒有意義的。可以定義全域性的Tag,也就是全域性的Tag定義之後,會附加到所有的使用到的Meter上(只要是使用同一個MeterRegistry),全域性的Tag可以這樣定義: ```java 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"))); ``` 像上面這樣子使用,就能通過主機,例項,區域,堆疊等操作環境進行多維度深入分析。 還有兩點點需要注意: - 1、`Tag`的值必須**不為NULL**。 - 2、`Micrometer`中,`Tag`必須成對出現,也就是`Tag`必須設定為**偶數個**,實際上它們以Key=Value的形式存在,具體可以看`io.micrometer.core.instrument.Tag`介面: ```java 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`可以疊加或者組成鏈實現使用者最終的過濾策略。例如: ```java MeterRegistry registry = ... registry.config() .meterFilter(MeterFilter.ignoreTags("http")) .meterFilter(MeterFilter.denyNameStartsWith("jvm")); ``` 表示忽略"http"標籤,拒絕名稱以"jvm"字串開頭的`Meter`。更多用法可以參詳一下`MeterFilter`這個類。 `Meter`的命名和`Meter`的`Tag`相互結合,以命名為軸心,以`Tag`為多維度要素,可以使度量資料的維度更加豐富,便於統計和分析。 ## Meters 前面提到Meter主要包括:`Timer`,`Counter`,`Gauge`,`DistributionSummary`,`LongTaskTimer`,`FunctionCounter`,`FunctionTimer`和`TimeGauge`。下面逐一分析它們的作用和個人理解的實際使用場景(應該說是生產環境)。 ### Counter `Counter`是一種比較簡單的`Meter`,它是一種單值的度量型別,或者說是一個單值計數器。`Counter`介面允許使用者使用一個固定值(必須為正數)進行計數。準確來說:`Counter`就是一個增量為正數的單值計數器。這個舉個很簡單的使用例子: ```java 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`。用下單業務舉個例子: ```java //實體 @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(); } } ``` 控制檯輸出: ```java 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`的使用方式如下: ```java 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 `FunctionCounter`是`Counter`的特化型別,它把計數器數值增加的動作抽象成介面型別`ToDoubleFunction`,這個介面JDK1.8中對於`Function`的特化型別介面。`FunctionCounter`的使用場景和`Counter`是一致的,這裡介紹一下它的用法: ```java 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`的基本單位基於服務端的指標而定,但是實際上我們不需要過於關注`Timer`的基本單位,因為`Micrometer`在儲存生成的時間序列的時候會自動選擇適當的基本單位。`Timer`介面提供的常用方法如下: ```java 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); ... } ``` 實際上,比較常用和方便的方法是幾個函式式介面入參的方法: ```java Timer timer = ... timer.record(() -> dontCareAboutReturnValue()); timer.recordCallable(() -> returnValue()); Runnable r = timer.wrap(() -> dontCareAboutReturnValue()); Callable c = timer.wrap(() -> returnValue()); ``` **使用場景:** 根據個人經驗和實踐,總結如下: - 1、記錄指定方法的執行時間用於展示。 - 2、記錄一些任務的執行時間,從而確定某些資料來源的速率,例如訊息佇列訊息的消費速率等。 這裡舉個實際的例子,要對系統做一個功能,記錄指定方法的執行時間,還是用下單方法做例子: ```java 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構造: ```java 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兩個方法記錄兩者之間的邏輯的執行耗時。例如: ```java Timer.Sample sample = Timer.start(registry); // 這裡做業務邏輯 Response response = ... sample.stop(registry.timer("my.timer", "response", response.status())); ``` ### FunctionTimer `FunctionTimer`是`Timer`的特化型別,它主要提供兩個單調遞增的函式(其實並不是單調遞增,只是在使用中一般需要隨著時間最少保持不變或者說不減少):一個用於計數的函式和一個用於記錄總呼叫耗時的函式,它的建造器的入參如下: ```java public interface FunctionTimer extends Meter { static Builder builder(String name, T obj, ToLongFunction countFunction, ToDoubleFunction totalTimeFunction, TimeUnit totalTimeFunctionUnit) { return new Builder<>(name, obj, countFunction, totalTimeFunction, totalTimeFunctionUnit); } ... } ``` 官方文件中的例子如下: ```java 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。簡單的使用方式如下: ```java 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 `LongTaskTimer`是`Timer`的特化型別,主要用於記錄長時間執行的任務的持續時間,在任務完成之前,被監測的事件或者任務仍然處於執行狀態,任務完成的時候,任務執行的總耗時才會被記錄下來。`LongTaskTimer`適合用於長時間持續執行的事件耗時的記錄,例如相對耗時的定時任務。在`Spring(Boot)`應用中,可以簡單地使用`@Scheduled`和`@Timed`註解,基於`spring-aop`完成定時排程任務的總耗時記錄: ```java @Timed(value = "aws.scrape", longTask = true) @Scheduled(fixedDelay = 360000) void scrapeResources() { //這裡做相對耗時的業務邏輯 } ``` 當然,在非`Spring`體系中也能方便地使用`LongTaskTimer`: ```java 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 `Gauge`(儀表)是獲取當前度量記錄值的控制代碼,也就是它表示一個可以任意上下浮動的單數值度量`Meter`。`Gauge`通常用於變動的測量值,測量值用`ToDoubleFunction`引數的返回值設定,如當前的記憶體使用情況,同時也可以測量上下移動的"計數",比如佇列中的訊息數量。官網文件中提到`Gauge`的典型使用場景是用於測量集合或對映的大小或執行狀態中的執行緒數。一般情況下,`Gauge`適合用於監測有自然上界的事件或者任務,而`Counter`一般使用於無自然上界的事件或者任務的監測,所以像`HTTP`請求總量計數應該使用`Counter`而非`Gauge`。`MeterRegistry`中提供了一些便於構建用於觀察數值、函式、集合和對映的Gauge相關的方法: ```java List list = 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`: ```java AtomicInteger n = registry.gauge("numberGauge", new AtomicInteger(0)); n.set(1); n.set(2); ``` 除了使用`MeterRegistry`建立`Gauge`之外,還可以使用建造器流式建立: ```java //一般我們不需要操作Gauge例項 Gauge gauge = Gauge .builder("gauge", myObj, myObj::gaugeValue) .description("a description of what this gauge does") // 可選 .tags("region", "test") // 可選 .register(registry); ``` **使用場景:** 根據個人經驗和實踐,總結如下: - 1、有自然(物理)上界的浮動值的監測,例如實體記憶體、集合、對映、數值等。 - 2、有邏輯上界的浮動值的監測,例如積壓的訊息、(執行緒池中)積壓的任務等,其實本質也是集合或者對映的監測。 舉個相對實際的例子,假設我們需要對登入後的使用者傳送一條簡訊或者推送,做法是訊息先投放到一個阻塞佇列,再由一個執行緒消費訊息進行其他操作: ```java public class GaugeMain { private static final MeterRegistry MR = new SimpleMeterRegistry(); private static final BlockingQueue QUEUE = 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 `TimeGauge`是`Gauge`的特化型別,相比`Gauge`,它的構建器中多了一個`TimeUnit`型別的引數,用於指定`ToDoubleFunction`入參的基礎時間單位。這裡簡單舉個使用例子: ```java public class TimeGaugeMain { private static final SimpleMeterRegistry R = new SimpleMeterRegistry(); public static void main(String[] args) throws Exception { AtomicInteger count = new AtomicInteger(); TimeGauge.Builder timeGauge = 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}] ``` ### DistributionSummary `Summary`(摘要)主要用於跟蹤事件的分佈,在`Micrometer`中,對應的類是`DistributionSummary`(分散式摘要)。它的使用方式和`Timer`十分相似,但是它的記錄值並不依賴於時間單位。常見的使用場景:使用`DistributionSummary`測量命中伺服器的請求的有效負載大小。使用`MeterRegistry`建立`DistributionSummary`例項如下: ```java DistributionSummary summary = registry.summary("response.size"); ``` 通過建造器流式建立如下: ```java DistributionSummary summary = DistributionSummary .builder("response.size") .description("a description of what this summary does") // 可選 .baseUnit("bytes") // 可選 .tags("region", "test") // 可選 .scale(100) // 可選 .register(registry); ``` **使用場景:** 根據個人經驗和實踐,總結如下: - 1、不依賴於時間單位的記錄值的測量,例如伺服器有效負載值,快取的命中率等。 舉個相對具體的例子: ```java public class DistributionSummaryMain { private static final DistributionSummary DS = DistributionSummary.builder("cacheHitPercent") .register(new SimpleMeterRegistry()); private static final LoadingCache CACHE = 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()); } } ``` ## 基於SpirngBoot、Prometheus、Grafana整合 集成了`Micrometer`框架的`JVM`應用使用到`Micrometer`的`API`收集的度量資料位於記憶體之中,因此,需要額外的儲存系統去儲存這些度量資料,需要有監控系統負責統一收集和處理這些資料,還需要有一些UI工具去展示資料,**一般情況下大佬或者老闆只喜歡看炫酷的儀表盤或者動畫**。常見的儲存系統就是時序資料庫,主流的有`Influx`、`Datadog`等。比較主流的監控系統(主要是用於資料收集和處理)就是`Prometheus`(一般叫普羅米修斯,下面就這樣叫吧)。而展示的UI目前相對用得比較多的就是`Grafana`。另外,`Prometheus`已經內建了一個時序資料庫的實現,因此,在做一套相對完善的度量資料監控的系統只需要依賴目標`JVM`應用,`Prometheus`元件和`Grafana`元件即可。下面花一點時間從零開始搭建一個這樣的系統,之前寫的一篇文章基於`Windows`系統,操作可能跟生產環境不夠接近,這次使用`CentOS7`。 ### SpirngBoot中使用Micrometer `SpringBoot`中的`spring-boot-starter-actuator`依賴已經集成了對`Micrometer`的支援,其中的`metrics`端點的很多功能就是通過`Micrometer`實現的,`prometheus`端點預設也是開啟支援的,實際上`actuator`依賴的`spring-boot-actuator-autoconfigure`中集成了對很多框架的開箱即用的`API`,其中`prometheus`包中集成了對`Prometheus`的支援,使得使用了`actuator`可以輕易地讓專案暴露出`prometheus`端點,使得應用作為`Prometheus`收集資料的客戶端,`Prometheus`(服務端軟體)可以通過此端點收集應用中`Micrometer`的度量資料。 ![jvm-m-1.png](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202007/jvm-m-1.png) 我們先引入`spring-boot-starter-actuator`和`spring-boot-starter-web`,實現一個`Counter`和`Timer`作為示例。依賴: ```xml 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
``` 接著編寫一個下單介面和一個訊息傳送模組,模擬使用者下單之後向用戶傳送訊息: ```java //實體 @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 ResponseEntity createOrder(@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的配置如下: ```yaml server: port: 9091 management: server: port: 10091 endpoints: web: exposure: include: '*' base-path: /management ``` 注意多看[spring官方文件](https://spring.io)關於`Actuator`的詳細描述,在`SpringBoot2.x`之後,配置Web端點暴露的許可權控制和`SpringBoot1.x`有很大的不同。總結一下就是:除了`shutdown`端點之外,其他端點預設都是開啟支援的(**這裡僅僅是開啟支援,並不是暴露為Web端點,端點必須暴露為Web端點才能被訪問**),禁用或者開啟端點支援的配置方式如下: ```properties management.endpoint.${端點ID}.enabled=true/false ``` 可以檢視[actuator-api文件](https://docs.spring.io/spring-boot/docs/2.1.0.RELEASE/actuator-api//html/)檢視所有支援的端點的特性,這個是2.1.0.RELEASE版本的官方文件,不知道日後連結會不會掛掉。端點只開啟支援,但是不暴露為Web端點,是無法通過`http://{host}:{management.port}/{management.endpoints.web.base-path}/{endpointId}`訪問的。暴露監控端點為Web端點的配置是: ```properties 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的安裝和配置 [Prometheus](https://prometheus.io)目前的最新版本是2.5,鑑於筆者當前沒深入玩過`Docker`,這裡還是直接下載它的壓縮包解壓安裝。 ```bash 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`節點的屬性: ```yaml 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`應用: ```bash # 可選引數 --storage.tsdb.path=儲存資料的路徑,預設路徑為./data ./prometheus --config.file=prometheus.yml ``` `Prometheus`引用的預設啟動埠是9090,啟動成功後,日誌如下: ![jvm-m-2.png](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202007/jvm-m-2.png) 此時,訪問`http://${虛擬機器host}:9090/targets`就能看到當前`Prometheus`中執行的`Job`: ![jvm-m-3.png](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202007/jvm-m-3.png) 訪問`http://${虛擬機器host}:9090/graph`可以查詢到我們定義的度量`Meter`和`spring-boot-starter-actuator`中已經定義好的一些關於JVM或者`Tomcat`的度量`Meter`。我們先對應用的`/order`介面進行呼叫,然後檢視一下監控前面在應用中定義的`order_count_total`和`method_cost_time_seconds_sum`: ![jvm-m-4.png](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202007/jvm-m-4.png) ![jvm-m-5.png](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202007/jvm-m-5.png) 可以看到,`Meter`的資訊已經被收集和展示,但是顯然不夠詳細和炫酷,這個時候就需要使用Grafana的UI做一下點綴。 ## Grafana的安裝和使用 `Grafana`的安裝過程如下: ```bash 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`服務端從而可以拉取它裡面的度量資料。資料來源新增面板如下: ![jvm-m-6.png](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202007/jvm-m-6.png) 其實就是指向Prometheus服務端的埠就可以了。接下來可以天馬行空地新增需要的面板,就下單數量統計的指標,可以新增一個`Graph`的面板: ![jvm-m-7.png](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202007/jvm-m-7.png) 配置面板的時候,需要在基礎(General)中指定Title: ![jvm-m-9.png](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202007/jvm-m-9.png) 接著比較重要的是Metrics的配置,需要指定資料來源和Prometheus的查詢語句: ![jvm-m-8.png](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202007/jvm-m-8.png) 最好參考一下`Prometheus`的官方文件,稍微學習一下它的查詢語言`PromQL`的使用方式,一個面板可以支援多個`PromQL`查詢。前面提到的兩項是基本配置,其他配置項一般是圖表展示的輔助或者預警等輔助功能,這裡先不展開,可以去`Grafana`的官網挖掘一下使用方式。然後我們再呼叫一下下單介面,過一段時間,圖表的資料就會自動更新和展示: ![jvm-m-10.png](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202007/jvm-m-10.png) 接著新增一下專案中使用的Timer的Meter,便於監控方法的執行時間,完成之後大致如下: ![jvm-m-11.png](https://throwable-blog-1256189093.cos.ap-guangzhou.myqcloud.com/202007/jvm-m-11.png) 上面的面板雖然設計相當粗糙,但是基本功能已經實現。設計面板並不是一件容易的事,如果有需要可以從`Github`中搜索一下`grafana dashboard`關鍵字找現成的開源配置使用或者二次加工後使用。 ## 小結 常言道:工欲善其事,必先利其器。`Micrometer`是`JVM`應用的一款相當優異的度量框架,它提供基於`Tag`和豐富的度量型別和`API`便於多維度地進行不同角度度量資料的統計,可以方便地接入`Prometheus`進行資料收集,使用`Grafana`的面板進行炫酷的展示,提供了天然的`spring-boot`體系支援。但是,在實際的業務程式碼中,度量型別`Counter`經常被濫用,一旦工具被不加思考地濫用,就反而會成為混亂或者毒瘤。因此,這篇文章就是對`Micrometer`中的各種`Meter`的使用場景基於個人的理解做了調研和分析,後面還會有系列的文章分享一下這套方案在實戰中的經驗和踩坑經歷。 參考資料: - https://micrometer.io/docs - https://grafana.com - https://prometheus.io (本文完 To be continue c-10-d n-e-20181102 最近有點忙,沒辦法經常更新) 技術公眾號《Throwable文摘》(id:throwable-doge),不定期推送筆者原創技術文章(絕不抄襲或者轉載): ![](https://public-1256189093.cos.ap-guangzhou.myqcloud.com/static/wechat-account-l