做后端这几年,有个感受特别深:微服务拆得越细,排查问题的难度就越大。以前单体应用报错,翻日志定位一下午基本能搞定;现在的服务一拆分,一个请求跨五六个节点,日志散落在不同机器上,数据库慢查询、Redis超时、消息积压各种因素纠缠在一起,故障现场跟拆盲盒一样。我接手过一个订单系统,高峰期用户反馈下单卡顿,我们查了两天——看CPU、看内存、看日志、看数据库连接数,全是正常范围,最后发现是某个服务的线程池被打满,请求在队列里排队等了几秒。如果当时有链路追踪和合理的指标监控,这个问题十分钟就能定位。那次之后我彻底想明白了一件事:微服务架构的复杂度,必须靠一套完整的可观测体系来对冲。这篇就讲讲我搭建Java微服务监控运维体系时的思路、选型和实操经验。
1. 整体设计思路:可观测性到底在观测什么
1.1 从故障场景倒推监控需求
我见过不少团队把监控做成了数据收集器——Prometheus起来了,Grafana面板也配了,各种图表花花绿绿,但出了事故照样抓瞎。原因很简单:监控体系的建设不是从工具出发,而是从故障场景倒推的。
先梳理一下微服务运维中最常见的几类问题。第一类是资源型故障,比如CPU飙高、内存溢出、磁盘写满,这类问题靠宿主机和JVM层面的指标基本能覆盖。第二类是依赖型故障,比如数据库连接池耗尽、下游服务超时、消息队列积压,这类问题需要服务间的调用关系和延迟数据才能说清楚。第三类是业务逻辑型故障,比如某个接口突然返回异常、某个订单状态流转不对,这类问题必须靠日志和调用链路联合才能还原现场。
三类问题对应三类数据:指标(Metrics)、日志(Logs)和链路(Traces),也就是业内常说的可观测性三大支柱。指标告诉你系统哪里不对,日志告诉你具体发生了什么,链路告诉你一个请求完整走过了哪些环节。三者交叉验证,才能真正定位问题,这三类数据支撑的正是完整的可观测体系。
1.2 技术选型:为什么是这套组合
市面上的监控工具一大堆,Zabbix、SkyWalking、Pinpoint、Jaeger,各有各的拥趸。我最终选择的是Prometheus + Grafana + Loki + Tempo这套组合,加上OpenTelemetry做链路数据采集,配合Grafana统一展示。
选择这套组合的最核心原因是数据源和展示层的解耦。Prometheus负责抓取指标数据,Loki负责收集日志,Tempo负责存储链路追踪数据,三个数据源各干各的,最后统一接到Grafana上展示。Tempo的亮点在于它只需要对象存储(比如MinIO或者S3兼容存储),不需要单独维护一套数据库,运维成本低很多。
有人可能会问,SkyWalking不也能做链路追踪吗?确实能,而且做得不错。但我在实际使用中发现,SkyWalking的Agent和OpenTelemetry的Agent在兼容性上存在差异,如果团队内部同时有Java、Go、Python的服务,统一用OpenTelemetry做埋点规范更划算。另一方面,OpenTelemetry是CNCF的项目,社区活跃度和标准化程度更高,后续接入其他语言的服务会轻松很多。
架构上分四层:采集层、存储层、展示层、告警层。采集层包含Micrometer(指标埋点)、OpenTelemetry Agent(链路和日志关联)、Promtail或Alloy(日志采集推送);存储层就是Prometheus、Loki、Tempo三个组件;展示层和告警层统一走Grafana和Alertmanager。这套结构搭好之后,后续扩展新服务只需要在部署侧加上Agent,基本上不用改动业务代码。
2. 核心细节解析:三大数据支柱的落地方式
2.1 指标采集:从Spring Boot Actuator到Prometheus
指标是最先要打通的一环。Spring Boot应用接入指标监控其实很简单,核心依赖是micrometer-registry-prometheus。Micrometer是Spring Boot默认的指标门面,它做的事情是屏蔽不同监控后端(Prometheus、InfluxDB、Graphite等)的差异,代码里就用统一的Meter API做埋点。
在pom.xml里加上依赖后,再确认application.yml里Actuator相关的配置:
management: endpoints: web: exposure: include: health,info,prometheus,metrics metrics: tags: application: ${spring.application.name}这里的management.metrics.tags.application很重要,它会让所有指标自动带上应用名这个标签。标签这玩意儿是Prometheus的灵魂,后面做聚合查询、按服务分组看数据都靠它。
Prometheus侧的抓取配置需要区分是静态服务还是Kubernetes环境下的动态服务。我这边早期是虚拟机部署,用静态配置,在prometheus.yml里写清楚targets就行:
scrape_configs: - job_name: 'order-service' metrics_path: '/actuator/prometheus' static_configs: - targets: ['10.10.1.5:8080']后来服务迁到Kubernetes之后,改成基于注解的服务发现方式,Pod上打上prometheus.io/scrape: "true"的注解,Prometheus就能自动发现并抓取,不用每加一个实例就改一次配置。
2.2 JVM监控与自定义业务指标
光靠Actuator默认指标还不够。默认指标能告诉我们JVM内存、GC情况、线程状态,这些是基础。例如JVM老年代内存持续走高、GC频率上升,基本可以推断存在内存泄漏风险。但有些场景必须自己埋点,比如订单服务的支付接口耗时、某类消息的消费速率。
Micrometer自定义埋点的方式很直接。以下是我在订单服务里加的一个支付耗时指标的例子:
@RestController @RequestMapping("/order") public class OrderController { private final MeterRegistry meterRegistry; private final Timer payTimer; public OrderController(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; this.payTimer = Timer.builder("order.pay.cost") .description("order pay cost") .publishPercentileHistogram() .register(meterRegistry); } @PostMapping("/pay") public Result pay(@RequestBody PayRequest request) { return this.payTimer.record(() -> { // 实际支付逻辑 return payService.pay(request); }); } }这里的Timer是Micrometer里专门用来记录耗时和调用次数的类型,底层会生成order_pay_cost_seconds_sum、order_pay_cost_seconds_count和order_pay_cost_seconds_max三个指标。publishPercentileHistogram()会额外生成直方图数据,方便在Grafana上画P99、P95这类分位数曲线,比只看平均值靠谱得多。
平均值有个经典的坑:接口平均200ms,看着挺健康,实际上99%的请求都是150ms,有1%的请求慢到了5秒。这种问题只有通过分位数指标才能暴露出来。所以我在Grafana面板上永远优先看P99线,平均值只作为参考。
2.3 指标监控的实际收益
指标这块做得比较到位之后,我们解决过一个非常典型的问题。某次线上服务出现偶发超时,传统排查方式非常被动,因为服务没宕机、CPU不高、内存正常。但Grafana上接口P99曲线出现了明显的锯齿状波动,每隔几分钟就会跳一次。于是我们同时观察JVM GC曲线,发现老年代GC的耗时也在同步波动,顺着这条线排查,定位到是一个定时任务每次运行时会构造大量对象,触发频繁的Full GC,GC停顿导致请求变慢。后来把定时任务拆出去独立线程池,P99曲线就平滑了。这就是指标监控的价值:数据不会撒谎,它会告诉你异常产生的时间线,帮你逐步缩小排查范围。
在做JVM监控时,GC耗时、GC频率、堆内存使用、活跃线程数这四项是我认为必看的核心指标。堆内存使用率持续超过80%且不下降,通常意味着内存中某个缓存对象没有及时清理,或者存在隐性泄漏。活跃线程数异常飙升,普遍对应线程池配置不合理或存在慢调用阻塞的问题。
3. 日志体系与链路追踪的协同
3.1 结构化日志:机器可读是前提
很多团队在日志这块是凑合着用的——logback输出一段文本,中间夹杂几个变量,人眼看着没问题,但想用程序去筛选的时候就头疼了。可观测体系下的日志必须是结构化的,也就是每行日志是固定字段的JSON格式,字段包括时间戳、日志级别、服务名、traceId、spanId、业务关键词等。
Logback配置JSON格式需要引入logstash-logback-encoder依赖。下面是我用的一个简化配置模板:
<appender name="JSON_CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> <customFields>{"service_name":"order-service"}</customFields> </encoder> </appender>这样改完之后,日志从这段文本变成了结构化数据:
{"@timestamp":"2024-11-20T10:15:33.123Z","level":"ERROR","service_name":"order-service","traceId":"a1b2c3d4e5f6","message":"支付接口调用失败","exception":"java.lang.RuntimeException: connection timeout"}结构化日志的最大收益是让日志可以被程序精确过滤和聚合。Loki用LogQL查询语言,可以直接用{service_name="order-service"} |= "timeout"这样的方式捞出所有超时相关的日志,这是文本日志很难做到的。
3.2 OpenTelemetry接入链路追踪
链路追踪是排查分布式问题的最强工具。Java服务接入OpenTelemetry最简单的方式是使用Java Agent,把Agent的jar包通过JVM参数挂载到应用上,无需改动代码。
OpenTelemetry Java Agent的工作机制是字节码增强,启动时通过-javaagent参数加载,在类加载阶段动态修改已编译的字节码,自动注入链路追踪所需的拦截逻辑。它对Spring Boot、Dubbo、gRPC等主流框架的兼容性很好,常见的HTTP调用、线程池、数据库客户端、消息队列消费者都能自动捕获。
Java Agent方式的具体参数如下:
java -javaagent:/opt/otel/opentelemetry-javaagent.jar \ -Dotel.resource.attributes=service.name=order-service \ -Dotel.traces.exporter=otlp \ -Dotel.exporter.otlp.endpoint=http://tempo-mono:4318 \ -Dotel.metrics.exporter=none \ -Dotel.logs.exporter=none \ -jar order-service.jar这里的Otlp导出协议是OpenTelemetry的标准数据格式,导出到Tempo的端口4318就可以,Tempo负责将这些链路数据写入对象存储并支持按traceId查询。
3.3 让日志和链路关联起来
链路追踪的黄金组合是traceId贯穿。一个请求生成一个traceId,在日志里也打印这个traceId,这样出错的时候,从一个traceId既能查到整条调用链路各阶段的耗时,又能查到所有相关服务的日志片段。
实现上有个小技巧。不管用字节码增强还是代码埋点,关键是拿到当前线程上下文里的traceId,然后把它塞进MDC(Mapped Diagnostic Context)里。SLF4J的MDC可以理解为每个线程私有的一个上下文容器,logback在打印日志时自动读取MDC里的值并输出到日志字段。
不用Java Agent时,可以用OpenTelemetry的API手动获取traceId,也可以加Filter拦截请求做映射。如果用Java Agent,它在启动时自动把traceId和spanId设置到MDC的trace_id和span_id字段,只要在logback的JSON layout里声明这两个字段即可,省去了手写的麻烦。
3.4 日志和链路采集的注意事项
实际部署时,有个细节经常被忽略:容器环境下,日志默认输出到stdout,采集侧需要用一个组件把stdout的日志转发到Loki。早期我用Promtail,后来Grafana发布了Alloy,功能类似但配置更简洁。Alloy的日志采集配置大概是这样的:
loki.write: endpoint: url: http://loki:3100/loki/api/v1/push labels: service_name: order-service log.sources.file: include: - /var/log/containers/order-service-*.log log.process: stage.json: expressions: timestamp: "@timestamp" level: level traceid: traceId log.destinations: - loki.write这个配置做到的事情是把容器日志文件实时读取,解析JSON字段,打上标签后推送到Loki。注意include那段路径在Kubernetes下用软链接指向宿主机上的容器日志目录,需要挂载才能看到。
链路数据采样也是个权衡问题。全量采集每条链路在高并发下存储量惊人,Tempo虽然便宜,但查询性能和成本还是问题。我这边生产环境用的是尾部采样策略,按service.name和http.route维度做聚合判断,只保留错误链路和P99以上的慢链路,正常链路按1%的比例采样。这样做监控的日常覆盖率基本不受影响,存储成本降了很多。
4. 告警体系:从发现到响应
4.1 告警规则的设定
监控体系的最后一公里是告警。没有告警的监控就像没有闹钟的早起计划,数据都存在那里,但你根本不会去看。Prometheus告警规则的写法不复杂,难的是规则设定的合理性和分级策略。
以下是我在订单系统上用过的几个典型告警规则:
groups: - name: order-service-alerts rules: - alert: 服务可用性下降 expr: (1 - sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count[5m]))) < 0.99 for: 2m labels: severity: critical annotations: summary: "{{ $labels.application }} 5xx错误率异常" - alert: P99接口延迟过高 expr: histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, application, uri)) > 1.0 for: 5m labels: severity: warning annotations: summary: "{{ $labels.application }} 的 {{ $labels.uri }} P99超过1秒"上面第一条规则用错误率判定服务健康度,# 微服务监控与运维体系:构建可观测的Java微服务
做后端这几年,有个感受特别深:微服务拆得越细,排查问题的难度就越大。以前单体应用报错,翻日志定位一下午基本能搞定;现在的服务一拆分,一个请求跨五六个节点,日志散落在不同机器上,数据库慢查询、Redis超时、消息积压各种因素纠缠在一起,故障现场跟拆盲盒一样。我接手过一个订单系统,高峰期用户反馈下单卡顿,我们查了两天——看CPU、看内存、看日志、看数据库连接数,全是正常范围,最后发现是某个服务的线程池被打满,请求在队列里排队等了几秒。如果当时有链路追踪和合理的指标监控,这个问题十分钟就能定位。那次之后我彻底想明白了一件事:微服务架构的复杂度,必须靠一套完整的可观测体系来对冲。这篇就讲讲我搭建Java微服务监控运维体系时的思路、选型和实操经验。
1. 整体设计思路:可观测性到底在观测什么
1.1 从故障场景倒推监控需求
我见过不少团队把监控做成了数据收集器——Prometheus起来了,Grafana面板也配了,各种图表花花绿绿,但出了事故照样抓瞎。原因很简单:监控体系的建设不是从工具出发,而是从故障场景倒推的。
先梳理一下微服务运维中最常见的几类问题。第一类是资源型故障,比如CPU飙高、内存溢出、磁盘写满,这类问题靠宿主机和JVM层面的指标基本能覆盖。第二类是依赖型故障,比如数据库连接池耗尽、下游服务超时、消息队列积压,这类问题需要服务间的调用关系和延迟数据才能说清楚。第三类是业务逻辑型故障,比如某个接口突然返回异常、某个订单状态流转不对,这类问题必须靠日志和调用链路联合才能还原现场。
三类问题对应三类数据:指标、日志和链路,也就是业内常说的可观测性三大支柱。指标告诉你系统哪里不对,日志告诉你具体发生了什么,链路告诉你一个请求完整走过了哪些环节。三者交叉验证,才能真正定位问题。
1.2 技术选型:为什么是这套组合
市面上的监控工具一大堆,各有各的拥趸。我最终选择的是Prometheus + Grafana + Loki + Tempo这套组合,加上OpenTelemetry做链路数据采集,配合Grafana统一展示。这套方案在Java技术栈里算得上主流,生态成熟,踩坑的人多,出门撞墙的概率就小。
选择这套组合的最核心原因是数据源和展示层的解耦。Prometheus负责抓取指标数据,Loki负责收集日志,Tempo负责存储链路追踪数据,三个数据源各干各的,最后统一接到Grafana上展示。Tempo的亮点在于它只需要对象存储,比如MinIO或者S3兼容存储,不需要单独维护一套数据库,运维成本低很多。
有人可能会问,SkyWalking不也能做链路追踪吗?确实能,而且做得不错。但我在实际使用中发现,SkyWalking的Agent和OpenTelemetry的Agent在兼容性上存在差异,如果团队内部同时有Java、Go、Python的服务,统一用OpenTelemetry做埋点规范更划算。另一方面,OpenTelemetry是CNCF的项目,社区活跃度和标准化程度更高,后续接入其他语言的服务会轻松很多。
架构上分四层:采集层、存储层、展示层、告警层。采集层包含Micrometer(指标埋点)、OpenTelemetry Agent(链路和日志关联)、Alloy(日志采集推送);存储层就是Prometheus、Loki、Tempo三个组件;展示层和告警层统一走Grafana和Alertmanager。这套结构搭好之后,后续扩展新服务只需要在部署侧加上Agent,基本上不用改动业务代码。
2. 核心细节解析:三大数据支柱的落地方式
2.1 指标采集:从Spring Boot Actuator到Prometheus
指标是最先要打通的一环。Spring Boot应用接入指标监控其实很简单,核心依赖是micrometer-registry-prometheus。Micrometer是Spring Boot默认的指标门面,它做的事情是屏蔽不同监控后端的差异——代码里统一用一套Meter API做埋点,至于数据送到Prometheus还是InfluxDB,由配置决定,不用改业务代码。
在pom.xml里加上依赖后,再确认application.yml里相关的配置:
management: endpoints: web: exposure: include: health,info,prometheus,metrics metrics: tags: application: ${spring.application.name}这里的management.metrics.tags.application很重要,它会让所有指标自动带上应用名这个标签。标签这玩意儿是Prometheus的灵魂,后面做聚合查询、按服务分组看数据都靠它。
Prometheus侧的抓取配置需要区分是静态服务还是容器环境下的动态服务。我这边早期是虚拟机部署,用静态配置,在prometheus.yml里写清楚targets就行:
scrape_configs: - job_name: 'order-service' metrics_path: '/actuator/prometheus' static_configs: - targets: ['10.10.1.5:8080']后来服务迁到集群环境之后,改成基于注解的服务发现方式,实例上打上prometheus.io/scrape: "true"的注解,Prometheus就能自动发现并抓取,不用每加一个实例就改一次配置。
2.2 JVM监控与自定义业务指标
光靠Actuator默认指标还不够。默认指标能告诉我们JVM内存、GC情况、线程状态,这些是基础。例如JVM老年代内存持续走高、GC频率上升,基本可以推断存在内存泄漏风险。但有些场景必须自己埋点,比如订单服务的支付接口耗时、某类消息的消费速率。
Micrometer自定义埋点的方式很直接。以下是我在订单服务里加的一个支付耗时指标的例子:
@RestController @RequestMapping("/order") public class OrderController { private final MeterRegistry meterRegistry; private final Timer payTimer; public OrderController(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; this.payTimer = Timer.builder("order.pay.cost") .description("order pay cost") .publishPercentileHistogram() .register(meterRegistry); } @PostMapping("/pay") public Result pay(@RequestBody PayRequest request) { return this.payTimer.record(() -> { // 实际支付逻辑 return payService.pay(request); }); } }这里的Timer是Micrometer里专门用来记录耗时和调用次数的类型,底层会生成order_pay_cost_seconds_sum、order_pay_cost_seconds_count和order_pay_cost_seconds_max三个指标。publishPercentileHistogram()会额外生成直方图数据,方便在Grafana上画P99、P95这类分位数曲线,比只看平均值靠谱得多。
平均值有个经典的坑:接口平均200ms,看着挺健康,实际上99%的请求都是150ms,有1%的请求慢到了5秒。这种问题只有通过分位数指标才能暴露出来。所以我在Grafana面板上永远优先看P99线,平均值只作为参考。
某个线上案例很能说明问题:订单服务出现偶发超时,传统排查方式看CPU、内存、网络都正常,但接口P99曲线出现明显的锯齿状波动,每隔几分钟就跳一次。结合JVM GC曲线,发现老年代GC耗时同步波动,顺着这条线排查,定位到是一个定时任务每次运行时会构造大量对象,触发频繁的Full GC。后来把定时任务拆出去独立线程池,P99曲线就平滑了。这就是指标监控的价值:数据不会撒谎,它会告诉你异常产生的时间线,帮你逐步缩小排查范围。
2.3 日志体系:从文本到结构化
很多团队在日志这块是凑合着用的——Logback输出一段文本,中间夹杂几个变量,人眼看着没问题,但想用程序去筛选的时候就头疼了。可观测体系下的日志必须是结构化的,也就是每行日志是固定字段的JSON格式,字段包括时间戳、日志级别、服务名、traceId、spanId、业务关键词等。
Logback配置JSON格式需要引入logstash-logback-encoder依赖。下面是我用的一个简化配置模板:
<appender name="JSON_CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder class="net.logstash.logback.encoder.LogstashEncoder"> <customFields>{"service_name":"order-service"}</customFields> </encoder> </appender>这样改完之后,日志从这段文本变成了结构化数据:
{"@timestamp":"2024-11-20T10:15:33.123Z","level":"ERROR","service_name":"order-service","traceId":"a1b2c3d4e5f6","message":"支付接口调用失败","exception":"java.lang.RuntimeException: connection timeout"}结构化日志的最大收益是让日志可以被程序精确过滤和聚合。Loki用LogQL查询语言,可以直接用{service_name="order-service"} |= "timeout"这样的方式捞出所有超时相关的日志,这是文本日志很难做到的。
2.4 链路追踪:OpenTelemetry的接入方式
链路追踪是排查分布式问题的最强工具。Java服务接入OpenTelemetry最简单的方式是使用Java Agent,把Agent的jar包通过JVM参数挂载到应用上,无需改动代码。
OpenTelemetry Java Agent的工作机制是字节码增强,启动会时通过-javaagent参数加载,在类加载阶段动态修改已编译的字节码,自动注入链路追踪所需的拦截逻辑。它对Spring Boot、Dubbo、gRPC等主流框架的兼容性很好,常见的HTTP调用、线程池、数据库客户端、消息队列消费者都能自动捕获。
Java Agent方式的具体参数如下:
java -javaagent:/opt/otel/opentelemetry-javaagent.jar \ -Dotel.resource.attributes=service.name=order-service \ -Dotel.traces.exporter=otlp \ -Dotel.exporter.otlp.endpoint=http://tempo-mono:4318 \ -Dotel.metrics.exporter=none \ -Dotel.logs.exporter=none \ -jar order-service.jar这里的OTLP是OpenTelemetry的标准数据格式,导出到Tempo的端口4318就可以,Tempo负责将这些链路数据写入对象存储并支持按traceId查询。
2.5 让日志和链路关联起来
链路追踪的黄金组合是traceId贯穿。一个请求生成一个traceId,在日志里也打印这个traceId,这样出错的时候,从一个traceId既能查到整条调用链路各阶段的耗时,又能查到所有相关服务的日志片段。
实现上有个小技巧。不管用字节码增强还是代码埋点,关键是拿到当前线程上下文里的traceId,然后把它塞进MDC(Mapped Diagnostic Context)里。SLF4J的MDC可以理解为每个线程私有的一个上下文容器,Logback在打印日志时自动读取MDC里的值并输出到日志字段。
不用Java Agent时,可以用OpenTelemetry的API手动获取traceId,也可以加拦截器做映射。如果用Java Agent,它在启动时自动把traceId和spanId设置到MDC的trace_id和span_id字段,只要在Logback的JSON layout里声明这两个字段即可,省去了手写的麻烦。
2.6 日志和链路采集的注意事项
实际部署时,有个细节经常被忽略:容器环境下,日志默认输出到stdout,采集侧需要用一个组件把stdout的日志转发到Loki。早期我用Promtail,后来Grafana发布了Alloy,功能类似但配置更简洁。Alloy的日志采集配置大概是这样的:
loki.write: endpoint: url: http://loki:3100/loki/api/v1/push labels: service_name: order-service log.sources.file: include: - /var/log/containers/order-service-*.log log.process: stage.json: expressions: timestamp: "@timestamp" level: level traceid: traceId log.destinations: - loki.write这个配置做到的事情是把容器日志文件实时读取,解析JSON字段,打上标签后推送到Loki。注意include那段路径在Kubernetes下用软链接指向宿主机上的容器日志目录,需要挂载才能看到。
链路数据采样也是个权衡问题。全量采集每条链路在高并发下存储量惊人,Tempo虽然便宜,但查询性能和成本还是问题。我这边生产环境用的是尾部采样策略,按service.name和http.route维度做聚合判断,只保留错误链路和P99以上的慢链路,正常链路按1%的比例采样。这样做监控的日常覆盖率基本不受影响,存储成本降了很多。
3. 告警配置:让监控说话
3.1 告警规则的设定
监控体系的最后一公里是告警。没有告警的监控就像没有闹钟的早起计划,数据都存在那里,但你根本不会去看。Prometheus告警规则的写法不复杂,难的是规则设定的合理性和分级策略。
以下是我在订单系统上用过的几个典型告警规则:
groups: - name: order-service-alerts rules: - alert: 服务可用性下降 expr: (1 - sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count[5m]))) < 0.99 for: 2m labels: severity: critical annotations: summary: "{{ $labels.application }} 5xx错误率异常" - alert: P99接口延迟过高 expr: histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, application, uri)) > 1.0 for: 5m labels: severity: warning annotations: summary: "{{ $labels.application }} 的 {{ $labels.uri }} P99超过1秒"上面第一条规则用错误率判定服务健康度,5xx比例超过1%就告警。注意for: 2m这个参数的含义是持续两分钟才触发告警,这个设计是为了过滤瞬时抖动。如果一有波动就告警,值班手机一晚上能被震醒十几次,久了值班人员会产生告警疲劳。
第二条规则用了histogram_quantile函数从直方图数据中计算P99耗时,这依赖于之前Micrometer埋点时生成的_bucket系列指标。对于不同重要程度的接口,阈值应该不同,下单支付这类核心链路的P99阈值可以设得严格一些,比如500ms,非核心查询接口可以放宽到2秒。
告警分级和路由也需要提前设计。我这边采用三档分级:critical级表示业务受损,需要立即处理,走电话或IM强提醒;warning级表示有潜在风险,工作时间处理即可;info级只是通知,一般只发到群里不做强制响应。
4. 实操过程中的常见问题与排查实录
4.1 指标断点与数据漂移
实际运维中,我碰到过几次Prometheus指标突然消失的情况,最典型的是服务重启后指标前缀变了。比如某次升级时不小心改了spring.application.name,所有指标的应用名标签全变了,Grafana查询旧标签查不到数据,面板上出现大片空白。
排查这类问题有个固定套路:先确认目标服务/actuator/prometheus端点在浏览器里能不能直接访问到数据,如果端点有数据而Prometheus查不到,那就是抓取配置或服务发现的问题;如果端点本身返回不了数据,那就是服务侧的问题,看日志基本就知道。
另一个容易踩的坑是容器环境下的端口漂移问题。服务实例重启后Pod的IP会变,如果服务发现配置没跟上,Prometheus会一直抓取旧IP导致数据中断。换成基于注解的服务发现模式后这个问题就不存在了,这也是我在迁移到容器环境后把静态配置全部废弃的原因。
4.2 traceId在跨服务调用时断了
链路追踪和日志关联有一个非常典型的故障场景:日志系统里能看到traceId字段,但按这个traceId去Tempo查链路,查不到数据;或者A服务的日志里有traceId,B服务的日志里却没有。
这类问题大多数出在两点上。第一,调用链经过MQ时,消息生产者和消费者的链路传播没有打通。RabbitMQ这种情况下需要额外配置消息头传播,OpenTelemetry对主流消息队列有自动支持,但需要在Agent参数里显式开启对应插件的传播。第二,使用HTTPClient或者其他自定义线程池时,上下文没有透传。Java Agent对很多常见的HTTP客户端库有自动支持,但自定义封装后的线程池、异步任务是重灾区,需要手工把traceId传递到新线程里。
排查时我的做法是先在Tempo里直接把A服务的span查出来,看它的下游span是否能关联上。如果B服务完全没有被关联到链路里,问题出在传播环节;如果B服务有span但不在同一链路树上,问题出在context传递环节。两种情况的处理方式不同,前者查插件配置,后者查代码。
4.3 告警风暴与值班响应
上线初期,我这边经历过高频告警风暴。原因很简单:规则粒度太粗,一两条规则挂了所有服务,某个下游数据库抖动一下,所有依赖它的服务同时触发告警。手机一小时内收到几十条推送,值班人员直接麻木。
解决告警风暴要从三个角度做减法。第一是收敛规则,给告警规则加上for持续时长,过滤瞬时抖动,同时把同类型的服务合并到同一个规则组里,避免每个服务一套规则重复轰炸。第二是做依赖关系编排,数据库故障时只发出根源告警,下游服务因为依赖产生的连带告警自动抑制,Alertmanager里可以用inhibit_rules实现抑制规则。第三是设置静默窗口,夜间低峰期的告警自动延迟到第二天早上合并发送,非紧急issue不打扰睡眠。
Grafana的-alert功能在9.x之后改动很大,云厂商的托管版和自建版的告警模型也有差异。我在自建环境里用的是Grafana Alerting,直接在面板编辑页面配置告警条件,可视化程度更高,团队新人上手更快。Prometheus原生告警规则适合配置复杂聚合查询,两种方式各有适用场景,按团队习惯选择即可。
5. 这套体系的扩展空间与延伸思考
5.1 从监控到容量预测
指标监控做久了,数据积累得多了,可以做一件很有意思的事情:容量预测。Prometheus里的数据是带时间戳的时序数据,通过观察过去几个月的资源使用趋势,可以大致推算未来的增长曲线。比如订单服务的Pod内存使用量如果每个月增长10%,那半年后可能就需要扩容或者优化代码。这个预测不一定非常精确,但用来提前规划资源绰绰有余。
5.2 从被动响应到主动发现
我个人的体会是,监控体系的建设最难的不是技术实现,而是持续运营。工具搭起来只完成了30%的工作,剩下70%靠的是日常对告警规则的打磨、对指标的持续审视。走过一段弯路之后我意识到,告警规则不是配好就不动了,每隔一段时间要重新审视告警阈值是否还合理,是否有新的异常模式需要新增规则覆盖。
最后分享一个我自己一直在用的小技巧:每次处理完一个线上问题,我会顺手把这次问题的特征写成一个检查项,比如某个服务在高峰期Pod内存达到多少、某个接口的P99在哪个时间段开始劣化,然后去Grafana里看这些检查项在历史数据上的表现,如果特征明显,就配成一条新的告警规则。时间久了,告警规则会越来越精准,大部分问题在用户感知之前就已经被系统发现并处理了。这套体系不是建完就完事,它是一个不断生长的过程,越用越灵敏,越用越贴合业务本身的运行规律。