微服务链路追踪SkyWalking第五课 SkyWalking中Trace落地实现方案

1193 篇文章 260 订阅
订阅专栏
564 篇文章 141 订阅
订阅专栏

第12讲:剖析 Trace 在 SkyWalking 中的落地实现方案(上)

通过前面几课时的学习,我们已经了解 SkyWalking Agent 启动的基本流程、插件增强代码的基本逻辑以及核心 BootService 实现的功能。从本课时开始,我们将深入分析 SkyWalking Agent 中 Trace 相关的基础组件。

在 04 课时中我们介绍了 OpenTracing 的基本概念,SkyWalking 中 Trace 的相关概念以及实现类与 OpenTracing 中的概念基本类似,像 Trace、Span、Tags、Logs 等核心概念,在 SkyWalking Agent 中都有对应实现,只是在细微实现上略有区别的,其中最重要的是: SkyWalking 的设计在 Trace 级别和 Span 级别之间加了一个 Segment 概念,用于表示一个服务实例内的 Span 集合。

Trace ID

在分布式链路追踪系统中,用户请求的处理过程会形成一条 Trace 。Trace ID 作为 Trace 数据的唯一标识,在面对海量请求的时候,需要保证其唯一性。与此同时,还要保证生成 Trace ID 不会带来过多开销,所以在业务场景中依赖数据库(自增键或是类似 Meituan-Dianping/Leaf 的 ID 生成方式)都不适合 Trace 的场景。

这种要求快速、高性能生成唯一 ID 的需求场景,一般会将 snowflake 算法与实际的场景集合进行改造。

snowflake 算法是 Twitter 开源的分布式 ID 生成算法 。snowflake 算法的核心思想是将一个 ID(long类型)的 64 个 bit 进行切分,其中使用 41 个 bit 作为毫秒数,10 个 bit 作为机器的 ID( 5 个 bit 记录数据中心的 ID,5 个 bit 记录机器的 ID ),12 bit 作为毫秒内的自增 ID,还有一个 bit 位永远是 0。snowflake 算法生成的 ID 结构如下图所示:

snowflake 算法的好处是 ID 可以直接靠算法在内存中产生,内存内的锁控制并发,不需依赖 MySQL 这样的外部依赖,无维护成本。缺点就是每个机器节点在每毫秒内只可以产生 4096 个 ID,超出这个范围就会溢出。另外,如果机器回拨了时间,就会生成重复的 ID。

ID 类是 SkyWalking 中对全局唯一标识的抽象,其生成策略与 snowflake 算法类似。SkyWalking ID 由三个 long 类型的字段(part1、part2、part3)构成,分别记录了 ServiceInstanceId、Thread ID 和 Context 生成序列。Context 生成序列的格式是:

${时间戳} * 10000 + 线程自增序列([09999])

ID 对象序列化之后的格式是将 part1、part2、part3 三部分用“.”分割连接起来 :

${ServiceInstanceId}.${Thread ID}.(${时间戳} * 10000 + 线程自增序列([09999]))

GlobalIdGenerator 是 Agent 中用来生成全局唯一 ID 的基础工具类,在 generate() 方法中的实现如下:

public static ID generate() {
    // THREAD_ID_SEQUENCE是 ThreadLocal<IDContext>类型,即每个线程
    // 维护一个 IDContext对象
    IDContext context = THREAD_ID_SEQUENCE.get(); 
    return new ID(SERVICE_INSTANCE_ID, // service_intance_id
        Thread.currentThread().getId(), // 当前线程的ID
        context.nextSeq() // 线程内生成的序列号
    );
}

IDContext.nextSeq() 方法的实现如下,其中 timestamp() 方法在返回时间戳的时候,会处理时间回拨的场景(使用 Random 随机生成一个时间戳),nextThreadSeq() 方法的返回值在 [0 , 9999] 这个范围内循环:

private long nextSeq() {
    return timestamp() * 10000 + nextThreadSeq();
}

GlobalIdGenerator 不仅用于生成 Trace ID ,其他需要唯一 ID 的地方也会通过其 nextSeq() 方法生成。

SkyWalking 中使用 DistributedTraceId 类来抽象 Trace ID,其中封装了一个 ID 类型的字段。DistributedTraceId 有两个实现类,如下图所示:

其中,NewDistirbutedTraceId 负责生成新 Trace ID,请求刚刚进入系统时,会创建 NewDistirbutedTraceId 对象,其构造方法内部会调用 GlobalIdGenerator.generate() 方法生成 ID 对象。

PropagatedTraceId 负责处理 Trace 传播过程中的 TraceId。PropagatedTraceId 的构造方法接收一个 String 类型参数(也就是在跨进程传播时序列化后的 Trace ID),解析之后得到 ID 对象。

在后面的介绍中还会涉及另一个与 Trace ID 相关的类 —— DistributedTraceIds,它表示多个 Trace ID 的集合,其底层封装了一个 LinkedList<DistributedTraceId> 集合,用于记录相关的 Trace ID。

TraceSegment

在 SkyWalking 中,TraceSegment 是一个介于 Trace 与 Span 之间的概念,它是一条 Trace 的一段,可以包含多个 Span。在微服务架构中,一个请求基本都会涉及跨进程(以及跨线程)的操作,例如, RPC 调用、通过 MQ 异步执行、HTTP 请求远端资源等,处理一个请求就需要涉及到多个服务的多个线程。TraceSegment 记录了一个请求在一个线程中的执行流程(即 Trace 信息)。将该请求关联的 TraceSegment 串联起来,就能得到该请求对应的完整 Trace。

下面我们先来介绍 TraceSegment 的核心字段:

  • traceSegmentId(ID 类型):TraceSegment 的全局唯一标识,是由前面介绍的 GlobalIdGenerator 生成的。
  • refs(List<TraceSegmentRef> 类型):它指向父 TraceSegment。在我们常见的 RPC 调用、HTTP 请求等跨进程调用中,一个 TraceSegment 最多只有一个父 TraceSegment,但是在一个 Consumer 批量消费 MQ 消息时,同一批内的消息可能来自不同的 Producer,这就会导致 Consumer 线程对应的 TraceSegment 有多个父 TraceSegment 了,当然,该 Consumer TraceSegment 也就属于多个 Trace 了。
  • relatedGlobalTraces(DistributedTraceIds 类型):记录当前 TraceSegment 所属 Trace 的 Trace ID。
  • spans(List<AbstractTracingSpan> 类型):当前 TraceSegment 包含的所有 Span。
  • ignore(boolean 类型):ignore 字段表示当前 TraceSegment 是否被忽略。主要是为了忽略一些问题 TraceSegment(主要是对只包含一个 Span 的 Trace 进行采样收集)。
  • isSizeLimited(boolean 类型):这是一个容错设计,例如业务代码出现了死循环 Bug,可能会向相应的 TraceSegment 中不断追加 Span,为了防止对应用内存以及后端存储造成不必要的压力,每个 TraceSegment 中 Span 的个数是有上限的(默认值为 300),超过上限之后,就不再添加 Span了。

