【SkyWalking从入门到精通】第53篇:SkyWalking插件开发基础——四大核心概念和Span的三种形态
下一篇【第52篇】SkyWalking存储扩展:把数据存到你想存的地方
上一篇【第54篇】SkyWalking核心对象API详解——AbstractSpan、ContextManager与SpanLayer全解
一、为什么需要理解这些基础概念
假如你被派去一个陌生的城市出差,第一件事是什么?当然是拿一张地图。没有地图,你会在陌生的街道上迷失方向,找不到酒店,找不到地铁站,找不到客户公司。
SkyWalking插件开发也是如此。它的"城市地图"就是四大基础概念:Span、TraceSegment、ContextCarrier、ContextSnapshot。不理解这四个概念,你看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插件开发的四大支柱:
- Span:一次原子操作记录,有EntrySpan/LocalSpan/ExitSpan三种形态
- TraceSegment:一个线程内所有Span的集合,边界就是线程边界
- ContextCarrier:跨进程的链路信使,通过sw8协议传递上下文
- ContextSnapshot:跨线程的上下文快照,解决异步编程的追踪难题
这四个概念就像乐高积木的基本组件——学会了它们,你就能拼出任何追踪插件。
下一篇文章我们将深入SkyWalking核心对象的API详解,学习AbstractSpan的各种操作方法和ContextManager的完整用法。
下一篇【第52篇】SkyWalking存储扩展:把数据存到你想存的地方
上一篇【第54篇】SkyWalking核心对象API详解——AbstractSpan、ContextManager与SpanLayer全解
更多推荐



所有评论(0)