下一篇【第52篇】SkyWalking存储扩展:把数据存到你想存的地方
上一篇【第54篇】SkyWalking核心对象API详解——AbstractSpan、ContextManager与SpanLayer全解


一、为什么需要理解这些基础概念

假如你被派去一个陌生的城市出差,第一件事是什么?当然是拿一张地图。没有地图,你会在陌生的街道上迷失方向,找不到酒店,找不到地铁站,找不到客户公司。

SkyWalking插件开发也是如此。它的"城市地图"就是四大基础概念:SpanTraceSegmentContextCarrierContextSnapshot。不理解这四个概念,你看SkyWalking源码就像看天书——每个字都认识,连在一起不知道什么意思。

今天这篇文章,我们就来绘制这张"插件开发地图"。我会用快递配送的类比帮你理解每个概念,保证你看完后不再觉得它们抽象。

二、四大基础概念速览

在深入细节之前,先给这四个概念一个快速的定义:

+------------------------------------------------------------------+
|                    SkyWalking核心概念关系图                          |
+------------------------------------------------------------------+
|                                                                    |
|   Trace (一次完整请求的链路追踪)                                    |
|   ┌──────────────────────────────────────────────────────────┐    |
|   │                                                          │    |
|   │  TraceSegment (本地线程内的一段)                          │    |
|   │  ┌─────────────────────────────────────┐                │    |
|   │  │                                     │                │    |
|   │  │  Span (一次具体的操作)              │                │    |
|   │  │  ┌──────────────────────┐          │                │    |
|   │  │  │ operation: GET /api  │          │                │    |
|   │  │  │ startTime: t1        │          │                │    |
|   │  │  │ endTime: t2          │          │                │    |
|   │  │  │ component: SpringMVC │          │                │    |
|   │  │  └──────────────────────┘          │                │    |
|   │  │                                     │                │    |
|   │  │  Span (异步子线程恢复上下文)        │                │    |
|   │  │  ← ContextSnapshot 恢复 →           │                │    |
|   │  │                                     │                │    |
|   │  └─────────────────────────────────────┘                │    |
|   │                          │                              │    |
|   │            ContextCarrier (跨进程传递)                   │    |
|   │                          ↓                              │    |
|   │  TraceSegment (远程服务中的一段)                         │    |
|   │  ┌─────────────────────────────────────┐                │    |
|   │  │  Span (接收到的远程调用)            │                │    |
|   │  └─────────────────────────────────────┘                │    |
|   │                                                          │    |
|   └──────────────────────────────────────────────────────────┘    |
|                                                                    |
+------------------------------------------------------------------+

2.1 Span —— 原子操作记录

Span是SkyWalking中最基本的操作记录单元。 它就像快递系统中的"一个扫码动作"——快递员扫描一个包裹,系统记录下"谁、什么时候、在哪里、做了什么"。一个Span记录了一次具体的操作,比如一次HTTP请求处理、一次数据库查询、一次RPC调用。

// Span的基本结构(简化版)
Span {
    spanId: 0,              // 在当前TraceSegment中的序号
    parentSpanId: -1,       // 父Span的ID(-1表示没有父Span)
    startTime: 1625140800000,  // 开始时间
    endTime: 1625140800500,    // 结束时间
    operationName: "GET:/api/user",  // 操作名称
    peer: "192.168.1.100:8080",      // 对端地址
    spanLayer: SpanLayer.HTTP,       // Span层级
    componentId: 14,                 // 组件ID(SpringMVC)
    tags: [                     // 自定义标签
        {key: "http.method", value: "GET"},
        {key: "http.status_code", value: 200}
    ],
    logs: [                     // 日志记录
        {time: xxx, data: [{key: "error", value: "timeout"}]}
    ],
    refs: [                     // 引用关系(跨进程/跨线程)
        {type: CrossProcess, traceId: "...", parentSegmentId: "..."}
    ]
}

2.2 TraceSegment —— 本地线程的工作单元