下图展示了一个 TraceSegment 的核心结构:

Span

TraceSegment 是由多个 Span 构成的,AbstractSpan 抽象类是 SkyWalking 对 Span 概念的抽象,下图是 Span 的继承关系:

首先需要明确的是,我们最终直接使用的 Span 分为 3 类:

  • EntrySpan:当请求进入服务时会创建 EntrySpan 类型的 Span,它也是 TraceSegment 中的第一个 Span。例如,HTTP 服务、RPC 服务、MQ-Consumer 等入口服务的插件在接收到请求时都会创建相应的 EntrySpan。
  • LocalSpan:它是在本地方法调用时可能创建的 Span 类型,在后面介绍 @Trace 注解的时候我们还会看到 LocalSpan。
  • ExitSpan:当请求离开当前服务、进入其他服务时会创建 ExitSpan 类型的 Span。例如, Http Client 、RPC Client 发起远程调用或是 MQ-producer 生产消息时,都会产生该类型的 Span。

下面我们按照 Span 的继承结构,自顶层接口开始逐个向下介绍。首先,AsyncSpan 接口定义了一个异步 Span 的基本行为:

  • prepareForAsync() 方法:Span 在当前线程结束了,但是未被彻底关闭,依然是存活的。
  • asyncFinish()方法:当前 Span 真正关闭。它与 prepareForAsync() 方法成对出现。

这两个方法在异步框架的插件中会见到。

AbstractSpan 也是一个接口,其中定义了 Span 的基本行为,其中的方法比较重要:

  • getSpanId() 方法:用来获得当前 Span 的 ID,Span ID 是一个 int 类型的值,在其所属的 TraceSegment 中唯一,在创建 Span 对象时生成,从 0 开始自增。
  • setOperationName()/setOperationId() 方法:用来设置 operation 名称(或 operation ID),这两个信息是互斥的。它们在 AbstractSpan 的具体实现(即 AbstractTracingSpan)中,分别对应 operationId 和 operationName 两个字段,两者只能有一个字段有值。

operationName 即前文介绍的 EndpointName,可以是任意字符串,例如,在 Tomcat 插件中 operationName 就是 URI 地址,Dubbo 插件中 operationName 为 URL + 接口方法签名。

  • setComponent() 方法:用于设置组件类型。它有两个重载,在 AbstractTracingSpan 实现中,有 componentId 和 componentName 两个字段,两个重载分别用于设置这两个字段。在 ComponentsDefine 中可以找到 SkyWalking 目前支持的组件类型。
  • setLayer() 方法:用于设置 SpanLayer,也就是当前 Span 所处的位置。SpanLayer 是个枚举,可选项有 DB、RPC_FRAMEWORK、HTTP、MQ、CACHE。
  • tag(AbstractTag, String) 方法:用于为当前 Span 添加键值对的 Tags。一个 Span 可以有多个 Tags。AbstractTag 中不仅包含了 String 类型的 Key 值,还包含了 Tag 的 ID 以及 canOverwrite 标识。AbstractTracingSpan 实现通过维护一个  List<TagValuePair> 集合(tags 字段)来记录 Tag 信息,TagValuePair 中则封装了 AbstractTag 类型的 Key 以及 String 类型的 Value。
  • log() 方法:用于向当前 Span 中添加 Log,一个 Span 可以包含多条日志。在 AbstractTracingSpan 实现中通过维护一个 List<LogDataEntity> 集合(logs 字段)来记录 Log。LogDataEntity 会记录日志的时间戳以及 KV 信息,以异常日志为例,其中就会包含一个 Key 为“stack”的 KV,其 value 为异常堆栈。
  • start() 方法:开启 Span,其中会设置当前 Span 的开始时间以及调用层级等信息。
  • isEntry() 方法:判断当前是否是 EntrySpan。EntrySpan 的具体实现后面详细介绍。
  • isExit() 方法:判断当前是否是 ExitSpan。ExitSpan  的具体实现后面详细介绍。
  • ref() 方法:用于设置关联的 TraceSegment 。

AbstractTracingSpan 实现了 AbstractSpan 接口,定义了一些 Span 的公共字段,其中的部分字段在介绍 AbstractSpan 接口时已经提到了,下面简单介绍一下前面未涉及的字段含义:

protected int spanId; // span的ID
protected int parentSpanId; // 记录父Span的ID
protected List<TagValuePair> tags; // 记录Tags的集合
protected long startTime, endTime; // Span的起止时间
protected boolean errorOccurred = false; // 标识该Span中是否发生异常
protected List<TraceSegmentRef> refs; // 指向所属TraceSegment
// context字段指向TraceContext,TraceContext与当前线程绑定,与TraceSegment
// 一一对应
protected volatile AbstractTracerContext context;

AbstractTracingSpan 中提供的方法也比较简单,基本都是上述字段的 getter/setter 方法,这些方法不再展开赘述。这里需要注意两个方法:

  • finish(TraceSegment) 方法:该方法会关闭当前 Span ,具体行为是用 endTime 字段记录当前时间,并将当前 Span 记录到所属 TraceSegment 的 spans 集合中。
  • transform() 方法:该方法会在 Agent 上报 TraceSegment 数据之前调用,它会将当前 AbstractTracingSpan 对象转换成 SpanObjectV2 对象。SpanObjectV2 是在 proto 文件中定义的结构体,后面 gRPC 上报 TraceSegment 数据时会将其序列化。

StackBasedTracingSpan 在继承 AbstractTracingSpan 存储 Span 核心数据能力的同时,还引入了栈的概念,这种 Span 可以多次调用 start() 方法和 end() 方法,但是两者调用次数必须要配对,类似出栈和入栈的操作。

下面以 EntrySpan 为例说明为什么需要“栈”这个概念,EntrySpan 表示的是一个服务的入口 Span,是 TraceSegment 的第一个 Span,出现在服务提供方的入口,例如,Dubbo Provider、Tomcat、Spring MVC,等等。 那么为什么 EntrySpan 继承 StackBasedTracingSpan 呢? 从前面对 SkyWalking Agent 的分析来看,Agent 插件只会拦截指定类的指定方法并对其进行增强,例如,Tomcat、Spring MVC 等插件的增强逻辑中就包含了创建 EntrySpan 的逻辑(后面在分析具体插件实现的时候,会看到具体的实现代码)。很多 Web 项目会同时使用到这两个插件,难道一个 TraceSegment 要有两个 EntrySpan 吗?显然不行。

SkyWalking 的处理方式是让 EntrySpan 继承了 StackBasedTracingSpan,多个插件同时使用时,整个架构如下所示:

其中,请求相应的 EntrySpan 处理流程如下:

  1. 当请求经过 Tomcat 插件时(即图中 ① 处),会创建 EntrySpan 并第一次调用 start() 方法,启动该 EntrySpan。

