基于OpenTelemetry实现从Recoil状态到MyBatis查询的端到端追踪


一个典型的线上问题排查场景:用户反馈某个页面加载“感觉很慢”,但后端监控显示所有相关API的P99响应时间均在200ms以内,数据库查询也无任何异常。问题出在哪?延迟可能隐藏在前端数据获取后的状态管理、组件渲染,或是网络传输的某个不起眼的角落。传统的后端APM与前端RUM(Real User Monitoring)是两个割裂的世界,技术栈的分离导致了可观测性的鸿沟。要定位这类问题,必须建立一个能贯穿用户交互、前端状态、网络请求、后端处理直至数据库操作的统一追踪链。

定义问题:割裂的数据孤岛

在我们的技术栈中,前端使用React与Recoil进行状态管理,后端是基于Spring Boot和MyBatis的Java服务。监控工具为Datadog。

初始的监控方案是标准的组合:

  • Datadog RUM: 自动采集前端页面加载时间、资源加载、用户会话和浏览器内的错误。
  • Datadog APM: 通过Java Agent自动埋点,追踪后端服务的Controller、Service以及常见的HTTP客户端和数据库驱动(如JDBC)。

这种方案能回答“哪个API慢”或“哪个页面有JS错误”,但无法回答“用户点击按钮后,从Recoil发起异步请求到数据最终渲染到屏幕上,整个过程耗时多久,瓶颈在哪里?”。问题在于,RUM生成的trace和APM生成的trace是两个独立的实体,虽然Datadog会尝试通过时间戳和用户信息进行关联,但这种关联是弱的、不精确的,无法形成一个严谨的、包含因果关系的调用链。我们需要的是一个从浏览器端发起,并将其上下文无缝传递到后端每一个角落的单一分布式追踪。

方案A:手动上下文传递与日志关联

一个直接的想法是手动在前端生成一个唯一的请求ID,通过请求头传递给后端,并在前端和后端的日志中都打印这个ID。

  • 前端实现:
    1. 在发起API请求前,生成一个UUID作为X-Request-ID
    2. 在Recoil的selectoratom的异步逻辑中,通过console.log记录关键节点,如[Request-ID] - Start fetching data[Request-ID] - Data received, updating state
  • 后端实现:
    1. 通过FilterInterceptor从请求头中获取X-Request-ID
    2. 将其存入MDC (Mapped Diagnostic Context),这样Logback等日志框架会自动在每条日志中包含这个ID。
    3. 在Datadog中,通过这个Request-ID来搜索和关联前后端的日志。

优势:

  • 实现简单,不依赖复杂的框架。
  • 对现有代码侵入性较低。

劣势:

  • 非结构化,分析困难: 这本质上是日志关联,而非真正的分布式追踪。你得到的是一堆按时间排序的日志,而不是一个带有父子关系、时间轴和延迟分析的火焰图。
  • 信息维度单一: 只能传递一个ID,无法传递更丰富的上下文,如用户ID、业务ID、采样决策等。
  • 维护成本高: 需要手动在代码的各个角落添加日志,容易遗漏,且日志格式难以统一。
  • 无法与APM/RUM Trace集成: 手动方案产生的日志与Datadog自动生成的Trace是脱钩的,你依然无法在一个视图中看到完整的调用链。

这个方案在小规模、低复杂度的系统中或许能临时解决问题,但在追求高效运维和深度洞察的生产环境中,它的局限性很快就会暴露。

方案B:基于OpenTelemetry的统一追踪模型

OpenTelemetry (OTel) 提供了一套标准的、与供应商无关的API和SDK,用于生成、收集和导出遥测数据(traces, metrics, logs)。其核心优势在于能够创建跨服务、跨进程、跨技术栈的统一上下文。

我们的目标是:

  1. 在前端,使用Datadog RUM SDK,它与OTel Web SDK兼容,自动启动一个trace。
  2. 这个trace的上下文(trace_id, span_id)会通过W3C Trace Context标准(traceparent请求头)自动注入到所有出站的API请求中。
  3. 后端Java Agent(同样兼容OTel)会自动识别traceparent头,并将其作为父上下文,继续创建后续的span(如Controller处理、Service调用等)。
  4. 核心挑战: 自动埋点无法深入到MyBatis的SQL执行层面和Recoil的状态变化层面。我们需要对这两处进行手动、精细化的埋点,将它们作为子span挂载到主trace上。
sequenceDiagram
    participant User
    participant Browser (Recoil)
    participant Java Service (Spring Boot)
    participant MyBatis
    participant Database

    User->>Browser (Recoil): 点击按钮,触发异步selector
    Browser (Recoil)->>Browser (Recoil): OTel: 创建根Span "LoadUserProfile"
    Note right of Browser (Recoil): RUM SDK 自动启动Trace
    Browser (Recoil)->>Java Service (Spring Boot): 发起API GET /api/user/123 
