你还在手动计算API耗时吗?看请求开始日志的时间,再看请求结束日志的时间,然后自己心算或者拿计算器减一下?这办法不仅效率低,而且容易出错,当需要排查大量请求的性能问题时,简直是场灾难。
在我们的手写链路追踪系列教程中,我们已经实现了用户信息在全链路中的自动透传。今天,我们来完成一个更实用的功能:全局API性能监控。目标是让系统自动识别每个API接口,并在请求结束时,将URI、耗时和响应状态码清晰地打印在日志里,彻底告别人工计算。
1. 核心思路与实现
实现全局API监控的核心在于 Spring MVC的拦截器(Interceptor) 和 MDC(Mapped Diagnostic Context)。我们将在请求进入时记录开始时间,在请求处理完成时计算耗时,并最终输出到日志。
1.1 增强MDC工具类:记录时间
首先,我们需要在已有的 MdcUtils 工具类中增加记录请求开始时间和计算耗时的功能。
package com.sandwich.logtracing.util;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.MDC;
/**
* @Description: MDC工具类
*/
public class MdcUtils {
public static final String TRACE_ID = "traceId";
public static final String USER_ID = "userId";
public static final String USER_NAME = "username";
public static final String CLIENT_IP = "clientIp";
// 新增:用于记录API开始时间和请求耗时
public static final String START_TIME = "startTime";
public static final String REQUEST_TIME = "requestTime";
// ... 其他已有的 get/set 方法(如 setTraceId, getUserId等) ...
/**
* 在请求开始时,设置开始时间到MDC
*/
public static void setStartTime() {
MDC.put(START_TIME, System.currentTimeMillis() + "");
}
/**
* 获取MDC中记录的开始时间
*/
public static String getStartTime() {
return MDC.get(START_TIME);
}
/**
* 计算请求耗时,并设置到MDC中
*/
public static void setRequestTime() {
// 计算请求耗时
String startTimeStr = MDC.get(START_TIME);
if (startTimeStr != null) {
long startTime = Long.parseLong(startTimeStr);
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
MDC.put(REQUEST_TIME, String.valueOf(duration));
}
}
/**
* 获取计算出的请求耗时
*/
public static String getRequestTime() {
return MDC.get(REQUEST_TIME);
}
public static void clearAll() {
MDC.clear();
}
}
1.2 改造拦截器:植入监控逻辑
接下来,我们在负责链路追踪的拦截器 MDCInterceptor 的关键生命周期方法中插入时间记录和日志输出逻辑。这是实现Java应用全局监控的核心步骤。
1. 在 preHandle 开始位置记录API开始时间:
@Override
public boolean preHandle(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) {
MdcUtils.setStartTime(); // <-- 新增:记录请求开始时间
// ... 原有的 traceId, userId 等用户信息设置逻辑 ...
return true;
}
2. 在 postHandle 中计算请求耗时:
postHandle 方法在处理器执行后、视图渲染前被调用,是计算耗时的合适位置。
@Override
public void postHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response,
@NotNull Object handler, @NotNull ModelAndView modelAndView) {
// 请求处理完成后计算耗时
MdcUtils.setRequestTime(); // <-- 新增:计算并设置耗时
}
3. 在 afterCompletion 中输出监控日志并清理:
afterCompletion 在整个请求结束后调用,无论成功或异常。在这里输出最终日志并清理MDC,防止内存泄漏。
@Override
public void afterCompletion(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response,
@NotNull Object handler, @Nullable Exception ex) {
log.info("API [{}] process finished, request time [{}]ms, response code [{}]",
request.getRequestURI(), MdcUtils.getRequestTime(), response.getStatus()); // <-- 输出监控日志
// 请求完全结束后清理MDC,防止内存泄漏
MdcUtils.clearAll();
}
完整拦截器代码示例:
package com.sandwich.logtracing.interceptor;
import com.sandwich.logtracing.util.MdcUtils;
import com.sandwich.logtracing.util.RandomStrUtils;
import com.sandwich.logtracing.util.ThreadLocalContext;
import io.micrometer.common.util.StringUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
@Slf4j
@Component
public class MDCInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) {
MdcUtils.setStartTime();
// 从header中获取用户信息
String traceId = request.getHeader("x-request-correlation-id");
//if the request header don't have a trace id,then generate a random one
if (StringUtils.isBlank(traceId)) {
traceId = RandomStrUtils.generateRandomString(15);
}
MdcUtils.setTraceId(traceId);
String userId = request.getHeader("X-User-ID");
if (StringUtils.isNotBlank(userId)) {
// 添加用户ID到MDC
MdcUtils.setUserId(userId);
}
String username = request.getHeader("X-Username");
if (StringUtils.isNotBlank(username)) {
// 添加用户名到MDC
MdcUtils.setUserName(username);
}
// 可以添加其他请求相关信息
String clientIp = getClientIp(request);
MdcUtils.setClientIp(clientIp);
ThreadLocalContext.setTraceId(traceId);
ThreadLocalContext.setUserId(userId);
ThreadLocalContext.setUserName(username);
ThreadLocalContext.setClientIp(clientIp);
return true;
}
@Override
public void postHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response,
@NotNull Object handler, @NotNull ModelAndView modelAndView) {
// 请求处理完成后可以添加响应信息
MdcUtils.setRequestTime();
}
@Override
public void afterCompletion(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response,
@NotNull Object handler, @Nullable Exception ex) {
log.info("API [{}] process finished, request time [{}]ms, response code [{}]",
request.getRequestURI(), MdcUtils.getRequestTime(), response.getStatus());
// 请求完全结束后清理MDC,防止内存泄漏
MdcUtils.clearAll();
}
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
}
1.3 优化日志格式(可选)
为了让包含用户信息的日志输出更整齐,我们可以微调一下application.yml中的日志配置模式。这里主要是调整了MDC字段的输出顺序,使其左对齐更美观。
spring:
application:
name: log-tracing
# 日志配置
logging:
level:
com.sandwich.logtracing: DEBUG
org.springframework: INFO
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %X{traceId:-} %replace(%X{userId}){'^(?!$)(.+)$','[userId: $1] '}%replace(%X{username}){'^(?!$)(.+)$','[username: $1] '}%replace(%X{clientIp}){'^(?!$)(.+)$','[clientIp: $1] '}[%thread] %-5level %logger{50} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %X{traceId:-} %replace(%X{userId}){'^(?!$)(.+)$','[userId: $1] '}%replace(%X{username}){'^(?!$)(.+)$','[username: $1] '}%replace(%X{clientIp}){'^(?!$)(.+)$','[clientIp: $1] '}[%thread] %-5level %logger{50} - %msg%n"
file:
name: ./logs/log-tracing.log
logback:
rollingpolicy:
max-file-size: 100MB
max-history: 30
注意:API耗时信息是通过日志消息(%msg)输出的,并未直接嵌入到日志模式中。这样做灵活性更高,修改起来也更方便。
2. 效果验证
部署代码后,我们发起一个登录请求进行测试。
1. 客户端请求:
$ ./login.sh
Generated traceId from client side: A4LnbakJlqHVcMl
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 144 0 100 100 44 121 53 --:--:-- --:--:-- --:--:-- 175
Response from login API:
{
"responseCode": 200,
"message": "success",
"data": "Sandwich login success",
"traceId": "A4LnbakJlqHVcMl"
}
2. 服务端日志分析:
我们在日志中追踪 traceId: A4LnbakJlqHVcMl,可以看到请求链路中的所有步骤。重点关注最后一行由拦截器输出的监控日志:
2025-09-17 22:30:03.456 A4LnbakJlqHVcMl [userId: 123456] [username: Sandwich] [clientIp: 0:0:0:0:0:0:0:1] [http-nio-8080-exec-2] INFO c.sandwich.logtracing.interceptor.MDCInterceptor - API [/test/login] process finished, request time [735]ms, response code [200]
这行日志清晰地告诉我们:
- API接口:
/test/login
- 请求耗时:
735ms
- 响应状态码:
200
所有信息一目了然,无需任何人工计算。由于这是在全局拦截器中实现的,因此每一个经过该拦截器的API请求都会自动生成这样的监控日志。
3. 总结与展望
通过本次改造,我们成功为Spring Boot应用添加了轻量级、无侵入的全局API性能监控能力。核心是利用拦截器的生命周期,配合MDC存储临时数据,最终在请求结束时输出关键性能指标。
这种方法有几个优点:
- 全局自动:无需在每个Controller方法里手动添加计时代码。
- 信息完整:天然关联了traceId、用户信息、耗时、状态码。
- 低开销:仅增加少量时间记录和一次日志打印操作。
- 灵活:监控日志格式和内容可以方便地在拦截器中调整。
这个监控数据是后续进行性能分析、慢接口排查的基础。你可以直接将日志采集到ELK(Elasticsearch, Logstash, Kibana)或Graylog等日志平台,通过traceId或URI进行聚合查询和统计分析,快速定位性能瓶颈。
更进一步,你还可以将此数据推送至时序数据库(如InfluxDB、Prometheus),并利用Grafana绘制实时的API耗时仪表盘,构建更直观的可观测性体系。这正是在复杂后端 & 架构中保障系统稳定性的常见实践。
希望这个实战案例能为你实现应用监控提供清晰的思路。如果你有更好的实现建议或遇到其他问题,欢迎在云栈社区的技术论坛与我们交流讨论。