链路追踪:Dapper 模型与埋点的取舍

📅
2 分钟阅读
·

系列目录

  1. MySQL 索引与慢查询:B+ 树如何减少扫描
  2. 声明式事务之下:InnoDB 的 MVCC 与锁
  3. 接入层:Nginx 反向代理与 OpenResty 的边界
  4. Web 容器与 Netty:线程模型之下的 IO 模型
  5. Redis(上):缓存用法与单线程模型的限制
  6. Redis(下):超出缓存用途的用法:锁、队列与排行榜
  7. 数据库访问层:连接池与 MyBatis 的显式 SQL
  8. Kafka(上):吞吐的来源是顺序 IO
  9. Kafka(下):生产端、broker 与消费端的可靠性配置
  10. RPC 框架:像本地调用一样调远程的代价
  11. 消息语义:按 at-least-once 设计业务代码
  12. 熔断与限流中间件:把失败当作正常状态管理
  13. 唯一 ID 中间件 Leaf:号段模式与雪花模式
  14. RocketMQ 的事务消息与延迟消息
  15. 分布式任务调度:同一时刻只跑一份
  16. ZooKeeper/etcd:小数据强一致的协调服务
  17. 分库分表:用应用层复杂度换数据库容量
  18. 分布式事务:2PC 的代价与业务补偿模式
  19. Elasticsearch 的近实时模型与误用为数据库的后果
  20. 链路追踪:Dapper 模型与埋点的取舍(本篇)

使用现场:日志关联与调用树

2015 年做外卖服务稳定性大盘时(见 外卖服务稳定性热力图大盘),我想看的是各服务的负载状态:每个服务是一个节点,颜色按 QPS 高低变化,连线粗细按调用量变化。大盘展示服务负载,无法说明某一条具体请求经过哪些服务、各花多少时间。一个下单接口超时,大盘只能显示订单服务负载较高,无法区分是订单服务调用库存服务慢,还是订单服务自身写库慢。

2016 年做日志规范时(见 日志与线上问题定位),我们用 MDC 加 traceId 把同一次请求在各服务的日志串起来。从入口错误日志取得 tid 后,检索四个环节带该 tid 的日志,结合 cost 字段判断耗时集中在哪个环节。这套做法能显示请求经过的服务,但仍有三个限制:tid 只关联日志,不生成调用树和耗时瀑布;耗时靠每段日志里的 cost 字段,漏埋的段落就没有耗时数据;跨机器比对时间戳还要求时钟足够同步。

2021 年前后公司链路追踪平台已经成熟,接入后,排查接口超时可以在平台页面输入 traceId,或从入口错误日志的链接跳转。页面直接展示调用树:根节点是网关,子节点是各下游 RPC,再往下是 DB 调用和缓存调用;每条 span 带开始时间、结束时间和耗时。因此可以定位耗时或错误出现在哪一跳。

本文只讨论使用层面。公司平台内部的采集、存储和查询实现不在本文范围;本文描述使用方可见的形态和机制。数据模型来自 Google 的 Dapper 论文,相关部分按论文和 OpenTracing 规范说明。

Dapper 的数据模型

一次跨服务请求的 Trace/Span 树与上下文传递路径

链路追踪的数据模型来自 Google 2010 年的 Dapper 论文。一次请求经过的所有服务调用组成一棵 Trace,树上的每个节点是一个 Span。Span 记录一次操作的开始时间、结束时间、所属服务、操作名,以及一组键值对标签(Annotation,如 HTTP 状态码、SQL 语句摘要)。Span 之间的父子关系通过三个 ID 还原:

  • traceId:整条 Trace 的唯一标识,从入口生成,全程不变。
  • spanId:当前 Span 的唯一标识,每跳生成新的。
  • parentId:父 Span 的 spanId,指向调用方的 spanId。

根 Span 的 parentId 为空,它是调用树的起点。下游服务收到请求时,从请求头里读出 traceId 和调用方的 spanId,生成自己的 spanId,并将 parentId 记为调用方的 spanId。每跳都接续上下文并生成自己的 span。沿 parentId 链回溯可以还原整棵树。每个 span 的耗时由自身的开始和结束时间戳计算,不使用跨机器时间差。

上下文传递靠请求头。HTTP 调用把 traceId/spanId/parentId(以及采样标志 sampled)塞进自定义请求头,如 X-Trace-IdX-Parent-Span-Id;RPC 框架把上下文塞进调用附件(attachment)。Dapper 论文里 Google 用的是自己内部的协议,OpenTracing/OpenTelemetry 规范把这套语义标准化了,定义了 tracer.injecttracer.extract 两个操作,把上下文从载体里写入和读出。无论载体是 HTTP 头还是 RPC 附件,语义一致。

