构建从 Ant Design 到 JPA 的端到端可观测性:基于 Zipkin 和时序数据关联前端交互与后端慢查询


我们的时序数据分析平台最近频繁收到投诉,核心问题只有一个:慢。用户在前端通过一个复杂的 Ant Design Pro 表单进行多维度筛选,点击查询后,图表加载的Spin组件要旋转10到30秒。前端团队用 Chrome DevTools 分析,发现是某个 /api/v1/metrics/query 接口耗时过长。后端团队接到问题后,查看了 Zipkin 链路,确实观察到该接口的整体耗时很长,但内部并没有调用其他微服务,所有耗时都集中在自身服务的 database span 上。DBA介入,反馈说数据库整体负载正常,没有发现全局性的慢查询。问题就这样悬停了——我们知道慢,但无法定位到是哪一次具体的用户操作,对应到哪一个具体的 Hibernate 生成的 SQL 语句上。

传统的链路追踪在这里失效了。Zipkin 告诉我们数据库操作慢,但对于一个使用了 JPA/Hibernate 的复杂应用而言,一个API背后可能根据不同的输入参数动态生成数十种形态各异的SQL。我们需要一个更精细的手段,将一次前端的点击操作,与后端 Java 代码中的方法调用,以及最终在数据库中执行的那条具体的、动态生成的SQL语句完全关联起来。

初步构想:注入一个贯穿始终的“交互ID”

问题的本质是上下文的丢失。Zipkin 的 TraceID 解决了服务间的调用链问题,但在服务内部,特别是从业务逻辑到ORM框架内部的黑盒,上下文是断裂的。

我们的方案是手动创建一个贯穿前端操作、后端处理、直到数据库执行的唯一标识:Interaction-ID

sequenceDiagram
    participant User as 用户
    participant Frontend as Ant Design 前端
    participant Backend as Spring Boot 后端
    participant Hibernate as Hibernate ORM
    participant Database as 数据库

    User->>Frontend: 点击查询按钮
    Frontend->>Frontend: 生成唯一的 Interaction-ID (例如: IID-xyz-123)
    Frontend->>Backend: 发起API请求 (Header中携带Interaction-ID)
    Backend->>Backend: 中间件/Filter捕获Interaction-ID
    Backend->>Backend: 将ID存入MDC, 并为Zipkin Span打上Tag
    Backend->>Hibernate: 调用JPA Repository方法
    Hibernate->>Hibernate: 实现 StatementInspector 拦截SQL
    Hibernate->>Hibernate: 从MDC获取Interaction-ID, 与SQL一同记录
    Hibernate->>Database: 执行SQL
    Database-->>Hibernate: 返回结果
    Hibernate-->>Backend: 返回数据
    Backend-->>Frontend: 返回API响应
    Frontend-->>User: 渲染图表

这个流程的核心在于两点:

  1. 前端在每次触发关键业务操作时(如点击查询),生成一个唯一ID,并通过HTTP Header传递给后端。
  2. 后端通过某种机制,在 Hibernate 即将执行SQL的最后一刻,捕获到这条SQL,并将它与从上游传递过来的 Interaction-ID 关联起来。

前端改造:从源头注入上下文

我们在 Ant Design Pro 项目中使用 axios 作为请求库。通过创建请求拦截器,我们可以无侵入地为所有相关请求注入这个ID。

首先,我们需要一个生成唯一ID的工具。uuid 库是个不错的选择。

// src/utils/interactionId.js
import { v4 as uuidv4 } from 'uuid';

export const generateInteractionId = () => {
  // 格式可以自定义,方便识别
  return `iid-${uuidv4()}`;
};

然后,在需要追踪的业务组件中,当用户发起查询时,生成并传递这个ID。我们不希望每个业务组件都手动处理,更好的方式是在发起请求的service层统一处理。

// src/services/metrics.js
import request from '@/utils/request';
import { generateInteractionId } from '@/utils/interactionId';

