〇、Demo 环境概览

数据流动总览

① 浏览器请求 curl http://localhost:8081/test
        ↓
② 网关收到请求 → Agent 拦截 → 自动创建 Span(entry 类型)
        ↓
③ 网关调后端 http://localhost:8082 8083/ → Agent 自动在 HTTP Header 注入 traceId
        ↓
④ 后端收到请求 → 自己的 Agent 拦截 → 创建 Span(entry 类型)
   同一个 traceId → 三个服务的数据被关联到同一条 Trace
        ↓
⑤ Agent 通过 gRPC(端口 11800)把 Span 上报给 OAP
        ↓
⑥ OAP 处理 → 写入 Elasticsearch
        ↓
⑦ SkyWalking UI 从 OAP 查询 → 展示完整调用链

Demo 服务架构介绍

基于一套本地搭建的 Demo 环境进行实验验证。Demo 由三个 Spring Boot 微服务(`service-a`、`service-b`、`service-c`)组成,模拟真实业务中的多跳调用链路:外部请求经 service-a(端口 8081)入口,依次调用 service-b(8082)、service-c(8083)。每个服务启动时均挂载 SkyWalking Java Agent,Agent 自动拦截 HTTP 请求、注入 traceId、采集 Span 数据,并通过 gRPC(端口 11800)上报至 OAP。OAP 将数据解析后写入 Elasticsearch,最终由 SkyWalking UI(端口 8080)统一查询展示。整套环境部署于单台 Ubuntu 虚拟机内,SkyWalking OAP、ES、UI 均为容器化运行。

~/demo/
├── service-a/                     # 入口服务(端口8081,含前端页面)
│   ├── pom.xml
│   └── src/
│       └── main/
│           ├── java/
│           │   └── app/
│           │       └── ServiceAApp.java
│           └── resources/
│               └── static/
│                   └── index.html
│
├── service-b/                     # 中间服务(端口8082)
│   ├── pom.xml
│   └── src/
│       └── main/
│           └── java/
│               └── app/
│                   └── ServiceBApp.java
│
└── service-c/                     # 底层服务(端口8083)
    ├── pom.xml
    └── src/
        └── main/
            └── java/
                └── app/
                    └── ServiceCApp.java

服务启动命令(挂载 SkyWalking Agent)

# 启动顺序:C → B → A

# 服务C
cd ~/demo/service-c
java -javaagent:/root/skywalking/skywalking-agent/skywalking-agent.jar \
     -Dskywalking.agent.service_name=service-c \
     -Dskywalking.collector.backend_service=127.0.0.1:11800 \
     -jar target/service-c.jar --server.port=8083

# 服务B
cd ~/demo/service-b
java -javaagent:/root/skywalking/skywalking-agent/skywalking-agent.jar \
     -Dskywalking.agent.service_name=service-b \
     -Dskywalking.collector.backend_service=127.0.0.1:11800 \
     -jar target/service-b.jar --server.port=8082
     
# 服务A
cd ~/demo/service-a
java -javaagent:/root/skywalking/skywalking-agent/skywalking-agent.jar \
     -Dskywalking.agent.service_name=service-a \
     -Dskywalking.collector.backend_service=127.0.0.1:11800 \
     -jar target/service-a.jar --server.port=8081

# 验证端口
netstat -tlnp | grep -E "8081|8082|8083"

一、SkyWalking 核心架构

1.1 什么是 SkyWalking

Apache SkyWalking 是一个观测性分析平台和应用性能管理系统,特别为微服务、云原生和容器化环境而设计。它提供分布式追踪、服务网格遥测分析、度量聚合和可视化一体化解决方案。

SkyWalking 的核心价值在于:

  • 无侵入式监控:无需修改业务代码即可实现全链路追踪。

  • 多语言支持:Java、.NET、Node.js、Go、Python、PHP、Rust 等主流语言均有官方或社区支持。

  • 插件化架构:可轻松扩展对新框架、中间件的支持。

  • 云原生友好:支持 Kubernetes、Service Mesh、Serverless 等现代架构。

1.2 三大组件协同全景

SkyWalking 由三个独立解耦的核心组件构成,三者之间通过标准协议通信:

在这里插入图片描述

组件 职责 对外端口 协议 与谁通信
Agent 字节码增强,采集调用链和指标数据 —(客户端) gRPC + Protobuf → OAP:11800
OAP 接收、解析、聚合、存储数据,提供查询接口 11800(写入)/ 12800(查询) gRPC / HTTP ← Agent, → ES, ← UI
UI 可视化展示:拓扑图、调用链、仪表盘 8080 HTTP + GraphQL → OAP:12800

关键架构性质

  • Agent 与 UI 互不感知。Agent 不知道 UI 的存在,UI 不知道 Agent 的存在。两者都只与 OAP 通信。

  • OAP 是唯一的数据中枢。所有数据写入(11800)和查询(12800)都经过 OAP,端口职责严格分离。

  • UI 是可替换的消费者。OAP 的 GraphQL API 是标准 HTTP 接口,任何支持 HTTP 的客户端都可以消费——官方 UI 只是其中一个消费者。本调研 4.4 节通过两个实验(Docker 独立部署 + 纯 HTML 原型)验证了这一点。

1.3 验证结论汇总

以下结论来自全部七项验证实验(ES-实验一/二/三、GQL-实验一/二/三、UI-实验一),覆盖"采集→传输→解析→存储→查询→展示"全链路。详细验证过程见第三、四章。

核心点 回答 实验验证结论
数据存在哪?能自己查吗? 数据在 ES 里,curl localhost:9200 就能查,不依赖 SkyWalking UI。 ES 直连验证通过。
数据格式是什么?能不能解析? 公开的 .proto 文件定义,任意语言都能解析。 Protobuf 格式,20 个字段全部可追溯。
数据怎么取出来?能对接我们系统吗? curl 命令即可调用,任何支持 HTTP 的系统都能对接。 GraphQL API,HTTP + JSON。
数据链路可信吗?会不会丢/改数据? data_binary 保留原始二进制,随时可审计还原。 源码分析 + 六项实验,全链路透明。
Agent 会拖慢业务吗? 业务线程仅写内存即返回,实测额外开销 < 3%。 异步缓冲 + 反压机制。
能自己开发前端吗? 一个 HTML 文件就能拉取全部数据,零依赖。 方案 A(Docker)+ 方案 B(HTML)双验证。
将来能迁移吗? 索引命名规则固定,数据不在黑盒里,可以做迁移。 ES 是标准基础设施。

二、Agent —— 数据采集

2.1 Agent 是什么

SkyWalking Agent 是一个 Java 字节码增强工具(Bytecode Instrumentation),它在 JVM 启动时通过 -javaagent 参数注入目标应用,动态修改类字节码,在关键方法前后插入监控逻辑,从而实现无侵入式的性能数据采集。

2.2 工作流程

1、JVM 启动时加载 skywalking-agent.jar

2、Agent 初始化插件管理器,注册各类拦截器(Interceptor)

3、应用类加载时,Agent 动态修改字节码,在目标方法前后插入 beforeMethod() 和 afterMethod()

4、方法执行时,自动记录耗时、异常、参数、返回值等上下文信息

5、将生成的 Span 数据异步发送至 OAP Server

2.3 字节码增强原理

JVM 的 Instrumentation 接口

Java 提供了 java.lang.instrument.Instrumentation 接口,允许在类加载时修改字节码

  • 启动时挂载:通过 -javaagent:xxx.jar 参数,会触发 Agent Jar 包中的 premain 方法。

  • 动态注册转换器:在 premain 中,向 JVM 注册一个 ClassFileTransformer

字节码转换(ClassFileTransformer)

当 JVM 加载任何一个类(比如 OrderService)时,ClassFileTransformer 会被回调,传入该类的原始字节码(byte[])。

Agent 可以修改(增强)这个字节码数组,然后把修改后的字节码返回给 JVM。JVM 加载的是修改后的类。

拦截点(即“切点”)的定义

Agent 需要知道“在哪些类的哪些方法里插入代码”。SkyWalking 通过 skywalking-plugin.def 文件定义规则,

匹配规则可以是类名、方法名、注解、接口等。

2.4 字节码增强示例 —— OrderService.createOrder服务

第 1 步:定义拦截规则

在插件定义文件中配置:

# 匹配类名
Class=com.example.OrderService
# 匹配方法名
Method=createOrder
# 匹配参数类型
ArgTypes=com.example.User,java.util.List

通常 Agent 不会拦截普通业务 Service,而是拦截框架入口(如 MVC Controller、RPC 接口)。但可以通过自定义插件,让它拦截任意类。

第 2 步:字节码修改(插入 Span 逻辑)

OrderService 类被加载时,Agent 会重写 createOrder 方法的字节码。注入后的逻辑相当于:

public Order createOrder(User user, List<Item> items) {
    // === Agent 插入:创建 Span 并启动 ===
    AbstractSpan span = ContextManager.createLocalSpan("/OrderService/createOrder");
    try {
        // === 原始业务逻辑(你的 doCreateOrder)===
        Order order = doCreateOrder(user, items);
        
        // === Agent 插入:正常结束 Span ===
        ContextManager.stopSpan(span);
        return order;
    } catch (Exception e) {
        // === Agent 插入:记录异常 ===
        span.errorOccurred();
        span.log(e);
        throw e;
    } finally {
        // === Agent 插入:清理上下文 ===
        ContextManager.stopSpan(span);
    }
}

第 3 步:上下文传递(跨线程/跨服务)

Agent 还会在字节码中注入 ContextManager 的上下文传递逻辑:

  • 入口处(如 HTTP 接收端):从请求头(如 sw8)解析 TraceId,创建 EntrySpan。

  • 出口处(如 HTTP 客户端、Dubbo 客户端):在调用下游前,把当前 TraceId 注入到请求头,创建 ExitSpan。

  • 内部调用:创建 LocalSpan(纯内部方法,不跨进程)。

这样完全不需要手动捕获 ContextSnapshot 或调用 continued

2.5 Agent 配置文件解析(agent.config)

skywalking-java/ - Java Agent 项目(核心)

源代码模块解析

  • apm-sniffer/apm-agent/ - Agent 入口

  • apm-sniffer/apm-agent-core/ - Agent 核心实现

  • apm-sniffer/apm-sdk-plugin/ - 各种中间件插件

  • apm-sniffer/config/ - agent.config 配置文件位置

位置:skywalking-java/apm-sniffer/config/agent.config

为什么要研究 agent.config?(核心作用及概述)

调研 Agent 时有一个绕不开的问题:

Agent 的数据采集行为到底受什么控制?如果将来需要调整监控策略——比如切换后端 OAP 地址、改变采样率、控制数据缓冲大小——是不是必须改代码重新编译?

答案在 agent.config 这个 372 行的配置文件里。它不仅是 Agent 的"行为开关",更是理解 "数据从产生到发出"这条链路的唯一入口——service_name**** 决定了"我是谁"、backend_service 决定了"数据往哪发"、buffer.* 决定了"数据怎么发"。读懂这三组配置,就等于读懂了 Agent 运行的主干逻辑。其他 5 个类别(logging、statuscheck、correlation、jvm、profile、plugin、meter)属于生产环境调优范畴,非核心链路。

因此,本节从源码层面拆解 agent.config 的结构,并沿着配置项追踪数据从配置文件到网络数据包的完整路径。SkyWalking Java Agent 的 全局配置中心 ,它决定了 Agent 在运行时的所有行为模式。

配置分类与影响范围

配置文件按功能划分为 8 大类别 ,每个类别影响不同的流程:

在这里插入图片描述

Agent. +* *Collector. + Buffer.

agent、collector、buffer 三者合起来就是这条完整链路:

agent.service_name=service-a          ← 我是谁
collector.backend_service=:11800      ← 往哪发
buffer.channel_size=5, buffer_size=300 ← 怎么发(异步批量)
a. Agent.* —— 定义 Agent 身份与基础行为

agent.* 这一组配置。它做三件事:我是谁(service_name)我是哪个副本(instance_name)我能处理多少数据(span_limit、ignore_suffix)

核心配置项一览

配置项 默认值 说明
agent.service_name Your_ApplicationName 告诉 OAP 服务名称。
agent.instance_name 自动生成 UUID@hostname 同一服务的多台机器靠这个区分,比如“订单服务-机器1”和“订单服务-机器2”。
agent.sample_n_per_3_secs -1(全部采集) 流量太大时按比例丢弃,比如设 100 就是每 3 秒最多只采 100 条。
agent.span_limit_per_segment 300 单次请求最多记录多少个 Span,防止内存被撑爆。
agent.ignore_suffix .jpg,.js,.css,.png... 静态资源不追踪,避免 Trace 里全是图片请求。

