Scheduler 和 BlockManager 的交互流程,是理解 Spark 任务调度、数据本地性和存储联动的一条主线。很多人看 Spark UI 时只看到哪个 Stage 慢、哪个 Task 失败,却没意识到背后其实一直在发生“调度器问存储层要数据位置、存储层回报块状态、调度器据此决定把任务发给谁”的循环。我最早是被一个诡异现象逼着去读这部分源码的:同一个任务,在 A 集群跑得飞快,在 B 集群却经常出现大量 NODE_LOCAL 甚至 ANY,性能差了好几倍。查到最后,问题就出在 Scheduler 与 BlockManager 的交互细节上。这篇文章我会把这对交互完整拆开,适合两类人看:一是正在做 Spark 作业调优、被数据本地性困扰的开发,二是想深入 Spark 内核、准备啃源码的读者。
1. 先把两个角色和它们的关系说清楚
1.1 Scheduler 的职责边界:不只是“派活”
在 Spark 里,“Scheduler”不是一个类,而是一组分工明确的调度组件。DAGScheduler 负责把 Job 拆成 Stage,再为每个 Stage 生成一组 Task;TaskScheduler 负责把 Task 分配到具体的 Executor 上执行;TaskSetManager 则在 TaskScheduler 内部管理同一个 Stage 的 Task 集合,包括追踪每个 Task 的本地性级别、失败重试、推测执行等。
很多人容易忽略一个点:Scheduler 不只是“把任务发下去”,它还要判断“任务发到哪里最划算”。这个“划算”就取决于数据位置。一个 Task 要计算某个 RDD 分区,如果这个分区已经被缓存到某台机器的内存里,那么把 Task 调度到那台机器的 Executor 上,就能直接读本地内存;如果数据在 HDFS 上,调度器也会尽量让 Task 跑到数据块所在的节点,避免跨网络拉数据。也就是说,Scheduler 在做调度决策之前,必须先拿到“数据在哪”这个信息。这个信息从哪儿来?存储层。
1.2 BlockManager 的职责边界:数据到底在哪
BlockManager 是 Spark 存储层的核心组件。每个 Executor 上都有一个 BlockManager 实例,负责管理这个 Executor 上的 MemoryStore 和 DiskStore;Driver 端还有一个 BlockManagerMaster,负责维护全局的 Block 元数据——哪个 Block 在哪个 Executor 上、以什么存储级别保存、状态是什么。
BlockManager 平时做的事包括:写入 Block、读取 Block、淘汰 Block、向 Master 上报 Block 状态变化、在 Executor 之间传输 Block 数据。一个 RDD 分区被 persist 到内存后,这个分区的数据会被包装成一个 Block,并注册一个形如 rdd_0_0 的 BlockId。每个 Executor 上的 BlockManager 在 Block 写入、更新或删除时,都会主动把状态同步给 Driver 端的 BlockManagerMaster。
这里要特别理解一个概念:BlockManagerMaster 只保存“元数据”,不保存“数据本身”。数据在 Executor 的内存和磁盘里,Master 保存的是“哪份数据在哪个 Executor”的映射表。这样一来,调度器问 Master,就能快速拿到全局数据分布,而不用挨个 Executor 去问。
1.3 为什么二者必须深度绑定
Spark 的核心设计思想是“数据不动、计算动”。就是说,数据在网络传输成本很高,不如把计算逻辑打包发给数据所在的节点。但要做到这一点,调度器必须知道数据在哪,而存储层是唯一掌握这个信息的模块。所以 Scheduler 和 BlockManager 的交互,本质上就是一套“寻址→调度→执行→写回→再寻址”的循环。
另外,任务执行过程中也不是只由 Scheduler 说了算。Executor 拿到 Task 开始执行后,算子代码运行时需要读取缓存的数据、写出 shuffle 文件,这些操作都直接跟本地的 BlockManager 打交道。任务执行完,结果数据如何回传 Driver,也由 BlockManager 参与决定。所以这对交互不是一个简单的调用链,而是贯穿任务生命周期的多条路径。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 任务提交前:DAGScheduler 如何拿到“数据在哪”
2.1 getCacheLocs:一次典型的元数据查询
DAGScheduler 在生成 Task 时,会通过 getCacheLocs 方法查询每个 RDD 分区的缓存位置。这个过程并不复杂:对每个 Partition,构造对应的 BlockId,然后调用 BlockManagerMaster 的 getLocations 方法,拿到一串 BlockManagerId。
这里有个容易看漏的细节:getCacheLocs 的结果会被缓存起来,避免每次生成 Task 都重复查询 Master。DAGScheduler 内部维护了一个缓存结构,记录“RDD id + 分区索引 → 位置列表”。一旦某个 RDD 的 Block 状态发生变化(比如缓存被清除),对应的缓存条目也会被主动失效。这样设计的原因是,一个 Job 里往往有成千上万个 Task,如果每个 Task 都实时请求 BlockManagerMaster,Master 会成为明显的性能瓶颈,而且数据分布短时间内基本不会变,缓存是完全合理的。
这个查询就是 Scheduler 与 BlockManager 交互的入口。注意,getCacheLocs 只负责查“已经被持久化的 Block 位置”,对于没有被缓存的 RDD,它的返回值是空。那调度器怎么知道未缓存的数据在哪呢?答案是往上游 RDD 回溯,我在 2.3 节会展开讲。
2.2 从缓存位置到调度偏好:TaskLocation 怎么用
查询到 Block 位置之后,DAGScheduler 并不会直接把这些原始位置信息塞给 Task,而是把它们包装成 TaskLocation 对象。TaskLocation 有两种常见实现:
ExecutorCacheTaskLocation(executorId, host):表示这个 Block 在某个 Executor 上。调度时如果能把这个 Task 发到同一个 Executor,则达到 PROCESS_LOCAL(进程本地)。HostTaskLocation(host):只知道 Block 在哪台机器上,但不知道具体在哪个 Executor。调度到该机器的任意 Executor 上,都属于 NODE_LOCAL(节点本地)。
这个区分很重要。如果你在 Spark UI 上看到某个 Stage 的本地性级别长期停留在 NODE_LOCAL,那可能是缓存数据在多个 Executor 之间存在多副本(BlockManager 默认支持跨 Executor 冗余),或 Block 位置上报时只给了 Host 粒度信息。另一个常见场景是,RDD 没有被缓存,但上游是 HDFS 文件,这时的位置偏好可能来自 HDFS 的 Block 分布,属于 HostTaskLocation,所以只能做到节点级本地。
2.3 没有缓存时怎么找位置:向祖先 RDD 回溯
绝大多数 RDD 在第一次计算时并没有被缓存,但调度器仍然需要猜测它们的数据位置偏好。DAGScheduler 的 getPreferredLocs 方法会从目标 RDD 开始向前回溯,查找最近的“有位置信息”的 RDD。
回溯规则大致是这样:如果当前 RDD 是 Checkpoint 过的,直接用 Checkpoint 数据的位置;如果是依赖父 RDD 的窄依赖(如 map、filter),父 RDD 分区和子 RDD 分区是一一对应的,那子分区的偏好位置就是父分区的位置,于是继续向上传播;如果遇到宽依赖(如 groupByKey),说明父 RDD 的数据已经被 shuffle 分散出去了,位置信息不再有意义,回溯到此为止。
这个过程本质上也是“数据位置寻址”,只是它不直接访问 BlockManagerMaster,而是沿着血缘关系向上找。它和 getCacheLocs 配合,构成了 Task 位置偏好的两个来源:一个查“谁缓存了”、一个查“谁可能在哪”。这两个来源最终会合并进 Task 的 preferredLocations。
2.4 一个容易忽略的点:Stage 划分与缓存状态的先后关系
还有一个不少文章没有讲清楚的地方:Stage 划分其实发生在缓存位置查询之前,而且 Stage 划分本身不依赖 BlockManager 的数据。DAGScheduler 在划分 Stage 时,只根据 RDD 依赖关系判断哪些地方需要 shuffle;只要有宽依赖,就要断开形成新的 Stage。
但这里有一个衍生问题:如果某个父 RDD 已经被 Cache,那它所在的 Stage 还需要重新计算吗?答案是,如果父 RDD 被缓存,并且子 Stage 通过 shuffle 依赖它,那么父 Stage 的 ShuffleMapTask 一样要执行,因为 shuffle 需要写入真正的 shuffle 数据文件,即使数据可以从缓存读取,还是要经过一次 shuffle write 才能给下游使用。缓存在这里减少的是“父 RDD 的计算开销”,而不是“shuffle 的传输开销”。这块内容很多人会搞混,我建议调试时别只看缓存有没有命中,还要看 Stage 的 shuffle 依赖是不是必须被完整执行。
3. TaskScheduler 落地执行:从数据位置到“把任务送上门”
3.1 TaskSetManager 如何给任务分桶
DAGScheduler 把 Task 集合交给 TaskScheduler 之后,具体的调度逻辑是由 TaskSchedulerImpl 和 TaskSetManager 一起完成的。每个 Stage 对应一个 TaskSetManager,它会在内存里维护所有 Task 的调度状态。
TaskSetManager 拿到每个 Task 的 preferredLocations 后,会做一件很有意思的事:把 Task 按照“能从哪里提供资源”进行分桶。具体来说,它会维护 pendingTasksForExecutor、pendingTasksForHost、pendingTasksWithNoPrefs、pendingTasksAll 这样几组集合。Executor 来时,先在 pendingTasksForExecutor 里找最匹配的 Task;找不到,再看 pendingTasksForHost;再不行,看 pendingTasksWithNoPrefs;实在没有,就从 pendingTasksAll 里拿。
这套分桶逻辑,对应了 Spark 的本地性级别从高到低:PROCESS_LOCAL、NODE_LOCAL、NO_PREF、RACK_LOCAL、ANY。调度器的妥协本质就在这里:优先把任务送给数据所在进程,但如果没有合适资源,就一步步放宽到同一节点,再到同一机架,最后干脆发给任意节点。
3.2 Delay Scheduling 的灵魂:要不要等数据
延迟调度是 TaskScheduler 里最关键的一个机制,也是 Scheduler 与 BlockManager 交互中最能体现“权衡”的部分。当一个 Executor 向 Driver 发起资源请求(Offer)时,TaskSetManager 会先计算当前累积的等待时间,再判断是否接受这个 Offer 的本地性级别。
举个例子:一个 Task 最喜欢的 Executor 是 exec-1,但现在空闲的只有 exec-2。按照严格本地性原则,这个 Task 不应该被调度到 exec-2 去;但如果一直等 exec-1 空出来,可能整个 Stage 都会被卡住。所以 Spark 允许等待一段时间,比如默认配置是 spark.locality.wait=3s。如果 3 秒内 exec-1 一直没出现,TaskSetManager 会更新本地性级别,从 PROCESS_LOCAL 降到 NODE_LOCAL,然后可以调度到 exec-2 上。
这里我提醒一下,延迟调度的时间要跟 BlockManager 的数据分布一起看。如果你的数据块只在 exec-1 上,而你的 Executor 数量和并行度设置不合理,导致 exec-1 一直忙,那么等待时间超时后,Task 就会被调度到其他 Executor 上,通过网络拉数据。这是非常常见的“本地性良好但性能依然差”的原因之一。
3.3 本地性为什么不生效:hostname 和 executor 映射的坑
本地性判断不是简单比字符串。Task 的位置偏好里包含 executorId 和 host,而 Offer 里也包含 executorId 和 host。Spark 判断 PROCESS_LOCAL 时,要求 executorId 完全相等;判断 NODE_LOCAL 时,要求 host 相等。
但这里的 host 到底用什么格式,非常考验集群配置。如果 Executor 启动时拿到的是 IP 地址,而 BlockManager 上报的位置偏好里用的是主机名,那即使数据明明在同一台机器上,Spark 也无法识别出 NODE_LOCAL,只能继续放宽到 RACK_LOCAL 甚至 ANY。这类问题的排查特征很明显:Spark UI 上几乎看不到 PROCESS_LOCAL 和 NODE_LOCAL,任务却全部成功,因为跨节点拉数据也能算对。
排查方法我后面会写,但这里先说一个经验:保持所有节点的 spark.hostname、SPARK_LOCAL_HOSTNAME、/etc/hosts 配置一致,能解决绝大多数本地性不生效的问题。尤其要小心那些把主机名解析成 localhost 的配置,这种最容易让 BlockManager 的 host 信息彻底乱掉。
3.4 如何在 UI 和日志里验证本地性
在 Spark UI 的 Stage 页面,每个 Stage 的 Summary Metrics 区域会显示 Locality Level Summary,里面列出了 PROCESS_LOCAL、NODE_LOCAL、NO_PREF、RACK_LOCAL、ANY 这几种级别的 Task 数量和耗时。这是直观查看数据本地性的入口。
如果想更细,可以打开 Event Log,用 spark.eventLog.enabled=true 把任务事件落盘。在 Event Log 里,每个 TaskEnd 事件里有一个 TaskLocality 字段,精确标记了这个 Task 最终以什么本地性级别执行。配合 Task 的 ExecutorId,能还原出调度器当时做了什么选择。
如果不想动日志,还有一个土办法:在 Executor 的 stderr 日志里搜 Scheduler 相关的 DEBUG 输出,日志级别调高后会看到类似 Subtask X has no locality preference 或 Serialized task X 之类的信息。这些日志对确认“Task 是否真的被调度到了期望位置”非常有用。
4. 任务执行到结果回传:BlockManager 全程参与的三个关键时刻
4.1 任务结果小,直连回传;任务结果大,BlockManager 中转
Scheduler 和 BlockManager 的交互不仅发生在“派任务前”,还发生在“收结果后”。Task 在 Executor 上执行完后,会把结果序列化。这里 Spark 做了一个很有意思的阈值判断:如果结果序列化后的大小小于 spark.task.maxDirectResultSize(默认 1MB),就直接把结果放进 DirectTaskResult 里,通过网络回传 Driver;如果结果大小超过这个阈值,Executor 不会硬传,而是先把结果数据通过 BlockManager 保存为一个 Block,然后只向 Driver 发送一个 IndirectTaskResult,里面包含这个 Block 的 BlockId。
Driver 端收到 IndirectTaskResult 后,会由 TaskResultGetter 向对应 Executor 的 BlockManager 发起远程读取,把真正的结果数据拉回来。这个机制的本质是:小结果走进程间通信,大结果走 BlockManager 的数据通道。这样做的好处是,大结果的传输可以被 BlockManager 统一管理,支持分块、缓存、失败重传等机制,不会让 Akka/Netty 的直接通信链路承载过重。
我在实际调优中发现,如果一个作业的 Driver 频繁 GC,而且 Executor 日志里大量出现 IndirectTaskResult 相关字样,可以适当调大 spark.task.maxDirectResultSize,减少中间 Block 的存储和拉取开销。但要权衡,因为调大后传输压力会转嫁给通信链路,如果集群网络本身就紧张,反而更容易出问题。
4.2 读取缓存 RDD 和 checkpoint 的实时决策
任务真正开始执行算子时,RDD 的 iterator 方法会检查 RDD 的 StorageLevel。如果 StorageLevel 不是 NONE,就会先去本地 BlockManager 里拿 Block。这个路径非常关键:Task 读到的是 BlockManager 里的缓存数据,而不是重新计算。
如果 BlockManager 里没有这个 Block,那么 RDD 会走“计算”分支,执行完产生数据后,判断是否满足缓存条件,如果满足就通过 BlockManager 写入存储。整个流程就是 getOrCompute:先查缓存,命不中则计算,计算完按需写缓存。
这里有一个值得注意的细节:BlockManager 的 get 可以指定读取位置,但实际执行时,Task 会先尝试本地获取,本地没有才会走远程拉取。所以即使任务被调度到了没有数据的节点,它也未必会失败,而是通过网络把数据拉过来计算。这也就是为什么本地性差不会导致任务失败,但一定导致性能下降。
4.3 Shuffle Map 输出的位置并不在 BlockManagerMaster,而在 MapOutputTracker
Shuffle 是 Scheduler 和 BlockManager 交互中容易被错误理解的一部分。ShuffleMapTask 执行完 shuffle write 后,产生的 shuffle 文件中包含多个分区。这里要注意:BlockManager 参与了 shuffle 数据块的写入、读取和管理,但是shuffle 文件位置元数据并不是由 BlockManagerMaster 保存的,而是由 MapOutputTracker 保存的。
具体来说,ShuffleMapTask 完成后,会返回一个 MapStatus,里面含有每个 reduce 分区对应的数据文件位置(Executor 和 Host),这个 MapStatus 会由 DAGScheduler 汇总,注册到 MapOutputTrackerMaster。下一个 Stage 的 Task 要读取 shuffle 数据时,会通过 MapOutputTracker 查询“某个 shuffle 的某个 map 输出在哪个节点”,从而决定该把 Task 调度到哪里。
所以在一个完整的 Shuffle 场景里,Scheduler 会跟两个存储元数据系统打交道:查询 RDD 缓存位置时找 BlockManagerMaster,查询 Shuffle 输出位置时找 MapOutputTracker。两者最终都服务于同一个目标:让后续 Task 尽量靠近数据。BlockManager 在这里的作用是提供真正的读写通道,比如 shuffle write 时通过 DiskBlockObjectWriter 落盘、shuffle read 时通过远程 Block 拉取服务读取。
4.4 Executor 加入、心跳与丢失时的联动
还有一条交互容易被忽略,就是 Executor 生命周期里的注册和退出。每个 Executor 启动后,BlockManager 会先初始化,然后向 Driver 端的 BlockManagerMaster 注册,注册信息包括 ExecutorId、Host、端口等。只有注册完成后,这个 Executor 才会被标记为可用。TaskScheduler 在 Executor 加入时会触发相应回调,后续的 Offer 才会包含这个新 Executor。
当 Executor 异常退出时,BlockManagerMaster 会清理掉该 Executor 上的所有 Block 元数据,然后 TaskScheduler 的 executorLost 事件被触发,所有在该 Executor 上运行或等待运行的任务都会被重新调度。如果这个 Executor 正好保存了某个 RDD 的缓存 Block,那么依赖它的任务在重试时会发现缓存没了,只能通过血统重算。这个“重算”过程对用户是透明的,但会明显拉高 Stage 耗时,也是不少集群在 Executor 频繁被 kill 时性能暴跌的原因之一。
5. 常见问题定位与排查实录
5.1 本地性一直偏低,怎么办
问题现象:UI 上 Stage 的 Locality Level 显示大量 NODE_LOCAL、RACK_LOCAL 甚至 ANY,PROCESS_LOCAL 很少。我遇到过最典型的场景是:RDD 缓存生效了,但 Executor 数量太多,每个 Executor 上只缓存了少量分区,后续 Action 的并行度又很高,导致很大一批 Task 无法命中缓存所在 Executor。
处理思路是先看数据是否真的生成了本地缓存。去 Spark UI 的 Storage 页,如果缓存项的“Replication”和“Memory”正常,再回头检查 Stage 的每个 Task 的 preferredLocations 里是否有 ExecutorId。如果没有,那就是位置信息没传递对。常见原因包括:使用了 repartition 打破位置相关性的算子、窄依赖链过长导致位置偏好没有有效传播、hostname 不一致导致 NODE_LOCAL 无法判断。
另外一个容易被忽略的因素是 spark.locality.wait 设置过短。如果一份数据在某个 Executor 上,但这个 Executor 当前正在运行长任务,3 秒等待时间一到,调度器就把任务分到其他 Executor 去了。此时可以适当调大 spark.locality.wait,比如 10s,给数据节点更多机会腾出资源。但注意,本地性等待是全局性的,调得太大,在资源不匹配的场景下会让整个 Stage 一直等。需要结合等待时间的耗时分布来权衡。
5.2 缓存好了但任务还是没去缓存节点
有次我遇到一个奇怪场景:RDD 已经显式 cache 并触发了 action,Storage 页也显示缓存成功了,但后续又一个 action 跑出来的任务却大量走了 ANY。当时排除了 hostname 问题后,仔细一查才发现,后续作业是通过 spark-submit 重新启动的应用,跟缓存数据根本不在同一个 Driver 的同一个 BlockManagerMaster 元数据空间里。也就是说,缓存是按 Application 隔离的,不是按集群全局的。不同 Application 之间不可能共享缓存。
同一个应用内如果出现这种情况,建议检查 getCacheLocs 相关的缓存是否被提前失效。有一种情况是缓存数据被 MemoryStore 淘汰,BlockManagerMaster 上对应的 Block 元数据已经被移除,但调度器在某个时间点拿到的位置快照还没有失效,导致 Task 被派到了原本有数据的 Executor,结果读的时候发现数据没了,只能重算。这类问题在内存压力大的集群上更容易出现。
5.3 IndirectTaskResult 变多,Driver 压力增大
如果 Job 的 Result 很大,而且任务数量很大,Driver 端可能频繁从 Executor 远程拉取大结果块,导致网络和 GC 压力上升。这时可以从 Event Log 或 Spark UI 的 Task 列表看到输出大小分布。如果大量 Task 的输出都接近或超过 1MB 这个阈值,就需要权衡是否调大 spark.task.maxDirectResultSize。
有时候问题反过来:Task 输出不大,但 IndirectTaskResult 的比例却很高。那是因为 Spark 启用了序列化结果压缩后,压缩前大小超过阈值,压缩后可能已经很小了。这种情况可以调大阈值,或开启结果压缩相关配置(spark.rdd.compress 等不是同一个,但要注意结果的压缩逻辑)。我的习惯是先用 Event Log 统计一下结果大小分布,再决定调参方向,避免拍脑袋。
5.4 日志怎么开,关键词怎么搜
排查“数据本地性”相关问题时,可以临时把日志级别调低,定位更精准。对 Scheduler 和 BlockManager 分别开启 DEBUG:
bash复制log4j.logger.org.apache.spark.scheduler=DEBUG
log4j.logger.org.apache.spark.storage=DEBUG
然后重点搜索这些关键词:
Subtask、Serialized task:确认 Task 的序列化、资源分配和本地性等级。Getting block、Put block:确认 BlockManager 在读写具体 Block。IndirectTaskResult、TaskResultGetter:确认大结果走的是 BlockManager 中转。executorLost、BlockManager removed:确认 Executor 失效后元数据的清理。
输出日志虽然多,但建议只在定位问题时临时开启,定位完立即恢复,避免影响线上性能。
6. 写一个最小实验自己验证这套交互
6.1 实验设计
理解原理之后,最好自己动手验证一次。这个实验不需要复杂数据,用一个简单的 RDD 缓存 + shuffle 作业就够了:
scala复制import org.apache.spark.storage.StorageLevel
val data = sc.parallelize(1 to 10000000, 8)
.map(x => (x % 100, x))
.persist(StorageLevel.MEMORY_ONLY)
data.count() // 触发缓存,让 BlockManager 真正写入 Block
data.groupByKey().map(x => (x._1, x._2.size)).collect()
先运行第一段 count(),观察 Storage 页出现 rdd_0_0 到 rdd_0_7 这 8 个 Block。然后查一下这些 Block 分别在哪几个 Executor 上。再运行下游的 groupByKey(),对比 Task 最终执行的 Executor 分布和 Block 所在 Executor 的重合度。
6.2 预期现象
正常情况下,如果资源充足、集群空闲,第二段作业的 Task 应该尽可能调度到缓存所在的 Executor 上。如果你用 --num-executors 2 --executor-cores 2 这类配置运行,可能会看到 PROCESS_LOCAL 占比很高;如果设置的 Executor 数量很多,缓存只分布在少数几个 Executor 上,而 groupByKey 的并行度也被调得很大,就会出现大量 NO_PREF 或 ANY。
这个实验能直观演示一个结论:缓存数据分布越集中,调度器的本地性选择空间越小。所以很多调优实践会把缓存 RDD 的分区数、Executor 数量、并行度结合起来考虑,而不是单独调一个参数。
6.3 实际排错中一个比较有用的观察顺序
我自己的排查顺序通常是这样的:先在 UI 的 Storage 页确认缓存是否生效、Block 位置在哪;再到 Stage 页看本地性分布,确认是 PROCESS_LOCAL 还是 ANY;然后打开 Event Log 或 Executor 日志,看具体 Task 的 TaskLocality 和 BlockManager 的读写日志;最后根据结果决定改代码还是改配置。
这套顺序屡试不爽,而且大部分“本地性差”的问题,在第一步和第二步就能看出端倪。真正需要打开 DEBUG 日志去定位的,往往是 hostname 不一致、Block 元数据失效这类隐藏问题。
我在实际使用中最大的体会是:Scheduler 与 BlockManager 的交互不是一条直线,而是围绕“数据位置”展开的一套动态博弈。调度器努力让任务靠近数据,存储层努力让数据在正确的位置被写入和读出,而配置和集群环境则在每个环节影响最终的走向。搞懂这套博弈之后,再看 Spark UI 上那些奇奇怪怪的耗时分布,就会清晰很多。