在 start() 方法中会有下面几个操作:

  1. 将 stackDepth 字段(定义在 StackBasedTracingSpan 中)加 1,stackDepth 表示当前所处的插件栈深度 。
  2. 更新 currentMaxDepth 字段(定义在 EntrySpan 中),currentMaxDepth 会记录该EntrySpan 到达过的插件栈的最深位置。
  3. 此时第一次启动 EntrySpan 时会更新 startTime 字段,记录请求开始时间。

此时插件栈(这是为了方便理解而虚拟出来一个栈结构,实际上只有 stackDepth、currentMaxDepth 两个字段,并不会用到栈结构,也不会记录请求经过的插件)的状态如下图所示:

  1. 当请求经过 Spring MVC 插件时(即图中 ② 处),不会再创建新的 EntrySpan 了,而重新调用该 EntrySpan 的 start() 方法,其中会继续将 stackDepth 以及 currentMaxDepth 字段加 1 。注意,再次调用 start() 方法时不会更新 startTime 字段了,因为请求已经开始处理了。此时插件栈的状态如下图:

  1. 当请求经过业务逻辑处理完成之后,开始进入 Spring MVC 插件的后置处理逻辑时(即图中 ③ 处),会第 1 次调用 EntrySpan.finish() 方法,其中会将 stackDepth 减 1,即 Spring MVC 插件出栈,此时插件栈的状态如下图:

  1. 最后进入 Tomcat 插件的后置处理逻辑(即图中 ④ 处),其中会第 2 次调用 finish() 方法,此时 stackDepth 再次减 1,此时 stackDepth 减到了 0 ,整个插件栈已经空了,会调用父类 AbstractTracingSpan 的 finish() 方法将当前 EntrySpan 添加到关联的 TraceSegment 中。

这里需要注意两个点,一是在调用 start() 方法时,会将之前设置的 component、Tags、Log 等信息全部清理掉(startTime不会清理),上例中请求到 Spring MVC 插件之前(即 ② 处之前)设置的这些信息都会被清理掉。二是 stackDepth 与 currentMaxDepth 不相等时(上例中 ③ 处),无法记录上述字段的信息。通过这两点,我们知道 EntrySpan 实际上只会记录最贴近业务侧的 Span 信息。

StackBasedTracingSpan 除了将“栈”概念与 EntrySpan 结合之外,还添加了 peer(以及 peerId)字段来记录远端地址,在发送远程调用时创建的 ExitSpan 会将该记录用于对端地址。

ExitSpan 表示的是出口 Span,如果在一个调用栈里面出现多个插件嵌套的场景,也需要通过“栈”的方式进行处理,与上述逻辑类似,只会在第一个插件中创建 ExitSpan,后续调用的 ExitSpan.start() 方法并不会更新 startTime,只会增加栈的深度。当然,在设置 Tags、Log 等信息时也会进行判断,只有 stackDepth 为 1 的时候,才会能正常写入相应字段。也就是说,ExitSpan 中只会记录最贴近当前服务侧的 Span 信息。

一个 TraceSegment 可以有多个 ExitSpan,例如,Dubbo A 服务在处理一个请求时,会调用 Dubbo B 服务,在得到响应之后,会紧接着调用 Dubbo C 服务,这样,该 TraceSegment 就有了两个完全独立的 ExitSpan。

LocalSpan 则比较简单,它表示一个本地方法调用。LocalSpan 直接继承了 AbstractTracingSpan,由于它未继承 StackBasedTracingSpan,所以也不能 start 或 end 多次,在后面介绍 @Trace 注解的相关实现时,还会看到 LocalSpan 的身影。


第13讲:剖析 Trace 在 SkyWalking 中的落地实现方案(下)

TraceSegmentRef

TraceSegment 中除了 Span 之外,还有另一个需要介绍的重要依赖 —— TraceSegmentRef,TraceSegment 通过 refs 集合记录父 TraceSegment 的信息,它的核心字段大概可以分为 3 类:

  • 父 Span 信息

    • traceSegmentId(ID 类型):父 TraceSegment 的 ID。

    • spanId(int 类型):父 Span 的 ID,与 traceSegmentId 结合就可以确定父 Span。

    • type(SegmentRefType 类型):SegmentRefType 是个枚举,可选值有:CROSS_PROCESS、CROSS_THREAD,分别表示跨进程调用和跨线程调用。

  • 父应用(或者说,上游调用方)信息

    • peerId 和 peerHost:父应用(即上游调用方)的地址信息。

    • parentServiceInstanceId(int 类型):父应用(即上游应用)的 ServiceInstanceId。

    • parentEndpointName 和 parentEndpointId:父应用的(即上游应用)的 Endpoint 信息。

  • 入口信息(在整条 Trace 中都会传递该信息)

    • entryServiceInstanceId:入口应用的 ServiceInstanceId。

    • entryEndpointName 和 entryEndpointId:入口 Endpoint 信息。

Context

SkyWalking 中的每个 TraceSegment 都与一个 Context 上下文对象一对一绑定,Context 上下文不仅记录了 TraceSegment 的上下文信息,还提供了管理 TraceSegment 生命周期、创建 Span 以及跨进程(跨线程)传播相关的功能。


AbstractTracerContext 是对上下文概念的抽象,其中定义了 Context 上下文的基本行为:

  • inject(ContextCarrier) 方法:在跨进程调用之前,调用方会通过 inject() 方法将当前 Context 上下文记录的全部信息注入到 ContextCarrier 参数中,Agent 后续会将 ContextCarrier 序列化并随远程调用进行传播。ContextCarrier 的具体实现在后面会详细分析。

  • extract(ContextCarrier) 方法:跨进程调用的接收方会反序列化得到 ContextCarrier 对象,然后通过 extract() 方法从 ContextCarrier 中读取上游传递下来的 Trace 信息并记录到当前的 Context 上下文中。

  • ContextSnapshot capture() 方法:在跨线程调用之前,SkyWalking Agent 会通过 capture() 方法将当前 Context 进行快照,然后将快照传递给其他线程。

  • continued(ContextSnapshot) 方法:跨线程调用的接收方会从收到的 ContextSnapshot 中读取 Trace 信息并填充到当前 Context 上下文中。

  • getReadableGlobalTraceId() 方法: 用于获取当前 Context 关联的 TraceId。

  • createEntrySpan()、createLocalSpan() 方法、createExitSpan() 方法:用于创建 Span。

  • activeSpan() 方法:用于获得当前活跃的 Span。在 TraceSegment 中,Span 也是按照栈的方式进行维护的,因为 Span 的生命周期符合栈的特性,即:先创建的 Span 后结束。

  • stopSpan(AbstractSpan) 方法:用于停止指定 Span。


AbstractTraceContext 有两个实现类,如下图所示:



