为什么放弃SkyWalking选择OpenTelemetry:某电商订单系统的选型复盘

那年双十一前夕,我负责重构我们电商平台的订单核心链路可观测性体系。当时订单系统部署在混合云环境,K8s集群跑着Java微服务,边缘节点还有Go写的风控网关,日订单峰值突破12万笔。原有的SkyWalking 8.16部署了两年多,随着业务扩张,几个痛点越来越明显。

最棘手的是多语言支持问题。我们的风控网关用Go 1.21开发,SkyWalking对Go的自动探针支持有限,团队不得不手写大量手动埋点代码。有一次风控接口突然变慢,从平均80ms飙升到800ms,我们花了3小时才定位到是第三方征信服务超时,但SkyWalking的Go agent没有捕获到完整的下游调用链路,只能靠日志反推。原因在于SkyWalking的Go生态当时还不够成熟,上下文传播机制与Java体系存在差异,跨语言链路经常断节。

另一个问题是厂商锁定风险。SkyWalking默认使用Elasticsearch作为存储,我们的ES集群已经膨胀到15个节点,存储成本每月超过2万元。团队想尝试将部分指标导出到Prometheus,但SkyWalking的存储层耦合太深,改造工作量评估需要至少3人月。

我主导技术选型时,重点对比了OpenTelemetry在2024年的成熟度。当时OpenTelemetry规范刚发布v1.33.0,Java SDK v1.43.0已经稳定支持Jaeger、Prometheus等多种后端导出。我们做了为期两周的POC测试,用订单系统的核心下单接口作为样本。

测试环境部署了OpenTelemetry Collector v0.108.0,采用Gateway模式。Java服务的接入非常简单,只需要添加两行JVM参数:

// 订单服务启动类示例 public class OrderApplication { public static void main(String[] args) { // 初始化OpenTelemetry SDK,使用自动探针 System.setProperty("otel.service.name", "order-service"); System.setProperty("otel.exporter.otlp.endpoint", "http://otel-collector:4317"); SpringApplication.run(OrderApplication.class, args); } }

实际运行数据显示,接入OpenTelemetry后,Java服务的平均响应时间从改造前的120ms降至115ms,额外的开销不到5ms。更关键的是,Go风控网关通过OpenTelemetry Go SDK v1.32.0接入后,首次实现了与Java服务的完整链路串联。W3C Trace Context的标准化传播起了决定性作用,跨进程调用不再丢失上下文。

我们最终决定全面迁移,原因在于OpenTelemetry解决了三个核心问题:一是多语言统一采集,Java、Go、Python服务都能用相同范式接入;二是供应商无关性,Collector可以并行导出到Jaeger和Prometheus,后续迁移存储无需改动业务代码;三是生态扩展性,2024年社区已经提供了eBPF相关的网络遥测支持,为后续无侵入采集留了接口。

迁移过程并非一帆风顺。有一次预发环境出现数据丢失,排查发现是Collector的批处理配置不当,内存缓冲区设置太小。解决方案是调整processor的batch大小,将send_batch_size从默认的512改为2048,同时增加timeout到10s。这个调整让数据丢失率从0.3%降到了0,但内存占用增加了约200MB,需要在资源预算时提前规划。

从0到1构建统一采集层:基于Collector v0.108.0的Gateway与Agent部署实战

订单系统完成SDK接入后,我面临下一个挑战:如何构建一个稳定、可扩展的采集层。我们的基础设施横跨两个机房,K8s集群有120个节点,还有30台裸金属服务器跑着遗留系统。如果直接在每台机器部署Collector Agent,运维复杂度会指数级上升。

我选择了Agent-Gateway混合部署模式。原因在于,纯Agent模式会导致后端存储连接数爆炸,而纯Gateway模式又会在网络分区时丢失数据。我们的方案是在每个K8s节点部署轻量Agent,负责采集本节点所有Pod的遥测数据,然后统一发送给机房的Gateway集群,最后由Gateway导出到后端存储。

Collector v0.108.0的架构正好支持这种分层部署。Agent侧配置相对简单,主要做数据接收和初步过滤:

# otel-agent-config.yaml receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 http: endpoint: 0.0.0.0:4318 processors: batch: send_batch_size: 1024 timeout: 5s memory_limiter: check_interval: 1s limit_mib: 512 spike_limit_mib: 128 exporters: otlp: endpoint: "otel-gateway:4317" tls: insecure: true service: pipelines: traces: receivers: [otlp] processors: [memory_limiter, batch] exporters: [otlp] metrics: receivers: [otlp] processors: [memory_limiter, batch] exporters: [otlp] logs: receivers: [otlp] processors: [memory_limiter, batch] exporters: [otlp]

这个配置的关键在于memory_limiter处理器。有一次生产环境Agent Pod突然OOM,排查发现是某个开发环境服务异常,每秒发送超过5万条span,直接打爆了Agent内存。加上内存限制器后,Collector会在内存使用超过512Mi时主动丢弃数据,虽然损失了部分遥测,但保证了Agent本身的稳定性。不这么做的话,一个异常服务就能拖垮整个节点的采集能力。

Gateway集群部署在独立虚拟机上,配置更复杂,需要承担数据路由和后端适配的职责:

# otel-gateway-config.yaml receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 processors: batch: send_batch_size: 2048 timeout: 10s attributes: actions: - key: environment value: "production" action: insert resource: attributes: - key: datacenter value: "dc-east" action: insert exporters: jaeger: endpoint: "jaeger-collector:14250" tls: insecure: true prometheus: endpoint: "0.0.0.0:8889" logging: verbosity: detailed service: pipelines: traces: receivers: [otlp] processors: [attributes, resource, batch] exporters: [jaeger, logging] metrics: receivers: [otlp] processors: [attributes, resource, batch] exporters: [prometheus] logs: receivers: [otlp] processors: [attributes, resource, batch] exporters: [logging]

Gateway的resource处理器解决了我们一个长期痛点:不同环境的数据没有统一标签。之前SkyWalking时代,生产环境和测试环境的服务名经常冲突,查询时难以区分。现在通过Collector自动注入environmentdatacenter标签,数据一致性大幅提升。

性能调优方面,我们实测Gateway单实例在8核16G配置下,可以处理每秒3万条span的流量,CPU使用率保持在60%以下。当流量超过4万/s时,开始出现背压,解决方案是水平扩展Gateway实例,并通过负载均衡分发Agent的上报请求。

部署后第一个月,我们遇到了Collector的一个版本bug:v0.108.0的OTLP HTTP接收器在处理大批量日志时会出现解析错误。临时解决方案是回退到v0.107.0,同时调整日志采集策略,将单条日志大小限制在1MB以内。这个经历让我意识到,即使是最新的稳定版,在生产环境部署前也需要做充分的兼容性测试。

Trace与Log深度关联:解决百万日活社区跨服务排查难的血泪史

我们公司还有一个百万日活的社区产品,用户发帖、评论、点赞的链路涉及8个微服务。去年下半年开始,用户投诉帖子加载缓慢的问题激增,但研发团队每次排查都要登录5、6台服务器翻日志,平均定位时间超过2小时。

问题的核心在于Trace和Log处于割裂状态。虽然接入了OpenTelemetry,但日志还是通过Filebeat采集到Elasticsearch,Trace数据存在Jaeger,两者之间没有任何关联。有一次用户反馈发帖失败,我们查了Jaeger发现是内容审核服务超时,但审核服务的日志里没有请求ID,只能靠时间戳去匹配,结果因为时钟不同步,匹配出来的日志根本不对。

解决方案是在日志中注入Trace上下文。OpenTelemetry提供了标准的traceparent字段定义,我们可以在日志输出时自动附加trace_idspan_id。以Java服务为例,使用Logback作为日志框架时,配置如下:

<!-- logback-spring.xml --> <configuration> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg trace_id=%X{trace_id} span_id=%X{span_id}%n</pattern> </encoder> </appender> <appender name="OTEL" class="io.opentelemetry.instrumentation.logback.v1_0.OpenTelemetryAppender"> <appender-ref ref="STDOUT" /> </appender> <root level="INFO"> <appender-ref ref="OTEL" /> </root> </configuration>