TraceSegment是同一个线程内所有Span的集合。 还是用快递系统类比——一个快递站的所有操作(入库、分拣、装车)组成一个工作段。TraceSegment就是这样:一个Java线程里连续执行的所有Span,构成一个TraceSegment。

关键理解点:线程边界 = TraceSegment边界。每当代码跨线程执行,就会产生新的TraceSegment。

// 一个典型的TraceSegment示例
TraceSegment {
    traceSegmentId: "a1b2c3d4.1.1625140800000",
    spans: [
        Span { spanId: 0, operationName: "SpringMVC:/api/user" },  // EntrySpan
        Span { spanId: 1, operationName: "MySQL:/select" },         // LocalSpan
        Span { spanId: 2, operationName: "Dubbo:/UserService" }     // ExitSpan
    ]
}

2.3 ContextCarrier —— 跨进程的信使

ContextCarrier负责在跨进程调用时传递链路上下文。 想象跨省快递——一个包裹从北京发往上海,转运单就是ContextCarrier。它记录了"这个包裹从哪来的(traceId)、经过了哪些站点(segmentId)、当前是哪一站(spanId)"。

在技术层面,ContextCarrier通常作为HTTP Header或RPC Attachment在服务间传递:

// ContextCarrier的注入(在客户端/调用方)
ContextCarrier carrier = new ContextCarrier();
ContextManager.inject(carrier);  // 将当前上下文注入carrier
// 通过HTTP Header传递
httpRequest.setHeader("sw8", carrier.serialize());

// ContextCarrier的提取(在服务端/被调用方)
String sw8Header = httpRequest.getHeader("sw8");
ContextCarrier carrier = new ContextCarrier();
carrier.deserialize(sw8Header);
ContextManager.extract(carrier);  // 恢复上下文

2.4 ContextSnapshot —— 跨线程的上下文快照

ContextSnapshot负责在同一个JVM内的不同线程之间传递上下文。 继续快递类比——同城内两个快递站点之间的调拨单就是ContextSnapshot。它不需要记录跨城市的运输信息,只需要"当前这个包裹的上下文状态"。

ContextSnapshot的核心使用场景是异步编程——当你在Spring @Async方法、CompletableFuture、或者自定义线程池中执行任务时,原始线程的链路上下文需要传递给新线程。

// 在主线程中捕获上下文快照
ContextSnapshot snapshot = ContextManager.capture();

// 在新线程中恢复上下文
executorService.submit(() -> {
    ContextManager.continued(snapshot);  // 恢复上下文
    Span span = ContextManager.createLocalSpan("/asyncTask");
    // ... 执行业务逻辑
    span.asyncFinish();
    ContextManager.stopSpan();
});

三、Span的三种形态 —— Entry、Local、Exit

SkyWalking把Span分为三种类型。这个分类非常重要,因为它直接决定了你在插件中应该创建哪种Span。

+------------------------------------------------------------------+
|                     Span三种形态的创建时机                          |
+------------------------------------------------------------------+
|                                                                    |
|   外部请求 → ┌─────────────┐                                       |
|              │ EntrySpan   │  ← "我是服务的入口"                   |
|              │ 接收请求     │     当外部请求到达时创建               |
|              └──────┬──────┘                                       |
|                     │                                              |
|                     ↓                                              |
|              ┌─────────────┐                                       |
|              │ LocalSpan   │  ← "我在做本地工作"                   |
|              │ 本地处理     │     本地逻辑处理时创建                 |
|              └──────┬──────┘                                       |
|                     │                                              |
|                     ↓                                              |
|              ┌─────────────┐                                       |
|              │ ExitSpan    │  ← "我要出去调别人了"                 |
|              │ 发起远程调用 │     调用外部服务/DB时创建             |
|              └─────────────┘                                       |
|                                                                    |
+------------------------------------------------------------------+

3.1 EntrySpan —— 服务的门童

EntrySpan在当前服务接收到外部请求时创建。比如一个HTTP请求到达你的Controller,或者你的Dubbo Provider被调用。它是整个链路在当前服务中的"起点"。

