前言:一次”盲飞”式排查

前段时间给 EduMind(一个基于 Spring Boot 4 + Vue 3 的 AI 教学助手)做了一轮生产级差距审计。结果让我后背发凉——整个项目没有任何可观测性

唯一和”监控”沾边的配置就一行:

1
management.endpoints.web.exposure.include=health

没有指标、没有追踪、没有结构化日志、没有告警。出问题只能靠 docker compose logs 人肉翻,排查一次线上问题的时间够写三个 feature。

审计结束后,我花了半天补齐了这块。本文记录这个过程。


一、为什么可观测性不是”可选”

很多开发者(包括以前的我)对可观测性的态度是:“等项目大了再说”

但可观测性不是锦上添花,它解决三个你绕不开的问题:

问题 没有可观测性 有可观测性
用户说”好慢” 翻代码猜、改完祈祷、等用户反馈 Grafana 一看,p99 延迟在哪段一目了然
凌晨报警 你是报警系统——用户打电话/P0 issue Alertmanager → 钉钉,起床处理,其他时间安心睡觉
排查线上错误 docker compose logs | grep ERROR,海量文本中找关键信息 Loki 里 {requestId="xxx"} 一条命令看到完整请求链路

可观测性不是成本,是保险。 相当于你花半小时装个刹车,而不是等撞了再修。


二、目标:三根支柱

我给自己定了三个目标,对应 Observability 三支柱:

1
2
3
4
5
6
7
8
9
10
┌─────────────────────────────────────────────┐
│ 可观测性三支柱 │
├──────────────┬──────────────┬───────────────┤
│ Metrics │ Logging │ Tracing │
│ (指标) │ (日志) │ (追踪) │
├──────────────┼──────────────┼───────────────┤
│ Prometheus │ Logback JSON │ RequestId │
│ + Grafana │ + logstash │ + MDC │
│ 看趋势、告警 │ 定位具体问题 │ 串联整个链路 │
└──────────────┴──────────────┴───────────────┘

三、Metrics:从裸奔到一目了然

3.1 为什么选 Prometheus + Grafana

  • Prometheus 是云原生时代的标配,Spring Boot Actuator 原生支持,一个依赖搞定
  • Grafana 的 Dashboard 生态太强了,JSON 定义即配置,Git 版本化管理
  • 两者都免费,社区活跃,出了问题 Stack Overflow 一搜就有

3.2 第一步:暴露 Prometheus 端点

pom.xml 加一个依赖:

1
2
3
4
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

application.properties 三行配置:

1
2
3
management.endpoints.web.exposure.include=health,metrics,prometheus
management.metrics.export.prometheus.enabled=true
management.metrics.tags.application=edumind

改完重启,访问 http://localhost:8080/actuator/prometheus,出来的就是这样的原始指标:

1
2
3
4
# HELP jvm_memory_used_bytes
jvm_memory_used_bytes{area="heap"} 1.2E8
# HELP http_server_requests_seconds_count
http_server_requests_seconds_count{uri="/api/chat/stream"} 42.0

3.3 第二步:Prometheus 抓取配置

1
2
3
4
5
6
7
# prometheus/prometheus.yml
scrape_configs:
- job_name: "edumind"
metrics_path: "/actuator/prometheus"
scrape_interval: 10s
static_configs:
- targets: ["app:8080"]

3.4 第三步:Grafana 一键 Dashboard

在 Grafana 里配置好 Prometheus 数据源,然后导入我预制好的 Dashboard。结果就是这张面板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────────────────────────────┐
│ JVM Memory (Heap & Non-Heap) │ GC Pause & Rate │
│ ┌─────────────────────────┐ │ ┌────────────┐ │
│ │ ▁▂▃▄▅▆▇█▇▆▅▄▃▂▁ │ │ │ ▁▁▂▃▂▁▁ │ │
│ └─────────────────────────┘ │ └────────────┘ │
│ │ │
│ API Request Rate (by URI) │ API Latency p95 │
│ ┌─────────────────────────┐ │ ┌────────────┐ │
│ │ /api/chat ████████ │ │ │ 1.2s → 0.8s│ │
│ │ /api/auth ██ │ │ │ p95 ↓ 33% │ │
│ └─────────────────────────┘ │ └────────────┘ │
│ │ │
│ 5xx Error Rate │ RAG Search Latency│
│ ┌──────┐ │ ┌────────────┐ │
│ │ 0.1% │ ← 绿色,安心 │ │ p50: 0.8s │ │
│ └──────┘ │ │ p99: 3.2s │ │
│ │ └────────────┘ │
└─────────────────────────────────────────────────┘