(携带 traceparent header) Java Service (Spring Boot)->>Java Service (Spring Boot): OTel Agent: 识别traceparent, 创建子Span "HTTP GET /api/user/{id}" Java Service (Spring Boot)->>MyBatis: 调用UserMapper.selectById(123) MyBatis->>MyBatis: 自定义Interceptor: 创建孙Span "DB Query: selectById" MyBatis->>Database: 执行SQL: SELECT ... FROM users WHERE id = ? Database-->>MyBatis: 返回查询结果 MyBatis-->>MyBatis: 自定义Interceptor: 结束Span, 记录SQL语句和耗时 MyBatis-->>Java Service (Spring Boot): 返回User对象 Java Service (Spring Boot)-->>Java Service (Spring Boot): OTel Agent: 结束HTTP Span Java Service (Spring Boot)-->>Browser (Recoil): 返回200 OK, User JSON数据 Browser (Recoil)->>Browser (Recoil): OTel: 创建子Span "Recoil State Update" Note right of Browser (Recoil): 数据到达,Loadable状态变化 Browser (Recoil)-->>Browser (Recoil): OTel: 结束所有前端Span Browser (Recoil)-->>User: 渲染UI,显示用户信息

优势:

  • 端到端可见性: 获得一个从用户点击到UI渲染的完整、统一的火焰图。
  • 标准化: 基于OpenTelemetry标准,未来可以平滑切换到其他支持OTel的可观测性后端。
  • 丰富的上下文: 除了调用关系,还可以在每个span上附加自定义属性(attributes/tags),如业务ID、用户等级、环境信息等,极大地增强了可筛选和分析能力。
  • 根本原因分析: 精准定位延迟,无论是前端渲染、网络抖动、GC暂停还是慢SQL,都在一个视图中清晰可见。

劣势:

  • 初始配置复杂: 需要对前后端进行OTel SDK的配置和初始化。
  • 需要定制化开发: 核心价值来自于对MyBatis和Recoil的自定义埋点,这需要编写特定的代码。

决策:
方案B虽然初期投入更高,但它解决的是系统性的可观测性问题。从长远看,这种投资对于保障系统稳定性和提升排障效率是至关重要的。在真实项目中,手动依赖日志排查复杂问题的时间成本是惊人的。因此,我们选择方案B。

核心实现:打通任督二脉

以下是实现方案B的关键代码和配置。

1. 后端:为MyBatis戴上OpenTelemetry的“听诊器”

我们需要实现一个MyBatis的Interceptor,它会在SQL执行的关键路径上(Executorqueryupdate方法)创建span。

pom.xml 依赖:
确保项目中包含了OpenTelemetry API和Datadog Agent。通常使用Datadog提供的Java aget,它已经打包了OTel。我们只需要引入opentelemetry-api来编写Interceptor。

<!-- pom.xml -->
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-api</artifactId>
    <version>1.31.0</version> <!-- 请使用最新稳定版 -->
</dependency>
<dependency>
    <groupId>org.mybatis</groupId>
    <artifactId>mybatis</artifactId>
    <version>3.5.13</version>
</dependency>
<!-- 其他Spring Boot, MyBatis等依赖... -->

自定义MyBatis拦截器 OpenTelemetryMyBatisInterceptor.java:

package com.example.config.mybatis;

import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.semconv.trace.attributes.SemanticAttributes;
import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Properties;

/**
 * MyBatis OpenTelemetry Interceptor.
 * 拦截Executor的query和update方法,为SQL执行创建span.
 * 这使得我们可以在Datadog中看到具体的Mapper方法和SQL语句的性能.
 */
@Intercepts({
    @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
    @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
    @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
})
public class OpenTelemetryMyBatisInterceptor implements Interceptor {

    private static final Logger log = LoggerFactory.getLogger(OpenTelemetryMyBatisInterceptor.class);
    // 推荐在Spring配置中注入Tracer,这里为简化使用全局Tracer
    private final Tracer tracer = GlobalOpenTelemetry.getTracer("mybatis-instrumentation");

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
        
        // Span的名称使用Mapper的ID,更具业务可读性
        String spanName = "DB Query: " + mappedStatement.getId();

        Span span = tracer.spanBuilder(spanName)
                .setSpanKind(SpanKind.CLIENT)
                .startSpan();