IgnoredTracerContext 表示该 Trace 将会被丢失,所以其中不会记录任何信息,里面所有方法也都是空实现。这里重点来看 TracingContext,其核心字段如下:

  • samplingService(SamplingService 类型):负责完成 Agent 端的 Trace 采样,后面会展开介绍具体的采样逻辑。

  • segment(TraceSegment 类型):它是与当前 Context 上下文关联的 TraceSegment 对象,在 TracingContext 的构造方法中会创建该对象。

  • activeSpanStack(LinkedList<AbstractSpan> 类型):用于记录当前 TraceSegment 中所有活跃的 Span(即未关闭的 Span)。实际上 activeSpanStack 字段是作为栈使用的,TracingContext 提供了 push() 、pop() 、peek() 三个标准的栈方法,以及 first() 方法来访问栈底元素。

  • spanIdGenerator(int 类型):它是 Span ID 自增序列,初始值为 0。该字段的自增操作都是在一个线程中完成的,所以无需加锁。

管理 Span

一般情况下,在 Agent 插件的前置处理逻辑中,会调用 createEntrySpan() 方法创建 EntrySpan,在 TracingContext 的实现中,会检测 EntrySpan 是否已创建,如果是,则不会创建新的 EntrySpan,只是重新调用一下其 start() 方法即可。TracingContext.createEntrySpan() 方法的大致实现如下:


public AbstractSpan createEntrySpan(final String operationName) {
    if (isLimitMechanismWorking()) {
       // 前面提到过,默认配置下,每个TraceSegment只能放300个Span
        NoopSpan span = new NoopSpan(); // 超过300就放 NoopSpan
        return push(span); // 将Span记录到activeSpanStack这个栈中
    }
    AbstractSpan entrySpan;
    final AbstractSpan parentSpan = peek(); // 读取栈顶Span,即当前Span
    final int parentSpanId = parentSpan == null ? -1 : 
            parentSpan.getSpanId();
    if (parentSpan != null && parentSpan.isEntry()) {
        // 更新 operationId(省略operationName的处理逻辑),省略
        // EndpointNameDictionary 的处理,其核心逻辑在前面的小节已经介绍过了。
        entrySpan = parentSpan.setOperationId(operationId);
        // 重新调用 start()方法,前面提到过,start()方法会重置
        // operationId(以及或operationName)之外的其他字段
        return entrySpan.start();
    } else {
        // 新建 EntrySpan对象,spanIdGenerator生成Span ID并递增
        entrySpan = new EntrySpan(spanIdGenerator++, parentSpanId, 
                        operationId);
        // 调用 start()方法,第一次调用start()方法时会设置startTime
        entrySpan.start();
        // 将新建的Span添加到activeSpanStack栈的栈顶
        return push(entrySpan);
    }
}


前面通过 demo-webapp 示例介绍了多次调用 EntrySpan.start() 方法中栈相关的概念,这里依旧通过 demo-webapp 示例简单介绍一下 activeSpanStack 这个栈的工作原理,示例 Trace 如下图所示:




当请求经过 Tomcat 插件时会创建 EntrySpan(调用 start() 方法)并入栈到 activeSpanStack 中;请求经过 Spring MVC 插件时不会创建新的 EntrySpan,只会重新调用 start() 方法。接下来在调用 first() 方法时会创建相应的 LocalSpan 并入栈,first() 方法调用结束之后会将该 LocalSpan 出栈;调用 second() 方法时与 Span 出入栈逻辑相同;最后在通过 Dubbo 远程调用 HelloService.say() 方法的时候,会创建相应的 ExitSpan 并入栈,结束 Dubbo 调用之后其相应的 ExitSpan 会出栈,此时整个 activeSpanStack 栈空了,TraceSegment 也就结束了。整个过程如下图所示:



createLocalSpan() 方法负责创建 LocalSpan 对象并添加到 activeSpanStack 集合中,LocalSpan 的 start() 方法中没有栈的概念,存在多次调用的情况,只在这里调用一次即可。


createExitSpan() 方法负责创建 ExitSpan,与 createEntrySpan() 方法类似:


public AbstractSpan createExitSpan(String operationName, 
         String remotePeer) {
    AbstractSpan exitSpan;
    // 从activeSpanStack栈顶获取当前Span
    AbstractSpan parentSpan = peek(); 
    if (parentSpan != null && parentSpan.isExit()) {
        // 当前Span已经是ExitSpan,则不再新建ExitSpan,而是调用其start()方法
        exitSpan = parentSpan; 
    } else {
        // 当前Span不是 ExitSpan,就新建一个ExitSpan
        final int parentSpanId = parentSpan == null ? -1 :
                parentSpan.getSpanId();
        exitSpan =  new ExitSpan(spanIdGenerator++, parentSpanId, 
                operationId, peerId);
        push(exitSpan); // 将新建的ExitSpan入栈
    }
    exitSpan.start();// 调用start()方法
    return exitSpan;
}


了解了 TracingContext 创建以及维护 3 类 Span 的实现之后,我们来看关闭 Span 的方法 —— stopSpan() 方法,它会将当前 activeSpanStack 栈顶的 Span 关闭并出栈,同时在整个 activeSpanStack 栈空了之后,会尝试关闭当前 TraceSegment,具体实现如下:


public boolean stopSpan(AbstractSpan span) {
    AbstractSpan lastSpan = peek(); // 获取当前栈顶的Span对象
    if (lastSpan == span) { // 只能关闭当前活跃Span对象,否则抛异常
        if (lastSpan instanceof AbstractTracingSpan) {
            if (lastSpan.finish(segment)) { // 尝试关闭Span
                //当Span完全关闭之后,会将其出栈(即从activeSpanStack中删除)
                pop(); 
            }
        } else {
            pop(); // 针对NoopSpan类型Span的处理
        }
    } else {
        throw new IllegalStateException("Stopping the unexpected...");
    }
    // TraceSegment中全部Span都关闭(且异步状态的Span也关闭了),则当前
    //  TraceSegment也会关闭,该关闭会触发TraceSegment上传操作,后面详述
    if (checkFinishConditions()) { 
        finish(); 
    }
    return activeSpanStack.isEmpty();
}

跨进程(跨线程)传播

在开始介绍 Context 与跨进程传播相关的实现之前,需要先介绍一下它们的参数 —— ContextCarrier。从类名就可以看出 ContextCarrier 是 Context 上下文的搬运工(Carrier),它实现了 Serializable 接口,负责在进程之间搬运 TracingContext 的一些基本信息,跨进程调用涉及 Client 和 Server 两个系统,所以 ContextCarrier 中的字段 Client 和 Server 含义不同:

  • traceSegmentId(ID 类型):它记录了 Client 中 TraceSegment ID;从 Server 角度看,记录的是父 TraceSegment 的 ID。

  • spanId(int 类型):从 Client 角度看,它记录了当前 ExitSpan 的 ID;从 Server 角度,看记录的是父 Span ID。

  • parentServiceInstanceId(int 类型):它记录的是 Client 服务实例的 ID。

  • peerHost(String 类型):它记录了 Server 端的地址(这里 peerName 和 peerId 共用了同一个字段)。以 "#" 开头时记录的是 peerName,否则记录的是 peerId,在 inject() 方法(或 extract() 方法)中填充(或读取)该字段时会专门判断处理开头的"#"字符。

  • entryEndpointName(String 类型):它记录整个 Trace 的入口 EndpointName,该值在整个 Trace 中传播。

  • parentEndpointName(String 类型):它记录了 Client  入口 EndpointName(或 EndpointId)。以 "#" 开头的时候,记录的是 EndpointName,否则记录的是 EndpointId。

  • primaryDistributedTraceId(DistributedTraceId 类型):它记录了当前 Trace ID。

  • entryServiceInstanceId(int 类型):它记录了当前 Trace 的入口服务实例 ID。