export async function queryMetrics(params, options) {
  const interactionId = generateInteractionId();
  console.log(`Starting metrics query with Interaction-ID: ${interactionId}`);

  return request('/api/v1/metrics/query', {
    method: 'POST',
    headers: {
      'X-Interaction-ID': interactionId,
      // ...其他headers
    },
    data: params,
    ...(options || {}),
  });
}

这里的 request 是 Ant Design Pro 封装好的 axios 实例。我们在调用API时,在 headers 中加入了 X-Interaction-ID。为了方便调试,我们在控制台打印了这个ID。当用户报告问题时,我们可以让他从浏览器控制台复制这个ID给我们。

后端链路:捕获并传递上下文

后端的任务是接收这个 X-Interaction-ID,并让它在整个线程的生命周期中都可被访问。Spring Boot体系中,FilterThreadLocal (通过 SLF4J 的 MDC - Mapped Diagnostic Context) 是实现这一目标的标准做法。

1. 创建一个Filter来处理Header

// package com.example.observability.filter;

import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class InteractionIdFilter extends OncePerRequestFilter {

    private static final String INTERACTION_ID_HEADER = "X-Interaction-ID";
    private static final String INTERACTION_ID_MDC_KEY = "interactionId";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            String interactionId = request.getHeader(INTERACTION_ID_HEADER);
            if (StringUtils.hasText(interactionId)) {
                MDC.put(INTERACTION_ID_MDC_KEY, interactionId);
            }
            filterChain.doFilter(request, response);
        } finally {
            // 在请求结束后清理MDC,防止内存泄漏
            MDC.remove(INTERACTION_ID_MDC_KEY);
        }
    }
}

这个Filter会在每个请求进入Controller之前执行,从Header中读取 X-Interaction-ID 并放入MDC。请求处理完毕后,finally 块确保MDC被清理,这对于使用线程池的Web服务器来说至关重要。

2. 配置Logback以在日志中打印 interactionId

<!-- logback-spring.xml -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
        <!-- %X{interactionId} 会从MDC中获取key为interactionId的值 -->
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId:-},%X{spanId:-},%X{interactionId:-}] - %msg%n</pattern>
    </encoder>
</appender>

现在,我们所有的日志输出都会自动带上 traceIdspanId (由Sleuth/Brave自动注入) 和我们自定义的 interactionId

3. 将 interactionId 添加到 Zipkin Span Tag

为了能在 Zipkin UI 中直接根据 interactionId 搜索,我们需要将它作为Tag附加到当前的Span上。我们可以通过编程式的方式用 brave.Tracer 来实现。

我们对之前的Filter进行扩展:

// package com.example.observability.filter;

import brave.Span;
import brave.Tracer;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
// ... imports

@Component
public class InteractionIdFilter extends OncePerRequestFilter {

    private static final String INTERACTION_ID_HEADER = "X-Interaction-ID";
    private static final String INTERACTION_ID_MDC_KEY = "interactionId";
    private static final String INTERACTION_ID_TAG_KEY = "interaction.id";

    private final Tracer tracer;

    public InteractionIdFilter(Tracer tracer) {
        this.tracer = tracer;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            String interactionId = request.getHeader(INTERACTION_ID_HEADER);
            if (StringUtils.hasText(interactionId)) {
                MDC.put(INTERACTION_ID_MDC_KEY, interactionId);

                // 获取当前活动的Span,如果存在则添加Tag
                Span currentSpan = this.tracer.currentSpan();
                if (currentSpan != null) {
                    currentSpan.tag(INTERACTION_ID_TAG_KEY, interactionId);
                }
            }
            filterChain.doFilter(request, response);
        } finally {
            MDC.remove(INTERACTION_ID_MDC_KEY);
        }
    }
}

这样,Zipkin中记录的这条链路信息就包含了 interaction.id 这个Tag,我们可以直接通过它进行精确的链路检索。

深入ORM内部:用 StatementInspector 捕获最终SQL

这是整个方案中最关键的一环。我们需要在Hibernate即将把SQL发送给JDBC驱动之前拦截它。在真实项目中,直接使用p6spy这类工具虽然方便,但可能会引入额外的性能开销和配置复杂性。Hibernate自身提供了一个强大的扩展点 StatementInspector,它允许我们在SQL执行前后进行干预,非常适合这个场景。