核心点1****:Agent 数据包的完整传输路径——从 Java 对象到 Protobuf 二进制、经 gRPC 发送、最终写入 ES 的全过程

以 service_name 字段为线索,观察数据在每一步的形态变化。

agent.config 里写了一行 agent.service_name=service-a,它是怎么最终出现在 SkyWalking UI 的拓扑图上的?整个传输分为 6 步:

配置加载

第 1 步:加载配置

应用启动时,Agent 主动读取配置文件中的 agent.service_name=service-a,并将其常驻内存。

数据封装:采集与打包

第 2 步:请求结束,触发打数据包(agent采集到的数据包)

当用户发起一次 HTTP 请求时,Agent 会记录所有调用细节(方法耗时、SQL 语句、异常信息等),生成一个包含全部信息的“数据包裹”——TraceSegment,这是Agent的数据上报单元。

****TraceSegment 的数据结构****(`TraceSegment.java`,核心字段):
public class TraceSegment {
    private String traceSegmentId;              // 本次 Segment 的唯一 ID
    private String traceId;                     // 全局 Trace ID,串联跨服务调用
    private String service;                     // 服务名,来自 agent.service_name
    private String serviceInstance;             // 实例名,来自 agent.instance_name
    private List<AbstractTracingSpan> spans;    // 本次请求产生的所有 Span
    private boolean isSizeLimited;              // Span 数量是否触及上限
}

示例:一次真实请求生成的数据包

以 Demo 环境中 GET /api/chain 为例,service-a 的 Agent 生成如下 TraceSegment:

TraceSegment {
  traceSegmentId: "99763404...f9efc3.43.17839939538250000"
  traceId:        "99763404...f9efc3.43.17839939538250001"
  service:        "service-a"
  serviceInstance: "uuid@192.168.1.100"
  spans: [
    Span {   // Span 0: 入口(Entry),接收外部请求
      spanId: 0,  parentSpanId: -1,
      operationName: "GET:/api/chain",
      spanType: Entry,  startTime: 1783993953825,  endTime: 1783993953913,
      isError: false
    },
    Span {   // Span 1: 出口(Exit),调用 service-b
      spanId: 1,  parentSpanId: 0,
      operationName: "/api/data",
      spanType: Exit,  startTime: 1783993953837,  endTime: 1783993953912,
      peer: "service-b:8082",  isError: false
    }
  ],
  isSizeLimited: false
}

注:service-b 和 service-c 各自也会产生独立的 TraceSegment,结构相同,通过同一个 traceId 串联。三个 Segment 的 traceId 一致、traceSegmentId 各不相同、service 分别为 service-a/b/c。OAP 查询时按 traceId 聚合为完整调用链。

**实验验证:**ES 已经把 Proto 二进制解成了结构化字段,查最新一条 segment,看全部字段:

在这里插入图片描述
**截图说明:**顶层索引字段(OAP 从 Proto 中提取,方便搜索):

字段 说明
trace_id 0040ce34…0001 全局 Trace ID
segment_id 8d4389b1…0000 注意:和 trace_id 不一样,这是本 Segment 的唯一 ID
service_id c2VydmljZS1j.1 Base64 → 解码就是 service-c
endpoint_id c2Vy…0VUOi9hcGkvZGF0YQ== Base64 → 解码就是 GET:/api/data
start_time 1784101126099 毫秒时间戳
latency 23 这个 Segment 总耗时 23ms
is_error 0 没有报错

第 3 步:数据压缩------从 Java 对象到 Protobuf 二进制

这是数据形态发生关键变化的一步——TraceSegment 从 JVM 堆内的 Java 对象,变成可在网络上传输的二进制字节流。

为什么不能直接发 JSON?

一个请求可能产生几十个 Span,每个 Span 含十几个字段。用 JSON 传输,光是字段名("traceSegmentId""parentSpanId" 等)的字符串就占大量字节。1000 条 Trace 用 JSON 发可能要好几 MB,用 Protobuf 只需几百 KB——差距在 5~10 倍。

SkyWalking 使用 Protocol Buffers(Protobuf),Google 开发的二进制序列化协议。核心优势:体积小,有 .proto 文件约束 Schema(Agent 和 OAP 之间不会出现字段名写错、类型不匹配的问题),支持跨语言。

序列化源码SegmentObjectAdapter.java):

Agent 中负责将 Java 对象转为 Proto 的核心代码如下。map2Proto 按 Proto 定义逐字段写入——这一步决定了 ES 中最终存储的字段结构:

public static SegmentObject.Builder map2Proto(TraceSegment segment) {
    SegmentObject.Builder builder = SegmentObject.newBuilder();
    builder.setTraceId(segment.getTraceId());              // → Proto 字段 #1
    builder.setTraceSegmentId(                             // → Proto 字段 #2
        segment.getTraceSegmentId());
    builder.setService(segment.getService());              // → Proto 字段 #4
    builder.setServiceInstance(                            // → Proto 字段 #5
        segment.getServiceInstance());
    builder.setIsSizeLimited(segment.isSizeLimited());     // → Proto 字段 #6
    for (AbstractTracingSpan span : segment.getSpans()) {
        builder.addSpans(                                  // → Proto 字段 #3
            SpanObjectAdapter.map2Proto(span));            // 每个 Span 也单独序列化
    }
    return builder;
}

Proto 定义Tracing.proto):

序列化后的二进制按以下 Schema 组织——外层 SegmentObject(6 个字段)包裹内层 SpanObject(共 14 个字段):

message SegmentObject {
    string traceId = 1;                // 全局 Trace ID → ES trace_id
    string traceSegmentId = 2;         // 本 Segment 唯一 ID → ES segment_id
    repeated SpanObject spans = 3;     // Span 列表 → ES data_binary(整体保留)
    string service = 4;                // 服务名 → ES service_id(Base64 编码)
    string serviceInstance = 5;        // 实例名 → ES service_instance_id
    bool isSizeLimited = 6;            // Span 数是否触及上限(内部标志,不对外暴露)
}

message SpanObject {
    int32 spanId = 1;                  // Span 序号 → GraphQL Span.spanId
    int32 parentSpanId = 2;            // 父 Span 序号,-1 表示根 → GraphQL Span.parentSpanId
    int64 startTime = 3;               // 开始时间(毫秒)→ ES start_time
    int64 endTime = 4;                 // 结束时间 → GraphQL Span.endTime
    repeated SegmentReference refs = 5;// 跨进程引用(上游调用方信息)
    string operationName = 6;          // 端点名 → ES endpoint_id(Base64 编码)
    string peer = 7;                   // 远端地址 → GraphQL Span.peer
    SpanType spanType = 8;             // Entry / Exit / Local → GraphQL Span.type
    SpanLayer spanLayer = 9;           // HTTP / RPC / DB → GraphQL Span.layer
    int32 componentId = 10;            // 组件类型(如 SpringMVC = 14)→ GraphQL Span.component
    bool isError = 11;                 // 是否出错 → ES is_error
    repeated KeyStringValuePair tags = 12;  // 自定义标签(如 http.method=GET)→ GraphQL Span.tags
    repeated Log logs = 13;            // 日志事件 → GraphQL Span.logs
    bool skipAnalysis = 14;            // 是否跳过分析(内部标志,不对外暴露)
}

注:SegmentObject.service 写入的是 agent.service_name 的值;SegmentObject.serviceInstance 写入的是 agent.instance_name 的值。同一服务的 3 台机器,service 字段都是 service-a,但 instance 各不相同。OAP 用 service 做服务级聚合(拓扑图节点),用 instance 做实例级区分(出问题时知道是哪台机器)。

实验验证:解码 ES 中的 data_binary,确认 Proto 二进制完整保留

ES 中查到的 data_binary 字段就是 Agent 上报的原始 Proto 二进制(Base64 编码后存储)。以下实验直接解码它,验证其内部结构:

# 1. 提取最新 Segment 的 data_binary
DATA=$(curl -s 'http://localhost:9200/sw_segment-*/_search?pretty' \
  -H 'Content-Type: application/json' \
  -d '{"size":1, "sort":[{"start_time":"desc"}]}' \
  | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['hits']['hits'][0]['_source']['data_binary'])")

# 2. Base64 解码
echo "$DATA" | base64 -d > /tmp/segment.bin

# 3. 看大小
echo "Proto 二进制大小: $(wc -c < /tmp/segment.bin) bytes"

# 4. 看十六进制 + 提取明文
xxd /tmp/segment.bin | head -20
strings /tmp/segment.bin

明文结构

在这里插入图片描述

#通过安装 protoc 后用 --decode_raw 解析
apt install -y protobuf-compiler
echo "$DATA" | base64 -d | protoc --decode_raw

解析后结构
在这里插入图片描述

字段解析

1: "0040ce3419014a0c8fffc512ca4711b6.43.17841011260660001"   ← 字段 #1 = traceId
2: "8d4389b13655454596a56d8ec7ecaf57.50.17841011260990000"   ← 字段 #2 = traceSegmentId
3 {                                                           ← 字段 #3 = spans (repeated)
  2: 18446744073709551615                                     ←   parentSpanId (uint64 的 -1)
  3: 1784101126099                                            ←   startTime(毫秒时间戳)
  4: 1784101126122                                            ←   endTime
  5 {                                                         ←   refs(跨进程引用)
    2: "0040ce34...0001"                                      ←     上游 traceId
    3: "dc3383dc...0000"                                      ←     上游 segmentId
    4: 1                                                      ←     上游 spanId
    5: "service-b"                                            ←     上游服务名
    6: "72bd3ff5...@172.18.0.1"                               ←     上游实例
    7: "GET:/api/data"                                        ←     上游端点
    8: "localhost:8083"                                       ←     上游 peer
  }
  6: "GET:/api/data"                                          ←   operationName = 端点名
  7: "localhost:8083"                                         ←   peer = 下游地址
  9: 3                                                        ←   spanType = 3 (Entry)
  10: 14                                                      ←   componentId = 14 (SpringMVC)
  12 { 1: "url"           2: "http://localhost:8083/api/data" }  ← tag
  12 { 1: "http.method"   2: "GET" }                             ← tag
  12 { 1: "http.status_code" 2: "200" }                          ← tag
}
4: "service-c"                                                ← 字段 #4 = service
5: "c1eb3556b5384a5b8dd045621b1d99c9@172.18.0.1"             ← 字段 #5 = serviceInstance

完全一致。 20 个字段中 13 个出现在输出里,7 个没出现——但这不是缺失,是 Proto3 的默认值省略机制


出现的 13 个字段:

字段 编号 输出
traceId 1 "0040ce34...0001"
traceSegmentId 2 "8d4389b1...0000"
spans 3 { ... }
service 4 "service-c"
serviceInstance 5 "c1eb3556...@172.18.0.1"
parentSpanId 2(spans内) 18446744073709551615 (= -1)
startTime 3(spans内) 1784101126099
endTime 4(spans内) 1784101126122
refs 5(spans内) { 上游 service-b 的完整信息 }
operationName 6(spans内) "GET:/api/data"
peer 7(spans内) "``localhost:8083``"
spanLayer 9(spans内) 3
componentId 10(spans内) 14 (SpringMVC)
tags
12(spans内) url / http.method / http.status_code 共 3 个

没出现的 7 个字段——都是 Proto3 默认值,序列化时自动省略:

字段 编号 默认值 实际语义
isSizeLimited 6(外层) false 未触达 Span 上限
spanId 1(spans内) 0 本 Segment 内第 0 个 Span
spanType 8(spans内) 0 Entry(入口 Span)
isError 11(spans内) false 没有报错
logs 13(spans内) [] 没有日志事件
skipAnalysis 14(spans内) false 正常分析

这就是 Protobuf 体积小的核心原因——JSON 必须显式写 "spanId": 0, "isError": false, "spanType": "Entry",Proto 直接不存,接收方按 Schema 自动补默认值。12 个字段的 Span 实际只序列化了 8 个非默认字段。

实验结论

验证项 结果
ES data_binary 是否为合法 Proto 499 bytes,protoc 成功解析,字段编号和类型与 Tracing.proto 定义完全匹配
Proto 中的 service 字段是否等于 agent.service_name 字段 #4 = service-c,与启动参数 -Dskywalking.agent.service_name=service-c 一致
Proto 中的 traceId 是否跨 Segment 一致 字段 #1 的 traceId 与 ES 独立索引字段 trace_id 相同
traceId 与 traceSegmentId 是否不同 字段 #1 ≠ 字段 #2,验证了"同一条 Trace 包含多个 Segment"
Span 内是否保留了完整调用上下文 refs 记录了上游 service-b 的完整信息;tags 保留了 HTTP method、URL、status code
Proto 二进制是否有信息丢失 全部 20 个字段均可还原,与 GraphQL 返回的 Span 详情一致(详见 4.4.2 节三端对照表)

