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

前提

最近線上的項目使用了spring-actuator做度量統計收集,使用Prometheus進行數據收集,Grafana進行數據展示,用於監控生成環境機器的性能指標和業務數據指標。一般,我們叫這樣的操作為”埋點”。SpringBoot中的依賴spring-actuator中集成的度量統計API使用的框架是Micrometer,官網是micrometer.io。在實踐中發現了業務開發者濫用了Micrometer的度量類型Counter,導致無論什麼情況下都只使用計數統計的功能。這篇文章就是基於Micrometer分析其他的度量類型API的作用和適用場景。全文接近3W字,內容比較干,希望能夠耐心閱讀,有所收穫。

Micrometer提供的度量類庫

Meter是指一組用於收集應用中的度量數據的接口,Meter單詞可以翻譯為”米”或者”千分尺”,但是顯然聽起來都不是很合理,因此下文直接叫Meter,直接當成一個專有名詞,理解它為度量接口即可。Meter是由MeterRegistry創建和保存的,可以理解MeterRegistryMeter的工廠和緩存中心,一般而言每個JVM應用在使用Micrometer的時候必須創建一個MeterRegistry的具體實現。Micrometer中,Meter的具體類型包括:TimerCounterGaugeDistributionSummaryLongTaskTimerFunctionCounterFunctionTimerTimeGauge。下面分節詳細介紹這些類型的使用方法和實戰使用場景。而一個Meter具體類型需要通過名字和Tag(這裡指的是Micrometer提供的Tag接口)作為它的唯一標識,這樣做的好處是可以使用名字進行標記,通過不同的Tag去區分多種維度進行數據統計。

MeterRegistry

MeterRegistryMicrometer是一個抽象類,主要實現包括:

  • 1、SimpleMeterRegistry:每個Meter的最新數據可以收集到SimpleMeterRegistry實例中,但是這些數據不會發佈到其他系統,也就是數據是位於應用的內存中的。
  • 2、CompositeMeterRegistry:多個MeterRegistry聚合,內部維護了一個MeterRegistry的列表。
  • 3、全局的MeterRegistry:工廠類io.micrometer.core.instrument.Metrics中持有一個靜態finalCompositeMeterRegistry實例globalRegistry

當然,使用者也可以自行繼承MeterRegistry去實現自定義的MeterRegistrySimpleMeterRegistry適合做調試的時候使用,它的簡單使用方式如下:

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");

對於不同的監控系統或者存儲系統,命名會自動轉換如下:

  • 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請求調用統計,一般推薦的做法是:

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"))); 

像上面這樣子使用,就能通過主機,實例,區域,堆棧等操作環境進行多維度深入分析。

還有兩點點需要注意:

  • 1、Tag的值必須不為NULL
  • 2、Micrometer中,Tag必須成對出現,也就是Tag必須設置為偶數個,實際上它們以Key=Value的形式存在,具體可以看io.micrometer.core.instrument.Tag接口:
public interface Tag extends Comparable<Tag> {
    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的名稱添加白名單,這個時候可以使用MeterFilterMeterFilter本身提供一些列的靜態方法,多個MeterFilter可以疊加或者組成鏈實現用戶最終的過濾策略。例如:

MeterRegistry registry = ...
registry.config()
    .meterFilter(MeterFilter.ignoreTags("http"))
    .meterFilter(MeterFilter.denyNameStartsWith("jvm"));

表示忽略”http”標籤,拒絕名稱以”jvm”字符串開頭的Meter。更多用法可以參詳一下MeterFilter這個類。

Meter的命名和MeterTag相互結合,以命名為軸心,以Tag為多維度要素,可以使度量數據的維度更加豐富,便於統計和分析。

Meters

前面提到Meter主要包括:TimerCounterGaugeDistributionSummaryLongTaskTimerFunctionCounterFunctionTimerTimeGauge。下面逐一分析它們的作用和個人理解的實際使用場景(應該說是生產環境)。

Counter

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去實例化CounterCounter.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

FunctionCounterCounter的特化類型,它把計數器數值增加的動作抽象成接口類型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<AtomicInteger>() {
			@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接口提供的常用方法如下:

public interface Timer extends Meter {
    ...
    void record(long var1, TimeUnit var3);

    default void record(Duration duration) {
        this.record(duration.toNanos(), TimeUnit.NANOSECONDS);
    }

    <T> T record(Supplier<T> var1);

    <T> T recordCallable(Callable<T> var1) throws Exception;

    void record(Runnable var1);

    default Runnable wrap(Runnable f) {
        return () -> {
            this.record(f);
        };
    }

    default <T> Callable<T> wrap(Callable<T> 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());

使用場景:

根據個人經驗和實踐,總結如下:

  • 1、記錄指定方法的執行時間用於展示。
  • 2、記錄一些任務的執行時間,從而確定某些數據來源的速率,例如消息隊列消息的消費速率等。

這裡舉個實際的例子,要對系統做一個功能,記錄指定方法的執行時間,還是用下單方法做例子:

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

FunctionTimerTimer的特化類型,它主要提供兩個單調遞增的函數(其實並不是單調遞增,只是在使用中一般需要隨着時間最少保持不變或者說不減少):一個用於計數的函數和一個用於記錄總調用耗時的函數,它的建造器的入參如下:

public interface FunctionTimer extends Meter {
    static <T> Builder<T> builder(String name, T obj, ToLongFunction<T> countFunction,
                                  ToDoubleFunction<T> 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

LongTaskTimerTimer的特化類型,主要用於記錄長時間執行的任務的持續時間,在任務完成之前,被監測的事件或者任務仍然處於運行狀態,任務完成的時候,任務執行的總耗時才會被記錄下來。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

Gauge(儀錶)是獲取當前度量記錄值的句柄,也就是它表示一個可以任意上下浮動的單數值度量MeterGauge通常用於變動的測量值,測量值用ToDoubleFunction參數的返回值設置,如當前的內存使用情況,同時也可以測量上下移動的”計數”,比如隊列中的消息數量。官網文檔中提到Gauge的典型使用場景是用於測量集合或映射的大小或運行狀態中的線程數。一般情況下,Gauge適合用於監測有自然上界的事件或者任務,而Counter一般使用於無自然上界的事件或者任務的監測,所以像HTTP請求總量計數應該使用Counter而非GaugeMeterRegistry中提供了一些便於構建用於觀察數值、函數、集合和映射的Gauge相關的方法:

List<String> list = registry.gauge("listGauge", Collections.emptyList(), new ArrayList<>(), List::size); 
List<String> list2 = registry.gaugeCollectionSize("listSize2", Tags.empty(), new ArrayList<>()); 
Map<String, Integer> map = registry.gaugeMapSize("mapGauge", Tags.empty(), new HashMap<>());

上面的三個方法通過MeterRegistry構建Gauge並且返回了集合或者映射實例,使用這些集合或者映射實例就能在其size變化過程中記錄這個變更值。更重要的優點是,我們不需要感知Gauge接口的存在,只需要像平時一樣使用集合或者映射實例就可以了。此外,Gauge還支持java.lang.Number的子類,java.util.concurrent.atomic包中的AtomicIntegerAtomicLong,還有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);

使用場景:

根據個人經驗和實踐,總結如下:

  • 1、有自然(物理)上界的浮動值的監測,例如物理內存、集合、映射、數值等。
  • 2、有邏輯上界的浮動值的監測,例如積壓的消息、(線程池中)積壓的任務等,其實本質也是集合或者映射的監測。

舉個相對實際的例子,假設我們需要對登錄後的用戶發送一條短訊或者推送,做法是消息先投放到一個阻塞隊列,再由一個線程消費消息進行其他操作:

public class GaugeMain {

    private static final MeterRegistry MR = new SimpleMeterRegistry();
    private static final BlockingQueue<Message> QUEUE = new ArrayBlockingQueue<>(500);
    private static BlockingQueue<Message> 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

TimeGaugeGauge的特化類型,相比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.Builder<AtomicInteger> 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實例如下:

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);

使用場景:

根據個人經驗和實踐,總結如下:

  • 1、不依賴於時間單位的記錄值的測量,例如服務器有效負載值,緩存的命中率等。

舉個相對具體的例子:

public class DistributionSummaryMain {

    private static final DistributionSummary DS = DistributionSummary.builder("cacheHitPercent")
            .register(new SimpleMeterRegistry());

    private static final LoadingCache<String, String> CACHE = CacheBuilder.newBuilder()
            .maximumSize(1000)
            .recordStats()
            .expireAfterWrite(60, TimeUnit.SECONDS)
            .build(new CacheLoader<String, String>() {
                @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應用使用到MicrometerAPI收集的度量數據位於內存之中,因此,需要額外的存儲系統去存儲這些度量數據,需要有監控系統負責統一收集和處理這些數據,還需要有一些UI工具去展示數據,一般情況下大佬或者老闆只喜歡看炫酷的儀錶盤或者動畫。常見的存儲系統就是時序數據庫,主流的有InfluxDatadog等。比較主流的監控系統(主要是用於數據收集和處理)就是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

我們先引入spring-boot-starter-actuatorspring-boot-starter-web,實現一個CounterTimer作為示例。依賴:

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>2.1.0.RELEASE</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.16.22</version>
        </dependency>
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
            <version>1.1.0</version>
        </dependency>
    </dependencies>

接着編寫一個下單接口和一個消息發送模塊,模擬用戶下單之後向用戶發送消息:

//實體
@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<Boolean> 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<Message> QUEUE = new ArrayBlockingQueue<>(500);
    private static BlockingQueue<Message> 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端點,是無法通過//{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默認指定的只有infohealth兩個端點,我們可以直接指定暴露所有的端點:management.endpoints.web.exposure.include=*,如果採用YAML配置,記得要在星號兩邊加上英文單引號。暴露所有Web監控端點是一件比較危險的事情,如果需要在生產環境這樣做,請務必先確認//{host}:{management.port}不能通過公網訪問(也就是監控端點訪問的端口只能通過內網訪問,這樣可以方便後面說到的Prometheus服務端通過此端口收集數據)。

Prometheus的安裝和配置

Prometheus目前的最新版本是2.5,鑒於筆者當前沒深入玩過Docker,這裡還是直接下載它的壓縮包解壓安裝。

wget //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=<job_name>` 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,啟動成功後,日誌如下:

jvm-m-2.png

此時,訪問//${虛擬機host}:9090/targets就能看到當前Prometheus中執行的Job

jvm-m-3.png

訪問//${虛擬機host}:9090/graph可以查找到我們定義的度量Meterspring-boot-starter-actuator中已經定義好的一些關於JVM或者Tomcat的度量Meter。我們先對應用的/order接口進行調用,然後查看一下監控前面在應用中定義的order_count_totalmethod_cost_time_seconds_sum

jvm-m-4.png

jvm-m-5.png

可以看到,Meter的信息已經被收集和展示,但是顯然不夠詳細和炫酷,這個時候就需要使用Grafana的UI做一下點綴。

Grafana的安裝和使用

Grafana的安裝過程如下:

wget //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,通過//${host}:3000訪問即可。初始的賬號密碼都為admin,權限是管理員權限。接着需要在Home面板添加一個數據源,目的是對接Prometheus服務端從而可以拉取它裏面的度量數據。數據源添加面板如下:

jvm-m-6.png

其實就是指向Prometheus服務端的端口就可以了。接下來可以天馬行空地添加需要的面板,就下單數量統計的指標,可以添加一個Graph的面板:

jvm-m-7.png

配置面板的時候,需要在基礎(General)中指定Title:

jvm-m-9.png

接着比較重要的是Metrics的配置,需要指定數據源和Prometheus的查詢語句:

jvm-m-8.png

最好參考一下Prometheus的官方文檔,稍微學習一下它的查詢語言PromQL的使用方式,一個面板可以支持多個PromQL查詢。前面提到的兩項是基本配置,其他配置項一般是圖表展示的輔助或者預警等輔助功能,這裡先不展開,可以去Grafana的官網挖掘一下使用方式。然後我們再調用一下下單接口,過一段時間,圖表的數據就會自動更新和展示:

jvm-m-10.png

接着添加一下項目中使用的Timer的Meter,便於監控方法的執行時間,完成之後大致如下:

jvm-m-11.png

上面的面板雖然設計相當粗糙,但是基本功能已經實現。設計面板並不是一件容易的事,如果有需要可以從Github中搜索一下grafana dashboard關鍵字找現成的開源配置使用或者二次加工後使用。

小結

常言道:工欲善其事,必先利其器。MicrometerJVM應用的一款相當優異的度量框架,它提供基於Tag和豐富的度量類型和API便於多維度地進行不同角度度量數據的統計,可以方便地接入Prometheus進行數據收集,使用Grafana的面板進行炫酷的展示,提供了天然的spring-boot體系支持。但是,在實際的業務代碼中,度量類型Counter經常被濫用,一旦工具被不加思考地濫用,就反而會成為混亂或者毒瘤。因此,這篇文章就是對Micrometer中的各種Meter的使用場景基於個人的理解做了調研和分析,後面還會有系列的文章分享一下這套方案在實戰中的經驗和踩坑經歷。

參考資料:

(本文完 To be continue c-10-d n-e-20181102 最近有點忙,沒辦法經常更新)

技術公眾號《Throwable文摘》(id:throwable-doge),不定期推送筆者原創技術文章(絕不抄襲或者轉載):