为什么又搞了一套 Prometheus
我们本来有 SkyWalking 8.6,链路追踪和拓扑都挺好用。但它有两个地方不满足需求:一是数据默认只存 7 天(ES 成本摆在那),二是加自定义业务指标比较麻烦,我更习惯"自己埋点 + 自己配告警"这套。
于是给核心的几个服务加了 Prometheus + Grafana,和 SkyWalking 并存:SkyWalking 看链路和慢调用,Prometheus 看资源水位和趋势告警。
接入:三步
Spring Boot 2.5 接 Micrometer 的 Prometheus 注册表非常简单。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
endpoint:
health:
show-details: always
probes:
enabled: true # K8s 的 liveness/readiness
metrics:
tags:
application: ${spring.application.name} # 全局 tag
distribution:
percentiles-histogram:
http.server.requests: true # 开直方图,才能算 P99
slo:
http.server.requests: 50ms,100ms,200ms,500ms,1s
启动后访问 /actuator/prometheus 就能看到文本格式的指标:
$ curl -s localhost:8080/actuator/prometheus | head -20
# HELP jvm_memory_used_bytes The amount of used memory
# TYPE jvm_memory_used_bytes gauge
jvm_memory_used_bytes{application="order-service",area="heap",id="G1 Old Gen",} 1.2436096E9
jvm_memory_used_bytes{application="order-service",area="heap",id="G1 Survivor Space",} 4.718592E7
jvm_memory_used_bytes{application="order-service",area="nonheap",id="Metaspace",} 1.3824792E8
jvm_threads_live_threads{application="order-service",} 187.0
Prometheus 侧配置抓取(我们是 K8s 环境,用 prometheus.yml 的 kubernetes_sd_configs 也行,这里给静态配置):
scrape_configs:
- job_name: 'spring-boot'
metrics_path: '/actuator/prometheus'
scrape_interval: 15s
scrape_timeout: 10s
static_configs:
- targets:
- '10.0.2.11:8080'
- '10.0.2.12:8080'
- '10.0.2.13:8080'
选了哪些指标
Micrometer 默认会吐出上百个指标,全看一遍不现实。我挑了这些放进看板:
| 指标 | 用途 | 告警阈值(我们的) |
|---|---|---|
jvm_memory_used_bytes{area="heap"} | 堆占用 | 持续 5 分钟 > 85% 最大值 |
jvm_gc_pause_seconds_max | 单次 GC 最大暂停 | 1 分钟 > 1 秒 |
rate(jvm_gc_pause_seconds_sum[5m]) | GC 总耗时占比 | > 0.1(即 10% 时间在 GC) |
jvm_threads_live_threads | 线程数 | > 800 |
process_cpu_usage | 进程 CPU | 持续 5 分钟 > 0.8 |
http_server_requests_seconds | 接口耗时/量 | P99 > 1s 持续 3 分钟 |
hikaricp_connections_pending | 等连接的线程数 | > 0 持续 1 分钟 |
tomcat_threads_busy | 繁忙线程 | 占比 > 90% |
logback_events_total{level="error"} | 错误日志速率 | rate 5m > 10 |
Grafana 上直接导入现成的看板更快,我用的是 JVM (Micrometer) - dashboard ID 4701,改了几个面板适配我们的 tag。另外 Spring Boot 官方的 12900 也不错,偏 HTTP 维度。
P99 怎么算
这是新手最容易搞错的地方。Micrometer 默认只暴露 _count 和 _sum,没有分位数,所以直接查 histogram_quantile 是查不出来的——这就是上面配置里 percentiles-histogram: true 的作用,它让 Micrometer 生成 http_server_requests_seconds_bucket 这一组直方图桶。
有了桶之后:
histogram_quantile(0.99,
sum by (le, uri) (
rate(http_server_requests_seconds_bucket{application="order-service"}[5m])
)
)
如果用 slo: 50ms,100ms,200ms,500ms,1s 指定自定义桶,桶数量会少很多(Prometheus 侧存储压力小),精度也够日常用。两种方案二选一,不要同时开,否则桶会翻倍。
自定义埋点
默认的 JVM / HTTP 指标只能反映"机器健不健康",业务上想知道的东西得自己埋。Micrometer 四个基本类型:
@Service
public class OrderMetrics {
private final Counter createCounter;
private final Timer payTimer;
private final DistributionSummary amountSummary;
private final AtomicInteger pendingQueue = new AtomicInteger();
public OrderMetrics(MeterRegistry registry) {
this.createCounter = Counter.builder("order.create.total")
.description("下单总数")
.tag("type", "create")
.register(registry);
this.payTimer = Timer.builder("order.pay.duration")
.description("支付耗时")
.publishPercentileHistogram()
.register(registry);
this.amountSummary = DistributionSummary.builder("order.amount")
.description("订单金额分布")
.baseUnit("yuan")
.register(registry);
// Gauge 是"测一次返回一个值",注册时给个 Supplier
Gauge.builder("order.pending.size", pendingQueue, AtomicInteger::get)
.description("待处理订单数")
.register(registry);
}
public void onCreate(String channel) {
createCounter.increment();
}
public void recordPay(long millis) {
payTimer.record(millis, TimeUnit.MILLISECONDS);
}
// 或者用注解,更省事
@Timed(value = "order.query.duration", percentiles = {0.5, 0.95, 0.99})
public OrderVO queryOrder(Long id) { ... }
}
几个注意点:
- Gauge 持有一个对象的弱引用,注册之后如果
pendingQueue被 GC 掉,这个指标就变成 NaN。所以要保证被观测的对象是长生命周期的(通常是static或单例 Bean 的字段)。 - tag 的取值必须有限。我第一次埋点用了
.tag("userId", userId.toString()),上线 4 小时 Prometheus 内存从 2 GB 涨到 14 GB,series 数从 12 万暴涨到 340 万,直接把 Prometheus 压垮了。这是指标基数(cardinality)爆炸,血泪教训。 - 同理,HTTP 指标的
uri标签一定要用路径模板(/order/{id}),Spring Boot 2.x 默认已经处理了,但如果你自己写了拦截器埋点,务必用模板而不是真实 URI。
告警规则
Prometheus 侧的 rules 文件:
groups:
- name: jvm-alerts
rules:
- alert: HeapUsageTooHigh
expr: |
sum by (application, instance) (jvm_memory_used_bytes{area="heap"})
/ sum by (application, instance) (jvm_memory_max_bytes{area="heap"}) > 0.85
for: 5m
labels:
severity: warning
annotations:
summary: "{{ $labels.application }} 堆内存使用率 {{ $value | humanizePercentage }}"
description: "实例 {{ $labels.instance }} 持续 5 分钟超过 85%,可能需要排查内存泄漏"
- alert: GCPauseTooLong
expr: |
rate(jvm_gc_pause_seconds_sum{action="end of major GC"}[5m])
/ rate(jvm_gc_pause_seconds_count{action="end of major GC"}[5m]) > 1
for: 2m
labels:
severity: critical
annotations:
summary: "{{ $labels.application }} Full GC 平均耗时超过 1 秒"
- alert: HttpP99TooHigh
expr: |
histogram_quantile(0.99,
sum by (le, application) (rate(http_server_requests_seconds_bucket[5m]))
) > 1
for: 3m
labels:
severity: warning
annotations:
summary: "{{ $labels.application }} 接口 P99 超过 1 秒"
- alert: InstanceDown
expr: up == 0
for: 1m
labels:
severity: critical
annotations:
summary: "{{ $labels.instance }} 抓取失败,服务可能已下线"
几个经验:
for一定要设。瞬时抖动就告警会让人麻木,我们统一设 2 到 5 分钟。- 告警的
expr里用sum by (...)而不是sum,否则多实例聚合后丢失实例维度,不知道该去哪台机器上看。 - 配
InstanceDown。我们之前漏了这个,有次一个实例 OOM 重启了三次才从别的告警里发现。 - 告警走 Alertmanager 分组发送,按
alertname+application做group_by,避免一次故障刷屏几十条。
三个容易写错的 PromQL
rate 和 increase。counter 类型的指标(GC 次数、请求数、错误数)是单调递增的,直接查原值没有意义,要查变化率:
# 错误:直接查累计值,看不出波动
logback_events_total{level="error"}
# 正确:查每秒速率
rate(logback_events_total{level="error"}[5m])
# 或者查一段时间内的增量
increase(logback_events_total{level="error"}[1h])
方括号里的 [5m] 是时间窗口。窗口不能小于抓取间隔的 2 倍,我们抓取间隔 15 秒,窗口至少给 [1m],一般用 [5m] 平滑一些。
分母要对齐。算 GC 平均耗时这类"总时间 / 总次数"的指标时,sum by 的维度必须包含所有用到的标签,否则会把不同实例的数据混在一起:
# 正确:sum by 里带上 instance
sum by (instance) (rate(jvm_gc_pause_seconds_sum[5m]))
/ sum by (instance) (rate(jvm_gc_pause_seconds_count[5m]))
# 错误:两边维度不一致,Prometheus 会报 "vector cannot contain metrics with the same labelset"
up 指标别忘。up{job="spring-boot"} 是 Prometheus 自己生成的,抓取失败时为 0,这是最基础也最容易被忽略的告警。我们一开始只配了业务指标告警,有个实例连续重启了三次,因为没有配 InstanceDown 所以谁都不知道。
下篇预告
这篇先把《Prometheus + Grafana 搭建 JVM 监控看板》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。