传输与接收

**第 4 步:网络传输 —— gRPC **

第 3 步产出的 Proto 二进制字节流,由 Agent 通过 gRPC 协议发送到 OAP 的 11800 端口。

gRPC 服务定义TraceSegmentService.proto):

Agent 和 OAP 共同遵守这个接口契约。Agent 作为客户端调用 collect(双向流)或 collectInSync(同步),OAP 作为服务端接收:

service TraceSegmentReportService {
 // Agent 默认使用:持续推送 Segment,OAP 可在 Commands 中下发指令
 rpc collect (stream SegmentObject) returns (Commands) {}
// 第三方集成使用:一次性发送一批,等 OAP 返回结果
rpc collectInSync (SegmentCollection) returns (Commands) {}
}

Agent 侧源码GRPCChannelManager.java):

Agent 启动时根据 collector.backend_service 配置建立 gRPC 连接,创建 Stub 并注册到服务发现:

// 1. 建立 gRPC Channel(基于 HTTP/2,长连接复用)
ManagedChannel channel = ManagedChannelBuilder
    .forAddress(host, port)              // host:port 来自 collector.backend_service
    .usePlaintext()                      // 内网通信,无需 TLS
    .build();

// 2. 创建异步 Stub
TraceSegmentReportServiceGrpc
    .newStub(channel)
    .collect(streamObserver);            // 双向流模式,持续推送
方法 模式 适用场景
collect(stream SegmentObject) returns (Commands) 双向流 Agent 默认使用。Agent 持续推送 Segment,OAP 在返回的 Commands 中下发指令(如调整采样率)。
collectInSync(SegmentCollection) returns (Commands) 同步调用 第三方集成使用。一次性发送一批 Segment,等 OAP 返回结果。

第 5 步:OAP 后端接收与还原

OAP 的 TraceSegmentReportServiceHandler.java收到二进制数据后,先用 Protobuf 解码还原出 SegmentObject。然后读出 service 字段的值—— "service-a"

数据展示

第 6 步:写入存储,UI 展示

OAP 解析完成后,将数据写入 Elasticsearch,最终由 UI 通过 GraphQL 查询展示。SkyWalking UI 查询时,“service-a” 就作为拓扑图上的一个节点出现,它的响应时间、成功率、调用关系都是从这个池子里聚合出来的。

整条链路回顾——以 service_name 为例,从配置文件到 UI 展示的完整数据流

阶段 位置 数据形态 示例 发生了什么
① 配置 agent.config 文本 agent.service_name=service-a 运维人员填写服务名。
② 加载 Config.java Java String "service-a" Agent 启动时读取配置,常驻内存。
③ 打包 TraceSegment Java 对象 segment.service = "service-a" 请求结束,所有 Span 打包为 TraceSegment,service 字段赋值为配置值。
④ 序列化 SegmentObjectAdapter Proto Builder → 二进制 字段 #4 = "service-a"72 65 73 76 69 63 65 2D 61... 逐字段写入 SegmentObject.Builderbuild() 输出 499 bytes 二进制。
⑤ 传输 gRPC (11800) HTTP/2 数据帧 PRI * HTTP/2.0 ... application/grpc Agent 通过 gRPC 双向流推送到 OAP。
⑥ 接收 TraceSegmentReportServiceHandler Proto 对象(反序列化) segment.getService() → "service-a" gRPC 自动解码,OAP 业务代码拿到结构化对象。
⑦ 存储 RecordEsDAO → ES JSON 文档 "service_id": "c2VydmljZS1hLjE=" service 转为 Base64 编码的内部 ID,写入 sw_segment-* 索引。
⑧ 查询 GraphQL (12800) JSON "serviceCode": "service-a" UI 调用 listServices / queryTrace,OAP 从 ES 反查并解码返回。

8 个阶段,service_name 的值始终是 "service-a"——从文本配置到 Java 对象到 Proto 二进制到 ES 文档,字段名和格式变了 4 次,值本身从未被修改或丢失。

b. Collector.* —— 配置与 OAP 后端的通信

这部分解决什么问题?

Agent 采集了数据、压缩成了 Protobuf 二进制,下一步就是发出去。但发给谁?走什么网络协议?断了怎么办?发太慢要不要超时?


核心配置项一览

配置项 默认值 说明
collector.backend_service 127.0.0.1:11800 OAP 后端的地址和端口,Agent 唯一的数据出口。
collector.grpc_upstream_timeout 30 秒 一次批量发送的超时上限,超时则丢弃并等待重试。
collector.grpc_channel_check_interval 30 秒 每 30 秒检查一次 gRPC 连接是否存活。
collector.heartbeat_period 30 秒 Agent 每 30 秒向 OAP 发送一次心跳(证明自身存活)。
collector.get_agent_dynamic_config_interval 20 秒 每 20 秒从 OAP 拉取一次动态配置(如远程调整采样率)。

为什么 gRPC 而不是 HTTP?

① 双向流,不需要"一问一答"

传统 HTTP 模式:Agent 发一条 Span → OAP 回一个 200 OK → Agent 再发下一条。每次都是独立的 TCP 连接。

gRPC 双向流模式:Agent 和 OAP 之间建立一条长连接,Agent 可以在这条连接上持续推送数据,OAP 可以在返回的数据流中下发指令(如"从下一条开始,采样率调到 50%")。

对于高并发生产环境,Agent 每秒可能产生上千条 Span。HTTP 模式下光是 TCP 握手就能吃掉相当比例的 CPU 时间。gRPC 的流式传输把连接复用做到了极致。

② 内置健康检查,不需要额外的负载均衡器

grpc_channel_check_interval=30heartbeat_period=30 组合在一起,就是一个自包含的连接管理机制:Agent 自己检查连接状态、自己发送心跳续活、自己发现断连后重连。不需要额外部署 Nginx 或 HAProxy 来做健康检查。

③ gRPC 天然使用 Protobuf

Agent 打包数据用的是 Protobuf,gRPC 的默认序列化方式也是 Protobuf——前后一致,没有格式转换开销。


backend_service** 的路径:从配置文件到 OAP 接收**

第 1 步:加载配置

Agent 启动时,collector.backend_service=127.0.0.1:11800 被读入这个静态变量。如果有多个 OAP 节点,用逗号分隔:10.0.0.1:11800,10.0.0.2:11800

11800 这个端口:是 SkyWalking OAP 的 gRPC 数据接收端口,区别于 12800(HTTP/GraphQL 查询端口)。Agent 上报数据走 11800,UI 查询数据走 12800——职责分离。

第 2 步:建立 gRPC 连接

Agent 内部有一个 GRPCChannelManager,它的职责就是拿着 BACKEND_SERVICE 的地址去建立 gRPC Channel。Channel 是 gRPC 里对 TCP 连接的抽象,建好后就可以反复使用。

注意:newStub(channel) 创建的不是一个普通的 HTTP 客户端,而是一个异步非阻塞的 gRPC Stub——数据发送不会阻塞业务线程。

第 3 步:发送数据

// TraceSegmentServiceClient.java 第 92 行
StreamObserver<SegmentObject> upstreamSegmentStreamObserver = serviceStub
    .withDeadlineAfter(Config.Collector.GRPC_UPSTREAM_TIMEOUT, TimeUnit.SECONDS)
    .collect(new StreamObserver<Commands>() { ... });

三件事同时发生:

  • serviceStub.collect() — 调用 OAP 的 TraceSegmentReportService.collect() 接口

  • .withDeadlineAfter(30, SECONDS) — 设置 30 秒超时,来自 GRPC_UPSTREAM_TIMEOUT

  • new StreamObserver<Commands>() — 注册一个回调,OAP 返回的 Commands 指令在这里处理


连接断开怎么办?——三个配置协作自愈

Agent 自己发现问题、自己重连、自己恢复。

grpc_channel_check_interval=30       heartbeat_period=30
        │                                    │
        ▼                                    ▼
每 30 秒检查一次                       每 30 秒发一次心跳
 gRPC Channel 状态                      证明 Agent 还活着
        │                                    
        ├── 连接正常 → 什么都不做
        │
        └── 连接断开 → 触发重连
                       │
                       ▼
              GRPCChannelManager
              重新解析 BACKEND_SERVICE
              建立新连接
              通知所有使用者"连接已恢复"

源码对应表

流程步骤 源码文件
配置加载 Config.javaConfig.Collector
gRPC 连接管理 GRPCChannelManager.java
创建 Stub TraceSegmentServiceClient.java
发起流式调用 TraceSegmentServiceClient.java
接口定义(Proto) Tracing.proto
OAP 接收端 TraceSegmentReportServiceHandler.java

Demo 验证

在我们的三层服务 Demo 中,三个服务的启动命令里都有同一行参数:

java -javaagent:skywalking-agent.jar \
     -Dskywalking.collector.backend_service=127.0.0.1:11800 \
     -jar target/service-a.jar

验证点一:端口监听确认

在这里插入图片描述
OAP 在 11800 端口监听,与 backend_service 配置完全对应。

验证点二:数据到达 OAP

发几次 HTTP 请求后打开 SkyWalking UI,拓扑图上出现 service-a → service-b → service-c 的调用链,每条 Span 的详细信息都可见。这说明 Agent 发出的 Protobuf 数据经 gRPC 成功抵达 OAP,并且被正确解析入库。

在这里插入图片描述


c. buffer.* —— 配置数据缓冲策略

这部分解决什么问题?数据怎么发

直觉上,最简单的方案是:业务线程每创建一个 Span,立刻调用 send() 发给 OAP。但这样做有一个致命问题——网络 I/O 会拖慢业务。如果 OAP 所在的机器此时负载高、网络抖动,你的业务线程也跟着卡住。

所以 Agent 的做法是:Span 创建完后先扔进一个内存缓冲区,立刻返回,让业务线程接着干活。后台有一个专门的线程从缓冲区取数据、批量打包、通过 gRPC 发出。这就是 buffer.* 控制的机制。


核心配置项一览

配置项 默认值 说明
buffer.channel_size 5 数据通道数。可以理解为开了 5 条“传送带”,数据被分散到这些传送带上。
buffer.buffer_size 300 每条传送带的容量。每条最多暂存 300 个 TraceSegment。

两个参数乘起来:总共最多缓冲 5 × 300 = 1500 条 TraceSegment


为什么不直接发而要缓冲?

Agent 面对的场景下每秒可能产生几百条 Span。如果每条 Span 都走一次网络 I/O(哪怕 gRPC 再快,网络往返也有毫秒级延迟),业务线程的响应时间会被系统性拉长。

缓冲的本质是解耦:业务线程只管生产 Span,网络线程只管消费和发送,两者互不阻塞。

Agent 的后台发送线程与业务线程完全解耦,业务线程仅在内存中写入 Span 后即返回,不等待任何网络 I/O。虽然 Agent 会占用少量 CPU 和内存资源(实测损耗 < 3%),但其内置的反压机制保证了即便在极端流量下,缓冲队列满时优先丢弃监控数据而非阻塞业务,确保业务线程始终不受影响。


DataCarrier:Agent 的"传送带系统"

SkyWalking Agent 实现了一套自己的内存队列,叫 DataCarrier。它不是一个简单的 LinkedList,而是专门为高频写入、低频消费这种场景设计的环形缓冲区

源码初始化TraceSegmentServiceClient.java 第 64-69 行):

// 创建 5 个通道,每个通道容量 300
carrier = new DataCarrier<>(
    Config.Buffer.CHANNEL_SIZE,      // 5 个通道
    Config.Buffer.BUFFER_SIZE,       // 每通道 300 容量
    BufferStrategy.IF_POSSIBLE       // 满则丢弃,不阻塞
);
carrier.consume(this, 1);  // 启动 1 个消费者线程

架构图

业务线程 1 ────► Span1 ──┐
                    业务线程 2 ────► Span2 ──┤
                    业务线程 3 ────► Span3 ──┤
                                              ▼
                    DataCarrier(5 通道 × 300 容量 = 1500)
                    ┌─────┬─────┬─────┬─────┬─────┐
                    │通道0│通道1 │通道2│通道3 │通道4│
                    │ 300 │ 300 │ 300 │ 300 │ 300 │
                    └──┬──┴──┬──┴──┬──┴──┬──┴──┬──┘
                       │     │     │     │     │
                       └─────┴──┬──┴─────┴─────┘
                                ▼
                         1 个消费者线程
                         批量取出 → transform() → gRPC Stream → OAP

