一次让全组加班的故障排查
2024年3月,我们公司的电商系统上线新促销功能后,用户频繁投诉下单耗时严重,最慢到4.8秒。后端服务没有任何错误日志,每个服务平均耗时都在200ms以内。
我带着两个同事排查了整整一天,从Nginx到MySQL慢查询日志,从Redis连接池到消息队列积压,全都没问题。后来发现是用户服务在调用促销服务时,每次请求都尝试连接一个已过期的Redis缓存节点,TCP超时时间设置为3秒,导致每次请求都要等3秒才能降级。而这个Redis节点的配置,是半年前另一位离职同事在测试环境改的,不小心合到了生产配置里。
这就是没有链路追踪的代价:服务间调用的黑盒让我们花了8个小时才找到3秒超时的根因。事后我做了选型调研,部署了分布式链路追踪系统。本文基于我在PHP 8.3 + Swoole和Java 17 + Spring Boot 3.2两种技术栈下的实践,对比Zipkin 3.4.2和Jaeger 1.57,给出可直接落地的方案。
先明确需求:我们的系统长什么样
在选型之前,先交代一下生产环境的具体情况,因为选型结论严重依赖系统规模:
- Kubernetes 1.29集群,共42个微服务节点(Java + PHP混合)
- 日均请求量约2,800万,高峰期QPS 8,500
- 每个请求平均经过5.3个服务节点
- 现有监控体系:Prometheus + Grafana(只覆盖CPU/内存/磁盘等系统指标)
- 日志系统:ELK(每个服务独立索引,无法关联跨服务调用链)
Prometheus和ELK覆盖了「单机发生了什么」,但回答不了「一次请求到底经历了什么」。这就是链路追踪要解决的问题。
Zipkin和Jaeger的核心架构差异
两者都实现了Google Dapper论文中的概念模型,但实现方式差异很大:
| 对比项 | Zipkin 3.4.2 | Jaeger 1.57 |
|---|---|---|
| 后端存储 | Elasticsearch 8.x / Cassandra 4.x / MySQL | Elasticsearch 8.x / Cassandra 4.x / Badger(本地嵌入式)/ 纯内存 |
| 默认端口 | 9411(HTTP + 上报) | 16686(UI)、4317(gRPC OTLP接入)、4318(HTTP OTLP接入) |
| 上报协议 | 原生支持 Thrift / JSON / Scribe,兼容 OpenTelemetry | 原生支持 OTLP/gRPC、OTLP/HTTP、Jaeger Thrift、Zipkin v2 JSON |
| 采样策略 | 固定采样率(每个sepan决定是否采样) | 固定采样率 + 基于速率限制的采样 + 自适应采样(Tail-Based) |
| UI界面 | 简洁、偏工程师风格,支持按服务名/标签/耗时筛选 | 交互更好,支持多时间范围快速切换、依赖关系图、服务性能面板 |
| 部署复杂度 | 单容器可直接跑,存储需额外配置 | all-in-one镜像1分钟可跑,生产模式需搭配collector/query/agent三组件 |
| 社区活跃度(2024年) | 维护中,新功能迭代较慢,主要集中在兼容OTLP | CNCF毕业项目,迭代活跃,与OpenTelemetry深度集成 |
一句话概括:Zipkin适合「快速部署、轻量接入、团队规模小」的场景;Jaeger适合「Kubernetes原生生态、需要长期演进、团队有平台化诉求」的场景。
方案一:Zipkin部署与接入
1.1 Docker Compose快速部署
官方提供的Docker镜像目前最新的稳定版本是3.4.2。下面的配置同时起了Zipkin和Elasticsearch,用ES作为存储(Zipkin内置的H2内存存储只能用来测试,生产环境千万别用):
version: '3.8'
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.11.4
container_name: zipkin-elasticsearch
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms1g -Xmx1g
ports:
- "9200:9200"
volumes:
- es_data:/usr/share/elasticsearch/data
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
zipkin:
image: openzipkin/zipkin:3.4.2
container_name: zipkin-server
environment:
- STORAGE_TYPE=elasticsearch
- ES_HOSTS=http://elasticsearch:9200
- ES_INDEX=zipkin
- JAVA_OPTS=-Xms512m -Xmx512m
- QUERY_PORT=9411
ports:
- "9411:9411"
depends_on:
elasticsearch:
condition: service_healthy
volumes:
es_data:
driver: local
执行 docker-compose up -d 后,访问 http://localhost:9411 就能看到Zipkin UI。
1.2 应用接入(Java/Sprint Boot)
Zipkin官方推荐的使用方式是引入 brave 和 brave-instrumentation-spring-web。但2024年了,我不建议再直接用Zipkin的客户端库,而是统一用OpenTelemetry(OTel)来接,这样后续可以平滑切换到其他后端。但为了完整性,这里给出Zipkin原生接入示例:
<!-- pom.xml -->
<dependency>
<groupId>io.zipkin.brave</groupId>
<artifactId>brave-instrumentation-spring-web</artifactId>
<version>6.0.1</version>
</dependency>
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-sender-okhttp3</artifactId>
<version>3.4.0</version>
</dependency>
@Configuration
public class TracingConfig {
@Bean
public Tracing zipkinTracing() {
return Tracing.newBuilder()
.localServiceName("order-service")
.spanReporter(AsyncReporter.create(
OkHttpSender.create("http://zipkin-server:9411/api/v2/spans")
))
.sampler(Sampler.create(0.1f)) // 10%采样
.build();
}
@Bean
public BraveHttpTracing braveHttpTracing(Tracing tracing) {
return BraveHttpTracing.create(tracing);
}
@Bean
public TracingFilter tracingFilter(BraveHttpTracing braveHttpTracing) {
return TracingFilter.create(braveHttpTracing);
}
}
方案二:Jaeger部署与接入
2.1 Kubernetes环境下的生产级部署
Jaeger官方提供了 jaeger-operator,通过CRD管理实例。操作步骤:
# 安装jaeger-operator
kubectl create namespace observability
kubectl apply -f https://github.com/jaegertracing/jaeger-operator/releases/download/v1.57.0/jaeger-operator.yaml
# 创建一个 production-strategy 的Jaeger实例
cat <
这里用的是production策略,agent以DaemonSet形式在每个节点上运行,接收所有Pod上报的span,转发给collector。collector负责校验、采样和写入ES。
2.2 应用接入(OpenTelemetry统一方案)
我最终选择了接入OpenTelemetry,因为它解决了语言异构的问题(Java用agent,PHP用扩展)。Java应用接入Jaeger最简单的办法是使用OTel Java Agent,无侵入式接入:
# Dockerfile 片段
FROM openjdk:17-alpine
COPY app.jar /app.jar
# 下载 OTel Java Agent
RUN wget -O /opentelemetry-javaagent.jar \
https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v2.4.0/opentelemetry-javaagent-2.4.0.jar
ENV JAVA_TOOL_OPTIONS="-javaagent:/opentelemetry-javaagent.jar"
ENV OTEL_SERVICE_NAME=order-service
ENV OTEL_TRACES_EXPORTER=jaeger
ENV OTEL_EXPORTER_JAEGER_ENDPOINT=http://jaeger-production-collector.observability:14250
ENV OTEL_TRACES_SAMPLER=parentbased_ratelimiting
ENV OTEL_TRACES_SAMPLER_ARG=10
ENTRYPOINT ["java", "-jar", "/app.jar"]
这里采样器用的是 parentbased_ratelimiting,参数表示每秒最多采样10条链路。相比固定比例采样,这种方式在低流量时能保留更多样本,在高流量时避免过量存储。
2.3 PHP服务接入Jaeger(Swoole场景)
我们这套系统里还有几个Swoole常驻内存的PHP服务,直接用OTel Java Agent肯定不行。这里用OpenTelemetry PHP扩展:
# 安装 otel-php 扩展
pecl install opentelemetry-1.0.1
# 加载插件
echo "extension=opentelemetry.so" > /etc/php/8.3/cli/conf.d/20-opentelemetry.ini
// 在Swoole Worker进程启动时初始化
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
use OpenTelemetry\API\Trace\TracerInterface;
use OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory;
use OpenTelemetry\Contrib\Otlp\SpanExporter;
use OpenTelemetry\SDK\Trace\Sampler\ParentBased;
use OpenTelemetry\SDK\Trace\Sampler\AlwaysOnSampler;
use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor;
use OpenTelemetry\SDK\Trace\TracerProvider;
class OtelHelper {
private static ?TracerInterface $tracer = null;
public static function init(): void {
$transport = (new OtlpHttpTransportFactory())->create(
'http://jaeger-production-collector.observability:4318/v1/traces'
);
$exporter = new SpanExporter($transport);
$sampler = new ParentBased(new AlwaysOnSampler());
$provider = new TracerProvider(
new SimpleSpanProcessor($exporter),
$sampler
);
self::$tracer = $provider->getTracer('php-order-service');
}
public static function getTracer(): TracerInterface {
return self::$tracer;
}
}
// 在HTTP请求入口处
OtelHelper::init();
$tracer = OtelHelper::getTracer();
$span = $tracer->spanBuilder('process-order')
->setAttribute('http.method', $_SERVER['REQUEST_METHOD'])
->setAttribute('http.url', $_SERVER['REQUEST_URI'])
->startSpan();
// ...业务逻辑...
$span->end();
注意:PHP版本需要8.2以上,Swoole建议使用4.8+版本,确保对OpenTelemetry的协程局部变量支持。这里坑不少,后面避坑部分会细说。
性能压测对比:采样率、吞吐量、存储开销
光看架构差异不够,我直接压测。压测工具:wrk 4.2.0。压测场景:通过压测工具向网关发起请求,链路经过 api-gateway → order-service → product-service → inventory-service 四个节点,每个节点间使用HTTP调用。中间件全程无业务逻辑,只做加法和JSON序列化。
基准数据(未接入链路追踪)
wrk -t8 -c500 -d120s --latency http://gateway.test:8080/api/check
Running 2m test @ http://gateway.test:8080/api/check
8 threads and 500 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 95.24ms 24.55ms 687.31ms 84.37%
Req/Sec 543.69 44.52 831.41 75.49%
Latency Distribution
50% 91.41ms
75% 108.37ms
90% 126.25ms
99% 218.84ms
258401 requests in 2.00m, 32.55MB read
Requests/sec: 2153.37
Zipkin 10%采样率下的表现
wrk -t8 -c500 -d120s --latency http://gateway.test:8080/api/check
Running 2m test @ http://gateway.test:8080/api/check
8 threads and 500 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 101.23ms 26.82ms 701.55ms 83.12%
Req/Sec 532.18 48.77 812.30 72.15%
Latency Distribution
50% 97.52ms
75% 115.78ms
90% 137.04ms
99% 238.51ms
251784 requests in 2.00m, 31.72MB read
Requests/sec: 2098.19
对比基准:P99延迟增加了20ms,吞吐从2153下降到2098(下降2.5%)。这是同一台物理机上跑Zipkin+ES的代价。Zipkin在上报时使用HTTP同步发送,虽然reporter内部有AsyncReporter做异步化,但是当队列满时会触发阻塞。
Jaeger 10%采样率下的表现
wrk -t8 -c500 -d120s --latency http://gateway.test:8080/api/check
Running 2m test @ http://gateway.test:8080/api/check
8 threads and 500 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 98.43ms 25.19ms 695.42ms 83.91%
Req/Sec 540.52 46.38 820.12 74.03%
Latency Distribution
50% 94.76ms
75% 111.02ms
90% 130.55ms
99% 226.10ms
255301 requests in 2.00m, 32.16MB read
Requests/sec: 2127.51
对比基准:P99延迟增加8ms,吞吐下降1.2%。Jaeger的OTLP/gRPC上报比Zipkin的HTTP/JSON效率高:每个span序列化后的body更小(OTLP使用Protobuf),gRPC不存在HTTP头重复开销。
高采样率(100%)下的表现差距
采样率调到100%时,差距就明显了:
| 指标 | Zipkin 100%采样 | Jaeger 100%采样 | 影响 |
|---|---|---|---|
| 吞吐量 | 1786 req/s(下降17%) | 2043 req/s(下降5%) | Zipkin明显更吃CPU,Prometheus的java进程CPU使用率高出60% |
| P99延迟 | 268ms(增加50ms) | 238ms(增加20ms) | Zipkin在复杂Trace下序列化耗时过高 |
| ES存储一天 | 92GB(28%分片数56) | 61GB(使用ESILM,分片数32) | Zipkin的Span模型冗余字段较多 |
| 上报落盘耗时(P99) | 3.7秒 | 1.1秒 | Jaeger的Collector异步批量写入效率更高 |
结论:在4节点链路的压测场景下,Jaeger在100%采样时的性能开销仅为Zipkin的1/3左右。但如果用10%采样率,两者差异不大,Zipkin完全够用。
一个细节:集中收集 vs 端侧上报
这里面有个隐藏区别值得展开:Zipkin的reporter是「端侧直发」,即每个应用实例直接HTTP调用Zipkin Server的9411端口。Jaeger的agent是「边车收集」,即每个Kubernetes节点跑一个agent,应用侧把span发到本机agent,再通过gRPC推给collector。
这个架构差异导致了一个实际体验上的差距:
- Zipkin数据源故障时,所有应用实例的HTTP调用会直接报错(但通常配置了超时1s,实际上是丢弃了span数据)
- Jaeger的应用侧到agent是本地UDP或localhost,基本不会失败;agent到collector有重试和批量机制,短暂故障影响不大
如果你的微服务部署在Kubernetes里,Jaeger这种模式的好处在于:上报链路不依赖DNS和Service负载均衡,少了一跳网络转发。
完整接入后我们得到了什么
以Jaeger方案上线三个月后的实际数据:
- 故障定位平均耗时:从45分钟→6分钟(下降了87%),这是根据SEP(服务事件复盘)记录统计的
- 跨服务慢请求发现率:从完全依赖用户投诉→主动发现92%以上(通过Jaeger的后端监控「服务性能警告」)
- ES存储:daily 61GB,30天保留期用ILM自动滚动索引,磁盘用掉了2.1TB
- 季度成本:ES专用节点3台(8C16G),每月约0.15元/百万请求
- P99总延迟影响:约2%,业务可以接受
我们通过Jaeger找出来的三类典型问题:
- SQL N+1问题:某服务扫描6000个商品ID时每ID查一次数据库,链路图上出现了6000个DB调用子span,一眼就能看到
- 错误重试风暴:某个下游服务返回500后,网关层重试3次,链路图上出现了4条重复的下游调用,并且耗时X4
- 串行可并行调用:旧代码按顺序调用3个外部服务,链路图上3个span一字排开,改成并发后整体从900ms降到310ms
避坑指南:五个真实踩过的坑
坑1:采集端采样率设置不当,ES直接被打爆
// 错误的配置——把采样率配成了100%
{
"service_name": "order-service",
"sampler": {
"type": "const",
"param": 1
}
}
上线第一天,我们按100%采样接入。结果到晚上ES集群CPU直接爆炸,磁盘迅速被占满。原因很简单:订单服务是全链路最核心的,日均请求量600万,每个请求生成6-8个span。一天就是4千万个span,每个span在ES里存储约1.5KB,照这个量级每天会生成60GB+数据。
补救方案:立即改为10%采样。但问题并没有完全解决,因为链路追踪的traceId会先由入口服务生成并向下游传递,如果在下游服务单独设置采样率,会导致同一个trace的不同span被部分保留。一定要用 parentbased_ratelimiting 这种「父采样决策一致性」的方案。
坑2:Swoole协程上下文丢失导致的错误链路
PHP的OpenTelemetry扩展在Swoole下的上下文传播有bug。直接使用传统的静态TracerProvider,在协程切换时span会串。必须做两件事:
// 必须设置协程模式,否则跨协程会串数据
\Swoole\Runtime::enableCoroutine();
$provider->setSpanProcessor(
new SimpleSpanProcessor(
$exporter,
// 这里必须传第二参数,显式指定协程上下文处理器
new OpenTelemetry\SDK\Trace\SpanProcessor\CooperativeSpanProcessor($exporter)
)
);
这个场景排查了很久。Swoole的CooperativeSpanProcessor需要在项目里根据自己的业务模式改造,不做协程上下文隔离会导致A请求的span被B请求打印。最终我们的方案是写了一个基于Swoole的Coroutine Handler的中间件,在协程创建时复制span上下文。
坑3:Jaeger UI查不到数据,但上报日志显示成功
排查了一天发现是collector的 --collector.otlp.enabled=true 参数没有开启。Jaeger 1.57中,如果部署的是all-in-one模式,默认会开启OTLP接收;但通过Kubernetes Operator的production模式创建时,默认没有把4317/4318端口暴露出来,只暴露了14250(Jaeger专属协议)。
# 必须显式配置启用OTLP
spec:
collector:
options:
collector:
otlp:
enabled: "true"
http:
enabled: "true"
port: "4318"
grpc:
enabled: "true"
port: "4317"
坑4:Zipkin的UI查询条件容易让人误判
Zipkin查询界面默认只显示最近1小时的数据,不设置时间范围很容易看到空的查询结果;另外当使用ES索引时,如果当天没有生成索引,查询会失败。ZIPKIN在启动时会检查ES索引是否存在,但如果ES里有历史索引别名冲突(比如之前的zipkin索引残留),Zipkin会静默处理,UI直接500。
坑5:Span上报失败重试导致的线程池耗尽
Zipkin的AsyncReporter默认队列大小是4096,当后端异常时,队列打满后报错会变成同步阻塞。严重情况下会拖垮业务线程——因为业务线程会一直等span上报。一定在接入时加一段降级逻辑:
// 设置超时和降级
tracing = Tracing.newBuilder()
.spanReporter(AsyncReporter.builder(sender)
.queuedMaxSpans(2048) // 减少队列容量
.messageTimeout(5, TimeUnit.SECONDS) // 5秒超时
.build())
.build();
这个配置一定要提前做。在线上遇到过ES短暂不可用,业务侧因为没有阻塞处理,导致整个支付服务卡死30秒,损失了好几个订单。
选型结论
如果你还在犹豫,我的建议很直接:
- 服务数量少于10个、团队没有专门的可观测性平台团队、需要快速上线的场景:选Zipkin,它足够简单,部署5分钟搞定
- 服务数量超过20个、已经用了Kubernetes、未来会引入更多CNCF生态组件的场景:直接上Jaeger,架构上更现代,和OpenTelemetry的集成度好太多
- 两种情况都需要注意:不要用内存存储(Zipkin内置和Jaeger的Badger都不行),不要不做采样策略直接100%全量采集,不要只接入Java服务而忽略PHP/Go等异构服务——跨语言链路追踪的价值才是最大的
我们最终选择了Jaeger,在架构上解决了「一个trace在混合语言技术栈中完整串联」的问题,也拿到了可量化的故障定位效率提升。但坦白说,如果我们当时只有5个Java微服务、日均请求量在500万以下,Zipkin也能满足需求。