        try (var scope = span.makeCurrent()) {
            // 添加语义化属性,遵循OpenTelemetry规范
            // 这样Datadog等后端能正确解析和展示
            span.setAttribute(SemanticAttributes.DB_SYSTEM, "mysql"); // or postgresql, etc.
            span.setAttribute(SemanticAttributes.DB_OPERATION, mappedStatement.getSqlCommandType().name());
            span.setAttribute(SemanticAttributes.DB_STATEMENT, getSql(invocation, mappedStatement));
            span.setAttribute("mybatis.mapper.id", mappedStatement.getId());

            // 执行原始的SQL操作
            return invocation.proceed();

        } catch (Exception e) {
            span.setStatus(StatusCode.ERROR, e.getMessage());
            span.recordException(e);
            log.error("MyBatis instrumentation failed for mapper id: {}", mappedStatement.getId(), e);
            throw e;
        } finally {
            span.end();
        }
    }
    
    private String getSql(Invocation invocation, MappedStatement mappedStatement) {
        try {
            Object parameter = invocation.getArgs().length > 1 ? invocation.getArgs()[1] : null;
            BoundSql boundSql = mappedStatement.getBoundSql(parameter);
            return boundSql.getSql().replaceAll("[\\s]+", " ");
        } catch (Exception e) {
            // 在某些复杂情况下获取SQL可能会失败,这里做个保护
            log.warn("Failed to get SQL from MappedStatement. Mapper ID: {}", mappedStatement.getId());
            return "Failed to retrieve SQL";
        }
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        // 可以从mybatis-config.xml中接收属性
    }
}

注册拦截器:
在Spring Boot配置中,将这个拦截器添加到SqlSessionFactory

package com.example.config;

import com.example.config.mybatis.OpenTelemetryMyBatisInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MyBatisConfig {

    @Bean
    public OpenTelemetryMyBatisInterceptor openTelemetryMyBatisInterceptor() {
        return new OpenTelemetryMyBatisInterceptor();
    }
    
    // Spring Boot会自动将所有Interceptor类型的Bean注入到SqlSessionFactory中
    // 如果没有使用spring-boot-starter-mybatis,则需要手动配置:
    // @Autowired
    // public void addInterceptors(SqlSessionFactory sqlSessionFactory) {
    //     sqlSessionFactory.getConfiguration().addInterceptor(openTelemetryMyBatisInterceptor());
    // }
}

现在,每一次MyBatis的数据库操作都会在Datadog APM中显示为一个独立的span,清晰地展示其耗时和执行的SQL语句,并自动关联到上游的HTTP请求span。

2. 前端:用自定义Hook追踪Recoil的异步状态

前端的挑战在于,Recoil的异步selector是一个黑盒。我们只知道它处于loadinghasValuehasError状态。我们需要将这些状态的转换过程也变成trace中的一部分。

环境设置:
安装Datadog RUM SDK和OpenTelemetry Web SDK。

npm install @datadog/browser-rum @opentelemetry/api @opentelemetry/context-zone

Datadog RUM 初始化 (index.js或类似入口文件):
确保开启了与OpenTelemetry的集成。

import { datadogRum } from '@datadog/browser-rum';

datadogRum.init({
    applicationId: 'YOUR_APPLICATION_ID',
    clientToken: 'YOUR_CLIENT_TOKEN',
    site: 'datadoghq.com',
    service: 'my-react-app',
    env: 'production',
    version: '1.0.0',
    sessionSampleRate: 100,
    sessionReplaySampleRate: 20,
    trackUserInteractions: true,
    trackResources: true,
    trackLongTasks: true,
    defaultPrivacyLevel: 'mask-user-input',
    // 关键配置: 允许RUM与OpenTelemetry trace进行关联
    allowedTracingOrigins: ["http://localhost:8080", "https://api.yourdomain.com"],
});

自定义Hook useTracedRecoilValue.js:
这个Hook会包装useRecoilValueLoadable,在Loadable状态变化时创建和结束span。

import { useEffect, useRef } from 'react';
import { useRecoilValueLoadable, RecoilValue } from 'recoil';
import { trace, context, SpanStatusCode } from '@opentelemetry/api';

/**
 * 一个自定义Hook,用于追踪Recoil异步selector的性能。
 * 它会在selector开始加载时创建一个span,并在加载完成或失败时结束它。
 * 
 * @param {RecoilValue<T>} recoilValue - 一个异步的Recoil selector。
 * @param {string} spanName - 在追踪系统中显示的span名称,应具有业务意义。
 * @returns {import('recoil').Loadable<T>} 返回与useRecoilValueLoadable相同的Loadable对象。
 */