1. 实现自定义的 StatementInspector

// package com.example.observability.jpa;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Tags;
import io.micrometer.core.instrument.Timer;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.resource.jdbc.spi.StatementInspector;
import org.slf4j.MDC;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

@Slf4j
public class TraceableStatementInspector implements StatementInspector {

    private final MeterRegistry meterRegistry;
    
    // 使用 ConcurrentHashMap 来存储每个SQL的开始时间,确保线程安全
    private final ConcurrentHashMap<String, Long> queryStartTimes = new ConcurrentHashMap<>();

    public TraceableStatementInspector(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    @Override
    public String inspect(String sql) {
        String interactionId = MDC.get("interactionId");
        
        // 我们只关心包含 interactionId 的请求,避免干扰系统后台任务
        if (interactionId != null) {
            // SQL执行前记录日志和开始时间
            // 这里的日志是关键,它将SQL和interactionId关联起来
            long startTime = System.nanoTime();
            queryStartTimes.put(sql, startTime);
            
            log.info("[JPA_PREPARE_SQL] interactionId={}, sql={}", interactionId, formatSql(sql));
        }
        return sql; // 必须返回原始或修改后的SQL
    }

    // 在 Hibernate 5.x 之后,没有 afterStatement 方法,
    // 但可以通过与 AOP 或其他计时机制结合。
    // 这里我们用一个简化的逻辑来模拟。
    // 在生产级应用中,更稳健的方式是结合数据源代理 (e.g., datasource-proxy) 或 AOP。
    // 为了演示,我们假设 inspect 是在执行前立即调用的,
    // 我们可以在另一个切面中记录结束时间。
    //
    // 为了让这个例子可以自包含运行,我们做一个简化:
    // 在下一个 inspect 调用时,我们认为上一个已经结束,并记录其时间。
    // 这是一个不精确但可行的演示方法。更优的方案是使用 `SessionEventListener`。
    
    public void afterQuery(String sql) {
        Long startTime = queryStartTimes.remove(sql);
        if (startTime != null) {
            long durationNanos = System.nanoTime() - startTime;
            String interactionId = MDC.get("interactionId");

            log.info("[JPA_EXECUTE_SQL] interactionId={}, durationMs={}, sql={}", 
                interactionId, 
                TimeUnit.NANOSECONDS.toMillis(durationNanos),
                formatSql(sql));

            // 发射时序指标数据
            // 这里的`query.type`可以做得更智能,比如通过SQL关键字判断
            Timer.builder("jpa.query.execution.time")
                 .description("Timer for JPA query execution")
                 .tag("interaction.id", interactionId != null ? interactionId : "none")
                 .tag("query.type", getQueryType(sql))
                 .register(meterRegistry)
                 .record(durationNanos, TimeUnit.NANOSECONDS);
        }
    }


    private String formatSql(String sql) {
        return sql.replaceAll("\\s+", " ").trim();
    }
    
    private String getQueryType(String sql) {
        String lowerSql = sql.toLowerCase().trim();
        if (lowerSql.startsWith("select")) return "SELECT";
        if (lowerSql.startsWith("update")) return "UPDATE";
        if (lowerSql.startsWith("insert")) return "INSERT";
        if (lowerSql.startsWith("delete")) return "DELETE";
        return "OTHER";
    }
}

注意: StatementInspectorinspect 方法是在SQL准备阶段调用的,它本身没有提供一个afterExecute的回调。上面的afterQuery是一个模拟实现。在生产环境中,一个更健壮的方式是结合使用org.hibernate.event.spi.PostLoadEventListener等事件监听器,或者通过AOP切入JpaRepository的方法来精确计时。不过,仅凭inspect方法记录SQL和interactionId的关联日志,就已经解决了我们最初的核心问题。

2. 注册 StatementInspector

application.yml 中将我们的 StatementInspector 注册给 Hibernate。

# application.yml
spring:
  jpa:
    properties:
      hibernate.session.events.auto: "true"
      # 这是关键配置,告诉Hibernate使用我们的Inspector
      hibernate.session_factory.statement_inspector: com.example.observability.jpa.TraceableStatementInspector

但是,TraceableStatementInspector 需要一个 MeterRegistry 实例,而上述配置方式无法进行依赖注入。这是一个常见的问题。解决方案是使用 HibernatePropertiesCustomizer

// package com.example.observability.config;

import com.example.observability.jpa.TraceableStatementInspector;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Map;

@Configuration
public class JpaConfig {

