一个典型的线上问题排查场景:用户反馈某个页面加载“感觉很慢”,但后端监控显示所有相关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。
- 前端实现:
- 在发起API请求前,生成一个
UUID
作为X-Request-ID
。 - 在Recoil的
selector
或atom
的异步逻辑中,通过console.log
记录关键节点,如[Request-ID] - Start fetching data
,[Request-ID] - Data received, updating state
。
- 在发起API请求前,生成一个
- 后端实现:
- 通过
Filter
或Interceptor
从请求头中获取X-Request-ID
。 - 将其存入
MDC
(Mapped Diagnostic Context),这样Logback
等日志框架会自动在每条日志中包含这个ID。 - 在Datadog中,通过这个
Request-ID
来搜索和关联前后端的日志。
- 通过
优势:
- 实现简单,不依赖复杂的框架。
- 对现有代码侵入性较低。
劣势:
- 非结构化,分析困难: 这本质上是日志关联,而非真正的分布式追踪。你得到的是一堆按时间排序的日志,而不是一个带有父子关系、时间轴和延迟分析的火焰图。
- 信息维度单一: 只能传递一个ID,无法传递更丰富的上下文,如用户ID、业务ID、采样决策等。
- 维护成本高: 需要手动在代码的各个角落添加日志,容易遗漏,且日志格式难以统一。
- 无法与APM/RUM Trace集成: 手动方案产生的日志与Datadog自动生成的Trace是脱钩的,你依然无法在一个视图中看到完整的调用链。
这个方案在小规模、低复杂度的系统中或许能临时解决问题,但在追求高效运维和深度洞察的生产环境中,它的局限性很快就会暴露。
方案B:基于OpenTelemetry的统一追踪模型
OpenTelemetry (OTel) 提供了一套标准的、与供应商无关的API和SDK,用于生成、收集和导出遥测数据(traces, metrics, logs)。其核心优势在于能够创建跨服务、跨进程、跨技术栈的统一上下文。
我们的目标是:
- 在前端,使用Datadog RUM SDK,它与OTel Web SDK兼容,自动启动一个trace。
- 这个trace的上下文(
trace_id
,span_id
)会通过W3C Trace Context标准(traceparent
请求头)自动注入到所有出站的API请求中。 - 后端Java Agent(同样兼容OTel)会自动识别
traceparent
头,并将其作为父上下文,继续创建后续的span(如Controller处理、Service调用等)。 - 核心挑战: 自动埋点无法深入到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执行的关键路径上(Executor
的query
和update
方法)创建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
是一个黑盒。我们只知道它处于loading
、hasValue
或hasError
状态。我们需要将这些状态的转换过程也变成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的端到端追踪链,已经为我们从被动响应线上告警,转向主动进行性能分析和容量规划,迈出了决定性的一步。