采样决策由入口层完成。Dapper 论文规定由入口决定请求是否采样,并将 sampled 标志随上下文透传,下游不再二次决策。每条 Trace 的采样决定需要由所有下游服务遵守;如果每跳各自决策,采到的 span 无法还原完整的调用树。

埋点方式

Span 需要由代码在调用开始时创建、结束时关闭,并将上下文从父进程传给子进程。埋点方式可分为三类,它们在侵入性和运行成本上各有差异。

手动 API。 业务代码里显式调用追踪 SDK,创建 span、设标签并关闭 span。OpenTracing 规范定义了这套 API。它可以精确覆盖指定位置,但会侵入业务代码,也容易遗漏:每个外部调用都要包一层,漏掉的调用会使调用树断开。手动埋点可用于补充框架未覆盖的内部逻辑;将其作为主要埋点方式,需要持续检查覆盖情况。

// OpenTracing 手动埋点:围绕一次下游调用创建子 span
Tracer tracer = GlobalTracer.get();
Scope scope = tracer.buildSpan("callInventory").startActive(true);
try {
    // 上下文由 SDK 自动注入到 RPC 框架的 attachment
    Response resp = inventoryClient.deduct(req);
    tracer.activeSpan().setTag("http.status", resp.getCode());
    return resp;
} finally {
    scope.close();  // 必须在 finally 关闭,否则 span 泄漏
}

框架拦截器。 在 RPC 框架、HTTP 客户端、数据库连接池的拦截点埋点。Spring Cloud、Dubbo 都提供拦截器扩展点,在调用前后自动创建和关闭 span。业务代码无需修改,但只覆盖框架内置的调用路径;业务自行创建的 HttpClient、裸 JDBC 调用不会被拦截。框架拦截器适用于可由框架扩展点覆盖的调用。

Java Agent 字节码增强。 启动时通过 -javaagent 挂载探针,在类加载时修改字节码,往指定方法前后插入 span 创建和关闭逻辑。SkyWalking、Pinpoint 使用这种方式。业务代码不需要直接调用追踪 API,但探针仍需要匹配框架版本,并在运行时执行埋点逻辑。

探针的第一个成本是框架版本兼容。探针需要为每个框架版本编写增强规则,识别调用入口、参数和上下文传递方式。框架升级后,内部实现可能改变,例如类名或方法签名变化,探针的增强规则会失效,导致无法埋点或埋点位置错误。SkyWalking 8.x 的 plugin list 列出支持的框架和版本范围,表外版本不保证支持。升级框架前需要确认探针支持的版本范围。

第二个成本是性能开销。开销来自三处:每个 span 创建时分配对象、记录时间戳和标签;上下文在调用前后在线程上下文里 set 和 clear;span 数据从应用进程上报到后端,通常通过 HTTP 或 gRPC 批量上报。前两项消耗 CPU 和内存,第三项产生网络开销。Agent 在框架方法前后插入的逻辑同样要在运行时执行。高频调用路径上的 span 创建和上报开销会累积,因此通常需要采样。

采样策略

全量上报的成本较高。一次请求经过 N 跳服务就产生 N 个 span,每个 span 含时间戳、标签和上下文。万级 QPS 的入口、每条请求平均 5 到 10 跳时,一天会产生几十亿个 span,存储量达到 TB 级。绝大多数正常请求不会用于排障。采样率直接影响存储成本和低频问题的复现概率。

固定采样率(head-based)。 入口按固定概率决定是否采样,例如千分之一。采样请求的 sampled=true 随上下文透传,下游都采样;未采样请求的下游也不采样。实现简单,开销恒定。限制是低频问题的复现概率较低:千分之一的采样率下,一天发生几十次的偶发问题未必能采到;提高采样率会使存储成本线性上涨。

自适应采样。 采样率随流量动态调整:流量高峰自动降低采样率以降低存储量,流量低谷提高采样率以增加问题复现机会。目标是固定每天的存储量,并按流量调整采样率。实现比固定采样复杂,需要实时统计流量并调整决策。限制是采样率不再可预测,低流量时段即使提高采样率也未必能覆盖极低频问题。