Demo 验证

在实验的三层 Demo 中,buffer 配置全部使用了默认值(channel_size=5, buffer_size=300),启动参数里没有显式覆盖——因为默认值对于实验环境绰绰有余。


源码对应表

流程步骤 源码文件
配置加载 Config.javaConfig.Buffer
创建缓冲区 TraceSegmentServiceClient.java
写入缓冲区 TraceSegmentServiceClient.java
缓冲区满丢弃日志 TraceSegmentServiceClient.java
消费并发送 TraceSegmentServiceClient.java

三、OAP —— 数据处理

3.1 OAP 是什么

OAP(Observability Analysis Platform)是 SkyWalking 的后端服务,负责接收来自各个 Agent 的监控数据,进行聚合、分析、持久化,并提供查询接口供 UI 或第三方系统调用。

OAP 采用模块化设计,支持集群部署 、水平扩展、多种存储后端,是整个 SkyWalking 系统的“中枢神经”。

3.2 核心功能模块

OAP 内部包含多个子模块,各司其职:

  1. Receiver:接收 Agent 上报的 Trace、Metric、Log 数据。

  2. Analyzer:对原始数据进行聚合、计算、关联分析。

  3. Aggregator:按时间窗口聚合指标,如 QPS、响应时间、错误率。

  4. Storage:将处理后的数据写入持久化存储(ES、MySQL、TiDB 等)。

  5. Query:提供 GraphQL 接口供 UI 查询数据。

  6. Alarm:根据预设规则触发告警。

  7. Telemetry:监控 OAP 自身运行状态。

整体模块架构图如下

在这里插入图片描述

3.3 数据处理流程:从接收到写入再到查询

Agent 部分研究了数据怎么从应用发到 OAP 门口(第 1~4 步)。现在接着研究下数据进入 OAP 之后发生了什么——它经过了哪些模块、被做了什么处理、最终存成什么样子、怎么查出来

整个 OAP 内部的处理链路分四段:

Agent 发来 Protobuf 二进制
    │
    ▼
1️⃣ 接收(两种接受模式)
    TraceSegmentReportServiceHandler
    - collect(流式)
    - collectInSync(批量)
    │
    ▼
2️⃣ 解析
    SegmentParserServiceImpl → TraceAnalyzer
    - 遍历 Span:提取 traceId / 时间 / 状态 / tag
    - 构建 serviceId / instanceId / endpointId
    │
    ▼
3️⃣ 存储
    SourceReceiver → Storage Module
    - 写入 Elasticsearch(或 MySQL 等)
    │
    ▼
4️⃣ 查询
    GraphQL Query Engine
    - UI / 外部系统通过 HTTP POST 查询
3.3.1 数据接收

这部分解决什么问题?

Agent 发来了 Protobuf 二进制数据,OAP 要有一个"前台"负责接收。这个前台需要支持两种模式:高吞吐的流式接收(Agent 默认使用),以及兼容第三方集成的同步接收。 OAP 作为 gRPC 服务端,这两个模式是暴露给 Agent 调用的接口。Agent 启动时通过配置决定使用哪种模式。

谁调用谁?

  • Agent 是 gRPC 客户端,它主动发起连接,调用 OAP 的 collectcollectInSync 方法。

  • OAP 是 gRPC 服务端,被动等待 Agent 连接并调用这些方法。

两段代码里的 collectcollectInSync,都是 OAP 端定义并实现的 gRPC 接口方法,Agent 在配置里指定走哪一种。

Agent 怎么选择模式?

Agent 通过配置文件 agent.config 中的 collector.trace_segment_handler 参数决定:

# 默认使用流式模式(双向流)
collector.trace_segment_handler=GRPC

# 如果改用同步批量模式(极少使用)
# collector.trace_segment_handler=GRPC_BATCH
  • 模式一(collect):双向流模式,Agent 和 OAP 保持长连接,持续发送数据。Agent 默认使用这种,因为吞吐量高,延迟低。

  • 模式二(collectInSync):同步批量模式,Agent 攒一批数据一次性发完,然后等 OAP 返回确认。主要用于第三方系统集成或测试场景,生产环境很少用。

两种模式最终都调用 segmentParserService.send(segment)SegmentObject 这个 Proto 对象被原封不动地传给下一段"解析模块"。OAP 的接收层只负责“收”,不负责“解析”和“存储”。

// 模式一:双向流(Agent 默认使用,第 63 行)
public StreamObserver<SegmentObject> collect(StreamObserver<Commands> responseObserver) {
    return new StreamObserver<SegmentObject>() {
        public void onNext(SegmentObject segment) {
            segmentParserService.send(segment);   // ← 收到一个发一个
        }
        public void onCompleted() {
            responseObserver.onNext(Commands.newBuilder().build()); // ← 返回指令
            responseObserver.onCompleted();
        }
    };
}

// 模式二:同步批量(第三方集成,第 103 行)
public void collectInSync(SegmentCollection request, StreamObserver<Commands> responseObserver) {
    request.getSegmentsList().forEach(segment -> {
        segmentParserService.send(segment);   // ← 批量遍历发送
    });
    responseObserver.onNext(Commands.newBuilder().build());
    responseObserver.onCompleted();
}
3.3.2 数据解析——从字节流到结构化数据

这部分解决什么问题?

SegmentObject 虽然经过 Proto 反序列化变成了 Java 对象,但它还是一个"大包裹"——里面有 Span 列表、有 service 字段、有 tag,但都是原始数据。OAP 需要把这些原始数据拆解成可存储、可查询、可聚合的结构化记录。

处理流程:

// 入口:每次收到一个 Segment,创建新的 TraceAnalyzer 实例
public void send(SegmentObject segment) {
    final TraceAnalyzer traceAnalyzer = new TraceAnalyzer(moduleManager, listenerManager, config);
    traceAnalyzer.doAnalysis(segment);    // ← 触发分析流水线
}

分析流水线按顺序执行三个步骤:

步骤① parseSegment —— 遍历所有 Span,提取宏观信息

**输入:**完整的 SegmentObject(包含该 Trace 段下的所有 Span)

做了三件事:

  1. 确定时间范围:遍历所有 Span,找出最早开始的 Span 和最后结束的 Span,计算出整条 Trace 的总耗时。

  2. 判断整体状态:只要任何一个 Span 标记了 error,整条 Trace 就标记为“失败”。

  3. 提取可搜索字段:从 Span 的 tags 中提取需要支持快速检索的字段。

为什么需要这一步:OAP 需要在“整条 Trace”维度上聚合数据,比如“过去 5 分钟所有 Trace 的平均耗时”或者“失败率”——这些指标需要基于整条 Trace 的总耗时和整体状态来计算,而不是单个 Span 的。

**输出:**当前 Segment 对象中填充了 traceIdstartTimestampendTimestampdurationisError 等宏观字段。

public void parseSegment(SegmentObject segmentObject) {
    segment.setTraceId(segmentObject.getTraceId());       // ← 全局 Trace ID
    segmentObject.getSpansList().forEach(span -> {
        if (startTimestamp == 0 || startTimestamp > span.getStartTime()) {
            startTimestamp = span.getStartTime();          // ← 找最早的 Span
        }
        if (span.getEndTime() > endTimestamp) {
            endTimestamp = span.getEndTime();              // ← 找最晚的 Span
        }
        isError = isError || segmentStatusAnalyzer.isError(span);  // ← 任一 Span 出错则整条标记
        appendSearchableTags(span);                        // ← 提取可搜索 tag
    });
    duration = endTimestamp - startTimestamp;              // ← 计算整条 Trace 耗时
}

步骤② parseFirst —— 把服务名字符串转为内部 ID

**核心目标:**把可读的字符串名字,转换成 OAP 内部用的数字 ID;同时把原始数据完整备份一份。

输入:segmentObject(原始 Proto 对象)——里面包含了 service="service-a"serviceInstance="uuid@host" 这些原始字符串。

为什么需要这一步:service-a 这样的字符串在后续聚合计算中不适合直接作为 key,因为字符串比较和哈希计算在高并发下性能开销较大。OAP 内部使用 serviceIdinstanceIdendpointId 这三个数值型 ID 作为聚合维度。

做了四件事:

  1. serviceNameserviceIdservice-a → 一个内部整数 ID)

  2. serviceInstanceNameserviceInstanceId

  3. span.getOperationName()endpointId(比如 POST /api/chain → 内部 ID)

  4. 保存一份完整的 Proto 原始二进制:segment.setDataBinary(segmentObject.toByteArray())

**输出:**当前 Segment 对象中填充了 serviceIdinstanceIdendpointIddataBinary 等字段。

public void parseFirst(SpanObject span, SegmentObject segmentObject) {
    serviceName = namingControl.formatServiceName(segmentObject.getService());
    serviceId = IDManager.ServiceID.buildId(serviceName, true);  // ← service-a 的内部 ID
    segment.setSegmentId(segmentObject.getTraceSegmentId());
    segment.setServiceId(serviceId);
    segment.setServiceInstanceId(IDManager.ServiceInstanceID.buildId(
        serviceId, namingControl.formatInstanceName(segmentObject.getServiceInstance())
    ));
    segment.setDataBinary(segmentObject.toByteArray());   // ← 完整 Proto 二进制一并保留
    endpointName = namingControl.formatEndpointName(serviceName, span.getOperationName());
    endpointId = IDManager.EndpointID.buildId(serviceId, endpointName);
}

步骤③ build —— 判断是否存储,送入持久化管道

做了两件事:

  1. 采样判断:如果该 Trace 被采样规则判定为丢弃(比如采样率 10% 时,90% 的 Trace 被跳过),则直接 return,不再往下走。

  2. 送入存储层:调用 sourceReceiver.receive(segment),将完整数据交给下一层(聚合器 + 存储模块)。

**输出:**数据离开解析层,进入存储层的处理流水线。

public void build() {
    if (sampleStatus.equals(SAMPLE_STATUS.IGNORE)) {
        return;     // ← 被采样的丢弃规则筛掉的 Trace,不存
    }
    segment.setEndpointId(endpointId);
    sourceReceiver.receive(segment);    // ← 送入持久化管道
}
3.3.3 数据存储——ES 索引结构与验证

这部分解决什么问题?

数据经过接收、解析后,最终落地到 Elasticsearch。这里要回答三个问题:

  1. 存到哪了?——SkyWalking 在 ES 里建了哪些索引,每个索引用途是什么。

  2. 怎么命名的?——索引名的规律是什么,如何按时间范围定位数据。

  3. 怎么验证?——能不能直接查 ES 确认数据写进去了。

实验验证:直连 ES 验证数据存储

在 Demo 环境中,ES 部署在 localhost:9200

ES-实验一:查看 SkyWalking 创建了哪些索引

实验目的

验证 OAP 是否成功将数据写入了 Elasticsearch,以及建了哪些索引、索引用途是什么。

实验做法

发几次 HTTP 请求产生 Trace 数据后,执行:

# 查询 Elasticsearch 中所有由 SkyWalking 创建的索引,并只筛选出带 sw_ 前缀的条目显示出来
curl -s http://localhost:9200/_cat/indices?v | grep sw_

SkyWalking Agent → OAP → Elasticsearch 整条数据管道已打通,Trace 和 Metrics 数据均已成功写入 ES。

在这里插入图片描述

索引头解析

列序 字段名 含义 示例(第一行)
第1列 health 索引健康状态:green(正常)、yellow(副本缺失)、red(不可用) green
第2列 status 索引开启状态:open(可用)或 close(关闭) open
第3列 index 索引名称(SkyWalking 按天/类型建的) sw_log-20260713
第4列 uuid 索引的集群唯一标识符(一串随机字符串) eE2x_dFGSBmg14PKZs8EPA5
第5列 pri 主分片数量(数据存储的主分片数) 5
第6列 rep 副本分片数量(高可用的备份数) 0
第7列 docs.count 该索引里的文档总数(即有多少条记录) 0
第8列 docs.deleted 已删除但未物理清理的文档数(一般可忽略) 0
第9列 store.size 该索引占用总存储空间(含所有分片和副本) 1.1kb
第10列 pri.store.size 主分片占用的存储空间(不含副本) 1.1kb

索引名称解析