    @Bean
    public HibernatePropertiesCustomizer hibernatePropertiesCustomizer(MeterRegistry meterRegistry) {
        return new HibernatePropertiesCustomizer() {
            @Override
            public void customize(Map<String, Object> hibernateProperties) {
                // 通过编程方式注入我们自己管理的bean实例
                hibernateProperties.put("hibernate.session_factory.statement_inspector",
                        new TraceableStatementInspector(meterRegistry));
            }
        };
    }
}

通过这种方式,我们成功地将一个由Spring管理的、包含了MeterRegistryTraceableStatementInspector实例注入到了Hibernate的配置中。

成果:从一次点击到一条SQL的全链路诊断

现在,我们的新诊断流程如下:

  1. 用户报告问题: “我在XX页面,筛选了A、B、C条件后查询很慢。” 我们让用户打开开发者工具,在console找到我们打印的 Interaction-ID: iid-xxxx-yyyy
  2. 日志系统关联: 我们拿着这个 iid-xxxx-yyyy 去日志系统(如ELK, Grafana Loki)搜索。
    • grep "iid-xxxx-yyyy"
    • 我们会立即看到与此次交互相关的所有后端日志。
    • 关键是,我们会找到 [JPA_PREPARE_SQL] 这条日志,它清晰地记录了由Hibernate为这次查询生成的确切SQL语句。
  3. 链路追踪验证: 我们可以拿着日志中的traceId去Zipkin中查询,或者直接在Zipkin UI中通过 tagQuery=interaction.id=iid-xxxx-yyyy 进行搜索。
    • 我们会看到一个完整的链路,前端请求,后端处理,以及一个耗时很长的database span。
    • 这个链路确认了慢在了数据库层面,并提供了整体的耗时和上下文。
  4. 时序数据分析: 我们将[JPA_PREPARE_SQL]日志中的SQL拿到数据库中执行 EXPLAIN,DBA可以快速进行索引优化或查询重写。
    • 同时,我们可以在监控系统(如Prometheus + Grafana)中查询 jpa_query_execution_time{interaction_id="iid-xxxx-yyyy"} 这个时序指标。
    • 这可以帮助我们分析是否是偶然的抖动,或者该类查询的性能是否随着时间推移在恶化。

这个体系的价值在于,它将三个原本孤立的可观测性数据源——Tracing (Zipkin)**、Logging (SLF4J/MDC)** 和 **Metrics (Micrometer)**——通过一个业务层面的 Interaction-ID 紧密地联系在了一起,实现了从用户体验到数据库执行的端到端、细粒度的性能诊断。

局限性与未来迭代路径

这套方案并非没有成本。

首先,StatementInspector 对每一条SQL都会进行拦截,尽管开销极小,但在超高并发场景下仍需进行性能压测评估。一个可行的优化是,并非所有请求都生成和传递Interaction-ID,而是通过一个动态配置或特定规则(比如只针对某些用户或某些复杂的查询API)来开启,实现一种“按需诊断”的采样策略。

其次,当前的SQL计时机制依赖于日志,不够精确。引入datasource-proxy-spring-boot-starter或使用AOP切面编程来环绕@Repository方法或JDBC Statement的执行,可以提供更精准的执行耗时。

最后,目前的数据关联和分析还是一个手动的过程:从用户处获取ID -> 查日志 -> 查Zipkin -> 查监控。一个更高级的平台工程实践是,构建一个统一的诊断界面,输入Interaction-ID后,自动聚合和展示来自不同系统的关联数据,但这已经超出了单次问题修复的范畴,属于平台能力建设的下一步了。


  目录