创建方式:

// 插件中创建EntrySpan的典型代码
ContextCarrier carrier = new ContextCarrier();
carrier.deserialize(request.getHeader("sw8"));  // 从请求中提取上下文

AbstractSpan entrySpan = ContextManager.createEntrySpan(
    "/api/user/get",   // 操作名称
    carrier             // 携带的链路上下文
);

try {
    // 设置Span属性
    entrySpan.setComponent(ComponentsDefine.SPRING_MVC);
    entrySpan.setLayer(SpanLayer.HTTP);
    
    // 执行实际的业务逻辑
    Object result = method.proceed();
    
    entrySpan.setTag("http.status_code", "200");
    return result;
} catch (Exception e) {
    entrySpan.errorOccurred().log(e);
    throw e;
} finally {
    ContextManager.stopSpan();  // 必须停止Span!
}

3.2 LocalSpan —— 默默干活的老黄牛

LocalSpan用于记录当前服务内部的本地操作。比如一段复杂的业务计算、一次本地缓存查询、或者一个文件读取操作。它不涉及任何远程调用。

// 在拦截器中使用LocalSpan
AbstractSpan localSpan = ContextManager.createLocalSpan("/calculateDiscount");

try {
    // 本地业务逻辑
    BigDecimal discount = calculateComplexDiscount(order);
    localSpan.setTag("discount.amount", discount.toString());
    localSpan.setTag("discount.type", "member");
    return discount;
} catch (Exception e) {
    localSpan.errorOccurred().log(e);
    throw e;
} finally {
    ContextManager.stopSpan();
}

3.3 ExitSpan —— 外出访问的使者

ExitSpan在你当前服务调用外部服务时创建。比如调用另一个微服务、查询数据库、发送MQ消息等。

// ExitSpan的创建
ContextCarrier carrier = new ContextCarrier();
AbstractSpan exitSpan = ContextManager.createExitSpan(
    "/UserService/getUserById",  // 操作名称
    carrier,                      // 上下文载体
    "192.168.1.100:20880"        // 对端地址(peer)
);

try {
    exitSpan.setComponent(ComponentsDefine.DUBBO);
    exitSpan.setLayer(SpanLayer.RPC);
    
    // 注入上下文到RPC请求中
    rpcInvocation.setAttachment("sw8", carrier.serialize());
    
    // 发起远程调用
    Object result = rpcClient.invoke(rpcInvocation);
    
    exitSpan.setTag("rpc.status", "success");
    return result;
} catch (Exception e) {
    exitSpan.errorOccurred().log(e);
    throw e;
} finally {
    ContextManager.stopSpan();
}

四、Span的核心属性详解

Span不仅仅是"记录开始和结束时间",它有一系列丰富的属性来记录更多信息。

4.1 Tag —— 给Span打标签

Tag是Span的属性字典,用键值对记录操作的元数据:

// 常用的Tag设置
span.setTag("http.method", "POST");
span.setTag("http.url", "/api/order/create");
span.setTag("http.status_code", 200);
span.setTag("db.type", "mysql");
span.setTag("db.instance", "order_db");
span.setTag("db.statement", "SELECT * FROM orders WHERE id = ?");

// 通过Tags工具类设置(推荐方式,避免拼写错误)
Tags.HTTP.METHOD.set(span, "POST");
Tags.HTTP.URL.set(span, "/api/order/create");
Tags.DB.TYPE.set(span, "mysql");
Tags.DB.INSTANCE.set(span, "order_db");

4.2 Log —— 时间点记录

与Tag不同,Log不仅记录内容,还记录发生的时间点。适合记录异常、超时等事件:

// 添加带时间戳的日志
span.log(System.currentTimeMillis(), 
    new HashMap<String, Object>() {{
        put("event", "connection_timeout");
        put("retry_count", 3);
        put("error.stack", exception.getMessage());
    }}
);

// 在OAP端可以根据log做统计分析

4.3 SpanLayer —— 操作层级