同时,在应用启动时初始化日志与Trace的关联:

// 日志关联初始化类 import io.opentelemetry.api.trace.Span; import io.opentelemetry.context.Scope; import org.slf4j.MDC; public class TraceLogInitializer { public static void setupTraceLogging() { // 获取当前Span上下文 Span currentSpan = Span.current(); if (currentSpan != null && currentSpan.getSpanContext().isValid()) { String traceId = currentSpan.getSpanContext().getTraceId(); String spanId = currentSpan.getSpanContext().getSpanId(); // 注入到MDC,供日志框架使用 MDC.put("trace_id", traceId); MDC.put("span_id", spanId); } } // 在关键业务方法入口调用 public void createPost(String userId, String content) { setupTraceLogging(); try (Scope scope = Span.current().makeCurrent()) { // 业务逻辑 logger.info("开始创建帖子, userId={}, contentLength={}", userId, content.length()); // 调用下游服务 auditService.checkContent(content); } finally { MDC.clear(); } } }

这个改动上线后,效果立竿见影。有一次社区出现评论加载失败的问题,我直接在Jaeger里找到对应的Trace,复制trace_id到Elasticsearch查询,瞬间就看到了所有相关服务的日志。排查时间从原来的2小时缩短到10分钟。

但新的问题随之而来:日志量暴涨。因为每条日志都带了Trace字段,Elasticsearch的存储体积增加了约40%。解决方案是优化日志采集策略,只保留ERROR级别日志的完整Trace关联,INFO日志则采用采样方式。我们在Collector里配置了日志过滤处理器:

# 日志过滤配置片段 processors: filter: logs: exclude: - attributes["log.level"] == "INFO" and attributes["sampled"] == false

这里的sampled标记由SDK在创建Span时根据采样策略设置。我们采用了动态采样,对于耗时超过500ms的请求,强制保留完整日志;正常请求则按10%比例采样。这个策略让日志存储成本回落到合理范围,同时保证了问题排查所需的数据完整性。

还有一个细节值得注意:OpenTelemetry的Log Bridge在v1.43.0 Java SDK中才真正稳定。之前我们用v1.38.0测试时,发现日志时间戳偶尔会出现偏差,导致Trace和Log的时间线对不上。升级SDK后问题消失,原因是新版本修复了上下文传播的竞态条件。这提醒我们,在采用较新的可观测性特性时,版本选择至关重要。

4. 生产环境采样策略设计:如何用动态采样平衡成本与覆盖率(附压测数据)

去年双十一大促前,我们那个订单核心链路的服务突然收到了告警,原因是接入 OpenTelemetry 后,全量采集 Trace 数据导致后端存储 Jaeger 的磁盘 IO 直接被打满,QPS 才到 3000 的时候写入延迟就飙到了 2 秒。我当时盯着监控面板,看着存储成本一天涨了 800 多块,才意识到采样策略不是随便配个参数就完事的。

我们当时用的是 OpenTelemetry SDK for Java v1.43.0,最开始偷懒直接开了全量采样,结果大促压测时,一个下单接口每秒 5000 次调用,每个 Trace 平均携带 12 个 Span,单条 Trace 大小约 8KB,算下来每秒要往 Collector 吐 40MB 数据,Collector 的批处理队列直接爆了。后来我翻了下 2024 年 11 月发布的 OpenTelemetry 规范 v1.33.0,里面明确提到了采样策略要兼顾「错误优先」和「链路完整性」,才定下了动态采样的方案。

我现在的做法是分三层做采样:第一层是入口网关的头部采样,第二层是服务内部的尾部采样,第三层是 Collector 的二次过滤。头部采样我们用的是 TraceIdRatioBasedSampler,但只给正常请求开 10% 的采样率,错误请求直接拉满到 100%。这里有个细节,Java SDK 的采样器是线程安全的,但要注意不要在每次请求里 new 新的采样器实例,不然会有额外的对象创建开销。我们当时有个开发同学写错了,把采样器初始化放到了 Controller 方法里,结果压测时每秒多了 2000 次对象创建,GC 次数直接涨了 3 倍。