这一刻的感受: 之前就像在黑屋子里摸索,现在灯全开了。JVM 用了多少内存、哪个接口最慢、RAG 检索有没有瓶颈——一眼就能看到。

3.5 自定义业务指标

通用指标(JVM、HTTP)是 Spring Boot 自动给的。业务指标需要自己埋点。用 @Timed 注解一行搞定:

1
2
3
4
5
// RagService.java
@Timed(value = "rag.search", description = "RAG检索延迟", histogram = true)
public RagResult search(RagSearchRequest request) {
// ...
}

配合 TimedAspect

1
2
3
4
5
6
7
@Configuration
public class ObservabilityConfig {
@Bean
public TimedAspect timedAspect(MeterRegistry registry) {
return new TimedAspect(registry);
}
}

之后 RAG 检索的每次调用都会自动记录耗时、次数、分位数,出现在 Grafana 面板和 Prometheus 告警规则里。

3.6 告警:不要在凌晨当人肉 Alertmanager

定义了 6 条告警规则:

1
2
3
4
5
6
# prometheus/alerts.yml
- alert: EduMindDown # 应用挂了
- alert: HighErrorRate # 5xx 超过 5%
- alert: HighLatency # p99 超过 3 秒
- alert: RagSearchSlow # RAG 检索 p95 超过 5 秒
- alert: HighHeapUsage # 堆内存超过 90%

生产环境接入 Alertmanager,配好飞书/钉钉 Webhook,就能在凌晨收到这种消息:

🚨 [EduMind] HighErrorRate — 5xx 比例 8.2%,请立即检查!

凌晨不再是你在值班,是 Prometheus 在值班。


四、Logging:从”找日志”到”搜日志”

4.1 问题:纯文本日志的无助

之前 EduMind 没有 logback-spring.xml,日志是 Spring Boot 默认格式,多行一个请求的信息散布在几十行里:

1
2
3
2026-07-08 10:23:45 INFO  c.f.d.Controller - 收到请求
2026-07-08 10:23:45 DEBUG c.f.d.Service - 开始查询
2026-07-08 10:23:46 ERROR c.f.d.Service - 查询失败!!

没有 requestId、没有 userId、没有 JSON 格式。想查”用户 123 刚才那个失败的请求到底发生了什么”,等于大海捞针。

4.2 解决方案:logback-spring.xml + logstash-logback-encoder

加依赖:

1
2
3
4
5
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>8.0</version>
</dependency>

配置分环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- dev: 人类可读 -->
<springProfile name="dev | local">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>

<!-- prod: 机器可解析 -->
<springProfile name="prod">
<root level="INFO">
<appender-ref ref="ASYNC_JSON"/> <!-- JSON → stdout -->
<appender-ref ref="ASYNC_FILE"/> <!-- JSON → 滚动文件 -->
<appender-ref ref="ERROR_FILE"/> <!-- 错误日志单独输出 -->
</root>
</springProfile>

生产日志输出长这样:

1
2
3
4
5
6
7
8
9
10
{
"@timestamp": "2026-07-08T10:23:45.123Z",
"message": "RAG检索完成",
"level": "INFO",
"logger_name": "c.f.d.rag.RagService",
"thread_name": "tomcat-handler-3",
"requestId": "f3d85f5e4a0c40e8",
"userId": "42",
"application": "edumind"
}

JSON 格式的好处:直接接入 ELK 或 Loki,一个 Lucene 查询 requestId:"xxx" AND level:"ERROR" 就能还原完整请求链路。

4.3 RequestId:给每个请求发身份证

这是最容易被忽视但最实用的一点。实现也很简单——一个 OncePerRequestFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
@Order(Ordered.HIGHEST_PRECEDENCE) // 最早执行
public class RequestIdFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) {
String requestId = request.getHeader("X-Request-Id");
if (requestId == null) {
requestId = UUID.randomUUID().toString().replace("-", "");
}

MDC.put("requestId", requestId); // 注入日志上下文
response.setHeader("X-Request-Id", requestId); // 返回前端

try {
chain.doFilter(request, response);
} finally {
MDC.remove("requestId"); // 防止线程池泄漏
}
}
}

配合 JwtAuthenticationFilter 注入 userId:

1
2
// 认证成功后
MDC.put("userId", String.valueOf(userId));

效果对比:

1
2
3
4
5
6
7
8
9
# 之前
2026-07-08 10:23:45 INFO - 收到请求
2026-07-08 10:23:45 DEBUG - 查询数据库
2026-07-08 10:23:46 ERROR - 查询失败