SpanLayer告诉我们"这个操作属于哪一层",有助于在UI上分层展示:

// SpanLayer的取值
public enum SpanLayer {
    DB,      // 数据库层:MySQL, PostgreSQL, Redis, MongoDB
    RPC_FRAMEWORK,  // RPC框架层:Dubbo, gRPC, Feign
    HTTP,     // HTTP层:Spring MVC, OkHttp, HttpClient
    MQ,       // 消息队列层:Kafka, RabbitMQ, RocketMQ
    CACHE,    // 缓存层:Redis, Memcached, Caffeine
    FAAS,     // 函数计算层
    UNKNOWN   // 未知
}

4.4 Component —— 组件标识

每个Span需要标明自己属于哪个组件。SkyWalking为每种组件分配了唯一的ID:

// 组件标识示例
ComponentsDefine.SPRING_MVC      // id=14
ComponentsDefine.DUBBO           // id=3
ComponentsDefine.MYSQL_DRIVER    // id=33
ComponentsDefine.REDIS           // id=7
ComponentsDefine.KAFKA_PRODUCER  // id=45
ComponentsDefine.OKHTTP         // id=60

4.5 Peer —— 对端标识

Peer记录了"我跟谁在通信"。对于数据库,它是连接地址;对于RPC,它是服务地址:

// 各种场景下的peer格式示例
exitSpan.setPeer("192.168.1.100:3306");     // MySQL
exitSpan.setPeer("192.168.1.101:20880");    // Dubbo
exitSpan.setPeer("redis://10.0.0.1:6379");  // Redis
exitSpan.setPeer("kafka://broker1:9092");    // Kafka
exitSpan.setPeer("user-service.default:8080"); // K8s Service

五、ContextCarrier的注入与提取 —— 跨进程传递的奥秘

这是SkyWalking插件开发中最核心也最容易出错的环节。搞懂了ContextCarrier的注入(Inject)和提取(Extract),你就掌握了跨服务链路追踪的精髓。

5.1 注入(Inject)—— 客户端侧

当你的服务作为调用方发起远程调用时,需要把当前的Trace上下文注入到请求中:

+------------------------------------------------------------------+
|                    ContextCarrier注入流程                          |
+------------------------------------------------------------------+
|                                                                    |
|   Client Side (上游)                                               |
|   ┌───────────────────────────────────────────┐                   |
|   │  1. createExitSpan() 创建ExitSpan         │                   |
|   │          ↓                                 │                   |
|   │  2. ContextManager.inject(carrier)         │                   |
|   │          ↓                                 │                   |
|   │  3. carrier.serialize() → "1-xxx-xxx-..."  │                   |
|   │          ↓                                 │                   |
|   │  4. 放入HTTP Header / RPC Attachment       │                   |
|   │          ↓                                 │                   |
|   │  5. 发起远程调用 ──────────────────→       │                   |
|   └───────────────────────────────────────────┘                   |
|                                            ↓                       |
|                                     Network                        |
|                                            ↓                       |
|   Server Side (下游)                                               |
|   ┌───────────────────────────────────────────┐                   |
|   │  6. 从请求中提取 sw8 header               │                   |
|   │          ↓                                 │                   |
|   │  7. carrier.deserialize(headerValue)       │                   |
|   │          ↓                                 │                   |
|   │  8. ContextManager.extract(carrier)         │                   |
|   │          ↓                                 │                   |
|   │  9. createEntrySpan() 创建EntrySpan        │                   |
|   └───────────────────────────────────────────┘                   |
|                                                                    |
+------------------------------------------------------------------+

5.2 ContextCarrier序列化格式(sw8协议)

ContextCarrier序列化后的字符串遵循sw8协议格式:

1-TRACEID-SEGMENTID-SPANID-SERVICE-INSTANCE-ENDPOINT-PEER

各部分说明:

部分 说明 示例
1 协议版本号 1
TRACEID 全局唯一的Trace ID(Base64编码) a1b2c3d4e5f6.1.1625140800000
SEGMENTID 上游Segment ID a1b2c3d4e5f6.1.1625140800001
SPANID 上游Span ID 0
SERVICE 上游服务名 order-service
INSTANCE 上游实例名 order-service-pod-001
ENDPOINT 上游端点名 /api/order/create
PEER 对端地址 192.168.1.100:8080

5.3 提取(Extract)—— 服务端侧

// 在HTTP拦截器中提取ContextCarrier
public class HttpServerInterceptor implements InstanceMethodsAroundInterceptor {
    
    @Override
    public void beforeMethod(EnhancedInstance objInst, Method method, 
            Object[] allArguments, Class<?>[] argumentsTypes,
            MethodInterceptResult result) {
        
        HttpServletRequest request = (HttpServletRequest) allArguments[0];
        
        // 从HTTP Header获取sw8值
        String sw8Header = request.getHeader("sw8");
        
        if (sw8Header != null && !sw8Header.isEmpty()) {
            ContextCarrier carrier = new ContextCarrier();
            carrier.deserialize(sw8Header);  // 反序列化
            
            // 创建EntrySpan,建立父子关系
            AbstractSpan span = ContextManager.createEntrySpan(
                request.getRequestURI(), 
                carrier
            );
            
            span.setComponent(ComponentsDefine.SPRING_MVC);
            span.setLayer(SpanLayer.HTTP);
            Tags.HTTP.METHOD.set(span, request.getMethod());
        } else {
            // 没有上下文的情况(链路的第一个服务)
            ContextManager.createEntrySpan(
                request.getRequestURI(), 
                null  // carrier为null,表示这是链路的起点
            );
        }
    }
    
    @Override
    public Object afterMethod(EnhancedInstance objInst, Method method,
            Object[] allArguments, Class<?>[] argumentsTypes,
            Object ret) {
        // 获取响应状态码
        HttpServletResponse response = (HttpServletResponse) allArguments[1];
        AbstractSpan span = ContextManager.activeSpan();
        Tags.HTTP.STATUS.set(span, response.getStatus());
        return ret;
    }
    
    @Override
    public void handleMethodException(EnhancedInstance objInst, Method method,
            Object[] allArguments, Class<?>[] argumentsTypes,
            Throwable t) {
        // 标记异常
        ContextManager.activeSpan().errorOccurred().log(t);
    }
}

六、ContextSnapshot —— 线程间的上下文传递

多线程编程是SkyWalking链路追踪的最大挑战之一。当任务被提交到另一个线程执行时,ThreadLocal中存储的上下文就"丢失"了。

6.1 问题的本质

+------------------------------------------------------------------+
|                线程切换导致上下文丢失                              |
+------------------------------------------------------------------+
|                                                                    |
|   主线程 (Thread-1)              线程池线程 (pool-1-thread-1)      |
|   ┌───────────────────┐          ┌───────────────────┐            |
|   │ ThreadLocal:      │          │ ThreadLocal:      │            |
|   │  ┌─────────────┐  │          │  ┌─────────────┐  │            |
|   │  │ TraceSegment │  │          │  │   (空!)     │  │            |
|   │  │ ActiveSpan   │  │          │  │             │  │            |
|   │  └─────────────┘  │          │  └─────────────┘  │            |
|   │        │          │          │        ↑          │            │
|   │   submit(task) ───┼─────────→│   执行task → 无上下文!        │
|   │        │          │          │                  │            │
|   └───────────────────┘          └───────────────────┘            |
|                                                                    |
|   解决方案:在submit之前保存ContextSnapshot                         |
|                                                                    |
|   主线程 (Thread-1)              线程池线程 (pool-1-thread-1)      |
|   ┌───────────────────┐          ┌───────────────────┐            │
|   │ 1. capture()      │          │ 3. continued()    │            │
|   │    保存快照        │          │    恢复上下文      │            │
|   │        │          │          │        ↑          │            │
|   │   submit(task +   │          │   有上下文了!     │            │
|   │      snapshot) ───┼─────────→│                  │            │
|   └───────────────────┘          └───────────────────┘            │
|                                                                    |
+------------------------------------------------------------------+

