目录
[SkyWalking 源码复盘 ⑪:Trace Profiling 如何定位到具体慢方法](#SkyWalking 源码复盘 ⑪:Trace Profiling 如何定位到具体慢方法)
[一、Trace Profiling 到底是什么](#一、Trace Profiling 到底是什么)
[三、任务是 OAP 主动推给 Agent 吗](#三、任务是 OAP 主动推给 Agent 吗)
[四、OAP 如何判断哪些任务需要返回](#四、OAP 如何判断哪些任务需要返回)
[五、Agent 收到任务后如何安排执行](#五、Agent 收到任务后如何安排执行)
[七、请求到达时如何匹配 Endpoint](#七、请求到达时如何匹配 Endpoint)
[为什么还有一次 profilingRecheck](#为什么还有一次 profilingRecheck)
[八、Min Duration Threshold 是怎么实现的](#八、Min Duration Threshold 是怎么实现的)
[请求 A:总耗时 300 ms](#请求 A:总耗时 300 ms)
[请求 B:总耗时 2500 ms](#请求 B:总耗时 2500 ms)
[十二、采样数据如何上传到 OAP](#十二、采样数据如何上传到 OAP)
[十三、OAP 收到快照后保存什么](#十三、OAP 收到快照后保存什么)
[十四、OAP 如何将上百次线程栈合并成一棵树](#十四、OAP 如何将上百次线程栈合并成一棵树)
[十五、Dump Count 到底是什么](#十五、Dump Count 到底是什么)
[十六、Duration 是怎么计算的](#十六、Duration 是怎么计算的)
[Duration 示例](#Duration 示例)
[十七、Self Duration 是怎么计算的](#十七、Self Duration 是怎么计算的)
[情况一:Duration 高,Self Duration 低](#情况一:Duration 高,Self Duration 低)
[情况二:Duration 高,Self Duration 也高](#情况二:Duration 高,Self Duration 也高)
[情况三:Dump Count 高,但 Duration 不一定非常高](#情况三:Dump Count 高,但 Duration 不一定非常高)
[情况四:Thread.sleep 或网络读取节点很高](#情况四:Thread.sleep 或网络读取节点很高)
[十九、为什么你的 /order/query/1 必须在任务期间重新访问](#十九、为什么你的 /order/query/1 必须在任务期间重新访问)
[二十一、跨线程请求如何继续 Profiling](#二十一、跨线程请求如何继续 Profiling)
SkyWalking 源码复盘 ⑪:Trace Profiling 如何定位到具体慢方法
你之前在 SkyWalking UI 中创建过性能剖析任务,并分析:
GET:/order/query/1
最后看到了:
Duration
Self Duration
Dump Count
这一节解释整个源码链路:
UI 创建 Profiling 任务
→ OAP 保存任务
→ Agent 定时拉取任务
→ 请求命中指定 Endpoint
→ 等待超过最小耗时阈值
→ 周期性采样请求线程栈
→ 快照上传 OAP
→ OAP 合并大量线程栈
→ 计算 Duration、Self Duration、Dump Count
一、Trace Profiling 到底是什么
Trace Profiling 不是长期监控整个 JVM,也不是对所有线程做全量 CPU Profiling。
它的定位是:
针对某个出现高延迟的 Endpoint,动态创建短期任务,对命中该 Endpoint 的部分请求线程周期性采样。
官方说明中,Trace Profiling 与 Java Agent 绑定,任务动态下发;Agent 对指定 Endpoint 相关线程定期采集线程栈,再由 OAP 分析具体慢在哪一行业务代码。
和普通 Trace 的区别:
| 功能 | 主要回答 |
|---|---|
| Trace | 哪个 Span、哪个远程调用慢 |
| Trace Profiling | Span 内部具体哪个 Java 方法、哪一行附近慢 |
例如普通 Trace 只能看到:
GET:/order/query/1 2100 ms
└─ queryOrder 2050 ms
但 queryOrder 内部还有很多代码:
public OrderInfo queryOrder(Integer id) {
validate(id);
loadCache(id);
calculateSomething();
queryDatabase(id);
formatResult();
}
普通 Trace 未必会为这些方法都创建 Span。
Trace Profiling 则通过多次采集线程栈,判断线程长期停留在哪个方法上。
二、创建任务时的七个关键参数
在 UI 创建任务时,主要填写:
Service
Endpoint
Start Time
Duration
Min Duration Threshold
Dump Period
Max Sampling Count
官方文档对这些字段的定位如下:
| 参数 | 作用 |
|---|---|
| Service | 对哪个服务的 Agent 下发任务 |
| Endpoint | 对哪个入口请求进行剖析 |
| Start Time | 任务什么时候开始 |
| Duration | 任务持续多少分钟 |
| Min Duration Threshold | 请求运行多久后才开始采样 |
| Dump Period | 每隔多少毫秒采样一次线程栈 |
| Max Sampling Count | 最多选择多少条请求进行 Profiling |
假设配置:
Service = order-service
Endpoint = GET:/order/query/1
Start Time = 立即
Duration = 5 分钟
Min Duration Threshold = 1000 ms
Dump Period = 10 ms
Max Sampling Count = 5
其含义是:
在接下来 5 分钟内,遇到
GET:/order/query/1请求时,如果该请求执行超过 1000 ms,就开始每 10 ms 采样一次线程栈,最多分析 5 条请求。
三、任务是 OAP 主动推给 Agent 吗
严格来说,Java Agent 会定时向 OAP查询任务。
Agent 中负责通信的是:
ProfileTaskChannelService
它会周期性发送:
ProfileTaskCommandQuery
其中携带:
service
serviceInstance
lastCommandTime
源码:
builder.setService(Config.Agent.SERVICE_NAME)
.setServiceInstance(Config.Agent.INSTANCE_NAME);
builder.setLastCommandTime(
profileTaskExecutionService.getLastCommandCreateTime()
);
然后调用:
getProfileTaskCommands(...)
获取新的命令。
完整方向是:
Agent 定时询问 OAP:
"order-service 这个实例有没有新任务?"
↓
OAP 返回 Commands
↓
Agent 执行 ProfileTaskCommand
而不是 OAP 随时主动建立一条连接把任务强行推过来。
四、OAP 如何判断哪些任务需要返回
OAP 收到 Agent 查询后,根据:
Service
ServiceInstance
Last Command Time
查找该服务的 Profiling 任务。
核心逻辑:
List<ProfileTask> profileTaskList =
profileTaskCache.getProfileTaskList(serviceId);
然后过滤:
if (profileTask.getCreateTime() <= lastCommandTime) {
continue;
}
只有比 Agent 最后收到的任务更新,才会重新下发。最后将任务封装成 Commands 返回。
因此 lastCommandTime 的作用是:
避免 Agent 每次轮询都重复接收同一个任务
五、Agent 收到任务后如何安排执行
收到任务后进入:
ProfileTaskExecutionService.addProfileTask()
首先检查:
Endpoint 是否为空
Duration 是否合法
最小耗时阈值是否合法
Dump Period 是否过小
Max Sampling Count 是否合法
任务时间是否与其他任务冲突
检查成功后:
profileTaskList.add(task);
并根据任务开始时间进行调度:
long delay =
task.getStartTime()
- System.currentTimeMillis();
PROFILE_TASK_SCHEDULE.schedule(
() -> processProfileTask(task),
delay,
TimeUnit.MILLISECONDS
);
所以创建任务不代表立即采样。
它可能处于:
等待开始
→ 正在执行
→ 已结束
六、任务开始后,并不会立刻采样所有线程
任务开始时,Agent 创建:
ProfileTaskExecutionContext
然后启动一个独立 Profiling 线程:
currentTaskContext.startProfiling(PROFILE_EXECUTOR);
任务持续时间结束后,再自动停止:
schedule(
() -> stopCurrentProfileTask(...),
task.getDuration(),
TimeUnit.MINUTES
);
注意,这个 Profiling 线程此时只是进入待命状态。
真正要采样哪些线程,要等新的业务请求到达。
七、请求到达时如何匹配 Endpoint
当 Tomcat 请求进入并创建新的 TracingContext 时,构造函数会调用:
PROFILE_TASK_EXECUTION_SERVICE.addProfiling(
this,
segment.getTraceSegmentId(),
firstOPName
);
其中:
firstOPName
就是该 Trace 的第一个操作名,例如:
GET:/order/query/1
接下来:
ProfileTaskExecutionContext.attemptProfiling(...)
执行精确匹配:
if (!Objects.equals(
task.getFirstSpanOPName(),
firstSpanOPName
)) {
return ProfileStatusContext.createWithNone();
}
因此:
任务 Endpoint:
GET:/order/query/1
实际请求:
GET:/order/query/1
→ 匹配
而:
实际请求:
GET:/order/query/2
→ 如果操作名未被模板化为相同 Endpoint,可能不匹配
本质上匹配的是 Agent 当前识别出的:
firstSpan operationName
而不是 Controller 方法名。
为什么还有一次 profilingRecheck
某些插件会在 Trace 创建后重新修改入口 Span 的操作名。
例如最开始可能暂时是:
Tomcat/request
后面 Spring MVC 再将其改成:
GET:/order/query/1
因此 TracingContext 在嵌套 EntrySpan 修改名称时会重新检查:
profilingRecheck(parentSpan, operationName);
parentSpan.setOperationName(operationName);
这样可避免因 Endpoint 名称在插件链中发生变化而错过任务。
八、Min Duration Threshold 是怎么实现的
这是 Trace Profiling 中最容易误解的参数。
假设设置:
Min Duration Threshold = 1000 ms
Agent 不可能在请求刚开始时就知道:
这个请求最终会不会超过 1000 ms
因此它采用:
请求开始
→ 状态设为 PENDING
→ 等待请求运行
→ 已运行时间超过 1000 ms
→ 状态切换为 PROFILING
→ 开始采样
源码:
if (
System.currentTimeMillis()
- firstSegmentCreateTime
> task.getMinDurationThreshold()
) {
profilingStartTime = System.currentTimeMillis();
profilingStatus.updateStatus(
ProfileStatus.PROFILING,
tracingContext
);
}
所以它不是:
请求执行完成后发现总耗时 > 1000 ms
→ 再回头采样
线程栈无法倒流。
真实过程是:
请求执行超过阈值时仍未结束
→ 从这一刻开始采样
一个具体例子
设置:
Min Duration Threshold = 1000 ms
请求 A:总耗时 300 ms
0 ms 请求开始
300 ms 请求结束
没有超过阈值:
不采样
请求 B:总耗时 2500 ms
0 ms 请求开始
1000 ms 超过阈值
1000~2500 周期采样
2500 ms 请求结束
真正的采样时间段大约是:
后 1500 ms
所以前 1000 ms 内的方法可能没有被 Profiling 捕获。
九、谁真正执行线程栈采样
Agent 启动一个:
ProfileThread
它循环扫描所有正在被观察的:
ThreadProfiler
状态处理:
switch (status) {
case PENDING:
startProfilingIfNeed();
break;
case PROFILING:
snapshot = buildSnapshot();
addProfilingSnapshot(snapshot);
break;
}
一轮扫描结束后,根据任务的:
Thread Dump Period
进行休眠。
例如:
Dump Period = 10 ms
流程近似:
采样
→ 等待约 10 ms
→ 再采样
→ 等待约 10 ms
→ 再采样
这是统计采样,不是给每个方法加进入和退出计时器。
十、线程栈到底怎么采集
真正的核心代码是:
stackTrace =
profilingThread.getStackTrace();
其中:
profilingThread
就是实际处理当前请求的 Tomcat 线程,例如:
http-nio-8083-exec-4
采集到的原始线程栈可能是:
java.lang.Thread.sleep
OrderController.slow
OrderServiceImpl.queryOrder
DispatcherServlet.doDispatch
StandardHostValve.invoke
...
Agent 随后将顺序反转,构造成从外层调用到内层调用的结构:
StandardHostValve.invoke
└─ DispatcherServlet.doDispatch
└─ OrderController.slow
└─ OrderServiceImpl.queryOrder
└─ Thread.sleep
源码明确反向遍历线程栈,并将每一帧转换为:
className.methodName:lineNumber
例如:
com.demo.OrderServiceImpl.queryOrder:87
这就是 UI 中:
Code Signature
的来源。
十一、一次采样生成什么数据
每次采样都会生成:
TracingThreadSnapshot
主要包含:
taskId
traceSegmentId
sequence
dumpTime
stack
协议层的 ThreadSnapshot 也定义了这些字段,包括任务 ID、Segment ID、时间、序号和线程栈。
例如:
taskId = task-001
segmentId = segment-A
sequence = 17
dumpTime = 172...
stack:
StandardHostValve.invoke:...
DispatcherServlet.doDispatch:...
OrderServiceImpl.queryOrder:87
Thread.sleep:-2
其中:
sequence = 0、1、2、3......
表示该请求线程的第几次采样。
十二、采样数据如何上传到 OAP
Agent 将快照放入:
BlockingQueue<TracingThreadSnapshot>
队列容量由:
SNAPSHOT_TRANSPORT_BUFFER_SIZE
控制。
后台发送任务每 500 ms 执行一次:
snapshotQueue.drainTo(buffer);
if (!buffer.isEmpty()) {
sender.send(buffer);
}
所以采样线程并不是每采一次,就同步等待 OAP 网络响应。
链路是:
ProfileThread
→ 创建 Snapshot
→ 放入内存队列
→ 后台批量取出
→ gRPC 上传
十三、OAP 收到快照后保存什么
OAP 的:
ProfileTaskServiceHandler.collectSnapshot()
收到每个 ThreadSnapshot 后,转换为:
ProfileThreadSnapshotRecord
写入:
taskId
segmentId
dumpTime
sequence
stackBinary
timeBucket
然后:
RecordStreamProcessor.getInstance().in(record);
异步进入存储流程。
注意,OAP 此时只是保存原始采样快照。
真正的树形合并分析,通常发生在 UI 发起分析查询时。
十四、OAP 如何将上百次线程栈合并成一棵树
假设采样得到三份线程栈:
采样 1:
A → B → C
采样 2:
A → B → C
采样 3:
A → B → D
OAP 会合并成:
A
└─ B
├─ C 出现 2 次
└─ D 出现 1 次
ProfileAnalyzer 会查询指定:
segmentId
+ time range
范围内的快照,然后将它们反序列化为 ProfileStack,最后交给合并器构建树。
合并器按照相同的:
Code Signature
将节点聚合。
源码逻辑:
如果父节点下已经存在相同 codeSignature
→ 将当前快照计入该节点
不存在
→ 创建新的子节点
十五、Dump Count 到底是什么
源码中:
element.setCount(
this.detectedStacks.size()
);
所以:
Dump Count
表示:
该栈帧在多少次线程栈采样中出现过。
例如总共采样 100 次:
OrderServiceImpl.queryOrder 出现 90 次
Thread.sleep 出现 70 次
MySQL execute 出现 15 次
那么:
queryOrder Dump Count = 90
Thread.sleep Dump Count = 70
execute Dump Count = 15
它不是:
方法被调用了 90 次
而是:
采样时有 90 次看见线程正位于该方法调用路径中
📌 因此,Dump Count 越大,说明线程越经常停留在该方法及其子调用中。
十六、Duration 是怎么计算的
这里的 Duration 不是通过:
start = System.nanoTime();
method();
end = System.nanoTime();
得到的精确方法耗时。
OAP 会找出某节点出现的连续采样区间,然后计算:
最后一次采样时间
-
第一次采样时间
如果采样序号不连续,则拆成多个时间段再相加。
源码核心判断:
if (
previous.sequence + 1
!= current.sequence
) {
duration +=
previous.dumpTime
- windowStart.dumpTime;
windowStart = current;
}
最后再计算最后一个连续区间。
Duration 示例
采样周期:
10 ms
某方法在以下序号出现:
sequence:
10、11、12、13、14
采样时间:
100 ms
110 ms
120 ms
130 ms
140 ms
源码计算:
140 - 100 = 40 ms
虽然有 5 次采样,但相邻采样之间只有 4 个时间间隔。
因此它是:
基于连续线程栈样本推算出来的近似持续时间。
不是 JVM 对该方法的精确纳秒级计时。
非连续样本
某方法出现在:
sequence 1、2、3
sequence 8、9、10
则计算:
第一段:time(3) - time(1)
第二段:time(10) - time(8)
Duration = 第一段 + 第二段
中间没出现该方法的采样区间不会计入。
十七、Self Duration 是怎么计算的
源码中的实际字段名是:
Duration Child Excluded
UI 通常将其展示为:
Self Duration
计算公式:
当前节点 Duration
-
所有直接子节点 Duration 之和
源码:
element.setDurationChildExcluded(
element.getDuration()
- children.stream()
.mapToInt(child -> child.duration)
.sum()
);
可以理解为:
Duration
= 当前方法及其全部子调用占据的采样时间
Self Duration
= 尽量排除子方法后,
当前方法自身代码占据的采样时间
示例
queryOrder
├─ validate
└─ executeQuery
分析结果:
queryOrder Duration = 1800 ms
validate Duration = 100 ms
executeQuery Duration = 1500 ms
那么近似:
queryOrder Self Duration
= 1800 - 100 - 1500
= 200 ms
表示:
queryOrder 自身逻辑约 200 ms
其余大部分时间消耗在子方法
十八、三项指标应该如何联合阅读
| 指标 | 真正含义 |
|---|---|
| Duration | 当前方法及子调用在连续采样中的累计近似时间 |
| Self Duration | 排除子调用后,当前方法自身的近似时间 |
| Dump Count | 该方法出现在多少份线程栈快照中 |
情况一:Duration 高,Self Duration 低
queryOrder
Duration = 2000 ms
Self Duration = 50 ms
Dump Count = 180
说明:
queryOrder 整体很慢
但不是自身代码慢
主要是某个子方法慢
继续向下展开子节点。
情况二:Duration 高,Self Duration 也高
calculate
Duration = 1500 ms
Self Duration = 1300 ms
Dump Count = 140
说明线程频繁停留在该方法自身:
复杂循环
大量计算
字符串处理
集合遍历
自旋
它更可能是真正的业务代码热点。
情况三:Dump Count 高,但 Duration 不一定非常高
可能原因:
采样周期较长
样本不连续
该方法频繁出现但每次停留较短
所以不能只看 Count。
情况四:Thread.sleep 或网络读取节点很高
例如:
java.lang.Thread.sleep
Duration = 1900 ms
说明请求线程主要在休眠。
例如:
SocketInputStream.read
Duration = 1800 ms
说明线程主要等待网络或远端返回。
这不等于 CPU 使用率高。
Trace Profiling采集的是:
线程当时的调用栈
它可能处于:
运行
休眠
数据库等待
网络等待
锁等待
所以它更接近:
目标请求的线程时间分布分析。
而不是纯 CPU 火焰图。
十九、为什么你的 /order/query/1 必须在任务期间重新访问
任务创建完成后,Agent只对:
任务生效期间新进入的匹配请求
建立 ThreadProfiler。
已在任务开始前完成的旧 Trace:
没有线程可供继续采样
因此正确操作顺序是:
1. 创建 Profiling 任务
2. 等任务进入执行状态
3. 请求 GET /order/query/1
4. 让请求持续超过 Min Duration Threshold
5. 等请求完成
6. 查询任务详情
7. 选择可分析 Segment 和时间范围
8. 查看 Profiling 树
官方流程也是先创建任务,再生成匹配请求,随后查询任务详情并进行分析。
二十、为什么普通快速查询可能采不到数据
例如:
Min Duration Threshold = 1000 ms
/order/query/1 实际耗时 = 80 ms
请求在阈值之前就结束了。
结果:
有普通 Trace
但没有 Profiling Snapshot
所以可能出现:
Trace 页面能看到请求
Profile 页面没有可分析线程栈
这不是 Agent 异常,而是该请求没有达到任务触发条件。
为了实验,通常要保证:
实际请求耗时
>
Min Duration Threshold
+
至少若干次 Dump Period
例如:
Threshold = 1000 ms
Dump Period = 10 ms
请求耗时 = 2100 ms
则大约还有:
1100 ms
可用于采样。
二十一、跨线程请求如何继续 Profiling
前面讲过,普通 ThreadLocal 无法自动跨线程传播。
Profiling 状态也会放入:
ContextSnapshot
父线程捕获上下文时会携带:
profileStatus
子线程恢复上下文时,如果 Profiling 状态需要继续,执行:
PROFILE_TASK_EXECUTION_SERVICE.continueProfiling(
this,
segment.getTraceSegmentId()
);
这意味着异步任务可以形成新的:
TracingContext
ThreadProfiler
Segment
继续采样。
官方也明确说明,Java Agent 支持对跨线程请求继续 Trace Profiling。
二十二、为什么要限制并发和采样数量
采样需要不断执行:
targetThread.getStackTrace()
同时还要:
构造字符串
创建 Snapshot
缓存
网络上传
OAP 存储
后续合并分析
如果对所有慢请求无限采样,会增加明显开销。
Agent 因此限制:
最大并行 Profiling 请求数
最大采样 Trace 数量
最大栈深度
最大任务时长
快照传输缓冲区
ProfileTaskExecutionContext 会检查:
currentEndpointProfilingCount
>= Config.Profile.MAX_PARALLEL
以及:
totalStartedProfilingCount
> task.getMaxSamplingCount()
不满足条件的请求不再加入 Profiling。
这解释了:
即使任务期间发出 100 个匹配请求,最终也不一定对 100 个请求全部进行线程采样。
二十三、结合你之前的实验完整还原
你在 UI 创建:
Endpoint:
GET:/order/query/1
随后发起请求。
真实过程:
1. UI 创建 Profiling Task
2. OAP 保存任务
3. order-service Agent 周期查询任务
4. OAP 返回 ProfileTaskCommand
5. Agent 校验并安排任务开始
6. Tomcat 收到:
GET /order/query/1
7. 创建 TracingContext
8. firstSpanOPName 与任务 Endpoint 匹配
9. 创建 ThreadProfiler
状态:PENDING
10. 请求运行时间超过
Min Duration Threshold
11. 状态变为 PROFILING
12. ProfileThread 每隔 Dump Period:
requestThread.getStackTrace()
13. 生成 Snapshot:
taskId
segmentId
sequence
dumpTime
stack
14. Snapshot 放入 Agent 队列
15. 后台线程批量上传 OAP
16. OAP 持久化原始快照
17. 请求完成
18. Agent 停止该 TracingContext 的采样
19. UI 请求分析某个 Segment 的时间范围
20. OAP 查询所有相关 Snapshot
21. 相同 Code Signature 合并成树
22. 计算:
Duration
Self Duration
Dump Count
23. UI 展示具体慢方法和代码行
二十四、面试回答模板
SkyWalking Trace Profiling 是如何定位具体慢方法的?
可以回答:
Trace Profiling 是一种针对指定 Endpoint 的动态、采样式线程分析。用户在 OAP 创建任务后,Java Agent 会周期性拉取 Profiling 命令。任务生效期间,新建
TracingContext时会将首个 Span 的 Operation Name 与任务 Endpoint 进行匹配。匹配成功的请求先进入 Pending 状态,当执行时间超过 Min Duration Threshold 后转为 Profiling 状态。Agent 的独立 Profiling 线程按照 Dump Period 周期调用目标业务线程的getStackTrace(),生成包含 Task ID、Segment ID、采样序号、时间和代码栈的 Snapshot,并异步上传 OAP。OAP 将同一 Segment 和时间范围内的大量线程栈按 Code Signature 合并成调用树,根据节点在连续采样中的出现时间计算 Duration,以父节点 Duration 减去子节点 Duration 得到 Self Duration,并以出现的快照数量作为 Dump Count。因此它提供的是统计采样结果,不是每个方法的精确埋点计时。
二十五、本节必须记住
Trace Profiling
→ 针对指定 Endpoint 的短期线程采样
Agent 定时拉取任务
→ 不是普通业务服务接收 HTTP 推送
Endpoint 匹配
→ 匹配 firstSpan operationName
PENDING
→ 请求尚未超过最小耗时阈值
PROFILING
→ 开始周期性抓取线程栈
Thread.getStackTrace()
→ 真正的采样动作
Dump Period
→ 线程栈采样间隔
Max Sampling Count
→ 最多选取多少条 Trace
Snapshot
→ taskId + segmentId + sequence + time + stack
Dump Count
→ 栈帧被采样看见的次数,不是方法调用次数
Duration
→ 连续采样区间推算的近似总时间
Self Duration
→ Duration 减去直接子节点 Duration
高 Duration、低 Self Duration
→ 慢点主要在子方法
高 Duration、高 Self Duration
→ 当前方法自身更可能是热点
下一节将把 SkyWalking 全链路源码做最后收束:
-javaagent
→ 插件增强
→ Entry / Local / Exit Span
→ 跨服务传播
→ Segment 异步上报
→ OAP 指标与拓扑
→ 日志关联
→ 告警
→ Profiling
最后形成一张"从请求到监控结果"的完整源码地图。