下面是我们在订单服务里实际用的采样配置代码,基于 Java SDK v1.43.0:

import io.opentelemetry.api.trace.samplers.Sampler; import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; import io.opentelemetry.sdk.trace.samplers.TraceIdRatioBasedSampler; import io.opentelemetry.sdk.trace.samplers.ParentBasedSampler; import java.util.Map; public class OrderServiceTracerConfig { // 错误状态码集合,匹配到的请求全量采样 private static final Map<Integer, Boolean> ERROR_STATUS = Map.of( 500, true, 502, true, 503, true, 504, true, 400, true ); public static SdkTracerProvider buildTracerProvider() { // 自定义采样器:错误请求100%采样,正常请求10%采样 Sampler customSampler = new Sampler() { @Override public SamplingResult shouldSample( io.opentelemetry.context.Context parentContext, String traceId, String name, io.opentelemetry.api.trace.SpanKind spanKind, Map<String, Object> attributes, Map<String, Object> parentLinks) { // 先检查父Span的采样决策,如果有父Span就继承 if (parentContext != null) { io.opentelemetry.api.trace.Span parentSpan = io.opentelemetry.api.trace.Span.fromContext(parentContext); if (parentSpan.getSpanContext().isSampled()) { return SamplingResult.create(SamplingDecision.RECORD_AND_SAMPLE); } } // 检查请求属性里的状态码,错误请求全量采样 Object statusCode = attributes.get("http.status_code"); if (statusCode != null && ERROR_STATUS.containsKey(Integer.parseInt(statusCode.toString()))) { return SamplingResult.create(SamplingDecision.RECORD_AND_SAMPLE); } // 正常请求用10%的TraceId比例采样 return TraceIdRatioBasedSampler.create(0.1).shouldSample( parentContext, traceId, name, spanKind, attributes, parentLinks ); } @Override public String getDescription() { return "OrderServiceCustomSampler: error 100%, normal 10%"; } }; return SdkTracerProvider.builder() .addSpanProcessor(BatchSpanProcessor.builder( io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter.builder() .setEndpoint("http://otel-collector:4317") .build() ).build()) .setSampler(customSampler) .build(); } }

这个配置跑在 8C16G 的 K8s Pod 里,压测时 QPS 到 8000 的时候,Trace 数据量从之前的每秒 40MB 降到了 4.2MB,存储成本直接砍了 90%。但有个问题,10% 的采样率还是会漏掉一些偶发的慢请求,比如我们当时有个支付回调接口,偶尔会出现 800ms 以上的延迟,但采样率没覆盖到,排查了三天才找到是第三方支付接口的网络抖动。后来我们加了动态采样,根据接口的 P99 延迟调整采样率:如果接口 P99 超过 500ms,自动把采样率提到 50%,恢复正常后再降回 10%。这个逻辑是放在 Collector 里用 attributes 处理器做的,Collector 我们用的是 v0.108.0 版本,配置如下:

processors: attributes: actions: - key: sampling.rate value: "0.1" action: insert # 根据延迟动态调整采样率,这里用metric数据触发外部脚本更新配置,实际是结合Prometheus告警做的 # 简化版逻辑:如果http.duration_ms > 500,采样率改为0.5 # 真实场景我们会用otel-collector的extension做动态配置更新,避免重启

现在我们的采样策略是:错误请求 100% 采,慢请求(>500ms)50% 采,正常请求 10% 采,大促时整体采样率控制在 15% 以内,存储成本每月稳定在 1200 块左右,同时能覆盖 99% 的故障场景。之前有个同事觉得采样率越低越好,把正常请求采样率改成了 1%,结果有次库存同步接口出了逻辑错误,只影响了 0.5% 的请求,刚好没被采样到,导致问题扩散了 2 小时才发现。所以采样率不是越低越好,要在成本和覆盖率之间找平衡点,我们的经验是正常业务至少保留 5% 的采样率,核心链路不能低于 10%。

5. 性能优化与避坑指南:Java SDK v1.43.0在K8s中的资源调优与背压控制

上个月有个新来的开发同学问我,为什么他们组的用户服务接入 OpenTelemetry Java SDK v1.43.0 后,Pod 的内存占用从 1.2G 涨到了 2.8G,还时不时 OOM。我上去看了眼配置,发现他们把 BatchSpanProcessor 的队列大小设成了 50000,最大导出批次设成了 1000,这完全是在 K8s 环境里瞎配。

Java SDK 的 BatchSpanProcessor 默认队列大小是 2048,最大批次是 512,这个配置在 4C8G 的 Pod 里跑是没问题的,但如果你的服务 QPS 很高,比如我们的商品详情页服务,峰值 QPS 到 12000,每个请求平均 8 个 Span,那默认的队列很快就会满。队列满了之后,SDK 会触发背压控制,默认是把新的 Span 直接丢弃,同时打一条 warn 日志。但那个同学的服务更惨,他把队列设得太大,导致堆内存被 Span 对象占满,GC 回收不过来,直接 OOM。

我给他的第一个建议是把队列大小和 Pod 的内存挂钩。我们算过,一个 Span 对象在 Java 堆里大概占 1.2KB(包含 attributes、events、links 这些),如果队列大小是 2048,那最多占 2.4MB 内存,完全没问题。但如果设成 50000,就需要 60MB 内存,再加上其他业务对象,很容易撑爆堆。后来我们把他的配置改成了队列大小 4096,最大批次 1024,同时把导出超时时间从默认的 30 秒改成了 5 秒,因为 K8s 里的网络偶尔会有抖动,30 秒超时会导致队列里的 Span 堆积更多。

下面是我们在商品服务里实际用的 Java SDK 配置,适配 K8s 环境:

import io.opentelemetry.sdk.trace.SdkTracerProvider; import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; import java.time.Duration; public class ProductServiceTracerConfig { public static SdkTracerProvider initTracerProvider() { // OtlpGrpc导出器配置,适配K8s服务发现 OtlpGrpcSpanExporter spanExporter = OtlpGrpcSpanExporter.builder() .setEndpoint("http://otel-collector.prod:4317") // K8s内部Service地址 .setTimeout(Duration.ofSeconds(5)) // 导出超时5秒,避免长超时导致队列堆积 .setCompression("gzip") // 开启压缩,减少网络传输量,我们实测能减少40%的带宽占用 .build(); // BatchSpanProcessor配置,针对8C16G Pod调优 BatchSpanProcessor spanProcessor = BatchSpanProcessor.builder(spanExporter) .setMaxQueueSize(4096) // 队列大小,根据Pod内存调整,8C16G最多设到8192 .setMaxExportBatchSize(1024) // 每批次导出最大Span数,不要超过2048,不然导出耗时会增加 .setExporterTimeout(Duration.ofSeconds(5)) // 和导出器超时保持一致 .setScheduleDelay(Duration.ofMillis(100)) // 每100ms导出一次,默认是5000ms,我们调小了减少延迟 .build(); return SdkTracerProvider.builder() .addSpanProcessor(spanProcessor) .setSampler(ParentBasedSampler.create(TraceIdRatioBasedSampler.create(0.1))) .build(); } }

调完这个配置后,那个 Pod 的内存占用降到了 1.5G,再也没有 OOM 过。但还有个问题,就是 Collector 的背压会反向影响 SDK。我们之前用的 Collector v0.108.0 是 Agent 模式部署在每个 Pod 里,资源限制是 0.5C1G,当 QPS 到 10000 的时候,Collector 的接收队列满了,就会给 SDK 返回 429 状态码,SDK 收到 429 后默认会重试 3 次,每次重试间隔 1 秒,这会导致 SDK 的发送线程阻塞,进而影响业务线程。后来我们在 Collector 里加了 queued_retry 处理器,并且把 SDK 的导出器改成了不重试,因为 Collector 已经做了重试,SDK 再重试就是浪费资源:

# Collector配置片段,v0.108.0版本 receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 processors: queued_retry: num_workers: 4 # 根据CPU核数调整,我们0.5C就设了4个worker queue_size: 5000 # 队列大小,不要太大,不然内存占用高 retry_on_failure: true backoff_delay: 1s exporters: otlp: endpoint: jaeger-collector.prod:4317 compression: gzip service: pipelines: traces: receivers: [otlp] processors: [queued_retry] exporters: [otlp]

还有个坑是我们之前遇到的,Java SDK v1.43.0 里的 Resource 如果加了很多自定义属性,比如每个 Span 都加 user.idorder.id 这种高基数属性,会导致 Span 序列化后的体积变大,导出耗时增加。我们之前有个开发同学把用户的手机号加到了 Span 的 attributes 里,结果每个 Span 大了 200 字节,峰值的时候导出延迟从 50ms 涨到了 300ms。后来我们规范了 attributes 的使用,高基数属性只允许加 user.id(哈希后的),并且长度不超过 64 字符,低基数属性比如 http.methodservice.name 可以随便加。

另外要注意 K8s 的 Pod 资源限制和 SDK 的线程数匹配。Java SDK 默认会创建 2 个导出线程,每个线程栈大小是 1MB,如果 Pod 的 CPU 限制是 1C,那这 2 个线程还好,但如果 CPU 限制是 0.5C,导出线程就会抢业务线程的 CPU,导致接口耗时增加。我们的做法是把 Pod 的 CPU 限制至少设为 2C,给 SDK 的导出线程留足够的资源。之前有个服务 CPU 限制是 0.5C,接入 SDK 后接口 P99 从 120ms 涨到了 400ms,加了 CPU 限制到 2C 后就恢复正常了。

最后说下内存调优,我们给 Java 服务配的 JVM 参数是 -Xms2g -Xmx2g -XX:+UseG1GC,因为 G1 的停顿时间短,适合有遥测数据采集的场景。如果用了 ZGC 也可以,但要注意 ZGC 的内存占用会高一点。我们实测过,同样的配置,G1 的 GC 停顿时间是 10-50ms,ZGC 是 1-10ms,但 ZGC 会多占 20% 左右的堆内存,所以根据服务的延迟要求选就行。

站长实战手记

去年双十一前,我接了个挺头疼的活儿:给一个日订单量 30 万左右的电商系统做可观测性重构。当时系统里跑着 Java、Go 和 Python 三种技术栈,日志散在八个不同的地方,查一笔订单为什么没发货,得从网关、订单中心、库存服务到支付回调挨个翻日志,运气不好折腾一下午都定位不到根因。

我一开始直接上了 OpenTelemetry Java SDK v1.43.0,没做任何资源限制就往 K8s 里推。结果压测到 8000 QPS 的时候,Collector 直接把节点内存打满,业务 Pod 也开始频繁 OOM。后来我花了两个通宵排查,发现是 SDK 默认的批处理参数在容器环境里完全没适配,加上 Collector 的 memory_limiter 没提前配置,背压直接反噬到了应用侧。最后我把 batch processor 的 max_queue_size 调小,给 Collector 加了硬性的内存上限,又用 k8sattributes processor 自动补上 Pod 信息,才算把链路稳住。上线后,跨服务排查时间从平均 40 分钟降到了 5 分钟以内,这个数据是我实打实测出来的。

关于选型,我现在的看法很直接:如果你的系统规模小、技术栈单一,SkyWalking 这种开箱即用的方案反而更省心。但一旦你面临 多语言、多云或者未来有统一可观测性标准的需求,OTel 的生态优势就出来了。我踩过的坑是,别一上来就追求全量采集,动态采样一定要提前设计,不然成本会让你半夜惊醒。

给正在学这个的朋友一个建议:别光看文档,直接起一个本地 K8s 集群,把 Trace 和 Log 关联跑通,哪怕只跑通一个 Java 应用,也比看十篇理论文章强。