那年双十一前夕,我负责重构我们电商平台的订单核心链路可观测性体系。当时订单系统部署在混合云环境,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参数:
实际运行数据显示,接入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,需要在资源预算时提前规划。
订单系统完成SDK接入后,我面临下一个挑战:如何构建一个稳定、可扩展的采集层。我们的基础设施横跨两个机房,K8s集群有120个节点,还有30台裸金属服务器跑着遗留系统。如果直接在每台机器部署Collector Agent,运维复杂度会指数级上升。
我选择了Agent-Gateway混合部署模式。原因在于,纯Agent模式会导致后端存储连接数爆炸,而纯Gateway模式又会在网络分区时丢失数据。我们的方案是在每个K8s节点部署轻量Agent,负责采集本节点所有Pod的遥测数据,然后统一发送给机房的Gateway集群,最后由Gateway导出到后端存储。
Collector v0.108.0的架构正好支持这种分层部署。Agent侧配置相对简单,主要做数据接收和初步过滤:
这个配置的关键在于memory_limiter处理器。有一次生产环境Agent Pod突然OOM,排查发现是某个开发环境服务异常,每秒发送超过5万条span,直接打爆了Agent内存。加上内存限制器后,Collector会在内存使用超过512Mi时主动丢弃数据,虽然损失了部分遥测,但保证了Agent本身的稳定性。不这么做的话,一个异常服务就能拖垮整个节点的采集能力。
Gateway集群部署在独立虚拟机上,配置更复杂,需要承担数据路由和后端适配的职责:
Gateway的resource处理器解决了我们一个长期痛点:不同环境的数据没有统一标签。之前SkyWalking时代,生产环境和测试环境的服务名经常冲突,查询时难以区分。现在通过Collector自动注入environment和datacenter标签,数据一致性大幅提升。
性能调优方面,我们实测Gateway单实例在8核16G配置下,可以处理每秒3万条span的流量,CPU使用率保持在60%以下。当流量超过4万/s时,开始出现背压,解决方案是水平扩展Gateway实例,并通过负载均衡分发Agent的上报请求。
部署后第一个月,我们遇到了Collector的一个版本bug:v0.108.0的OTLP HTTP接收器在处理大批量日志时会出现解析错误。临时解决方案是回退到v0.107.0,同时调整日志采集策略,将单条日志大小限制在1MB以内。这个经历让我意识到,即使是最新的稳定版,在生产环境部署前也需要做充分的兼容性测试。
我们公司还有一个百万日活的社区产品,用户发帖、评论、点赞的链路涉及8个微服务。去年下半年开始,用户投诉帖子加载缓慢的问题激增,但研发团队每次排查都要登录5、6台服务器翻日志,平均定位时间超过2小时。
问题的核心在于Trace和Log处于割裂状态。虽然接入了OpenTelemetry,但日志还是通过Filebeat采集到Elasticsearch,Trace数据存在Jaeger,两者之间没有任何关联。有一次用户反馈发帖失败,我们查了Jaeger发现是内容审核服务超时,但审核服务的日志里没有请求ID,只能靠时间戳去匹配,结果因为时钟不同步,匹配出来的日志根本不对。
解决方案是在日志中注入Trace上下文。OpenTelemetry提供了标准的traceparent字段定义,我们可以在日志输出时自动附加trace_id和span_id。以Java服务为例,使用Logback作为日志框架时,配置如下:
同时,在应用启动时初始化日志与Trace的关联:
这个改动上线后,效果立竿见影。有一次社区出现评论加载失败的问题,我直接在Jaeger里找到对应的Trace,复制trace_id到Elasticsearch查询,瞬间就看到了所有相关服务的日志。排查时间从原来的2小时缩短到10分钟。
但新的问题随之而来:日志量暴涨。因为每条日志都带了Trace字段,Elasticsearch的存储体积增加了约40%。解决方案是优化日志采集策略,只保留ERROR级别日志的完整Trace关联,INFO日志则采用采样方式。我们在Collector里配置了日志过滤处理器:
这里的sampled标记由SDK在创建Span时根据采样策略设置。我们采用了动态采样,对于耗时超过500ms的请求,强制保留完整日志;正常请求则按10%比例采样。这个策略让日志存储成本回落到合理范围,同时保证了问题排查所需的数据完整性。
还有一个细节值得注意:OpenTelemetry的Log Bridge在v1.43.0 Java SDK中才真正稳定。之前我们用v1.38.0测试时,发现日志时间戳偶尔会出现偏差,导致Trace和Log的时间线对不上。升级SDK后问题消失,原因是新版本修复了上下文传播的竞态条件。这提醒我们,在采用较新的可观测性特性时,版本选择至关重要。
去年双十一大促前,我们那个订单核心链路的服务突然收到了告警,原因是接入 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:
这个配置跑在 8C16G 的 K8s Pod 里,压测时 QPS 到 8000 的时候,Trace 数据量从之前的每秒 40MB 降到了 4.2MB,存储成本直接砍了 90%。但有个问题,10% 的采样率还是会漏掉一些偶发的慢请求,比如我们当时有个支付回调接口,偶尔会出现 800ms 以上的延迟,但采样率没覆盖到,排查了三天才找到是第三方支付接口的网络抖动。后来我们加了动态采样,根据接口的 P99 延迟调整采样率:如果接口 P99 超过 500ms,自动把采样率提到 50%,恢复正常后再降回 10%。这个逻辑是放在 Collector 里用 attributes 处理器做的,Collector 我们用的是 v0.108.0 版本,配置如下:
现在我们的采样策略是:错误请求 100% 采,慢请求(>500ms)50% 采,正常请求 10% 采,大促时整体采样率控制在 15% 以内,存储成本每月稳定在 1200 块左右,同时能覆盖 99% 的故障场景。之前有个同事觉得采样率越低越好,把正常请求采样率改成了 1%,结果有次库存同步接口出了逻辑错误,只影响了 0.5% 的请求,刚好没被采样到,导致问题扩散了 2 小时才发现。所以采样率不是越低越好,要在成本和覆盖率之间找平衡点,我们的经验是正常业务至少保留 5% 的采样率,核心链路不能低于 10%。
上个月有个新来的开发同学问我,为什么他们组的用户服务接入 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 环境:
调完这个配置后,那个 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 再重试就是浪费资源:
还有个坑是我们之前遇到的,Java SDK v1.43.0 里的 Resource 如果加了很多自定义属性,比如每个 Span 都加 user.id、order.id 这种高基数属性,会导致 Span 序列化后的体积变大,导出耗时增加。我们之前有个开发同学把用户的手机号加到了 Span 的 attributes 里,结果每个 Span 大了 200 字节,峰值的时候导出延迟从 50ms 涨到了 300ms。后来我们规范了 attributes 的使用,高基数属性只允许加 user.id(哈希后的),并且长度不超过 64 字符,低基数属性比如 http.method、service.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 应用,也比看十篇理论文章强。