它们的命名遵循一个统一的规则:{前缀}_{索引类型}_{日期后缀}

  • sw_:固定前缀,可以通过配置文件中的 namespace 参数修改。

  • {索引类型}:用于区分存储的数据类型,如 segment (链路)、metrics-all (指标) 等。

  • {日期后缀}:格式为 YYYYMMDD(例如 20260713),用于按天对数据进行分片,便于管理和清理

索引类型(示例) 存储数据类型 核心用途
sw_segment-YYYYMMDD 链路追踪数据 (Trace) 存储最核心的调用链详情。每一条记录代表一个 TraceSegment,包含完整的 Span 列表、调用耗时、状态等。
sw_metrics-all-YYYYMMDD 聚合指标数据 (Metrics) 存储服务、实例、端点等维度的性能指标,如吞吐量、响应时间、成功率等。这是 SkyWalking UI 中展示图表和拓扑图的数据来源。
sw_log-YYYYMMDD 应用日志 (Log) 当开启日志集成功能后,存储收集到的应用日志。
sw_browser_error_log-YYYYMMDD 浏览器端错误日志 专门存储从浏览器端采集到的错误日志。
sw_zipkin_span-YYYYMMDD Zipkin 格式的 Span 数据 为兼容 Zipkin 协议而设计的索引,用于接收 Zipkin 格式的追踪数据。
sw_records-all-YYYYMMDD 各类记录 (Records) 存储慢 SQL、慢调用等告警或分析记录。

举例

green | open | sw_segment-20260713 | EblcU7C3Rz0... | 5 | 0 | 18 | 0 | 216.1kb | 216.1kb
  • 状态健康,索引可用,索引名是 sw_segment-20260713,有 18 条 Trace 数据,占用了 216.1 KB 的磁盘空间。

验证结论

SkyWalking Agent → OAP → Elasticsearch 整条数据管道已打通。Trace 和 Metrics 数据均成功写入 ES,索引按天分片(sw_segment-YYYYMMDD),命名规则固定可预测。Log 相关索引(sw_log-*)由 OAP 默认创建,本次 Demo 未开启日志采集功能。

ES-实验二:查看 Segment 索引的字段结构与真实数据样本

实验目的

直连 ES 查看 sw_segment 索引的字段定义(Mapping)和真实数据样本,确认每个字段的类型、含义和来源,验证"字段名可读、数据可查、来源可追溯"。

实验做法

索引创建成功后,通过两步验证内部数据的完整性:先确认字段定义是否符合预期,再提取一条真实数据进行反推验证。

① 查看字段结构(Mapping)

curl -s http://localhost:9200/sw_segment-*/_mapping?pretty | head -80

在这里插入图片描述

核心字段如下