跨进程传播 Context 上下文信息的核心流程大致为:远程调用的 Client 端会调用 inject(ContextCarrier) 方法,将当前 TracingContext 中记录的 Trace 上下文信息填充到传入的 ContextCarrier 对象。后续 Client 端的插件会将 ContextCarrier 对象序列化成字符串并将其作为附加信息添加到请求中,这样,ContextCarrier 字符串就会和请求一并到达 Server 端。Server 端的入口插件会检查请求中是否携带了 ContextCarrier 字符串,如果存在 ContextCarrier 字符串,就会将其进行反序列化,然后调用 extract() 方法从 ContextCarrier 对象中取出 Context 上下文信息,填充到当前 TracingContext(以及 TraceSegmentRef) 中。


例如在 demo-webapp 和 demo-provider 的示例中,ContextCarrier 的传播过程如图所示,序列化之后的 ContextCarrier 字符串会放到 RpcContext 中:



这里需要深入介绍一下 ContextCarrier 序列化之后的格式,具体实现在其 serialize() 方法中:


// 有多个版本的结构,这里只关注最新的V2版本
String serialize(HeaderVersion version) { 
    return StringUtil.join('-', "1",
        Base64.encode(this.getPrimaryDistributedTraceId().encode()),
        Base64.encode(this.getTraceSegmentId().encode()),
        this.getSpanId() + "",
        this.getParentServiceInstanceId() + "",
        this.getEntryServiceInstanceId() + "",
        Base64.encode(this.getPeerHost()),
        Base64.encode(this.getEntryEndpointName()),
        Base64.encode(this.getParentEndpointName()));
}


ContextCarrier 序列化之后得到的字符串分为 9 个部分,每个部分通过"-"(中划线)连接。在 deserialize() 方法中实现了 ContextCarrier 反序列化的逻辑,即将上述字符串进行切分并赋值到对应的字段中,具体逻辑为 serialize() 方法的逆操作,这里不再展开分析。


下面来看 TracingContext 对跨线程传播的支持,这里涉及 capture() 方法和 continued() 方法。跨线程传播时使用 ContextSnapshot 为 Context 上下文创建快照,因为是在一个 JVM 中,所以 ContextSnapshot 不涉及序列化的问题,也无需携带服务实例 ID 以及 peerHost 信息,其他核心字段与 ContextCarrier 类似,这里不再展开介绍。

总结

这个课时我们主要学习了 SkyWalking 对 Trace 基本概念的实现,首先介绍了 Trace ID 的实现结构,之后分析了 TraceSegment 如何维护底层 Span 集合以及父子关系,接下来深入剖析了 3 种类型的 Span 以及 StackBasedTracingSpan 引入的栈的概念。最后剖析了与 TraceSegment 相对应的 TracingContext 的实现,它管理着 3 类 Span 的生命周期,提供了跨进程/跨线程传播的基本方法。


在后面的课时中,我们将深入学习与 Trace 相关的 BootService 实现,分析 SkyWalking Agent 如何在这些基础组件上有条不紊的收集并发送 Trace 数据。


第14讲:收集、发送 Trace 核心原理,Agent 与 OAP 的大动脉

在前面的课时中,我们深入介绍了 SkyWalking 对 Trace 基本概念的实现。本课时我们将继续深入学习 Trace 相关的 BootService 接口实现类以及 Trace 收集和发送的核心逻辑。Trace 相关的 BootService 接口实现类如下图所示:

sw0.png

ContextManager

ContextManager 的主要职责就是管理前文介绍的 TracingContext,它会通过 ThreadLocal 将 TracingContext 对象与当前线程进行绑定,这样就实现了 TraceSegment、TracingContext 和 线程三方之间的关联。

ContextManager 有三个核心字段:

  • CONTEXT(ThreadLocal 类型)
    :通过该字段可以将一个 TracingContext 对象与一个线程进行关联。
  • RUNTIME_CONTEXT(ThreadLocal 类型)
    :RuntimeContext 底层封装了一个 ConcurrentHashMap 集合,可以为当前 TracingContext 记录一些附加信息。
  • EXTEND_SERVICE(ContextManagerExtendService 类型):ContextManagerExtendService 也实现了 BootService 接口,它主要负责创建 TracingContext 对象。

虽然 ContextManager 实现了 BootService 接口,但是其 prepare()、boot()、onComplete() 方法都为空实现。ContextManager 提供了与 TracingContext 对应的几乎所有方法,基本实现都是委托给当前线程绑定的 TracingContext 对象,这里以 createEntrySpan() 方法为例进行介绍:

public static AbstractSpan createEntrySpan(String operationName,
         ContextCarrier carrier) {
    SamplingService samplingService = ServiceManager.INSTANCE
            .findService(SamplingService.class);     // 采样相关
    AbstractSpan span;
    AbstractTracerContext context;
    // 检测ContextCarrier是否合法,其实就是检查它的核心字段是否已填充好
    if (carrier != null && carrier.isValid()) { 
        samplingService.forceSampled();
        // 获取当前线程绑定的TracingContext
        context = getOrCreate(operationName, true); 
        // 委托给当前线程绑定的TracingContext来创建EntrySpan
        span = context.createEntrySpan(operationName); 
        // 从ContextCarrier提取上游服务传播过来的Trace信息
        context.extract(carrier); 
    } else { // 没有上游服务的场景
        context = getOrCreate(operationName, false);
        span = context.createEntrySpan(operationName);
    }
    return span;
}

getOrCreate() 方法会从 CONTEXT 字段中获取当前线程绑定的 TracingContext 对象,如果当前线程没有关联 TracingContext 上下文,则会通过 ContextManagerExtendService 新建并绑定。

stopSpan() 方法在关闭 Span 的同时,还会检查当前 TraceSegment 是否结束,TraceSegment 结束时会将存储在 CONTEXT 中的 TracingContext 对象以及 RUNTIME_CONTEXT 中的附加信息一并清除,这也是为了防止内存泄露的一步重要操作。

Context 生成与采样

如果不做任何限制,每个请求都应该生成一条完整的 Trace。在面对海量请求时如果也同时产生海量 Trace,就会给网络和存储带来双倍的压力,浪费很多资源。为了解决这个问题,几乎所有的 Trace 系统都会支持采样的功能。SamplingService 就是用来实现采样功能的 BootService 实现。