后置采样(tail-based)。 入口不决策,所有请求都先采集 span,等整条 Trace 完成后再根据规则决定是否保留:慢于阈值、出错或命中特定标签的 Trace 保留,正常 Trace 丢弃。它能保留异常链路,固定采样率则容易漏掉低频异常。代价是所有 span 都要先写入内存或临时存储,决策后才丢弃,增加内存和临时存储压力;决策要等整条 Trace 完成,慢链路的决策等待时间更长;实现复杂度也高于 head-based。CAT 使用全量采集加实时聚合路线,依靠聚合粒度与保留期控制成本,不按 tail-based 的规则在整条 Trace 完成后丢弃数据。SkyWalking 8.x 没有 tail-based:探针侧只做 head-based 采样,服务端另外提供按错误 segment 强制保留等补充手段,但不会等整条 Trace 完成后再决策。

固定采样率的限制是低频问题复现概率,自适应采样的限制是采样率可预测性,tail-based 的成本是内存和实现复杂度。策略选择取决于成本预算和异常链路保留率要求。

SkyWalking、CAT 与 Zipkin 的差异

公司平台由我作为使用方接入过,但底层使用自研采集和类 SkyWalking 模型,本文未涉及其源码。SkyWalking 和 CAT 只接触过 demo 和文档,未在生产中深度运维;下述比较限于这些材料。

SkyWalking 8.x 使用 Java Agent 字节码增强,业务代码无需直接调用追踪 API,UI 提供拓扑图和调用树瀑布图。它的模型接近 Dapper:Trace 是 Span 树,上下文通过 SW8 协议头透传。使用 Java Agent 可以降低接入时对业务代码的改动,但需要处理前述框架版本兼容和性能开销。CAT 3.x 以埋点上报和聚合大盘为主,核心数据结构是消息树(MessageTree),用于实时聚合和告警,拓扑图和调用链是其视图之一。CAT 的埋点偏重手动和拦截器,对业务代码的侵入性高于 Agent 方式。Zipkin 是 Twitter 开源的早期实现,模型直接来自 Dapper,实现较轻量,UI 较简洁,但生态和自动化程度低于前两者。

我对这三个产品的比较来自文档和 demo,没有生产对比经验。生产选型需要确认探针对所用框架版本的覆盖情况、性能开销能否适应实际 QPS,以及采样策略和存储成本是否符合预算。压测和试运行才能验证这些条件。

使用边界

  1. 链路追踪定位耗时和错误位置。 trace 可以定位耗时集中在哪一跳、错误发生在哪一跳。GC 停顿、锁竞争、慢 SQL 执行计划等原因需要结合 metrics 和 logs 排查。trace 提供位置,metrics 提供量化趋势,logs 提供上下文细节,三者关注的维度不同。

  2. 埋点覆盖决定 trace 完整性。 未埋点的调用会使调用树断开。Agent 方式覆盖框架内置调用,业务自行创建的 HTTP 客户端、裸 JDBC、反射调用不会被自动拦截,需要手动补埋点。接入后要确认关键链路在树上连续,未覆盖的调用会限制排查范围。

  3. 异步和 MQ 链路需要显式传递上下文。 span 上下文存在线程上下文里,线程切换、线程池提交、MQ 投递和消费都会使上下文丢失。这与 2016 年 MDC 跨线程需要手动拷贝属于同一类问题(见 日志与线上问题定位)。未显式传递上下文时,trace 会在异步边界断开,调用树不完整。

  4. 采样率影响存储成本和问题复现概率。 固定采样率实现简单,但低频问题较难复现;tail-based 可以保留异常链路,但内存成本和实现复杂度较高。选型前要计算实际 QPS 和跳数下全量上报的日均存储量,确定可接受的采样率,并评估未采样问题的复现周期;这些条件决定采样策略和采样率。

  5. 单个 span 的耗时由本机时间戳计算。 每个 span 的耗时由自身的开始和结束时间戳计算,不使用跨机器时间差。跨机器时钟偏差会使 span 在时间轴上的先后顺序错位,影响瀑布图的解读。机器时钟同步(NTP)仍然需要执行,但不影响单个 span 耗时的正确性。

  6. 框架升级前需要检查 Agent 探针兼容性。 探针不支持新版本时,埋点会失效或位置错误。这类失效往往不会报错,只会使调用树缺少 span,较难发现。升级检查清单应包含探针版本和框架版本的兼容矩阵。

当时的局限

公司平台只涉及使用层面,本文未阅读其采集和存储实现源码,也不说明探针内部字节码增强机制、后端存储的 trace 索引和查询实现。SkyWalking 和 CAT 没有生产环境运维经验,三个产品的生产差异来自公开资料,未经实际对比验证。OpenTelemetry 在 2021 年仍在合并 metrics、traces、logs 三路能力;本文只引用 OpenTracing 规范的 API 语义,不展开 OpenTelemetry 的合并进展。

参考资料


725 字 · 69 段落
ximing

Follow onGitHub

相关文章