6.2 ContextSnapshot的使用方式

// ==================== 主线程 ====================
ContextSnapshot snapshot = ContextManager.capture();

// 将snapshot传给异步任务
executorService.submit(() -> {
    // ==================== 异步线程 ====================
    ContextManager.continued(snapshot);
    
    // 现在可以在新线程中创建Span了
    AbstractSpan span = ContextManager.createLocalSpan("/asyncProcess");
    try {
        // 执行异步业务逻辑
        processAsync(order);
        span.setTag("async.result", "success");
    } catch (Exception e) {
        span.errorOccurred().log(e);
    } finally {
        ContextManager.stopSpan();
    }
});

6.3 ContextSnapshot的"只读"特性

重要:ContextSnapshot创建后,主线程的上下文就不能再被修改了。 这是一个常见陷阱:

// 错误示例:capture后又修改Span
ContextSnapshot snapshot = ContextManager.capture();

// 此时不能再操作Span!下面的代码会导致问题
AbstractSpan span = ContextManager.activeSpan();  // 可能返回null
span.setTag("something", "bad");  // NPE风险!

// 正确做法:先完成所有Span操作,再capture
AbstractSpan span = ContextManager.createLocalSpan("/preparation");
span.setTag("prepared", "true");
ContextManager.stopSpan();
// 现在可以capture了
ContextSnapshot snapshot = ContextManager.capture();

七、完整的插件开发流程

到此,我们已经掌握了创建SkyWalking插件所需的所有核心概念。用一张流程图来总结:

+------------------------------------------------------------------+
|                SkyWalking插件开发通用流程                          |
+------------------------------------------------------------------+
|                                                                    |
|  ┌───────────────┐                                                |
|  │ 1. 确定拦截点  │ ← 你要拦截哪个类的哪个方法?                   |
|  └───────┬───────┘                                                |
|          ↓                                                         |
|  ┌───────────────┐                                                |
|  │ 2. 确定Span类型 │ ← EntrySpan? ExitSpan? LocalSpan?            |
|  └───────┬───────┘                                                |
|          ↓                                                         |
|  ┌───────────────┐                                                |
|  │ 3. 确定上下文   │ ← 需要ContextCarrier还是ContextSnapshot?     |
|  │    传递方式     │                                                |
|  └───────┬───────┘                                                |
|          ↓                                                         |
|  ┌───────────────┐                                                |
|  │ 4. 编写拦截器  │ ← beforeMethod / afterMethod / handleException│
|  │    代码         │                                                |
|  └───────┬───────┘                                                |
|          ↓                                                         |
|  ┌───────────────┐                                                |
|  │ 5. 注册插件    │ ← ClassEnhancePluginDefine + skywalking.def    |
|  └───────┬───────┘                                                |
|          ↓                                                         |
|  ┌───────────────┐                                                |
|  │ 6. 测试验证    │ ← 单元测试 + 端到端测试                        |
|  └───────────────┘                                                |
|                                                                    |
+------------------------------------------------------------------+

八、总结

这篇文章我们深入理解了SkyWalking插件开发的四大支柱:

  1. Span:一次原子操作记录,有EntrySpan/LocalSpan/ExitSpan三种形态
  2. TraceSegment:一个线程内所有Span的集合,边界就是线程边界
  3. ContextCarrier:跨进程的链路信使,通过sw8协议传递上下文
  4. ContextSnapshot:跨线程的上下文快照,解决异步编程的追踪难题

这四个概念就像乐高积木的基本组件——学会了它们,你就能拼出任何追踪插件。

下一篇文章我们将深入SkyWalking核心对象的API详解,学习AbstractSpan的各种操作方法和ContextManager的完整用法。


下一篇【第52篇】SkyWalking存储扩展:把数据存到你想存的地方
上一篇【第54篇】SkyWalking核心对象API详解——AbstractSpan、ContextManager与SpanLayer全解


Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