SamplingService 的采样逻辑依赖 samplingFactorHolder 字段(AtomicInteger 类型)的自增。ContextManagerExtendService 是负责创建 TracingContext 的 BootService 实现,在 ContextManagerExtendService 创建 TracingContext 时,会调用 SamplingService 的 trySampling() 方法递增 samplingFactorHolder 字段(CAS 操作),当增加到阈值(默认值为 3,可以通过 agent.sample_n_per_3_secs 配置进行修改)时会返回 false,表示采样失败,这时 ContextManagerExtendService 就会生成 IgnoredTracerContext,IgnoredTracerContext 是个空 Context 实现,不会记录 Trace 信息。

另外,SamplingService 中会启动一个定时任务,每秒都会将 samplingFactorHolder 字段清零,这样就实现了每秒采样指定条数的 Trace 数据,如下图所示:

sw1.png

Trace 的收集

这里我们先来回顾一个知识点,当 TracingContext 通过 stopSpan() 方法关闭最后一个 Span 时,会调用 finish() 方法关闭相应的 TraceSegment,与此同时,还会调用 TracingContext.ListenerManager.notifyFinish() 方法通知所有监听 TracingContext 关闭事件的监听器 —— TracingContextListener。TracingContext.finish() 方法的相关实现如下:

private void finish() {
    TraceSegment finishedSegment = 
          segment.finish(isLimitMechanismWorking());
    TracingContext.ListenerManager.notifyFinish(finishedSegment);
}

TraceSegmentServiceClient 是 TracingContextListener 接口的唯一实现,其主要功能就是在 TraceSegment 结束时对其进行收集,并发送到后端的 OAP 集群。

下图展示了 TraceSegmentServiceClient 的核心结构:

sw2.png

TraceSegmentServiceClient 底层维护了一个 DataCarrier 对象,其底层 Channels 默认有 5 个 Buffer,每个 Buffer 长度为 300,使用的是 IF_POSSIBLE 阻塞写入策略,底层会启动一个 ConsumerThread 线程。

TraceSegmentServiceClient 作为一个 TracingContextListener 接口的实现,会在 notifyFinish() 方法中,将刚刚结束的 TraceSegment 写入到 DataCarrier 中缓存。同时,TraceSegmentServiceClient 实现了前面介绍的 IConsumer 接口,封装了消费 Channels 中数据的逻辑,在 consume() 方法中会首先将消费到的 TraceSegment 对象序列化,然后通过 gRPC 请求发送到后端 OAP 集群。该过程涉及的 gRPC 接口定义如下:

service TraceSegmentReportService {
    rpc collect (stream UpstreamSegment) returns (Commands) {
    }
}

该 gRPC 请求中用到的 UpstreamSegment 结构体包含了 Trace ID 以及 TraceSegment 序列化之后的字节数组,定义如下所示:

message UpstreamSegment {
    repeated UniqueId globalTraceIds = 1;
    bytes segment = 2; // TraceSegment信息
}

这个过程中,TraceSegment 对象会转换成相应的 proto 结构体实例,下图展示了 UpstreamSegment 中包含的具体信息:

sw3.png

既然要发送 gRPC 请求,就必然要依赖网络连接,TraceSegmentServiceClient 实现了 GRPCChannelListener 接口,可以监听底层网络连接的变化情况。在 prepare() 方法中可将其作为 Listener 注册到前文介绍的 GRPCChannelManager 中。

明确了发送 Trace 时的具体数据,以及其涉及的 gRPC 请求和接口定义,我们再来看 consume() 方法的具体实现:

public void consume(List<TraceSegment> data) {
    if (CONNECTED.equals(status)) { // 根据底层网络连接的状态决定是否发送
        // 创建GRPCStreamServiceStatus对象
        final GRPCStreamServiceStatus status = 
             new GRPCStreamServiceStatus(false);
        StreamObserver<UpstreamSegment> upstreamSegmentStreamObserver
               = serviceStub.collect(new StreamObserver<Commands>() {
            public void onNext(Commands commands) {}
            public void onError(Throwable throwable) {
                // 发生异常会调用 finished()方法,停止等待
                status.finished();
                // 通知GRPCChannelManager重新创建网络连接
                ServiceManager.INSTANCE.findService(
                   GRPCChannelManager.class).reportError(throwable);
            }
            public void onCompleted() {
                // 发送成功之后,会调用finished()方法结束等待
                status.finished(); 
            }
        });
        for (TraceSegment segment : data) { 
            // 将TraceSegment转换成UpstreamSegment对象,然后才能进行序列化以
            // 及发送操作transform()方法实现的转换逻辑并不复杂,填充字段而已
            UpstreamSegment upstreamSegment = segment.transform();
            upstreamSegmentStreamObserver.onNext(upstreamSegment);
        }
        upstreamSegmentStreamObserver.onCompleted();
        status.wait4Finish(); // 等待全部TraceSegment数据发送结束
        segmentUplinkedCounter += data.size(); // 统计发送的数据量
    } else { // 网络连接断开时,只进行简单统计,数据将被直接抛弃
        segmentAbandonedCounter += data.size();
    }
    printUplinkStatus(); // 每隔 30s打印一下发送日志
}

注意,TraceSegmentServiceClient 在批量发送完 UpstreamSegment 数据之后,会通过 GRPCStreamServiceStatus 进行自旋等待,直至该批 UpstreamSegment 全部发送完毕。

最后总结一下,TraceSegmentServiceClient 同时实现了 BootService、IConsumer、GRPCChannelListener、TracingContextListener 四个接口,如下图所示,这四个接口的实现相互依赖,共同完成 Trace 数据的收集和发送:
sw4.png

总结

本课时我们重点介绍了 Trace 相关的 BootService 接口实现。首先介绍了 ContextManager 的核心实现,理清了它是如何将 TracingContext 与当前线程关联起来的。接下来介绍了 SamplingService 实现客户端 Trace 采样的逻辑。最后介绍了上报 Trace 的 gRPC 接口,深入分析了 TraceSegmentServiceClient 收集和上报 Trace 数据的核心逻辑。


