某次排查过程中,核心交易网关及下游订单服务突然出现大面积请求超时。监控大盘显示,网关集群 P99 延迟从日常的 30ms 飙升至 800ms 以上,Load Average 直接冲破 40,并伴随偶发的 502 错误。
排查的最终结论让人极度无语:业务逻辑没有 Bug,数据库也没有慢查,纯粹是因为开发团队在上线 OpenTelemetry(以下简称 OTel)Java Agent 时,在 15,000 QPS 的核心链路上开启了 100% 全量采样(always_on),甚至通过自定义拦截器把动辄上百 KB 的 HTTP 请求/响应 JSON Body 全部塞进了 Span Attribute 里。
结果就是,海量的 Span 数据序列化直接榨干了应用进程的 CPU,频繁触发 JVM Young GC;同时,突发的网络 I/O 洪峰瞬间打挂了后端的 OTel Collector 集群,导致监控流水线全线崩溃。监控系统把被监控业务搞瘫痪,这是架构设计上的奇耻大辱。
案发现场与硬核排查
接到告警后,第一时间登机查看宿主机指标。top 显示 Java 进程 CPU 使用率超 900%(宿主机 16C)。
习惯性用 perf top -p 和 jstack 抓取现场,发现大量业务线程并没有在等待 DB 或外部 RPC,而是阻塞在 OTel 的底层方法上:
# jstack 局部抓取
"http-nio-8080-exec-15" #45 daemon prio=5 os_prio=0 tid=0x00007f... runnable [0x00007f...]
java.lang.Thread.State: RUNNABLE
at java.lang.String.toCharArray(String.java:3000)
at io.opentelemetry.api.common.ArrayBackedAttributesBuilder.put(ArrayBackedAttributesBuilder.java:55)
at io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor.onStart(...)
...
at io.grpc.netty.shaded.io.netty.handler.codec.http2.Http2ConnectionHandler.write(...)
很明显,大量 CPU 时钟周期被消耗在了 OTel 提取 Attribute 属性和 gRPC 序列化上。 再去 K8s 集群里看一眼 OTel Collector 的状态:
kubectl get pods -n observability | grep otel-collector
otel-collector-7f8b9... 0/1 OOMKilled 14 2d
Collector 疯狂重启,dmesg -T | grep -i oom 显示 Collector 容器因为突破了内存 Limit 被系统无情抹杀。
翻看业务服务上线时注入的环境变量,果不其然,写着极其草率的配置:
# 致命配置 1:无脑全量采样
OTEL_TRACES_SAMPLER=always_on
# 致命配置 2:拦截并记录所有 Header
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST=.*
# 致命配置 3:业务代码中自定义的 Span Processor,把 Body 全 dump 进去了
为什么说这是愚蠢的设计?
1. 违背了可观测性的“无损”底线
链路追踪(Tracing)的核心原则是 Out-Of-Band(带外)和低消耗。在 15k QPS 的网关上搞 100% 采样,算一笔简单的数学题:
假设每个请求产生 10 个 Span,每个 Span 携带 5KB 的 Body/Header 垃圾数据。
15,000 * 10 * 5KB = 750 MB/s 的吞吐量。
这不仅意味着应用节点每秒要分配并回收将近 1GB 的字符串对象(疯狂推高 CPU 和 GC 耗时),还意味着集群内部产生了一个高达数十 Gbps 的监控流量洪峰。Collector 不 OOM 才有鬼。
2. 错把 Trace 当 Log 用
这是一个极度常见且低级的架构认知误区:企图在分布式追踪的 Span 里还原所有的业务细节。
Span 的作用是刻画请求的拓扑结构、耗时分布和关键状态转换(Status Code),它应该尽可能轻量。真正的业务载荷(Request Payload、Response Body、详细报错堆栈)应该落在结构化日志(Log)里,然后通过统一的 trace_id 进行关联。
正确的工程化落地姿势
想要既兼顾性能,又不漏掉关键错误的链路,必须从“采样策略”和“Trace-Log 关联”两方面下手整改。
1. 废弃 Head-Based 全量采样,启用 Tail-Based 尾部采样
在微服务端(OTel Agent),采用 parentbased_traceidratio 策略,比如只做 5% 的头部采样。
但在 OTel Collector 侧,配置 Tail Sampling Processor。把全量 Span 先缓冲在 Collector 的内存中,当且仅当该链路符合特定条件(如:包含 ERROR 状态、耗时超过 1000ms)时,才强制保留整条链路并上报;否则按比例丢弃。
微服务侧启动参数修正:
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.05
OTel Collector 核心配置片段(otel-collector-config.yaml):
processors:
# 必须配置内存限制防 OOM
memory_limiter:
check_interval: 1s
limit_mib: 4000
spike_limit_mib: 800
tail_sampling:
decision_wait: 10s # 等待链路收集的窗口期
num_traces: 50000
expected_new_traces_per_sec: 10000
policies:
# 策略1:保留所有包含错误的链路
[
{
name: errors-policy,
type: status_code,
status_code: {status_codes: [ERROR]}
},
# 策略2:保留所有 P99 耗时异常的链路 (例如 > 1000ms)
{
name: latency-policy,
type: latency,
latency: {threshold_ms: 1000}
},
# 策略3:其余正常链路,只做 5% 的兜底采样
{
name: probabilistic-policy,
type: probabilistic,
probabilistic: {sampling_percentage: 5}
}
]
2. Trace 与 Log 严格关联,卸载 Span 负担
严禁在 Span Attribute 中记录大型字符串。将业务细节全部转入日志输出,利用 SLF4J/Logback 的 MDC(Mapped Diagnostic Context)机制,自动将当前上下文的 trace_id 和 span_id 注入到日志记录中。
在 Logback 的 layout 配置中输出关联 ID:
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [trace_id=%X{trace_id:-} span_id=%X{span_id:-}] - %msg%n</pattern>
后续在 Kibana 或 Grafana Loki 中排查故障时,先通过 Trace 视图找到慢请求的 trace_id,再带着 trace_id 去日志中心一键捞出所有微服务的业务级打印(包括完整的 Request JSON),这才是现代可观测性架构的标配玩法。
排查清单
-
Agent 采样率检查:检查所有接入端环境变量
OTEL_TRACES_SAMPLER,严禁在生产高并发链路使用always_on。 -
Span 载荷审计:抓取 OTel 导出的 Span 数据,审查 Attribute 大小。清理未脱敏的、体积过大的 HTTP Body / SQL 绑定参数拦截记录。
-
Collector 内存兜底:检查 OTel Collector 配置,必须强行启用
memory_limiterProcessor,防止流量突刺引发组件 OOM 进而波及整个 K8s Node。 -
MDC 上下文验证:随机抽查业务日志(ES/Loki),验证是否 100% 覆盖了
trace_id,且在跨线程池异步处理时 MDC 传递没有丢失(如有丢失,需补充线程池包装类)。