export function useTracedRecoilValue(recoilValue, spanName) {
    const loadable = useRecoilValueLoadable(recoilValue);
    const spanRef = useRef(null);
    const tracer = trace.getTracer('recoil-instrumentation');

    useEffect(() => {
        // 如果状态是loading且我们还没有创建span,那么创建一个新的span
        if (loadable.state === 'loading' && !spanRef.current) {
            // 获取当前活动的span作为父span
            const parentSpan = trace.getActiveSpan();
            const ctx = parentSpan ? trace.setSpan(context.active(), parentSpan) : context.active();
            
            // context.with确保在这个回调中的所有操作都发生在新创建的span的上下文中
            context.with(ctx, () => {
                const span = tracer.startSpan(`Recoil State: ${spanName}`);
                span.setAttribute('recoil.selector.key', recoilValue.key);
                spanRef.current = span;
            });
        }
        // 如果状态不再是loading且我们有一个进行中的span,那么结束它
        else if (loadable.state !== 'loading' && spanRef.current) {
            const span = spanRef.current;
            if (loadable.state === 'hasError') {
                span.setStatus({ code: SpanStatusCode.ERROR, message: 'Recoil selector failed to resolve.' });
                span.recordException(loadable.contents); // 记录具体的错误信息
            } else {
                span.setStatus({ code: SpanStatusCode.OK });
            }
            // 可以在这里添加更多属性,比如加载到的数据摘要等
            // span.setAttribute('recoil.result.size', JSON.stringify(loadable.contents).length);
            
            span.end();
            spanRef.current = null; // 清理引用
        }
    }, [loadable.state, recoilValue.key, spanName, tracer]);

    // 组件卸载时,确保任何未关闭的span都被关闭,防止内存泄漏
    useEffect(() => {
        return () => {
            if (spanRef.current) {
                spanRef.current.setStatus({ code: SpanStatusCode.ERROR, message: 'Component unmounted before Recoil selector resolved.' });
                spanRef.current.end();
                spanRef.current = null;
            }
        };
    }, []);

    return loadable;
}

在组件中使用:
将原有的useRecoilValueLoadable替换为我们的自定义Hook。

import React, { Suspense } from 'react';
import { selector } from 'recoil';
import { useTracedRecoilValue } from './useTracedRecoilValue';

// 假设的异步selector
const currentUserQuery = selector({
    key: 'CurrentUserQuery',
    get: async () => {
        const response = await fetch('/api/user/current');
        if (!response.ok) {
            throw new Error('Failed to fetch user');
        }
        return response.json();
    },
});

function UserProfile() {
    // 使用我们的自定义Hook,并提供一个有意义的span名称
    const userLoadable = useTracedRecoilValue(currentUserQuery, 'Fetch Current User');

    switch (userLoadable.state) {
        case 'hasValue':
            return <div>Welcome, {userLoadable.contents.name}</div>;
        case 'hasError':
            return <div>Error loading user data.</div>;
        case 'loading':
            return <div>Loading...</div>;
    }
}

export function App() {
    return (
        <Suspense fallback={<div>Loading app...</div>}>
            <UserProfile />
        </Suspense>
    );
}

通过这一系列操作,我们在Datadog中得到了一个完整的、跨越前后端边界的trace。当排查最初提到的“感觉很慢”问题时,我们可以清晰地看到:

  • 用户点击操作触发的RUM Action。
  • Recoil State: Fetch Current User span的开始和结束时间,这代表了从数据请求到状态更新的完整前端耗时。
  • 一个子span HTTP GET /api/user/current,显示网络和后端处理时间。
  • 在这个HTTP span下,有一个更深的子span DB Query: com.example.mapper.UserMapper.selectById,精确显示数据库查询耗时。

如果Recoil State span很长,但其下的HTTP GET span很短,那么瓶颈就可能出在浏览器的主线程被阻塞,导致Recoil状态更新后到React重新渲染之间存在延迟。这种洞察力是传统割裂式监控方案无法提供的。

当前方案的局限性与未来展望

这套基于OpenTelemetry的统一追踪方案极大地提升了我们对全栈应用性能的洞察力,但它并非银弹。

首先,有状态的span管理。在前端,尤其是在React的并发模式下,组件的生命周期可能变得复杂。useTracedRecoilValue hook依赖useEffect,其行为在并发渲染下需要更仔细的测试和考量,以确保span的生命周期被正确管理,避免过早或过晚关闭。

其次,采样策略。在流量巨大的生产环境中,对100%的请求进行全链路追踪成本高昂且不必要。当前方案依赖Datadog Agent的默认采样配置。更成熟的系统需要实施更智能的采样策略,例如基于trace头部的采样决策、尾部采样(tail-based sampling),或者针对特定用户、特定业务流程的动态采样,这需要更复杂的后端基础设施支持。

最后,业务上下文的深度融合。目前我们在span上附加的是技术属性(如mapper ID, SQL语句)。下一步的演进方向是附加更多高价值的业务属性,比如用户ID、租户ID、订单号等。这需要建立一套从业务代码到可观测性基础设施的元数据传递机制,让技术监控真正服务于业务健康度的度量。

尽管存在这些待完善之处,但打通从Recoil到MyBatis的端到端追踪链,已经为我们从被动响应线上告警,转向主动进行性能分析和容量规划,迈出了决定性的一步。


  目录