1. Issue
开启OTel Trace跟踪事务后,因为项目中有用到ElasticSearch,考虑到适配方面的因素,我又引入了Elastic APM Server作为Trace的可视化UI展示项目的链路追踪。
本以为是一次平平无奇的选型,没想到在查看Trace后意外的发现 HTTP 所有事务均没有挂载到span节点,全都是空心span。
具体表现指标为:
transaction.span_count.started:0transaction.span_count.dropped:0怎么说呢,现在觉得自己挺蠢的,说来惭愧,被LLM推动着走,没有及时注意到有了OTel后为什么还要使用APM Agent的问题。
后续最好将基本概念掌握了再推进,好在浪费的时间也不多。
首先说一下结论:既然选择了OTel,那就应该让 OTel 通过 OTLP 上报到 Elastic APM Server,而不是再在中间插入一个 APM Java Agent传递到Elastic APM Server,这就是这个问题最核心的根源。
还是记录一下排查的过程吧。
在开始Troubleshooting之前,先给出我pom.xml中有用的信息:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.4</version> <relativePath/></parent><properties> <java.version>21</java.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <opentelemetry-instrumentation.version>2.9.0-alpha</opentelemetry-instrumentation.version></properties><dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <!-- Micrometer Tracing --> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-otel</artifactId> </dependency> </dependencies>micrometer-tracing-bridge-otel提供Micrometer → OTel 桥接,提供 Tracer/Span。
当然,仅仅靠这个是无法完成output的,后面还要有OTLP依赖,不过当时的我就只配了这个依赖,选择的链路是:
OTel -> APM Java Agent-> Elastic APM Server
但实际上应该选用的链路是:
OTel->OTLP->Elastic APM Server
这个暂且不表,记录下我到底是怎么才意识到链路的转换。
所以说,让我不看文档在这里瞎费劲。
2. Troubleshooting
以POST /approval/instance/page接口为例,需要先确认一下到底是什么情况。
2.1 数据库是否真的有参与?
MyBatis 打印了 SQL 与返回行数,说明数据库调用确实发生。
正常来说jdbc应该会被APM Agent自动劫持并且带上span标记以记录耗时,但是我的span很明显并没有挂载上这个指标,为什么呢?
2.2 Elastic APM Server的Agent是否正常运行?
检查 JVM 启动参数,发现包含:
-javaagent:elastic-apm-agent-1.55.3.jar- 多个
-Delastic.apm.*(含trace_methods、span_min_duration=0ms等)
基本上想到的JVM启动参数都填进去了,但其实这个时候我回过神来就觉得很不对劲,这么多参数在JVM启动时注入,这玩意儿在我眼里非常的黑箱+不可控。
2.3 查看APM Agent的日志
JVM启动带上日志参数,重新启动:
-Delastic.apm.log_level=TRACE-Delastic.apm.log_file=/tmp/elastic-apm.log关注点:
HTTP事务是否创建span是否创建span是否挂在HTTP事务下
发现:
-
HTTP事务确实有创建(startTransaction 'POST /approval/instance/page') -
trace_methods触发的业务方法 开启了独立事务 -
同一时刻存在
OTelContext激活(OTelBridgeContext)
// APM Agent 创建了 HTTP 事务... startTransaction 'POST /approval/instance/page' 00-6c2a8f...-2c68c0...-01// 但紧接着同一请求线程里 OTel 上下文被激活,traceId没变,spanId悄然变更:从2c68c0变成了c256a2// 从这一刻起,线程的当前父级变成了 OTel 的 c256a2,APM Agent开启的2c68c0不再活跃。... Activating OTelBridgeContext[{opentelemetry-trace-span-key=SdkSpan{traceId=6c2a8f..., spanId=c256a2..., kind=SERVER}}]// Agent代理的trace_methods找不到自己开启的2c68c0,于是触发了独立事务,traceId 与 HTTP 事务不同。// 主要体现在'POST /approval/instance/page'与'ApprovalInstanceServiceImpl#getApprovalInstancesPage'... startTransaction 'ApprovalInstanceServiceImpl#getApprovalInstancesPage' 00-ad40a3...-5b8b09...-01上面的内容里面,Https事务以及trace_methods事务的跟踪都是APM Agent创建的,中间夹杂着一条OTelBridgeContext,看对应的traceId可以发现,OTel跟APM Agent在获取同一个traceId的上下文。
APM Agent 用 ThreadLocal 存 Context,OTel 也用 ThreadLocal 存 Context(通过 ContextStorage),两者都想当”当前活跃的 parent span,于是后激活的 OTel Context 覆盖了 APM Agent 的Context。
也就是说,其实OTel跟APM Agent本身就已经分道扬镳了,OTel加的span不会挂载在APM Agent开启的独立事务上,后续OTel挂载的,以及APM Agent在trace_method开启的span,虽然身处同一个线程,但都**没有机会挂载回 APM Agent 最开始创建的那个 HTTP 事务的上下文(Context)里,所以最终从APM Server的看到的事务里面永远只能看到一个空心span。
后面APM Agent自动读取jdbc之类的级别的内容自然一样挂载在OTel的spanId下方,跟OTel最后一齐被忽略了,有采集,但是完全没有推送到Elastic APM Server。
我们可以愉快地确定APM Agent本身与OTel间产生了矛盾,直接去问一问LLM即可获得我应该启用OTLP的结论。
而且项目里面的跟踪完全是基于OTel,下面是Trace Aspect的示例:
/** * 事务监控切面 * 使用 AOP 统一管理(耗时、异常分类、Trace标签) */@Aspect@Component@Slf4j@RequiredArgsConstructorpublic class TransactionMonitorAspect {
private final Tracer tracer; // 自动注入基于micrometer的trace
91 collapsed lines
// 慢事务阈值 (毫秒),可配置化 private static final long SLOW_TX_THRESHOLD = 1000L;
@Around("@annotation(transactional)") public Object auditTransaction(ProceedingJoinPoint joinPoint, Transactional transactional) throws Throwable { long start = System.currentTimeMillis(); String methodSignature = joinPoint.getSignature().toShortString(); boolean isSuccess = false;
// 获取当前的 Span (Micrometer 会自动关联当前线程的 Span) Span currentSpan = tracer.currentSpan();
try { // --- 执行业务逻辑 (Spring 事务代理会在此处介入) --- Object result = joinPoint.proceed();
isSuccess = true; return result;
} catch (Throwable ex) { // --- 异常处理逻辑 --- handleException(currentSpan, ex); throw ex; // 必须抛出,否则事务不会回滚
} finally { // --- 耗时统计逻辑 --- long duration = System.currentTimeMillis() - start;
// 往链路追踪系统(Jaeger)里打标签 if (currentSpan != null) { tagSpan(currentSpan, isSuccess); }
// 打印日志 logTransaction(methodSignature, duration, isSuccess); } }
/** * 处理异常并分类 * Spring 的 DataAccessException 体系是处理 DB 错误的行业标准 */ private void handleException(Span span, Throwable ex) { if (span == null) return;
span.error(ex); // 标记 Span 为红色错误状态
String errorType = "BIZ_ERROR"; // 默认为业务错误
// 判断是否为数据库层面的系统错误 if (ex instanceof DataAccessException) { if (ex instanceof ConcurrencyFailureException) { // 死锁、乐观锁冲突、获取连接超时 errorType = "DB_CONCURRENCY"; } else { // SQL语法错误、约束冲突、连接断开等 errorType = "DB_SYSTEM"; } }
// 添加有助于排查的 Tag span.tag("tx.error_type", errorType); span.tag("tx.exception", ex.getClass().getSimpleName()); }
/** * 往 Span 中添加业务元数据 */ private void tagSpan(Span span, boolean isSuccess) { if (!isSuccess) { // 如果想在 Jaeger 搜索栏里通过 tag 搜失败的事务,可以加这个 span.tag("tx.status", "rollback"); } }
/** * 打印日志策略 * 生产环境通常不开 DEBUG,所以只有慢事务或失败事务才会出现在日志里 */ private void logTransaction(String method, long duration, boolean isSuccess) { if (!isSuccess) { // 失败时记录 Error log.error("TX_ROLLBACK | Method: {} | Cost: {}ms", method, duration); return; }
if (duration > SLOW_TX_THRESHOLD) { // 慢事务记录 Warn log.warn("TX_SLOW | Method: {} | Cost: {}ms", method, duration); } else { // 正常事务记录 Debug (生产环境通常忽略) log.debug("TX_COMMIT | Method: {} | Cost: {}ms", method, duration); } }}2.4 在切换到OTLP推送之前……
嗯,通过上面的日志,然后我去OTel那边查了一下,发现APM Agent自动获取的东西还真不少。
| entry | APM Agent | OTel SDK(无 agent) | 备注 |
|---|---|---|---|
| HTTP 入口 | 自动 | 依赖框架自动配置(Micrometer/OTel) | Spring Boot 框架下可以自动获取 |
| JDBC | 自动 | 需要手动 | 需 opentelemetry-jdbc 包装 DataSource |
| 线程池/异步 | 自动 | 需要手动/库支持 | 如 TraceAwareExecutor |
| Redis/HTTP Client | 自动 | 需对应 instrumentation | 取决于库与接入方式 |
| 自定义业务 span | 可配置/注解 | SDK 手动创建 | 业务侧更可控 |
| Context Propagation | 自动 | 需 SDK + 正确拦截器 | 需确保 header 注入/提取 |
最后的Context Propagation在没有明确需求的时候我决定不介入(这个主要是跨进程的跟踪,用于微服务),Redis与HTTP Client也应该是按需注入。
HTTP Client的跟踪主要解决的是如果项目里面调了某个服务的API,以HTTP GET / POST请求的形式获取内容,需要观测其可靠性以及返回时间的时候使用。
补充我已经实现的
TraceAwareExecutor:@Component@RequiredArgsConstructorpublic class TraceAwareExecutor {private final Tracer tracer;/*** 通用带Tracer跟踪并发执行器*/public <K, V, R> List<R> executeParallel(Map<K, V> inputs, BiFunction<V, K, R> action) {Span parentSpan = tracer.currentSpan();try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {List<StructuredTaskScope.Subtask<R>> tasks = inputs.entrySet().stream().map(entry -> scope.fork(() -> {// 进行span注入处理,否则子线程的每一次执行其spanTag都会成为一个单独的tag统计,而不是在并发内作为分支统计try (Tracer.SpanInScope ws = tracer.withSpan(parentSpan)) {return action.apply(entry.getValue(), entry.getKey());}})).toList();scope.join();scope.throwIfFailed();return tasks.stream().map(StructuredTaskScope.Subtask::get).toList();} catch (Exception e) {// 抛出去让调用方处理throw new RuntimeException("Parallel execution failed", e);}}}
2.5 实现OTLP的引入以及JDBC的跟踪
Maven
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.4</version> <relativePath/></parent><properties> <java.version>21</java.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <opentelemetry-instrumentation.version>2.9.0-alpha</opentelemetry-instrumentation.version></properties><dependencies>15 collapsed lines
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <!-- Micrometer Tracing --> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-tracing-bridge-otel</artifactId> </dependency> <dependency> <groupId>io.opentelemetry</groupId> <artifactId>opentelemetry-exporter-otlp</artifactId> </dependency> <dependency> <groupId>io.opentelemetry.instrumentation</groupId> <artifactId>opentelemetry-jdbc</artifactId> <version>${opentelemetry-instrumentation.version}</version> </dependency> </dependencies>application.yml
management: tracing: sampling: probability: ${OTEL_TRACING_SAMPLING_PROBABILITY:0.1} # 采样率,范围0.0~1.0 otlp: tracing: endpoint: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:http://localhost:8200/v1/traces} transport: http headers: Authorization: ${OTEL_EXPORTER_OTLP_HEADERS_AUTHORIZATION:} # 如果你的Elastic APM Server开启了AUTHConfig
@Configurationpublic class OtelJdbcConfig {
@Bean public BeanPostProcessor otelDataSourceBeanPostProcessor(ObjectProvider<OpenTelemetry> openTelemetryProvider) { return new BeanPostProcessor() { @Override public Object postProcessAfterInitialization(Object bean, String beanName) { if (bean instanceof DataSource dataSource) { if (bean instanceof OpenTelemetryDataSource) { return bean; } OpenTelemetry openTelemetry = openTelemetryProvider.getIfAvailable(); if (openTelemetry == null) { return new OpenTelemetryDataSource(dataSource); } return new OpenTelemetryDataSource(dataSource, openTelemetry); } return bean; } }; }}3. 总结
也没什么好总结的,就是APM Agent跟OTel的配合问题,但我觉得出这样的问题也好,如果我真的稀里糊涂地用APM Agent+ OTel的方案的话,可能后面还是会踩到坑后转OTLP转发吧。
文档还是很重要,直接上手就容易出这样的乌龙。
至此,该采集的jdbc也采集了,别的自己按需使用OTel的SDK提供的方法采集即可。
3.1 修复前图示:


3.2 修复后图示:

4. 补充ThreadLocal、Context、ContextStorage 概念
4.1 ThreadLocal(Java 原生机制)
每个线程独立拥有一份变量副本的存储机制。
// 基本用法ThreadLocal<String> userId = new ThreadLocal<>();
// 线程AuserId.set("user-123");userId.get(); // → "user-123"
// 线程B(完全隔离)userId.get(); // → null图示:
┌─────────────────────────────────────────────┐│ JVM ││ ┌─────────────┐ ┌─────────────┐ ││ │ Thread-A │ │ Thread-B │ ││ │ ┌───────┐ │ │ ┌───────┐ │ ││ │ │userId │ │ │ │userId │ │ ││ │ │"123" │ │ │ │ null │ │ ││ │ └───────┘ │ │ └───────┘ │ ││ └─────────────┘ └─────────────┘ │└─────────────────────────────────────────────┘ ↑ 互不干扰,线程安全4.2 Context
// Context 是一个在线程内不可变的上下文记录Context ctx = Context.current();
// 以OpenTelemetry为例,里面装着:// - 当前 Span(谁是我的父节点?)// - Baggage(跨服务传递的业务数据)// - 其他自定义数据
Span span = Span.fromContext(ctx); // 取出当前 Span它主要就是带着完整的链路信息,让后续的链路知晓前面的所有应该注意到的内容:
HTTP 请求进来 │ ▼┌─────────────────────────────────────────────────────┐│ Context: { traceId: "abc", spanId: "001" } ││ │ ││ ▼ ││ Controller.handleRequest() ││ │ ││ ▼ 传递 Context ││ Service.doSomething() ││ │ ││ ▼ 继续传递 ││ Repository.query() ← 这里创建的 span 知道父级是谁 │└─────────────────────────────────────────────────────┘4.3 ContextStorage(存放 Context 的地方)
决定 Context 存在哪里 以及 如何获取当前Context。
// 调用这个时,背后就是 ContextStorage 在工作Context current = Context.current();默认实现 = ThreadLocal:
// OpenTelemetry 默认的 ContextStorage 简化版public class ThreadLocalContextStorage implements ContextStorage {
private static final ThreadLocal<Context> CURRENT = new ThreadLocal<>();
@Override public Context current() { Context ctx = CURRENT.get(); return ctx != null ? ctx : Context.root(); }
@Override public Scope attach(Context toAttach) { Context previous = CURRENT.get(); CURRENT.set(toAttach); return () -> CURRENT.set(previous); // 返回时恢复 }}4.4 三者关系
┌────────────────────────────────────────────────────────────┐│ 应用代码 ││ │ ││ ▼ ││ Context.current() ││ │ ││ ▼ ││ ┌─────────────────────────────────────────────────────┐ ││ │ ContextStorage(接口) │ ││ │ │ │ ││ │ ▼ │ ││ │ ┌─────────────────────────────────────────────┐ │ ││ │ │ ThreadLocalContextStorage(默认实现) │ │ ││ │ │ │ │ │ ││ │ │ ▼ │ │ ││ │ │ ThreadLocal<Context> │ │ ││ │ │ │ │ │ ││ │ │ ▼ │ │ ││ │ │ Thread-1: Context{traceId=abc, span=001} │ │ ││ │ │ Thread-2: Context{traceId=xyz, span=002} │ │ ││ │ └─────────────────────────────────────────────┘ │ ││ └─────────────────────────────────────────────────────┘ │└────────────────────────────────────────────────────────────┘