# 之后
[f3d85f5e] [user:42] INFO - 收到请求
[f3d85f5e] [user:42] DEBUG - 查询数据库
[f3d85f5e] [user:42] ERROR - 查询失败: Duplicate entry 'xxx'

现在用户报 bug 时,前端拿到报错弹窗里的 X-Request-Id,后端 grep f3d85f5e 就能瞬间定位整条链路。


五、Nginx 也不能落下

应用层的日志已经有 requestId 了,但 Nginx 那层的请求耗时也得能追踪:

1
2
3
4
5
6
7
8
9
10
11
12
# 自定义日志格式:带上 requestId + 上游各段耗时
log_format trace '$remote_addr [$time_local] "$request" '
'$status $body_bytes_sent '
'"$http_x_request_id" '
'rt=$request_time '
'uct="$upstream_connect_time" '
'uht="$upstream_header_time"';

server {
access_log /var/log/nginx/access.log trace;
# ...
}

生产日志里就能看到这样的访问记录:

1
2
192.168.1.1 [08/Jul/2026:10:23:45] "GET /api/chat/stream" 200 1234
"f3d85f5e4a0c40e8" rt=0.045 uct="0.002" uht="0.040"

一个 requestId,从 Nginx → 应用 → 日志文件 → Grafana,全程对得上。排查问题不需要猜”是 Nginx 超时还是应用慢”——rt vs uht 一看就知道。


六、docker-compose 一键到位

所有监控组件都写进 docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
prometheus:
image: prom/prometheus:v2.55.0
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
ports: ["9090:9090"]

grafana:
image: grafana/grafana:11.2.0
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
volumes:
- ./grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./grafana/datasources:/etc/grafana/provisioning/datasources:ro
ports: ["3001:3000"]

开发环境只启监控容器:

1
docker compose up prometheus grafana -d --no-deps

打开 http://localhost:3001,Dashboard 自动加载,数据已经在面板上了。

七、成果清单

改完之后,整个可观测性栈长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
                ┌──────────────┐
│ Grafana │ ← 可视化面板
│ :3001 │
└──────┬───────┘
│ 查询
┌──────▼───────┐
│ Prometheus │ ← 指标存储 + 告警
│ :9090 │
└──────┬───────┘
│ scrape /actuator/prometheus
┌────────────┼────────────┐
│ │ │
┌─────────▼──┐ ┌──────▼─────┐ ┌──▼──────────┐
│ Spring Boot │ │ Nginx │ │ logback JSON │
│ @Timed/RAG │ │ trace log │ │ + requestId │
└─────────────┘ └────────────┘ └──────────────┘
改动 数量
新增文件 8 个(logback-spring.xml, RequestIdFilter, Prometheus 配置, Grafana Dashboard 等)
修改文件 8 个(pom.xml, application.properties, SecurityConfig, nginx.conf 等)
新增依赖 2 个(micrometer-registry-prometheus, logstash-logback-encoder)
总耗时 ~3 小时

八、现在还缺什么

诚实地说,这套方案覆盖了三支柱中的两个半:

1
2
3
✅ Metrics   — Prometheus + Grafana,趋势、告警、仪表盘
✅ Logging — 结构化 JSON + requestId + userId,可接入 Loki/ELK
⚠️ Tracing — 有 requestId 贯穿,但无 OpenTelemetry Span,内部调用链不可视化

真正的分布式追踪(跨服务 Span 传播、RAG 管线内部 step 级耗时)还需要引入 OpenTelemetry Agent。不过对单体的 Spring Boot 应用来说,当前的 requestId + Metrics 组合已经能覆盖 90% 的排查场景。

另外还有两个”五分钟”配置:

  • Alertmanager → 钉钉/飞书 Webhook:告警规则写好了,配置好通知渠道就能用
  • Sentry 接入:应用错误自动上报,不用等用户告诉你”页面白了”

这两个等上线前补上就行。


结语

可观测性不是大厂的专利。一个 OncePerRequestFilter,三行 Prometheus 配置,一个 JSON 日志格式——投入半天,换来的是:

  • 出问题时不用猜,看面板
  • 排查问题时不用翻,搜 requestId
  • 凌晨不用你是报警系统,Prometheus 替你值夜班

花半天修刹车,比修事故现场便宜一万倍。


相关代码:EduMind — AI 驱动的智能教学助手,Spring Boot 4 + Vue 3,Prometheus + Grafana 可观测性栈