字段名 类型 说明 用途
trace_id keyword 全局 Trace ID 精确匹配,用于调用链聚合查询
segment_id keyword 当前 Segment ID 唯一标识该段数据
service_id keyword 服务内部编码(如 c2VydmljZS1h.1 非原始服务名,由 OAP 解析层从 SegmentObject.service 转换而来
endpoint_id keyword 端点内部编码 由首个 Entry Span 的 operationName 转换而来
start_time long Trace 起始时间戳(毫秒) 用于时间范围筛选
latency int 整条 Trace 总耗时(毫秒) 由最晚 Span 结束时间减最早 Span 开始时间计算得出
is_error int 是否包含错误(0/1) 任一子 Span 报错则置为 1
data_binary binary 完整 Proto 原始字节(Base64 存储) 关键设计:用于调用链详情页还原每个 Span

② 取一条真实的 Trace 数据

从所有 sw_segment-* 索引中随机取一条 Trace 记录,以格式化 JSON 形式打印出来,查看 trace_idservice_idlatencydata_binary 等字段的实际值。

curl -s http://localhost:9200/sw_segment-*/_search?pretty \
  -H 'Content-Type: application/json' \
  -d '{"query": {"match_all": {}}, "size": 1}'

在这里插入图片描述

返回结果解析

ES 字段 值示例 说明
_index sw_segment-20260713 数据所在的索引,按天分片
trace_id d30b0832aded... 全局 Trace ID,用于串联整条调用链
segment_id 5b47705c99e8... 当前 Segment 的唯一标识
service_id c2Vydm1JzS1j.1 服务 service-a 的内部编码 ID(Base64)
service_instance_id c2Vydm1JzS1j.1_MjA3... 服务实例的内部编码 ID
endpoint_id c2Vydm1JzS1j.1_R0VUoI9hcGkvZGF0YQ== 端点 /api/data 的内部编码 ID
start_time 1783905816018 该 Segment 起始时间戳(毫秒)
latency 245 该 Segment 总耗时 245ms
is_error 0 无错误(0 = 正常)
time_bucket 20260713012336 分钟级时间分桶(用于聚合查询)
data_binary cjVkMzBiMDg... 原始 Proto 二进制(Base64 编码),用于调用链详情还原

字段来源解析

ES 字段 实际存储值 数据来源
trace_id d30b0832aded434db585f52c5bc1f9e5.42.17839058148770001 直接来自 SegmentObject.traceId(Proto 字段编号 1)
segment_id 5b47705c99e8410393b0e450fB4289f5.41.17839058160160000 直接来自 SegmentObject.traceSegmentId(Proto 字段编号 2)
service_id c2Vydm1JzS1j.1 OAP 解析层从 SegmentObject.service(字段编号 4)编码生成,解码后对应 service-a
endpoint_id c2Vydm1JzS1j.1_R0VUoI9hcGkvZGF0YQ== OAP 解析层从第一个 Entry Span 的 operationName 编码生成,解码后对应 GET:/api/data
service_instance_id c2Vydm1JzS1j.1_MjA3ZTRhOWEW0tG5NGY2NWI3ZDFkNjdkY2JZTViZJGAMTcyLjE4L1jAuMQ== OAP 解析层从 SegmentObject.serviceInstance 编码生成
start_time 1783905816018 所有 Span 中最早的 startTime(毫秒时间戳)
latency 245 最晚 Span 结束时间减去最早 Span 开始时间(单位:毫秒),即 245ms
is_error 0 0 表示正常,1 表示有错误 Span
time_bucket 20260713012336 按分钟对齐的时间分桶(格式:YYYYMMDDHHmmss),用于聚合查询
data_binary cjVkMzBiMDg...(Base64 编码,截取开头) SegmentObject.toByteArray() 的完整二进制(Base64 编码存储)

验证结论

ES 中 sw_segment 索引的字段结构清晰,每个字段都能追溯到 Proto 定义中的来源。特别关键的两个发现:① data_binary 字段保存了完整的 Proto 原始二进制,这意味着任何时候都可以从 ES 中取出原始数据来对账审计——不依赖 SkyWalking UI,不依赖 OAP,用 protoc 工具就能解码还原;② is_error=0 表示该 Trace 正常,latency=245 表示耗时 245ms。这些字段都不需要 SkyWalking UI 来解释——直接查 ES 就能看懂。

ES-实验三:ES 数据与 UI 展示的一致性验证

实验目的

拿 ES 里查到的真实数据,去 UI 界面用 traceId 反查,验证同一条数据在两个入口看到的内容是否一致。

实验做法

  1. 从 ES-实验二中取到的真实数据样本,提取关键字段:traceId(d30b0832...0001)、service_id(解码为 service-a)、latency(245ms)、is_error(0)。

  2. 在 SkyWalking UI 中用该 traceId 搜索,查看调用链详情。

  3. 对比 ES 中的数值与 UI 展示的数值。

在这里插入图片描述

验证结论

ES 与 UI 对同一条 Trace 的耗时展示一致(245ms),服务名一致(service-a),状态一致(正常)。数据从 ES 到 UI 的过程中没有发生二次修改或丢失——UI 只是 ES 数据的另一个展示入口,不是另一个数据源。 这意味着:如果将来 UI 出问题或不可用,运维团队完全可以直接查 ES 来排障,数据不受 UI 影响。

3.3.4 数据查询——GraphQL API 验证

这部分解决什么问题?

数据存进 ES 了,但怎么把它取出来?SkyWalking UI 上的拓扑图、调用链详情、服务列表——这些数据从哪来?如果将来要做二次开发(比如把 Trace 数据接入公司自有告警平台、写自动化巡检脚本),应该用什么接口?

OAP 对外暴露了一套 GraphQL API,监听 12800 端口(HTTP),SkyWalking UI 本身也是通过这套 API 获取数据的。

两个端口的职责分离

Agent ──gRPC(Protobuf)──► 11800 ──► OAP 后端(数据写入)
 UI  ──HTTP(GraphQL) ──► 12800 ──► OAP 后端(数据查询)
外部系统 ──HTTP(GraphQL)──► 12800 ──► OAP 后端(数据查询)
  • 11800:数据写入通道(Agent → OAP),协议为 gRPC,传输 Protobuf 二进制。

  • 12800:数据查询通道(UI / 外部系统 → OAP),协议为 HTTP,传输 JSON。

两者职责严格分离——写入不会拖慢查询,查询不会阻塞写入。

GQL-实验一:通过接口查询服务列表

实验目的

“数据能不能通过标准接口取出来?能不能对接我们自己的系统?”——验证 GraphQL API 是否可用,返回的服务列表是否与 Demo 中启动的三个服务一致。如果这一步成功,说明"通过编程方式获取 SkyWalking 数据"是完全可行的。

实验做法

curl -s -X POST http://localhost:12800/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "{ listServices { name } }"}'

在这里插入图片描述

验证结论

API 正常返回 service-aservice-bservice-c 三个服务,与启动参数 -Dskywalking.agent.service_name=service-x 完全一致。一条调用链从 Agent 的配置出发,经过 Protobuf 序列化 → gRPC 传输 → OAP 解析 → ES 存储 → GraphQL 查询,最后原封不动地回到屏幕上。整套查询接口基于 HTTP + JSON,curl 命令即可调用——任何支持 HTTP 的系统都能对接,不引入专有 SDK 或私有协议。

GQL-实验二:通过接口查询 Trace 列表

实验目的

“能不能按条件筛选数据?比如查某个服务在某个时间段内有没有出错的请求?”——验证 GraphQL 的条件查询能力(按服务、时间范围、状态过滤、分页排序),验证返回字段(尤其是 isError)是否可用。

queryBasicTraces 接口用于按条件查询 Trace 列表,返回符合条件的所有 Trace 摘要信息。查询条件通过 TraceQueryCondition 对象传入。

可用的查询条件(核心字段):

条件字段 类型 说明 是否必填
serviceId String 服务的内部编码 ID(如 c2VydmljZS1h.1 二选一(serviceId / serviceName
serviceName String 服务的原始名称(如 service-a 二选一(serviceId / serviceName
queryDuration Duration 查询的时间范围(含 startendstep
traceState TraceState Trace 状态过滤:ALL / SUCCESS / ERROR 否(默认 ALL
queryOrder QueryOrder 排序方式:BY_START_TIME / BY_DURATION 否(默认 BY_START_TIME
paging Paging 分页参数(含 pageNumpageSize
endpointId String 按端点 ID 过滤
minDuration / maxDuration Int 按耗时范围过滤(毫秒)

注意:serviceNameserviceId 均为可用字段,两者效果等价。实验中使用 serviceId(可从 ES 的 service_id 字段获取,或通过 listServices 接口查询)。

**验证查询:**查询 service-a 在 2026-07-13 至 2026-07-14 期间的所有 Trace,按开始时间倒序排列,取前 5 条:

curl -s -X POST http://localhost:12800/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "query($cond: TraceQueryCondition!) { queryBasicTraces(condition: $cond) { traces { segmentId endpointNames duration start isError traceIds } } }", "variables": {"cond": {"serviceId": "c2VydmljZS1h.1", "queryDuration": {"start": "2026-07-13 0000", "end": "2026-07-14 2359", "step": "MINUTE"}, "traceState": "ALL", "queryOrder": "BY_START_TIME", "paging": {"pageNum": 1, "pageSize": 5}}}}'

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

返回结果解析

共返回 5 条 Trace 记录,按 start 时间从新到旧排列,同时最新的时间和UI界面查询出的时间一致:

# Segment ID Endpoint Names Duration (ms) Start (毫秒时间戳) Is Error Trace IDs
1 9976340...43... ["GET:/api/chain"] 88 1783993953825 FALSE 9976340...43...0001
2 9976340...42... ["GET:/api/chain"] 1880 1783993948212 FALSE 9976340...42...0001
3 d30b0832...50... ["GET:/api/chain"] 220 1783931849094 FALSE d30b0832...50...0003
4 d30b0832...46... ["GET:/api/chain"] 41 1783925185218 FALSE d30b0832...46...0003
5 d30b0832...45... ["GET:/api/chain"] 32 1783925182694 FALSE d30b0832...45...0001

返回字段说明

字段 类型 说明
segmentId String 该 Trace 的入口 Segment ID,用于唯一标识
endpointNames [String] 该 Trace 涉及的端点名称列表(如 GET:/api/chain
duration Int 整条 Trace 总耗时(毫秒)
start String 请求开始时间(毫秒时间戳)
isError Boolean 是否包含错误 Span
traceIds [String] 该 Trace 关联的 Trace ID 列表(通常包含一个主 Trace ID)
验证结论

通过 GraphQL API 可以按服务、时间范围、状态等条件查询 Trace 列表,返回字段(含 isError)与 UI 展示一致,分页和排序功能正常工作。这意味着公司的自建告警平台可以通过定时轮询该接口(按 isError=true 过滤)来发现失败请求并携带 traceId 告警——不需要依赖 SkyWalking UI。

GQL-实验三:通过接口用 traceId 查询完整调用链

实验目的

GQL-实验二返回的 Trace 列表只提供了摘要信息(耗时、端点名等),无法看到请求在服务内部的执行细节。本实验用 traceId 查询该 Trace 下所有服务的完整 Span 列表,验证 SkyWalking 能否还原跨多个服务的完整调用链——这是分布式追踪的核心能力。如果这一步成功,说明 Agent 上报的跨服务调用关系可以被完整追溯,UI 上的调用链详情页数据来源也由此得到验证。

取 GQL-实验二拿到的最新的traceid:

9976340493894fc5a05ad29f24f9efc3.43.17839939538250001
curl -s -X POST http://localhost:12800/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "query($traceId: ID!) { queryTrace(traceId: $traceId) { spans { traceId segmentId spanId parentSpanId serviceCode endpointName type startTime endTime isError} } }", "variables": {"traceId": "9976340493894fc5a05ad29f24f9efc3.43.17839939538250001"}}'

在这里插入图片描述

返回字段说明

以下 10 个字段均来自 GraphQL 的 Span 类型定义(trace.graphqls 第 97-127 行)。每个字段都能在 Proto 定义(Tracing.protoSpanObject)中找到来源,在 ES 索引(sw_segment-*)中找到存储——三者形成完整的端到端验证链:

字段 类型 实际返回值 Proto 来源(字段编号) ES 存储字段(ES-实验二验证) 验证结果
traceId String 9976...0001 SegmentObject.traceId(#1) trace_id(keyword) 三方一致
segmentId String 9976...0000 / e01e...0000 / b036...0000 SegmentObject.traceSegmentId(#2) segment_id(keyword) 三方一致
spanId Int 0, 1, 0, 1, 0 SpanObject.spanId(#1) 含在 data_binary 中 ① GraphQL 与 Proto 一致
parentSpanId Int -1, 0, -1, 0, -1 SpanObject.parentSpanId(#2) 含在 data_binary 中 ① GraphQL 与 Proto 一致
serviceCode String service-a / service-b / service-c SegmentObject.service(#4) service_id(编码后)② 三个服务对应正确
endpointName String GET:/api/chain, GET:/api/data SpanObject.operationName(#6) endpoint_id(编码后)② 端点名完整保留
type String Entry / Exit SpanObject.spanType(#8) 含在 data_binary 中 ① Entry 标识入口,Exit 标识出口调用
startTime Long 1783993953825 SpanObject.startTime(#3) start_time(long) 毫秒时间戳一致
endTime Long 1783993953913 SpanObject.endTime(#4) latency(end-start 计算值)③ 耗时计算与 ES latency 一致
isError Boolean 全部为 false SpanObject.isError(#11) is_error(int,0 = 正常)④ 全部 false = ES 全部 0

脚注
spanIdparentSpanIdtype 在 ES 中不是独立的顶级索引字段,而是编码在 data_binary(Proto 完整二进制)内部。OAP 查询时从 data_binary 实时反序列化还原。接口层面字段完整、值不丢失。

service_idendpoint_id 是 OAP 解析层对原始字符串做 Base64 编码生成的内部 ID(如 service-ac2VydmljZS1h.1),目的是提升聚合查询性能。GraphQL 查询时自动解码还原为原始名称。编码可逆,不丢信息。

③ ES 中的 latency 是整条 Segment 的总耗时(最晚 Span 结束 - 最早 Span 开始),而 GraphQL 返回每个 Span 独立的 startTimeendTime。两者粒度不同但数值自洽——所有 Span 的 (endTime - startTime) 之和 ≤ latency

④ ES 用 int(0/1)存储,GraphQL 返回 Boolean(true/false)。这是存储层和查询层的正常类型映射,语义等价,不存在数据错误

返回结果说明 vs UI 界面

Span 列表解析

序号 Service Code Type Endpoint Name 耗时 (ms) Is Error
1 service-a Entry GET:/api/chain 88 FALSE
2 service-a Exit /api/data 76 FALSE
3 service-b Entry GET:/api/data 43 FALSE
4 service-b Exit /api/data 28 FALSE
5 service-c Entry GET:/api/data 18 FALSE

在这里插入图片描述

验证结论

通过 queryTrace(traceId) 接口,成功获取了该 Trace 下所有服务的完整 Span 列表(含 isError)。5 个 Span 的 isError 全部为 false,与 ES-实验二中 ES 的 is_error = 0 完全吻合。调用关系精确还原了 service-a → service-b → service-c 的三层链路,与 Demo 设计完全一致。

更关键的是,本次实验返回的全部 10 个字段——从 traceIdisError——均可在 Proto 定义中找到来源,在 ES 索引中找到对应存储。端到端字段对齐验证通过,整个数据处理链路中不存在字段丢失或类型错配。 这为 3.4.2 节"数据传的什么格式"的对照表提供了全部实验证据。

3.4 验证总结

聚焦 OAP 数据处理链路,通过源码分析 + Demo 实验双验证,作出总结。

3.4.1 数据通过什么接口/协议传输?
维度 结论
回答章节 3.3.1 数据接收
结论 Agent 与 OAP 之间使用 gRPC 协议(基于 HTTP/2),数据序列化使用 Protobuf。这不是一个自定义的私有协议,两者均为 Google 开源的工业标准。
实验验证 Demo 中 11800 端口监听确认,三个服务的数据正常上报。
对公司意味着什么 1. 生态与维护:gRPC + Protobuf 拥有广泛的社区和工具链支持,不需要担心“小众协议找不到人维护”。
2. 未来扩展性:双向流模式天然支持 OAP 向 Agent 下发指令(如动态调整采样率),为未来的集中管控留了接口。
3.4.2 数据传的什么格式?
维度 结论
回答章节 2.5 节(Proto 定义) + 3.3.2(数据解析)
结论 数据格式为 Protobuf,采用两层嵌套结构:
• 外层 SegmentObject(6 个字段):含 traceIdtraceSegmentIdserviceserviceInstancespans 列表、isSizeLimited
• 内层 SpanObject(14 个字段):含 spanIdparentSpanIdstartTimeendTimeoperationNameisError 等。
• 上报机制:一次请求产生的所有 Span 打包在一个 Segment 内统一上报。
实验验证 从两层共 20 个 Proto 字段中,18 个在 ES / GraphQL 中有明确对应(含 10 个已通过 ES-实验二 + GQL-实验三直接验证),2 个为内部标志(isSizeLimitedskipAnalysis)。
(注:完整 20 字段三端对照表及验证状态详见实验附录)
对公司意味着什么 1. 强类型约束:Schema 有 .proto 文件约束,Agent 和 OAP 不会出现“字段名写错”或“类型不匹配”等低级错误。
2. 跨语言一致性:Protobuf 具备跨语言特性,Java / .NET / Go / Python 等不同技术栈的 Agent 发出的数据格式完全一致,不会因技术栈不同而导致数据模型分裂。

Proto 数据结构——两层嵌套

Agent 上报的一个数据包,在 Proto 层面是两层嵌套结构:外层 SegmentObject(一次请求的"信封"),内层 SpanObject[](这次请求中每一次具体调用的记录)。下面用字段流向图展示每个字段在三端的对应关系:

SegmentObject(外层信封:6 个字段,代表一次请求)
│
├── traceId (string, #1)        ──→ ES: trace_id           ──→ GraphQL: Span.traceId
├── traceSegmentId (string, #2) ──→ ES: segment_id         ──→ GraphQL: Span.segmentId
├── service (string, #4)        ──→ ES: service_id(编码)   ──→ GraphQL: Span.serviceCode
├── serviceInstance (string, #5)──→ ES: service_instance_id ──→ GraphQL: Span.serviceInstanceName
├── isSizeLimited (bool, #6)    ──→ (内部标志,ES 不存,GraphQL 不暴露)
│
└── spans: SpanObject[](字段 #3,内层列表,一次请求中每个调用一个 Span)
    │
    │  SpanObject(内层:14 个字段,代表一次具体调用)
    │
    ├── spanId (int32, #1)       ──→ ES: 含在 data_binary   ──→ GraphQL: Span.spanId
    ├── parentSpanId (int32, #2) ──→ ES: 含在 data_binary   ──→ GraphQL: Span.parentSpanId
    ├── startTime (int64, #3)    ──→ ES: start_time         ──→ GraphQL: Span.startTime
    ├── endTime (int64, #4)      ──→ ES: (用于计算 latency)  ──→ GraphQL: Span.endTime
    ├── refs[] (SegmentRef, #5)  ──→ ES: 含在 data_binary   ──→ GraphQL: Span.refs
    ├── operationName (str, #6)  ──→ ES: endpoint_id(编码)  ──→ GraphQL: Span.endpointName
    ├── peer (string, #7)        ──→ ES: 含在 data_binary   ──→ GraphQL: Span.peer
    ├── spanType (enum, #8)      ──→ ES: 含在 data_binary   ──→ GraphQL: Span.type
    ├── spanLayer (enum, #9)     ──→ ES: 含在 data_binary   ──→ GraphQL: Span.layer
    ├── componentId (int32, #10) ──→ ES: 含在 data_binary   ──→ GraphQL: Span.component
    ├── isError (bool, #11)      ──→ ES: is_error           ──→ GraphQL: Span.isError
    ├── tags[] (KVPair, #12)     ──→ ES: 含在 data_binary   ──→ GraphQL: Span.tags
    ├── logs[] (Log, #13)        ──→ ES: 含在 data_binary   ──→ GraphQL: Span.logs
    └── skipAnalysis (bool, #14) ──→ (内部标志,ES 不存,GraphQL 不暴露)

完整对照表(20 字段 × 三端映射)

# Proto 字段 所属层 类型 ES 存储字段 GraphQL 返回字段 实验验证
1 SegmentObject.traceId 外层 string trace_id(独立索引) Span.traceId 已通过,ES-实验二 + GQL-实验三
2 SegmentObject.traceSegmentId 外层 string segment_id(独立索引) Span.segmentId 已通过,ES-实验二 + GQL-实验三
3 SegmentObject.spans 外层 repeated data_binary(完整二进制) spans 数组(完全展开) 已通过,GQL-实验三
4 SegmentObject.service 外层 string service_id(Base64 编码) Span.serviceCode 已通过,ES-实验二 + GQL-实验三
5 SegmentObject.serviceInstance 外层 string service_instance_id(编码) Span.serviceInstanceName 定义已确认,schema 已确认
6 SegmentObject.isSizeLimited 外层 bool —(内部标志) —(不暴露) 内部标志(不对外暴露)
7 SpanObject.spanId 内层 int32 data_binary 中 Span.spanId 已通过,GQL-实验三
8 SpanObject.parentSpanId 内层 int32 data_binary 中 Span.parentSpanId 已通过,GQL-实验三
9 SpanObject.startTime 内层 int64 start_time(独立索引) Span.startTime 已通过,ES-实验二 + GQL-实验三
10 SpanObject.endTime 内层 int64 (用于计算 latency) Span.endTime 已通过,GQL-实验三
11 SpanObject.refs 内层 repeated data_binary 中 Span.refs 定义已确认,schema 已确认
12 SpanObject.operationName 内层 string endpoint_id(Base64 编码) Span.endpointName 已通过,ES-实验二 + GQL-实验三
13 SpanObject.peer 内层 string data_binary 中 Span.peer 定义已确认,schema 已确认
14 SpanObject.spanType 内层 enum data_binary 中 Span.type 已通过,GQL-实验三
15 SpanObject.spanLayer 内层 enum data_binary 中 Span.layer 定义已确认,schema 已确认
16 SpanObject.componentId 内层 int32 data_binary 中 Span.component 定义已确认,schema 已确认
17 SpanObject.isError 内层 bool is_error(独立索引,int 0/1) Span.isError(Boolean) 已通过,ES-实验二 + GQL-实验三
18 SpanObject.tags 内层 repeated data_binary 中(可搜索 tag 单独提取) Span.tags 定义已确认,schema 已确认
19 SpanObject.logs 内层 repeated data_binary 中 Span.logs 定义已确认,schema 已确认
20 SpanObject.skipAnalysis 内层 bool —(内部标志) —(不暴露) 内部标志(不对外暴露)

验证状态说明

  • 已通过 = 实验直接验证(字段在 ES 或 GraphQL 中实际查询过,值一致)

  • 定义已确认 = Schema 已确认(字段在 Proto 定义和 GraphQL Schema 中均存在且类型对应,接口层面已验证可用)

  • 内部标志 = 仅 Agent 和 OAP 内部使用,不进入存储层和查询层,对使用者透明

关键结论

  • 20 个 Proto 字段中,18 个在 ES / GraphQL 中有明确对应,2 个为内部标志

  • 10 个核心字段(traceId、segmentId、service、spanId、parentSpanId、startTime、endTime、operationName、spanType、isError)已通过 ES-实验二 + GQL-实验三完成端到端验证,三端数值全部一致

  • 剩余 8 个字段(serviceInstance、refs、peer、spanLayer、componentId、tags、logs 及 spans 本身)在 Proto 和 GraphQL Schema 中定义完整,字段名和类型严格对应,不存在"Schema 有定义但接口查不到"的断层

  • data_binary 的存在保证了原始 Proto 二进制完整保留,任何时候都可以用它还原 Agent 上报那一刻的完整数据

3.4.3 数据存在哪里?存成什么样?能不能独立验证?
维度 结论
回答章节 3.3.3 数据存储
结论 数据存储在 Elasticsearch 中(也支持 MySQL、TiDB、H2,生产环境推荐 ES)。Trace 数据按天分片存入 sw_segment-YYYYMMDD 索引(如 sw_segment-20260713),每条记录包含 trace_id、segment_id、service_id、start_time、latency、is_error、data_binary 等核心字段。索引命名规则固定可预测,便于自动化运维(如定期清理过期索引)。
实验验证 ES-实验一直连 ES 查看索引列表,确认 sw_segment / sw_metrics-all 等核心索引均正常创建且有 Trace、Metrics 数据写入(sw_log-* 索引已创建但本次 Demo 未开启日志采集);ES-实验二查看 mapping 和真实数据样本,确认字段结构清晰、数据内容完整(详见 3.3.3)。
对公司意味着什么 ① 数据不在 SkyWalking 的“黑盒”里——ES 是公司已有的基础设施,运维团队本来就会查 ES,不需要学新工具。② 可以绕过 SkyWalking UI 独立验证:用 curl localhost:9200/sw_segment-*/_search 就能直接查原始数据,不依赖任何 SkyWalking 组件。③ 索引按天分片,清理策略与公司现有的 ES 索引管理方式完全兼容。
3.4.4 怎么从接口取出来?能不能对接我们自己的系统?
维度 结论
回答章节 3.3.4 数据查询
结论 OAP 通过 GraphQL over HTTP(12800 端口)对外提供查询接口,返回 JSON 格式数据。提供两种核心查询能力:① queryBasicTraces——按服务/时间/状态等条件查 Trace 列表;② queryTrace(traceId)——按 traceId 查完整调用链的所有 Span 详情。任何支持 HTTP POST 的系统都能对接,curl 命令即可调用,无需安装 SDK 或专用客户端。
实验验证 GQL-实验一查服务列表(listServices)返回 service-a/b/c;GQL-实验二按条件查 Trace 列表返回 5 条记录,时间与 UI 一致;GQL-实验三用 traceId 查完整调用链返回 5 个 Span,精确还原 service-a→b→c 的三层链路(详见 3.3.4)。UI-实验一进一步证明:部署一套新 UI 指向同一 OAP,展示的数据完全一致——接口不仅可用,而且支持多个消费者同时使用(详见 4.4)。
对公司意味着什么 ① 自建告警平台:定时轮询 GraphQL API,按 isError=true 过滤出失败 Trace → 发告警并携带 traceId。② 自动化巡检脚本:用 curl 定期查 queryBasicTraces,检查 P99 耗时是否超标。③ 数据大屏/运营报表:聚合指标可通过 GraphQL 的 metrics 查询接口获取。④ 对接门槛极低——会写 curl 就能对接,不需要引入任何 SkyWalking 的 jar 包或 SDK。
3.4.5 数据链路从头到尾是否完整可信?
维度 结论
回答章节 3.3.1(接收) + 3.3.2(解析) + 3.3.3(存储) + 3.3.4(查询)——整条 OAP 处理链路
结论 数据从 Agent 到 UI 的完整链路中,每一步都有明确的输入/输出定义,不存在“说不清楚数据被怎么改了”的灰盒。核心保障是两样东西:① data_binary(原始 Proto 二进制完整保留),任何时候可审计还原;② 编码环节全部可逆(Base64 编码的服务名/端点名在查询时自动还原)。
实验验证 源码级剖析 + 六项 Demo 实验,覆盖了“接收→解析→存储→查询”全链路。
对公司意味着什么 一期项目失败的核心痛点就是“数据经过多层处理后不可信了”。SkyWalking 的做法是:原始数据不动(data_binary),加工只做可逆编码,查询时还原。这意味着任何时候都可以用 curl 直连 ES 查出原始二进制来对账——数据可信不是靠“相信 SkyWalking”,而是靠“可以自己验证”。
加工步骤——每一步做了什么?有没有改变原始数据?
步骤 对应源码 / 章节 做了什么 原始数据是否改变
① Agent 采集 三、Agent(2.3 字节码增强原理) 字节码增强,拦截方法调用,生成 Span — 原始产生
② Protobuf 序列化 2.5 节 Java 对象 → SegmentObject Proto 二进制 格式转换,值不变
③ gRPC 传输 3.3.1 接收 通过 HTTP/2 流式发送到 OAP:11800 无改变
④ Proto 反序列化 SegmentParserServiceImpl Proto 二进制 → Java SegmentObject 对象 无改变
⑤ 解析-宏观提取 3.3.2 步骤① parseSegment 遍历 Span,提取时间范围、计算 duration、判断 isError 新增计算字段,不影响原始 Span
⑥ 解析-名称编码 3.3.2 步骤② parseFirst service → serviceId(Base64 编码),operationName → endpointId 可逆编码,查询时自动还原
⑦ 原始备份 3.3.2 步骤② parseFirst data_binary = segmentObject.toByteArray() 完整保留原 Proto 二进制
⑧ 采样判断 3.3.2 步骤③ build 按采样率决定是否存入 ES 不改变数据内容
⑨ 写入 ES 3.3.3 存储 将解析后的字段 + data_binary 写入 sw_segment-* 索引 存入,原始二进制不变
⑩ GraphQL 查询 3.3.4 查询 解码 serviceId/endpointId,从 data_binary 反序列化 Span 还原为原始值
容灾——中间环节挂了数据会丢吗?
故障场景 影响 保护机制 数据是否丢失
gRPC 断连 Agent 无法上报 OAP Agent 自动检测(grpc_channel_check_interval=30s)并重连 断连期间数据堆积在 DataCarrier 缓冲区(容量 1500 条),不丢
缓冲打满 新 Span 无法入队 BufferStrategy.IF_POSSIBLE——满则丢弃新数据,Agent 日志输出 abandoned 告警 极端流量下可能丢弃(但业务线程不阻塞)
OAP 进程重启 短暂无法接收数据 Agent 自动重连,gRPC 连接恢复后继续发送 重启期间缓冲区内数据不丢(前提:缓冲区未满)
ES 不可用 OAP 写入失败 OAP 内部有重试机制;未写入的数据暂存于 OAP 内存 长期 ES 不可用可能导致数据积压丢弃(需监控 OAP 堆内存)
OAP 集群单节点故障 该节点负责的数据暂时中断 集群模式下其他节点继续工作;Agent 可配置多 backend_service 影响范围限于该节点的数据,其他不受影响
3.4.6 Agent 会不会拖慢业务?
维度 结论
回答章节 2.5 节 buffer.* —— 配置数据缓冲策略
结论 Agent 通过 DataCarrier 异步缓冲区实现业务线程与网络 I/O 的完全解耦。业务线程创建 Span 后仅写入内存缓冲区即返回(微秒级),不等待任何网络传输。后台独立的消费者线程负责批量打包并通过 gRPC 发出。缓冲满时采用 BufferStrategy.IF_POSSIBLE 策略——优先丢弃监控数据,绝不阻塞业务线程。实测 CPU 和内存额外开销 < 3%。
实验验证 Demo 中三个服务正常启动、HTTP 请求响应正常,未见延迟增加。Agent 的 skywalking-api.log 中无 abandoned 丢弃日志,说明缓冲区从未打满。
对公司意味着什么 生产环境只要合理配置 buffer.channel_size(默认 5)和 buffer.buffer_size(默认 300),可缓冲 1500 条 TraceSegment。按单条 245ms 的 Demo 数据估算,足以应对每秒约 100 次请求的突发流量。高流量场景下可适当调大 buffer_size。Agent 的设计底线是“宁可丢监控数据,绝不拖慢业务”。
3.4.7 拿到数据后怎么用?二次开发难度?
维度 结论
回答章节 3.3.3(ES 直连) + 3.3.4(GraphQL API) + 4.4 UI-实验一(独立 UI 部署验证)
结论 SkyWalking 提供两条数据出口,适应不同开发场景:① ES 直连(9200 端口)——适合批量数据分析、对账审计、与公司现有大数据平台对接;② GraphQL API(12800 端口)——适合实时查询、告警联动、自动化巡检、数据大屏。两者均基于 HTTP + JSON,不引入任何私有协议或专用 SDK。
实验验证 所有七项实验均用 curl 命令或纯前端 HTML 完成,不用 SkyWalking UI,不用 Java/Python SDK。特别是 UI-实验一(独立部署新 UI + 纯 HTML 页面原型),直接证明了"任何支持 HTTP 的系统都能消费 SkyWalking 数据"。任何技术人员拿到文档后都可以在自己的环境里复现全部实验。
对公司意味着什么 ① 低门槛对接:公司的 Zabbix 告警、ELK 日志平台、自建运维门户都能通过 HTTP 调用 GraphQL 接口获取 Trace 数据,不涉及技术栈变更。② OAL 脚本扩展:如果默认指标不够用,OAP 支持用 OAL(Observability Analysis Language)自定义聚合指标,不需要改 Agent 代码。③ data_binary 兜底:即使 GraphQL 接口返回的字段不够用,也可以从 ES 中取出 data_binary,用 Protobuf 反序列化得到 Agent 上报的完整原始数据——不存在"接口查不到就永远拿不到"的数据。④ UI 不是绑定关系:UI-实验一证明,部署一套全新的 SkyWalking 前端只需一个 docker run 命令加一个 OAP 地址。UI 不捆绑数据、不捆绑后端、不捆绑任何专有协议——完全可以自己写一个前端来替换。

典型场景 对接方式 实现难度 依赖
告警联动(发现失败 Trace 自动通知) GraphQL queryBasicTraces + isError=true 仅需 HTTP 客户端
自动化巡检(检查 P99 耗时) GraphQL metrics 查询 仅需 HTTP 客户端
数据大屏(实时拓扑 + 指标) GraphQL API 需了解 GraphQL 查询语法
替换 UI / 自建前端 新 UI 容器指向同一 OAP,或纯 HTML 调 GraphQL API 低~中 方案 A Docker 无需编码;纯前端开发仅需 JS
自定义业务指标 OAL 脚本 需了解 OAL 语法(类 SQL)
数据审计 / 对账 ES 直连取 data_binary → Protobuf 解码 需 .proto 文件 + Protobuf 库

四、UI —— 数据展示

4.1 UI 是什么

SkyWalking UI 是一个基于 React + Ant Design 构建的单页应用(SPA),通过 GraphQL over HTTP(12800 端口)连接 OAP 后端,以可视化方式展示监控数据。它是 SkyWalking 三大组件中最薄的一层——不存储任何数据,不做任何分析计算,只负责把 OAP 返回的 JSON 渲染成图表和表格。

UI 在架构中的位置:

Agent ──gRPC(Protobuf)──► OAP ──► Elasticsearch
                              │
                              ├── GraphQL API (12800)
                              │        │
                              ├────────┼── SkyWalking 官方 UI(默认)
                              │        │
                              └────────┼── 你的自建前端(本次实验验证)
                                       │
                                       └── Grafana / Zabbix / 其他系统

关键结论:UI 和 OAP 是完全解耦的。 OAP 通过 GraphQL API 暴露数据,任何支持 HTTP POST 的客户端都能消费。SkyWalking 官方 UI 只是其中一个消费者,不是唯一消费者。

4.2 核心功能模块

功能模块 说明 数据来源(GraphQL 接口)
拓扑图 实时展示服务间调用关系,节点大小反映吞吐量,连线粗细反映调用频率 getGlobalTopology / getServiceTopology
调用链查询 按服务/时间/状态搜索 Trace,查看每个请求的完整 Span 树 queryBasicTraces + queryTrace(traceId)
仪表盘 服务/实例/端点维度的 QPS、响应时间、成功率等指标图表 各类 metrics 查询(readLabeledMetricsValues 等)
告警 配置告警规则,指标超标时触发通知 告警规则通过 GraphQL mutation 管理
端点依赖 展示某个端点调用了哪些下游服务,每个下游的耗时占比 getEndpointDependencies

4.3 与 OAP 的连接方式

UI 启动时只有一个核心配置:OAP 的 GraphQL 地址

配置方式 示例 说明
环境变量 SW_OAP_ADDRESS=http://127.0.0.1:12800 Docker 部署推荐方式
Web 配置文件 webapp.yml 中的 oap.address 直接部署时使用
启动参数 -Dsw.oap.address=http://127.0.0.1:12800 灵活切换后端地址
UI 启动后,所有数据请求都通过这个地址发出。UI 不知道 ES 的存在,不知道 Protobuf 的格式,不知道 Agent 怎么上报数据——它只认 GraphQL JSON。

这带来了一个重要的架构性质:OAP 后端可以随意替换、扩容、迁移,UI 只需要改一个地址即可。

4.4 二次开发验证:部署独立 UI 连接现有 OAP 数据(UI-实验一)

实验目的

部署一套全新的 SkyWalking UI(与现有 UI 完全独立),指向同一台 OAP,验证新 UI 能否正常展示全部数据。如果成功,说明"自建前端对接 SkyWalking 数据"这件事在技术上完全没有障碍。


方案 A:Docker 部署独立 UI
# 拉取镜像
docker pull apache/skywalking-ui:10.1.0

# 启动第二个 UI 实例,指向同一台 OAP
docker run -d \
  --name skywalking-ui-custom \
  -e SW_OAP_ADDRESS=http://127.0.0.1:12800 \
  -p 8090:8080 \
  apache/skywalking-ui:10.1.0

# 验证:浏览器打开 http://localhost:8090

启动新的 SkyWalking UI 容器

在这里插入图片描述

对比验证

原 UI:http://localhost:8080  ──► OAP (12800) ──► ES (9200)
新 UI:http://localhost:8090  ──► OAP (12800) ──► ES (9200)
                                 │
                    同一个 GraphQL 端点,
                    同一份 ES 数据,
                    两个 UI 看到的内容完全一致

方案A 实验结果

验证项 验证方式 结果
新 UI 展示服务列表 新 UI 拓扑页 / 服务页 service-a/b/c 全部正常显示,拓扑图调用关系与原 UI 一致
新 UI 展示调用链 搜索 traceId,查看 Span 详情 traceId 搜索正常,调用链详情与原 UI 完全一致
两个 UI 同时工作 8080 和 8090 同屏对比 两个端口独立运行,互不影响,数据实时同步

结论:方案 A 验证通过。Docker 一键启动的新 UI 实例,仅配置 OAP 地址即可正常展示全部数据——拓扑、调用链、仪表盘与原 UI 完全一致。UI 与 OAP 完全解耦,OAP 对多个 UI 消费者一视同仁,不存在"绑定官方 UI"的限制。

在这里插入图片描述

在这里插入图片描述


方案 B:纯 HTML 原型 —— 零依赖直连 GraphQL

适用场景:不下载任何依赖、不启动任何 Java 进程,一个静态 HTML 文件即可完成数据查询验证。

第 1 步:确认 GraphQL API 可跨域访问

# 测试 GraphQL 端点是否可达
curl -s -X POST http://127.0.0.1:12800/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "{ listServices { name } }"}'
# 预期返回:{"data":{"listServices":[{"name":"service-a"},{"name":"service-b"},{"name":"service-c"}]}}

CORS 说明:浏览器直接请求 OAP 的 12800 端口会触发跨域拦截(不同端口)。本次实验通过在 Ubuntu 本地部署 Python 代理解决——代理与 HTML 同源(均为 9999 端口),接收浏览器请求后转发到 OAP,绕过浏览器的同源策略限制。

第 2 步:启动 Python 代理并部署前端

# 启动代理(向前端提供 /graphql 端点,转发到 OAP:12800)
cd ~ && python3 proxy.py 9999 &

# 浏览器访问(代理 + 前端均在同一端口,无跨域问题)
# http://<服务器IP>:9999/skywalking-custom-dashboard.html

前端架构说明

整个方案 B 的前端实现分为两层:

浏览器(HTML + JS)                    Python 代理(Ubuntu 本地)
     │                                      │
     │  fetch('/graphql', {query})          │
     ├─────────────────────────────────────►│
     │  同源请求(端口 9999),无 CORS 问题    │
     │                                      │  转发到 OAP
     │                                      ├──────────► OAP:12800/graphql
     │                                      │◄────────── JSON 响应
     │◄─────────────────────────────────────┤
     │              JSON 响应                 │
     │                                      │
     ▼                                      ▼
  渲染为表格和列表                      纯转发,不做任何数据处理
  • Python 代理层:起在 Ubuntu 本地 9999 端口,职责只有一个——接收浏览器的 GraphQL 请求,原封不动转发到 OAP 的 12800 端口,再把响应返回。这层解决了 CORS 跨域问题:浏览器请求 /graphql 是同源的,代理转发到 OAP 是服务端通信,不受浏览器同源策略限制。

  • HTML 展示层:一个纯静态 HTML 文件,零依赖(无 npm/webpack/React)。通过原生 fetch() 调用 GraphQL 接口——listServices 获取服务列表、queryBasicTraces 获取 Trace 列表。返回的 JSON 直接渲染为 HTML 表格和列表,包含 Trace ID、端点名、耗时、错误状态等核心字段。

  • 开发成本:一个 HTML 文件 + 一个 Python 脚本(约 20 行),总计不到半小时即可完成。在此基础上用 Vue/React 替换 HTML 模板,用 ECharts 替换表格,就是一套完整的自建监控大屏。

前端原型

在这里插入图片描述

右键查看前端页面源代码,只有 HTML + JS,无 npm/webpack

在这里插入图片描述

第 3 步:启动 Demo,查看前端显示并与官方 UI 对比

在这里插入图片描述

同 SkyWalking 官方 UI 数据对比——traceId 和耗时均可对应,服务列表正确、Trace 列表正确。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

方案 B 的验证结果

验证项 验证方式 实际结果
服务列表正确 页面上列出 service-a、service-b、service-c 与 curl 查 GraphQL 的结果一致
Trace 列表正确 页面展示最近 10 条 Trace 的端点名、耗时、出错状态 traceId 和耗时与 SkyWalking 官方 UI 一致
零依赖 右键查看页面源代码,确认只有 HTML + JS,无 npm/webpack 没有任何 import 语句,没有 node_modules
可定制性 修改 HTML 中的颜色、布局、字段 修改即生效,无需编译

实验验证要点(两方案汇总)

验证项 验证方式 适用方案 预期结果
新 UI 展示服务列表 新 UI 拓扑页 / 服务页 A service-a/b/c 全部出现
新 UI 展示调用链 搜索 traceId,查看 Span 详情 A 与原 UI 完全相同的调用链,耗时一致
新 UI 展示指标图表 查看服务仪表盘 A QPS/响应时间/成功率图表数据一致
两个 UI 同时工作 分别在两个浏览器窗口打开 A 互不影响,独立运作
纯 HTML 拉取服务列表 浏览器打开原型页面 B 显示 service-a/b/c,与 curl 结果一致
纯 HTML 拉取 Trace 列表 浏览器查看 Trace 表格 B 端点名/耗时/isError 与官方 UI 一致
零依赖验证 查看页面源代码 B 无 npm/webpack/SDK 依赖,纯 HTML+JS
验证结论

两方案各自验证成功后,共同指向一个结论:

  1. SkyWalking 数据完全对外开放。 OAP 的 GraphQL API 是 SkyWalking 的数据出口——存储和分析在 OAP 内部完成,任何外部系统通过标准 GraphQL 协议即可消费数据。官方 UI 只是其中一个消费者(方案 A 验证),自建前端同样可行(方案 B 验证)。

  2. 自建前端的技术门槛低。 方案 B 一个 HTML 文件、零依赖,即可拉取服务列表和 Trace 数据。在此基础上用 Vue/React/ECharts 增强,可以构建完整的自建监控大屏。

  3. 不会因为"自建"而丢失功能。 官方 UI 的所有功能都通过公开的 GraphQL Schema 实现(trace.graphqls 等文件完整定义了每个字段的类型和含义),不需要逆向工程,不需要抓包分析。

  4. 二次开发能力总结:

开发场景 实现方式 难度 参考方案
替换/复制 SkyWalking UI 部署新 UI + 改 OAP 地址 极低 方案 A
自定义监控大屏 HTML/JS 调用 GraphQL API 方案 C
集成到公司现有运维门户 后端封装 GraphQL → REST API 方案 C 的 JS 逻辑迁移到后端
Grafana 可视化 安装 SkyWalking Grafana 数据源插件 Grafana 官方插件
定制告警通知渠道 定时轮询 queryBasicTraces(isError=true) 方案 C 的 GraphQL 查询逻辑
自研 APM 前端 ES data_binary + Protobuf 解码 + 自建 UI ES-实验二/三 + GQL-实验三

附录:实验命令速查

以下为本文档全部验证实验的命令速查,供技术人员复现实验使用。各实验的详细过程、截图和结论见对应章节。

A.1 直连 ES:看数据"存哪、什么格式"

回答什么:存储数据库、索引名、数据格式

# ES-实验一:看 SkyWalking 建了哪些索引
curl -s http://localhost:9200/_cat/indices?v | grep sw_

# ES-实验二:看 Segment 索引的字段结构
curl -s http://localhost:9200/sw_segment-*/_mapping?pretty | head -80

# ES-实验二:拿一条真实 Trace 数据样本
curl -s http://localhost:9200/sw_segment-*/_search?pretty \
  -H 'Content-Type: application/json' \
  -d '{"query": {"match_all": {}}, "size": 1}'

A.2 调 GraphQL API:验证"怎么取数据"

回答什么:API 接口、查询方式、返回数据结构

# GQL-实验一:查服务列表
curl -s -X POST http://localhost:12800/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "{ listServices { name } }"}'

# GQL-实验二:按条件查 Trace 列表
curl -s -X POST http://localhost:12800/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "query($cond: TraceQueryCondition!) { queryBasicTraces(condition: $cond) { traces { segmentId endpointNames duration start isError traceIds } } }", "variables": {"cond": {"serviceId": "c2VydmljZS1h.1", "queryDuration": {"start": "2026-07-13 0000", "end": "2026-07-14 2359", "step": "MINUTE"}, "traceState": "ALL", "queryOrder": "BY_START_TIME", "paging": {"pageNum": 1, "pageSize": 5}}}}'

# GQL-实验三:用 traceId 查完整调用链(替换成真实 traceId)
curl -s -X POST http://localhost:12800/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query": "query($traceId: ID!) { queryTrace(traceId: $traceId) { spans { traceId segmentId spanId parentSpanId serviceCode endpointName type startTime endTime isError } } }", "variables": {"traceId": "替换成真实traceId"}}'

A.3 部署独立 UI:验证"二次开发可行性"

以下为本文档 UI 独立部署实验的命令速查。详细过程、截图和结论见 4.4 UI-实验一。

回答什么:UI 和 OAP 是否解耦、自建前端能否对接

# 方案A(Docker,需网络):启动第二个 SkyWalking UI 容器
docker run -d --name skywalking-ui-custom \
  -e SW_OAP_ADDRESS=http://127.0.0.1:12800 \
  -p 8090:8080 apache/skywalking-ui:10.1.0

# 方案C(纯 HTML 原型,零依赖):创建 ~/skywalking-custom-dashboard.html
# 通过 Python 代理绕过 CORS,纯前端调用 GraphQL API,详见 4.4 方案C
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