Prometheus 監控實踐:Java 開發者如何通過 PromQL 和 Grafana 優化監控策略
因為近期工作比較忙碌,所以文章的更新相對慢了一些,近期筆者集成了一些比較核心的監控指標交由Prometheus采集,并通過promQL進行查詢分析實現圖表渲染。因為這一套完整的監控流程涉及計量器指標采集再通過Prometheus構建時間序列,再通過grafana結合promQL查詢渲染,所以了解每一個環節的實現和理念,才能準確串聯上述流程。
遺憾的是,就筆者近期了解的情況來看,這方面的資料要么面向全流程搭建的新手教程,要么就是非常突兀的promQL基本說明,并沒有做到筆者所認為的全流程泛化梳理,所以筆者打算綜合這些理念,結合一個比較有代表意義的案例將這些概念串聯,以幫助讀者更好的理念和運用監控。

一、案例項目前置說明
本文通過spring boot web項目作為演示案例,所有的采集指標都會通過Prometheus數據源發布到rgafana上并通過promQL進行增強渲染,所以該項目主要會引入暴露springboot監控指標進行和prometheus套件依賴:
<!--暴露spring監控指標-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<version>2.4.1</version>
</dependency>
<!--用于導出prometheus系統類型的指標數據-->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<version>1.1.4</version>
</dependency>同時筆者也變寫了一個測試的TestController:
- 聲明計時器采集test0的耗時(本質上通過休眠模擬)指標
- 其余兩個接口通過@Timed注解收集接口時間維度的各項指標
@RestController
@Slf4j
public class TestController {
@Autowired
private MeterRegistry registry;
private Timer timer;
@PostConstruct
private void init() {
//名稱設置為http.timer,標簽設置為uri為/hello,選用合適的名稱輔助開發推斷理解
timer = Timer
.builder("http.timer")
.publishPercentiles(0.1, 0.5, 0.95) //發布百分位數區間
.description("接口請求耗時統計") // 指標的描述
.tags("uri", "/hello") // url標簽指明為hello
.register(registry);
}
@GetMapping("/test0")
public String function() {
timer.record(RandomUtil.randomInt(200), TimeUnit.MILLISECONDS);
return "test0";
}
@GetMapping("/test1")
@Timed
public String test1() {
ThreadUtil.sleep(RandomUtil.randomInt(200));
return "test1";
}
@GetMapping("/test2")
@Timed
public String test2() {
ThreadUtil.sleep(RandomUtil.randomInt(200));
return "test2";
}
}因為用到的計時器注解 @Timed,所以我們還需要配置TimedAspect創建注解的代理是使之生效:
@Configuration
public class TimedConfiguration {
@Bean
public TimedAspect timedAspect(MeterRegistry registry) {
return new TimedAspect(registry);
}
}這里筆者也簡單普及一下TimedAspect這個切面的工作原理,在springboot進行自動裝配的時候掃描到TimedAspect,該切面會針對所有所有帶有Timed注解的bean的方法做一個環繞增強,在連接點前后記錄耗時并通過Timer記錄耗時到計時器中:

對應的TimedAspect的切點實現timedMethod如下:
@Around("execution (@io.micrometer.core.annotation.Timed * *.*(..))")
public Object timedMethod(ProceedingJoinPoint pjp) throws Throwable {
Method method = ((MethodSignature) pjp.getSignature()).getMethod();
Timed timed = method.getAnnotation(Timed.class);
//通過注解獲取方法的元信息
if (timed == null) {
method = pjp.getTarget().getClass().getMethod(method.getName(), method.getParameterTypes());
timed = method.getAnnotation(Timed.class);
}
final String metricName = timed.value().isEmpty() ? DEFAULT_METRIC_NAME : timed.value();
//啟動計時器
Timer.Sample sample = Timer.start(registry);
String exceptionClass = "none";
try {
//調用方法
return pjp.proceed();
} catch (Exception ex) {
//......
} finally {
try {
//記錄方法耗時
sample.stop(Timer.builder(metricName)
.description(timed.description().isEmpty() ? null : timed.description())
.tags(timed.extraTags())
.tags(EXCEPTION_TAG, exceptionClass)
.tags(tagsBasedOnJoinPoint.apply(pjp))
.publishPercentileHistogram(timed.histogram())
.publishPercentiles(timed.percentiles().length == 0 ? null : timed.percentiles())
.register(registry));
} catch (Exception e) {
// ignoring on purpose
}
}最后就是指標暴露和端口發布的配置:
server.port=8080
spring.application.name=web-service
# 暴露并開啟所有的端點,Spring Boot Actuator會自動配置一個 URL 為 /actuator/Prometheus 的 HTTP 服務來供 Prometheus 抓取數據
management.endpoints.web.exposure.include=*
# 展示所有的健康信息
management.endpoint.health.show-details=always
# 默認/actuator/Prometheus,添加這個tag方便區分不同的工程
management.metrics.tags.applicatinotallow=${spring.application.name}
# Actuator 監控端點獨立端口設置為 18080(與主應用端口分離)
management.server.port=18080二、詳解各大計量器工作原理
1. counter(計數器)
(1) 應用場景
counter從名字即可了解這個計量器本質上是一個只增不減的計數器,它是有狀態的(即依賴于歷史的值),從使用方法上來看,它是單調遞增的且上界是不可確定的,所以使用counter進行監控的指標一般是需要存在不斷累加且需要針對累加的趨勢進行分析的。
最典型的場景就是接口請求總數,例如我們需要針對上述的test1接口請求進行計數,從而構成時間序列存儲這些數據,同時針對單位時間內這個接口增量趨勢進行分析,主流的做法就是通過counter采集每一次請求,并將該指標通過prometheus交給grafana通過promQL進行即席查詢分析:

(2) 使用示例
針對counter計數器的核心本質,即只要做到針對并發請求進行高效計數即可,其余一些分析維度的工作全部交由prometheus等數據源進行定期的采集分析即可,對應筆者項目中的應用方式就如下這個環繞切面的代碼段:
- 攔截所有帶有http注解的接口
- 拉取該接口方法名并生成標簽
- 針對該url的counter進行累加
@Around("execution(@org.springframework.web.bind.annotation.GetMapping * *(..)) || " +
"execution(@org.springframework.web.bind.annotation.PostMapping * *(..)) || " +
"execution(@org.springframework.web.bind.annotation.RequestMapping * *(..))")
public Object countHttpRequest(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
//拉取接口方法名
String url = extractUrl(method);
//針對url創建切面
Counter counter = Counter.builder("http_requests_total")
.tag("url", url)
.register(meterRegistry);
//針對接口請求數進行累加
counter.increment();
return joinPoint.proceed();
}(3) 核心原理
所以其實現的核心主要還是強調計數的準確性和高效性,考慮到counter在并發場景下更多是針對計數進行累加,且只有在grafana等監控系統查詢時才需要獲取計數值,所以針對這種寫多讀少且需要保證并發安全的場景。
所以counter底層采用了基于數組分散并發累加壓力的計數器DoubleAdder:

對此我們可以查看counter底層的源碼實現即PrometheusCounter的increment印證這一點:
public class PrometheusCounter extends AbstractMeter implements Counter {
private DoubleAdder count = new DoubleAdder();
//......
@Override
public void increment(double amount) {
if (amount > 0)
//通過DoubleAdder完成并發累加
count.add(amount);
}
}2. guage(儀表類型)
(1) 應用場景
guage也是我們常用的儀表盤,和counter有所不同,guage計數器可以增減,它是無狀態的,即此刻的數值與歷史數值并沒有依賴關系,它更常用于觀察帶有上下界的指標,即側重于那些系統狀態的指標:
- cpu利用率
- 內存利用率
- 網絡帶寬
所以針對gauge的使用理念,我們也還是通過gauge采集單位時間下的指標的數值,然后交給grafana讓其通過promQL分析其增減趨勢亦或者上下浮動情況以準確針對系統情況進行深入分析:

(2) 使用示例
類似的我們通過spring boot的registry全局注冊一個guage,通過原子類進行設置值,即直接通過原子類記錄當前cpu使用率,讓prometheus定期采集交給grafana進行即席查詢分析:
AtomicInteger cost = registry.gauge("cpu.usage", Tags.of("core-number", "0"), new AtomicInteger(0));
//隨機數模擬cpu使用率
cost.set(RandomUtil.randomInt(100));(3) 工作原理
我們注冊gauge的時候通過構造函數指明底層采用AtomicInteger進行數值維護,后續我們就可以直接操作這個原子類引用完成數值維護修改:
AtomicInteger cost = registry.gauge("cpu.usage", Tags.of("core-number", "0"), new AtomicInteger(0));為什么可以采用原子類AtomicInteger呢?查看gauge底層實現,從源碼可以看到該參數為泛型T只需繼承Number類,保證獲取數值時可以通過doubleValue方法返回值即可。
而AtomicInteger恰好繼承Number類且保證并發計數安全,所以才適用于作為gauge底層的計數器:
@Nullable
public <T extends Number> T gauge(String name, Iterable<Tag> tags, T number) {
return gauge(name, tags, number, Number::doubleValue);
}結合這個泛型構造,我們也可以直接采用LongAdder作為gauge底層的計量器,由于其底層采用數組三列并發累加壓力,所以更適用于作為監控計數的指標:
LongAdder gauge = registry.gauge("cpu.usage", Tags.of("core-number", "0"), new LongAdder());3. timer
(1) 應用場景
timer計時器主要是跟蹤大量短耗時的事件進行多維度的采集,通過計時器統計某個事件耗時時,其底層會維護針對此事件:
- 事件總數:采用LongAdder維護
- 事件總耗時:同樣采用LongAdder維護
- 事件耗時最大值:通過TimeWindowMax時間窗口進行維護
后續我們就可以通過prometheus構成時間序列將其交給grafana,此時我們就可以根據這些指標計算:
- 當前一段時間請求總數
- 當前一段時間的平均耗時
- 當前一段時間的最大值

(2) 使用示例
對應的用法上文已經介紹過,我們可以自定義注冊一個timer,后續直接用這個timer的record方法記錄耗時:
@Autowired
private MeterRegistry registry;
private Timer timer;
@PostConstruct
private void init() {
//名稱設置為http.timer,標簽設置為uri為/hello,選用合適的名稱輔助開發推斷理解
timer = Timer
.builder("http.timer")
.publishPercentiles(0.5, 0.95) //發布百分位數區間
.description("接口請求耗時統計") // 指標的描述
.tags("url", "/test0") // url標簽指明為hello
.register(registry);
}耗時記錄使用示例如下:
@GetMapping("/test0")
public String function() {
int sleepTime = RandomUtil.randomInt(200);
ThreadUtil.sleep(sleepTime);
timer.record(sleepTime, TimeUnit.MILLISECONDS);
return "test0";
}(3) 工作原理
關于timer針對上述三個度量指標,從上文表述我們就知道大體就是通過:
- count記錄請求總數
- totalTime記錄總耗時
- max窗口工具類維護最大耗時
對應的我們也可以通過timer底層實現PrometheusTimer印證這一點:
public class PrometheusTimer extends AbstractTimer {
//......
//記錄請求總數
private final LongAdder count = new LongAdder();
//記錄總耗時
private final LongAdder totalTime = new LongAdder();
//窗口內記錄最大耗時
private final TimeWindowMax max;
//......
}當我們的通過timer記錄本次接口耗時,record方法本質做的是:
- count原子自增請求總數
- totalTime原子累加記錄總耗時
- max通過一個環形緩沖區維護1min以內請求的最大值
對應第一點和第二點都是簡單的原子累加操作,這里就不多做贅述了,我們著重的說明一下最大耗時這個操作的底層工作原理,這個記錄最大值的工具類TimeWindowMax本質上是用一個喚醒緩沖區實現(本質上就是一個數組),數組3個元素分別代表:
- 當前1min內的最大值
- 當前2min內的最大值
- 當前3min內的最大值:

我們都知道這個max計數器記錄的都是當前1min內耗時最大的值,假設我們當前這分鐘的最大值為200ms,那么ringbuffer[0]記錄的最大值就是200ms。 注意:TimeWindowMax維護最大值是會遍歷數組中每個元素進行比對,然后將最大值寫入:

一旦ringbuffer[0]使用時間超過1min,例如當前時間是3:50距離ringbuffer[0]使用開始時間1:00已經超過170s,TimeWindowMax就會執行如下步驟:
- 計算時間差為170,已經超過ringbuffer[0]的窗口區間(當前1min內的最大值),所以將該原子類重置為0,指針移動到ringbuffer[1]
- ringbuffer[1]代表當前2min內的最大值,170s也大于其窗口時間區間120s,所以這個窗口也過期直接重置為0,指針移動到ringbuffer[1]
- ringbuffer[2]代表當前3min內的數組,對應窗口活躍保質期為180s大于170s,所以沒過期
所以ringbuffer[2]這個窗口后續作為當前1min內的窗口,其他窗口循環重置后循環復用作為當前2min、3min內的窗口,這就是這個算法的巧妙所在:

對應的我們也可以通過源碼印證這一點,可以看到timer底層調用record入口來自AbstractTimer,這個抽象類對外暴露recordNonNegative這個抽象方法,對應也就是我們的工具類PrometheusTimer的recordNonNegative方法:
@Override
public final void record(long amount, TimeUnit unit) {
if (amount >= 0) {
//......
//記錄請求總數、耗時、最大值
recordNonNegative(amount, unit);
//......
}
}查看PrometheusTimer的recordNonNegative可以發現他做了如下三件事:
- counter自增維護請求總數
- totalTime累加計算總耗時
- max.record記錄最大耗時
@Override
protected void recordNonNegative(long amount, TimeUnit unit) {
//累計請求總數
count.increment();
long nanoAmount = TimeUnit.NANOSECONDS.convert(amount, unit);
//累加總耗時
totalTime.add(nanoAmount);
//維護最大值
max.record(nanoAmount, TimeUnit.NANOSECONDS);
//......
}關于請求和耗時累計邏輯比較直觀,筆者就不多做介紹了,步入max的record就可以看到核心所在:
- 調用rotate執行我們上述圖解的窗口滑動算法整理三個窗口
- 基于必要整理重置后的窗口數組和當前耗時進行比對,維護最新的最大值
public void record(double sample, TimeUnit timeUnit) {
//窗口旋轉維護
rotate();
//遍歷各個緩沖區并維護最大值
final long sampleNanos = (long) TimeUtils.convert(sample, timeUnit, TimeUnit.NANOSECONDS);
for (AtomicLong max : ringBuffer) {
updateMax(max, sampleNanos);
}
}查看rotate源碼中可以看到,rotate就是實現窗口旋轉的核心,其內部做了如下幾件事:
- 它會獲取當前時間距離上次窗口旋轉時間,判斷是否超期,若超過60s則說明存在過期窗口需要滑動窗口,進入步驟2
- cas上鎖保證只有一個線程執行此操作
- 遍歷各個元素,通過距離上次旋轉時間timeSinceLastRotateMillis不斷循環減去60s和durationBetweenRotatesMillis比較以做到
1. 第1次循環減去0個60,即查看第一個窗口得到的timeSinceLastRotateMillis是否超過60,若超過則說明過期
2. 第2次循環減去1個60,即查看第2個窗口得到的timeSinceLastRotateMillis-60s是否超過60(即是否超過2min),若超過則說明過期
3. ......完成窗口重置和滑動后,將本次的耗時分別于各個窗口進行比對,如果比窗口值大則直接寫入,這個算法比較巧妙,讀者可以結合筆者的說明自行理解:
private void rotate() {
//計算上次旋轉窗口的時間
long timeSinceLastRotateMillis = clock.wallTime() - lastRotateTimestampMillis;
//如果沒有超過60s則返回
if (timeSinceLastRotateMillis < durationBetweenRotatesMillis) {
// Need to wait more for next rotation.
return;
}
//上個自旋鎖保證并發互斥,進行窗口滑動操作
if (!rotatingUpdater.compareAndSet(this, 0, 1)) {
// Being rotated by other thread already.
return;
}
try {
int iterations = 0;
synchronized (this) {
do {
//重置當前窗口
ringBuffer[currentBucket].set(0);
//移動到下一個窗口,如果超過上界則回到索引0位置
if (++currentBucket >= ringBuffer.length) {
currentBucket = 0;
}
//減去60s查看這個窗口是否超過區間,因為是do while循環,所以多次循環就可以做到查看60s、120s(循環1次減去一個60和60進行比對)、180s(循環2次減去2個60和60進行比對)對應的3個緩沖區是否過期
timeSinceLastRotateMillis -= durationBetweenRotatesMillis;
//上次滑動窗口時間加上60s,即代表這個窗口區間理論上的旋轉窗口時間
lastRotateTimestampMillis += durationBetweenRotatesMillis;
//
} while (timeSinceLastRotateMillis >= durationBetweenRotatesMillis && ++iterations < ringBuffer.length);
}
} finally {
rotating = 0;
}
}三、詳解promQL
1. promQL 指標的基本構成說明
在正式介紹promQL表達式之前,我們需要先針對Prometheus風格的指標構成進行一下必要的對齊:
- #號部分為必要的描述和注釋說明,如下注釋分別對應我們自定義的指標描述和Prometheus的計量器說明(本例則是guage)
- jvm_threads_states_threads為指標名稱
- 后續{}部分則是針對jvm_threads_states_threads各個不同維度區分的標簽
# HELP jvm_threads_states_threads The current number of threads having NEW state
# TYPE jvm_threads_states_threads gauge
jvm_threads_states_threads{applicatinotallow="web-service",state="blocked",} 0.0
jvm_threads_states_threads{applicatinotallow="web-service",state="waiting",} 23.0
jvm_threads_states_threads{applicatinotallow="web-service",state="runnable",} 11.0
jvm_threads_states_threads{applicatinotallow="web-service",state="timed-waiting",} 4.0
jvm_threads_states_threads{applicatinotallow="web-service",state="new",} 0.0
jvm_threads_states_threads{applicatinotallow="web-service",state="terminated",} 0.0我們以jvm_threads_states_threads{applicatinotallow="web-service",state="blocked",} 0.0為例說明一下,這是一個典型的Prometheus風格的指標值,通過標簽名限定當前指標的語義,例如jvm_threads_states_threads就代表不同狀態的線程數,同時通過標簽聲明指標的維度,以該指標為例,則是通過應用名稱application和狀態state區分單指標下不同維度的數值,最后就是指標的數值:

2. promQL常見表達式
(1) promQL核心概念
瞬時向量(Instant vector):一組時間序列上,每個時間上只有一個樣本,他們共享相同的時間戳,即表達式的返回值只會包含該時間中的最新的樣本值:

區間向量(Range vector):即一個時間范圍內的每個時間序列包含一段時間范圍內的樣本數據:

時間向量:以時間為橫坐標,序列作為縱坐標構成一組反應狀態變化的向量圖,該向量圖通過定時周期性采集,隨著時間的流逝生成一個離散的樣本數據序列。 通過指標名稱結合標簽生成多條趨勢線條,也就是多條時間序列,而序列也就是我們常說的vector:

(2) 匹配表達式
有了上述對于計量器的基本介紹,我們在針對promQL中幾個比較常見的表達式和函數展開介紹,promQL中也存在著邏輯表達式,這其中涉及匹配表達式和邏輯表達式。
我們先來說說匹配表達式:
- 完全匹配:與字符串完全匹配即=
- 不匹配:與字符串不匹配即!=
- 正則匹配:與字符串正則匹配=~
- 正則反向過濾:與字符串正則不匹配!~
它可以針對多維度的指標進行篩選和檢索,例如我們從spring actuator上看到jvm線程各個狀態的指標及其對應的線程數:
# HELP jvm_threads_states_threads The current number of threads having NEW state
# TYPE jvm_threads_states_threads gauge
jvm_threads_states_threads{applicatinotallow="web-service",state="blocked",} 0.0
jvm_threads_states_threads{applicatinotallow="web-service",state="waiting",} 23.0
jvm_threads_states_threads{applicatinotallow="web-service",state="runnable",} 11.0
jvm_threads_states_threads{applicatinotallow="web-service",state="timed-waiting",} 4.0
jvm_threads_states_threads{applicatinotallow="web-service",state="new",} 0.0
jvm_threads_states_threads{applicatinotallow="web-service",state="terminated",} 0.0默認情況下,它在Prometheus的console渲染顯示如下:

如果我們希望只希望查看timed-waiting的線程數,此時我們就可以通過標簽結合相等匹配器實現,對應的表達式為:
jvm_threads_states_threads{state="timed-waiting"}此時視圖就會準確過濾篩選出狀態為timed-waiting的線程數:

同理過濾出狀態非timed-waiting的表達式為:
jvm_threads_states_threads{state!="timed-waiting"}同理,如果我們希望匹配r開頭的表達式則是:
jvm_threads_states_threads{state=~"r.*"}(3) 邏輯表達式
promQL也存在和各種邏輯運算的表達式匹配:
- and:即兩個序列即上述的vector進行與運算產生新的集合,只有兩個即可都存在的元素才會顯示
- or:只要左右任何一邊的vector表達式計算為真,就顯示左右vector的所有元素
- unless:即左右兩邊的vector進行或運算構成新的并集,然后通過unless右邊的vector進行過濾,將右邊vector存在的元素移除
對此我們不妨距離說明,關于邏輯表達式我們以一個針對http請求數計算的指標http_requests_total為例進行演示,對應指標如下:
# HELP http_requests_total
# TYPE http_requests_total counter
http_requests_total{applicatinotallow="web-service",url="/test2",} 1.0
http_requests_total{applicatinotallow="web-service",url="/test0",} 2.0
http_requests_total{applicatinotallow="web-service",url="/test1",} 340.0如果我們希望查詢請求數大于300且映射以test開頭,在promQL表達式則是采用and,對應的表達式如下,最終的輸出結果也是:
http_requests_total > 300 and http_requests_total{url=~"/test.*"}最終輸出的也是test1:

同理如果希望查詢請求數大于300或者映射為test0,則表達式如下:
http_requests_total > 300 or http_requests_total{url="/test0"}需要注意的是promQL表達式中的or并非短路運算,即表達式為真的情況下,左右vector都會輸出,也就是大于請求數大于300和test0映射都會輸出:

最后則是unless,相較于常規的邏輯表達式,該邏輯表達式的執行邏輯為將左右或運算得到交集后,結果交由右邊過濾得出目標標簽數據,例如我們需要查詢出請求總數大于0但要排除test0,對應的表達式就如下所示:
http_requests_total > 0 unless http_requests_total{url="/test0"}對應的推算過程為:
- 將請求數大于0和/test0的指標通過或運算構成新集合即/test0、/test1、/test2
- 基于右邊vector將非test0的元素過濾,最終得到/test1和/test2:

3. 常見函數
(1) 聚合函數
接下來就是介紹一些比較常見的函數,和常見的sql語句一樣,promQL也有如下常見內置函數:
- sum:指標求和
- avg:指標平均數
- max:指標最大值
- min:指標最小值
我們還是以http_requests_total為例,對應不同接口的請求總數如下:
# HELP http_requests_total
# TYPE http_requests_total counter
http_requests_total{applicatinotallow="web-service",url="/test2",} 1.0
http_requests_total{applicatinotallow="web-service",url="/test0",} 2.0
http_requests_total{applicatinotallow="web-service",url="/test1",} 340.0假設我們希望定位出http_requests_total的最大值,對應的就可以使用max(http_requests_total),其余函數同理,這些函數本質上就是基于當前指標通過函數聚合計算,比較簡單筆者就不多做演示了。
(2) 時間樣本分析常用函數
對于監控來說,我們更希望看到監控指標的整體趨勢,觀察系統的動態以便進行針對性的調優,這其中常見的函數有:
- max_over_time:指定一段時間的最大值
- avg_over_time:指定一段時間的平均值
- min_over_time:指定一段時間的最小值
- rate:計算指定時間范圍內平均每秒增長率
- delta:觀察系統一段時間指標上下浮動差
假設我們通過timer維護一份基于時間維度的各個接口耗時、請求總數、最大值等信息:
# HELP method_timed_seconds
# TYPE method_timed_seconds summary
method_timed_seconds_count{applicatinotallow="web-service",class="com.sharkchili.controller.TestController",exceptinotallow="none",method="test2",} 1.0
method_timed_seconds_sum{applicatinotallow="web-service",class="com.sharkchili.controller.TestController",exceptinotallow="none",method="test2",} 0.032457149
method_timed_seconds_count{applicatinotallow="web-service",class="com.sharkchili.controller.TestController",exceptinotallow="none",method="test1",} 840.0
method_timed_seconds_sum{applicatinotallow="web-service",class="com.sharkchili.controller.TestController",exceptinotallow="none",method="test1",} 84.248322878
# HELP method_timed_seconds_max
# TYPE method_timed_seconds_max gauge
method_timed_seconds_max{applicatinotallow="web-service",class="com.sharkchili.controller.TestController",exceptinotallow="none",method="test2",} 0.0
method_timed_seconds_max{applicatinotallow="web-service",class="com.sharkchili.controller.TestController",exceptinotallow="none",method="test1",} 0.198215155若我們希望查看過去1h耗時的最大值分布,對應表達式為 max_over_time(method_timed_seconds_max[1h]),對應輸出結果如下,其余平均值、最小值也是同理。

這其中還有用一個針對指標整體浮動變化的函數delta,例如我們有一個cpu的guage指標:
# HELP system_cpu_usage The "recent cpu usage" for the whole system
# TYPE system_cpu_usage gauge
system_cpu_usage{applicatinotallow="web-service",} 0.005636978579481398如果我們希望通過cpu浮動情況判斷程序資源消耗穩定性就可以通過delta即delta(system_cpu_usage[2h])檢測過去2h的cpu浮動變化:

我們在介紹一下比較實用的函數,針對請求接口總數這種單向攀升的指標,我們也會關注它的增長趨勢已判斷服務器整體資源是否符合未來增長趨勢,我們就可以通過rate函數來分析如下接口請求總數指標:
# HELP http_requests_total
# TYPE http_requests_total counter
http_requests_total{applicatinotallow="web-service",url="/test2",} 1.0
http_requests_total{applicatinotallow="web-service",url="/test0",} 2.0
http_requests_total{applicatinotallow="web-service",url="/test1",} 840.0對應表達式為rate(http_requests_total[1h]),對應輸出發布圖如下,我們可以非常直觀的看到test1接口在單位時間內瘋狂的攀升:

四、實踐——promQL與grafana的串聯
1. 長尾問題說明
監控的目的本質上是針對指標的趨勢分析確保能夠對系統有一個準確的決策優化思路,這其中就有一個比較經典的長尾問題,以我們監控接口耗時為例,1min內平均耗時為200ms,但是偶發出現5s,這種偶發波動對于rate等函數進行平均化之后就會被削平,從而無法及時的發現偶發飆升的數值進而無法及時發現問題,這種情況也就是長尾問題。
對于此類問題,我們就需要綜合指標多維度針對指標進行圖表分析,從而進行準確的進一步決策。我們還是以接口請求總數的指標為例,假設此時我們收到接口的請求總數counter情況如下,可以看到有大量請求打到test1上,所以test1的請求總數為910:
# HELP http_requests_total
# TYPE http_requests_total counter
http_requests_total{applicatinotallow="web-service",url="/test2",} 1.0
http_requests_total{applicatinotallow="web-service",url="/test0",} 2.0
http_requests_total{applicatinotallow="web-service",url="/test1",} 910.0針對這些接口,筆者也通過timer計時器針對性的進行指標采集,還是以test1說明:
- 請求總數為909(采集時間和上述有些誤差)
- 請求總耗時為91s
- 最大耗時為199ms
# HELP method_timed_seconds
# TYPE method_timed_seconds summary
# ......
method_timed_seconds_count{applicatinotallow="web-service",class="com.sharkchili.controller.TestController",exceptinotallow="none",method="test1",} 909.0
method_timed_seconds_sum{applicatinotallow="web-service",class="com.sharkchili.controller.TestController",exceptinotallow="none",method="test1",} 91.202510617
# HELP method_timed_seconds_max
# TYPE method_timed_seconds_max gauge
# ......
method_timed_seconds_max{applicatinotallow="web-service",class="com.sharkchili.controller.TestController",exceptinotallow="none",method="test1",} 0.199230275對應的我們將http_requests_total寫入粘貼到grafana上渲染后如下圖所示:

2. 基于計數器分析http請求增量情況
我們先通過http_requests_total對接口請求情況進行分析,從整體情況來看test1請求在不斷的飆升,所以我們希望針對該接口增量趨勢進行分析,于是鍵入rate(http_requests_total{url="/test1"}[1m])分析了test1接口的增長情況。 可以看到整體是一段時間一段時間的波動,按照實際業務場景可以是服務定時任務在單位時間內的feign請求:

3. 基于timer計時器分析接口耗時
看到此波動,就需要關心這個接口的耗時情況,通過timer計時器的指標(method_timed_seconds_)篩選業務峰值的時間區間真是這種飆升的量級請求的各維度耗時進行匯總分析,以確定的接口飆升是否存在瓶頸。
首先我們需要查詢接口最大耗時max_over_time(method_timed_seconds_sum[1h])查看過去1h的最大耗時,整體來看基本穩定在200ms以內,符合團隊指定的標準:

明確沒有存在瓶頸的情況下,我們也需要判斷接口單位時間內的平均耗時已確定系統過去一段時間是否穩定運行,已確定程序或者系統是否存在波動,已明確是否有隱患,表達式為method_timed_seconds_sum{method="test1"}/method_timed_seconds_count{method="test1"},結合上述指標來看在00:10那一刻請求數飆升所以那段時間平均耗時增加,請求降下來后耗時也將下來了:

4. 基于gauge分析CPU負載情況
最后我們還是需要通過分析一下cpu和內存使用情況已確定這種飆升對于系統的壓力如何,我們直接通過max_over_time(system_cpu_usage[1h])查看最大開始也就是3%并沒有超過業界認定的瓶頸70%,基本確定沒有問題:

關于內存筆者這里也直接關聯到jvm_memory_used_bytes這個guage,通過avg_over_time(jvm_memory_used_bytes[1h])查看過去1h的使用情況,可以看到在00:10新生代飆升到500m左右完成后直接壓降:

老年帶控制在30m以內穩定攀升,還未到達gc臨界點,整體來看飆升的接口會很快被gc,所以系統整體情況良好:

為方便筆者直接通過jmap查看當前java進程情況,可以看到堆內存分配的2g左右的堆內存,此時的老年代也沒用到最大值僅僅動態擴容到83MB,整體內存使用情況良好:
Heap Configuration:
MinHeapFreeRatio = 0
MaxHeapFreeRatio = 100
MaxHeapSize = 2065694720 (1970.0MB)
NewSize = 42991616 (41.0MB)
MaxNewSize = 688390144 (656.5MB)
OldSize = 87031808 (83.0MB)
NewRatio = 2
SurvivorRatio = 8
MetaspaceSize = 21807104 (20.796875MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 17592186044415 MB
G1HeapRegionSize = 0 (0.0MB)五、小結
本文深入分析了java常用計量儀micrometer中:
- 有狀態累加計量器counter
- 無狀態儀表盤gauge
- 大量短耗時時間指標采集工具timer
基于這些指標我們結合通過promQL函數進行多維度的演示并給出了日常生產故障分析和排查步驟,希望對你有幫助。





