SkyWalking 将方法加入追踪链路(@Trace
09-02 1126
版本:7.0.0 描述 可能存在这样的场景,当前应用某些方法没有被追踪。但是我们又想看这一部分方法的调用情况。这个时候就可以使用指定方法的追踪来实现。不过这种方式的缺点是对代码有侵入。 Maven 依赖 <dependency> <groupId>org.apache.skywalking</groupId> <artifactId>apm-toolkit-trace</artifactId>
微服务】分布式如何利用Skywalking实现链路追踪与监控?
热爱技术,享受生活
10-24 1314
整个分布式追踪的目的是什么?是为了让我们最终在页面上、UI上、和数据上能够复现这个过程。我们要拿到整个完整的链路,包括精确的响应时间,访问的方法、访问的 circle,访问的 Redis 的 key等,这些是我们在做分布式追踪的时候需要展现的一个完整的信息。
SkyWalking 实现跨线程 Trace 传递
谈谈1974
06-22 9275
在进程内采用异步多线程时,如果不做任何处理,SkyWalking 追踪执行链路的 trace 信息必然会出现断。一般来说保证执行链路信息的完整是刚性需求,这时候为了实现 trace 信息的跨线程传递,就需要使用 SkyWalking 的异步任务包装类SkyWalkingJava 客户端提供了异步任务包装类用于完成多线程下 trace 的跨线程传递功能,目前有如下几个实现:以 为例,其使用示例如下: 1.2 跨线程包装类的原理 1.2.1 @TraceCrossThread 注解 以下为 Suppl
SkyWalking链路追踪上下文TraceContext的traceId生成的实现原理剖析
简放视野的专栏
03-04 4615
SkyWalking通过字节码增强技术实现,结合依赖注入和控制反转思想,以SkyWalking方式将追踪身份traceId编织到链路追踪上下文TraceContext。 是不是很有趣,很有意思!!!
猿创征文|链路追踪-Skywalking入门
龙大
09-12 818
旁友,你的线上服务是不是偶尔来个超时,或者突然抖动一下,造成用户一堆反馈投诉。然后你费了九牛二虎之力,查了一圈圈代码和日志才总算定位到问题原因了
第12讲:剖析 TraceSkyWalking 落地实现方案(上)
Marion的博客
07-11 1447
SkyWalking Trace 的相关概念以及实现类与 OpenTracing 的概念基本类似,像 Trace、Span、Tags、Logs 等核心概念,在 SkyWalking Agent 都有对应实现,只是在细微实现上略有区别的,其最重要的是: SkyWalking 的设计在 Trace 级别和 Span 级别之间加了一个 Segment 概念,用于表示一个服务实例内的 Span 集合。
分布式链路追踪原理详解及SkyWalking、Zipkin介绍
热门推荐
Hello World
04-21 1万+
背景:追踪调用链路,监控链路性能,排查链路故障 随着微服务架构的流行,一次请求往往需要涉及到多个服务,需要一些可以帮助理解系统行为、用于分析性能问题的工具,以便发生故障的时候,能够快速定位和解决问题。 单体架构可以使用 AOP 在调用具体的业务逻辑前后分别打印一下时间即可计算出整体的调用时间,使用 AOP 来 catch 住异常也可知道是哪里的调用导致的异常。 基本实现原理 一个完整请求链路的追踪ID(traceid)用于查出本次请求调用的所有服务,每一次服务调用的跨度ID(spanid.
docker-compose搭建部署 Skywalking
qq_27735079的博客
10-11 2401
谷歌在 2010 年 4 月发表了一篇论文《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》介绍了分布式追踪的概念,之后很多互联网公司都开始根据这篇论文打造自己的分布式链路追踪系统。APM 系统的核心技术就是分布式链路追踪
skywalking学习
fangli2483的博客
08-09 909
Skywalking分布式追踪与监控 1. 字节码 1.1、字节码简述 Skywalking分布式追踪与监控 1. 字节码 1.1、字节码简述 Java和C语言很大的不同是C语言不是跨平台的,C语言编译后就是对应CPU的汇编指令,不同操作系统的汇编指令有差异,所以无法跨平台。而Java语言编译之后是字节码,字节码需要通过Java虚拟机来运行,而不同操作系统的Java虚拟机是定制的,所以针对不同的操作系统,JVM会将相同格式的字节码翻译成对应操作系统的汇编指令运行,所以JV...
面试题大杂烩(技术场景+链路追踪+设计模式)
Kaka_csdn14的博客
07-04 1163
> 写在开始 : > ① 本文大约3万字,阅读花费时间比较久,大家看个人实际需要; > ② 重点推荐第二部分的日志相关,日志命令,和生产排查,都是常见场景问题,高频; 设计模式的工厂,策略和责任链也是比较常见的,了解并有实际应用,能阐述出来的话,很加分; > ③ 链路追踪,在目前分布式很常见, 了解一下 Agent 和 SkyWalking ;自己可以拓展 traceId 等分布式唯一ID 的内容!
SkyWalking链路追踪Trace概念以及Trace与span的关系
阿丹的博客
07-25 3114
SkyWalking链路追踪Trace(追踪)是指一个请求或者一个操作从开始到结束的完整路径。它涵盖了分布式系统所有相关组件的调用关系和性能信息。具体来说,Trace包含了一系列的(跨度),每个代表了一个组件的调用或操作。一个会记录下该组件的开始时间、结束时间、耗时、操作类型等信息。通过组合多个,就可以构成一个完整的Trace,描述了请求在分布式系统的流转过程。Trace的概念在分布式系统非常重要,它可以帮助开发人员跟踪请求的路径,了解每个组件的耗时情况,从而定位性能瓶颈和系统故障。
skywalking04 - skywalking自定义链路追踪@Trace
过了这个村没这个老王的博客
02-05 7862
skywalking04 - skywalking自定义链路追踪@Trace ​ 当我们工程,有些重要的方法,没有添加在链路,而我们又需要时,就可以添加自定义链路追踪的Span,同时,我们还可以向链路的Span添加一些自定义的属性(Tag) 准备工程 ​ 工程我们采取上一章<skywalking03 - skywalking入门使用>使用到的demo工程,进行稍作改造.skywalking-plugin-example 准备需要自定义链路追踪的方法 private String tra
SkyWalking 源码分析 —— @Trace 注解想要追踪的任何方法
weixin_42073629的博客
08-17 4355
1. 概述 本文主要分享@Trace 注解想要追踪的任何方法。 我们首先看看@Trace的使用例子,再看看@Trace实现代码。涉及代码如下: 2. 使用例子 本节参考官方文档:Application-toolkit-trace-CN.md 1、使用 Maven 引入相应的工具包 <dependency> <groupId>org.skywalking</groupId> <artifact...
skywalking从入门到精通(四)-自定义链路追踪
ratel的博客
03-26 2977
skywalking如何自定义链路追踪? apm-toolkit-trace ,apm-toolkit-opentracing 2种方案
SkyWalking方法级trace粒度实现 @Trace和apm-customize-enhance-plugin介绍
zxh1991811的博客
04-01 6012
场景 在开发过程了,我们除了想知道链路的整体耗时以外,有的时候也想要知道某些方法的执行耗时。为了达到这个目的,我们需要做一些额外的配置。 今天就给大家介绍SkyWalking方法级trace实现实现 SkyWalking方法级trace实现具体分为侵入式和外部配置两种方式,各有优劣,可根据项目情况自行选择。首选给大家介绍侵入式实现方式。 侵入式实现 1、pom.xml依赖 <dependency> <groupId>org.apache.skywalking
Skywalking配置traceId
最新发布
zhangyifang_009的博客
05-15 1657
SkyWalking是一个开源的分布式系统观测平台,旨在解决微服务和云原生架构常见的性能监控和故障排除问题。自2015年由Apache基金会孵化以来,SkyWalking已经成为全球范围内广泛使用的APM(应用性能管理)解决方案之一。
skywalking trace跟踪原理
liuhehe的博客
03-17 2912
skywalking trace跟踪原理: 1.先理解trace的几个概念,EntrySpan EntrySpan ExitSpan https://skyapm.github.io/document-cn-translation-of-skywalking/zh/6.2.0/guides/Java-Plugin-Development-Guide.html 2.以百度的 brpc插件为例 ClientInterceptor @Override public void ...
第13讲:剖析 TraceSkyWalking 落地实现方案(下)
Marion的博客
07-19 769
一般情况下,在 Agent 插件的前置处理逻辑,会调用 createEntrySpan() 方法创建 EntrySpan,在 TracingContext 的实现,会检测 EntrySpan 是否已创建,如果是,则不会创建新的 EntrySpan,只是重新调用一下其 start() 方法即可。在 deserialize() 方法实现了 ContextCarrier 反序列化的逻辑,即将上述字符串进行切分并赋值到对应的字段,具体逻辑为 serialize() 方法的逆操作,这里不再展开分析。
写文章

热门文章

  • java+ElementUI前后端分离旅游项目第五天 移动端开发上 16931
  • Python爬虫第二课 Selenium介绍和反爬技术 9282
  • java物联网第三天 智慧农业物联网 9165
  • 前端基础第二天项目 大数据大屏可视化项目 8628
  • 物联网新零售项目 立可得2.0之“前世今生” 8520

分类专栏

  • 运营 61篇
  • 云原生 144篇
  • java 564篇
  • c++ 48篇
  • 人工智能 108篇
  • 网络安全 7篇
  • 教程 1193篇
  • 区块链 7篇
  • 技术管理 102篇
  • 大数据 86篇
  • 数据分析 44篇
  • 前端 287篇
  • go 84篇
  • PHP 1篇
  • 移动 38篇
  • 产品经理 6篇
  • 物联网 3篇
  • 测试 16篇
  • python 25篇
  • ASP.NET

最新评论

  • 设计模式之美16-理论二:如何做到“对扩展开放、修改关闭”?扩展和修改各指什么?

    Concern: 第二点是,同样的代码改动,在粗代码粒度下,可能被认定为“修改”;在细代码粒度下,可能又被认定为“扩展”。 意思是在同样的代码改动,不同的代码粒度得出的结论可能不同?

  • GO微服务实战第十八节 案例:Go-kit 如何集成 gRPC?

    坡爱吃坡: 步骤不全吧

  • Python黑马头条推荐系统第二天 离线用户召回集与排序计算

    Han_Lin_: 请问博主有相关的虚拟机或者原始数据嘛

  • 微信小程序开发01 双线程模型:为什么小程序不用浏览器的线程模型?

    weixin_39765413: 请问原文在哪呀

  • 设计模式之美92-项目实战一:设计实现一个支持各种算法的限流框架(实现)

    王哈哈哈.: 你好源码有吗

大家在看

  • Linux(4)——重定向、管道及tee命令 125
  • linux—基础命令及相关知识
  • 卷积编码器通过打孔(Puncturing)来修改码率

最新文章

  • 子比主题v7.4绕授权接口源码
  • OpenResty从入门到精通29-最容易失准的性能测试?你需要压测工具界的“悍马”wrk
  • OpenResty从入门到精通28-test-nginx还可以这样用?
2023年206篇
2022年1197篇

目录

目录

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或 充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值

玻璃钢生产厂家北京玻璃钢雕塑设计复原玻璃钢雕塑贵州商场美陈管理规定虹口玻璃钢雕塑厂玻璃钢熊猫雕塑批发价格如何玻璃钢雕塑三大要素陕西玻璃钢卡通雕塑定制南宁多彩玻璃钢雕塑市场玻璃钢雕塑 华韵雕塑滁州人物卡通玻璃钢雕塑特色商场美陈批发葫芦岛玻璃钢雕塑公司衡水玻璃钢雕塑玻璃钢雕塑沙盘玻璃钢猩猩雕塑德惠玻璃钢座椅雕塑楚雄玻璃钢卡通雕塑报价知名玻璃钢雕塑厂家畅销全国成都玻璃钢雕塑制造厂家电话动物玻璃钢雕塑销售电话江宁商场美陈装饰漳州玻璃钢抽象雕塑莱西玻璃钢雕塑钟祥玻璃钢卡通座椅雕塑龙岩园林玻璃钢雕塑工厂澳门城市几何玻璃钢雕塑上海玻璃钢名人雕塑寿光玻璃钢蔬菜雕塑制作泰州玻璃钢雕塑景观厂上海仿铜玻璃钢雕塑生产厂家香港通过《维护国家安全条例》两大学生合买彩票中奖一人不认账让美丽中国“从细节出发”19岁小伙救下5人后溺亡 多方发声单亲妈妈陷入热恋 14岁儿子报警汪小菲曝离婚始末遭遇山火的松茸之乡雅江山火三名扑火人员牺牲系谣言何赛飞追着代拍打萧美琴窜访捷克 外交部回应卫健委通报少年有偿捐血浆16次猝死手机成瘾是影响睡眠质量重要因素高校汽车撞人致3死16伤 司机系学生315晚会后胖东来又人满为患了小米汽车超级工厂正式揭幕中国拥有亿元资产的家庭达13.3万户周杰伦一审败诉网易男孩8年未见母亲被告知被遗忘许家印被限制高消费饲养员用铁锨驱打大熊猫被辞退男子被猫抓伤后确诊“猫抓病”特朗普无法缴纳4.54亿美元罚金倪萍分享减重40斤方法联合利华开始重组张家界的山上“长”满了韩国人?张立群任西安交通大学校长杨倩无缘巴黎奥运“重生之我在北大当嫡校长”黑马情侣提车了专访95后高颜值猪保姆考生莫言也上北大硕士复试名单了网友洛杉矶偶遇贾玲专家建议不必谈骨泥色变沉迷短剧的人就像掉进了杀猪盘奥巴马现身唐宁街 黑色着装引猜测七年后宇文玥被薅头发捞上岸事业单位女子向同事水杯投不明物质凯特王妃现身!外出购物视频曝光河南驻马店通报西平中学跳楼事件王树国卸任西安交大校长 师生送别恒大被罚41.75亿到底怎么缴男子被流浪猫绊倒 投喂者赔24万房客欠租失踪 房东直发愁西双版纳热带植物园回应蜉蝣大爆发钱人豪晒法院裁定实锤抄袭外国人感慨凌晨的中国很安全胖东来员工每周单休无小长假白宫:哈马斯三号人物被杀测试车高速逃费 小米:已补缴老人退休金被冒领16年 金额超20万

玻璃钢生产厂家 XML地图 TXT地图 虚拟主机 SEO 网站制作 网站优化