SLS:基于 OTel 的移动端全链路 Trace 建设思考和实践

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

作者高玉龙 (元泊)

首先我们了解一下移动端全链路 Trace 的背景

从移动端的视角来看一个 App 产品从概念产生到最终的成熟稳定产品研发过程中涉及到的研发人员、工程中的代码行数、工程架构规模、产品发布频率、线上业务问题修复时间等等都会发生比较大的变化。这些变化给我们在排查问题方面带来不小的困难和挑战业务问题会往往难以复现和排查定位。比如在产品初期的时候工程规模往往比较小业务流程也比较简单线上问题往往能很快定位。而等到工程规模比较大的时候业务流程往往涉及到的模块会比较多这个时候有些线上问题就会比较难以复现和定位排查。

本文汇集了笔者在 2022 D2 终端技术大会上的相关技术分享希望能给大家带来一些思考和启发。

端侧问题为什么很难复现和定位

线上业务问题为什么很难复现和排查定位经过我们的分析主要是由 4 个原因导致

  • 移动端 & 服务端日志采集不统一没有统一的标准规范来约束数据的采集和处理。

  • 端侧往往涉及的模块非常多研发框架也各不相同代码相互隔离设备碎片化网络环境复杂会导致端侧数据采集比较难。

  • 从端视角出发不同框架、系统之间的数据在分析问题时往往获取比较难而且数据之间缺少上下文关联信息数据关联分析不容易。

  • 业务链路涉及到的业务域往往也会比较多从端的视角去复现和排查问题往往需要对应域的同学参与排查人肉运维成本比较高。

这些问题如何来解决 我们的思路是四步走

  • 建立统一标准使用 标准协议 来约束数据的采集和处理。

  • 针对不同的平台和框架统一数据采集能力。

  • 对多系统、多模块产生的数据进行自动上下文关联分析和处理。

  • 我们也基于机器学习在自动化经验分析方面做了一些探索。

统一数据采集标准

如何统一标准 目前行业内也有各种各样的解决方案但存在的问题也很明显

  • 不同方案之间协议/数据类型不统一

  • 不同方案之间也比较难以兼容/互通。

标准这里我们选择了 OTelOTel 是 OpenTelemetry 的简称主要原因有两点

  • OTel 是由云原生计算基金会CNCF主导它是由 OpenTracing 和 OpenCensus 合并而来是目前可观测性领域的准标准协议

  • OTel 对不同语言和数据模型进行了统一可以同时兼容 OpenTracing 和 OpenCensus它还提供了一个厂商无关的 Collectors用于接收、处理和导出可观测数据。

在我们的解决方案中所有端的数据采集规范都基于 OTel数据存储、处理、分析是基于 SLS 提供的 LogHub 能力进行构建。

端侧数据采集的难点

只统一数据协议还不够还要解决端侧在数据采集方面存在的一些问题。总的来说端侧采集当前面临 3 个主要的难点

  • 数据串联难

  • 性能保障难

  • 不丢数据难

端侧研发过程中涉及到的框架、模块往往比较多业务也有一定的复杂性存在线程、协程多种异步调用 API在数据采集过程中如何解决数据之间的自动串联问题移动端设备碎片化严重系统版本分布比较散机型众多如何保障多端一致的采集性能App 使用场景的不确定性也比较大如何确保采集到的数据不会丢失

端侧数据串联的难点

我们先来分析一下端侧数据自动串联所面临的主要问题。

  1. 在端侧数据采集过程中不仅会采集业务链路数据还会采集各种性能&稳定性监控数据可观测数据源比较多

  1. 如果用到其他的研发框架如 OkHttp、Fresco 等可能还会采集三方框架的关键数据用于网络请求图片加载等问题的分析和定位。对于业务研发同学来说我们往往不会过多的关注这类三方框架技术能力涉及到这类框架问题的排查时过程往往比较困难

  1. 除此之外端侧几乎完全异步调用而且异步调用 API 比较多如线程、协程等链路打通也存在一定的挑战。

这里会有几个共性问题

  • 三方框架的数据如何采集如何串联

  • 不同可观测数据源之间如何串联

  • 分布在不同线程、协程之间的数据如何自动串联

端侧数据自动串联方案

我们先看下端侧数据自动串联的方案。

在 OTel 协议标准中是通过 trace 协议来约束不同数据之间的串联关系。OTel 定义了 trace 数据链路中每条数据必须要包含的必要字段我们需要确保同一条链路中数据的一致性。比如同一条 trace 链路中trace_id 需要相同其次如果数据之间有父子关系子数据的 parent_id 也需要与父数据的 span_id 相同。

我们知道不管是 Android 平台还是 iOS 平台线程都是操作系统能够调度的最小单元。也就是说我们所有的代码最终都会在线程中被执行。在代码被执行过程中如果我们能把上下文信息和当前线程进行关联在代码执行时就能自动获取当前上下文信息这样就可以解决同一个线程内的 trace 数据自动关联问题。

在 Android 中可以基于线程变量 ThreadLocal 来存储当前线程栈的上下文信息这样可以确保在同一线程中采集到的业务数据进行自动关联。如果是在协程中使用基于线程变量的方案就会存在问题。因为在协程中协程真实运行的线程是不确定的可能会在协程执行的生命周期内进行线程切换我们需要利用协程调度器和协程 Context 来保持当前上下文的正确性。在协程恢复时让关联的上下文信息在当前线程生效在协程挂起时再让上下文信息在当前线程失效。

在 iOS 中主要基于 activity tracing 机制来保持上下文信息的有效性。通过 activity tracing 机制在一个业务链路开始时会自动创建一个 activity我们把上下文信息与 activity 进行关联。在当前 activity 作用域范围内所有产生的数据都会与当前上下文自动关联。

基于这两种方案在产生 Trace 数据时SDK 会按照 OTel 协议的标准自动把上下文信息关联到当前数据中。最终产生的数据会以一棵树的形式进行逻辑关联树的根节点就是 Trace 链路的起点。这种方式不仅支持协程/线程内的数据自动关联还支持多层级嵌套。

三方框架的数据采集和串联

针对三方框架的数据采集我们先看看业内通行的做法目前主要有两类

  1. 如果三方库支持拦截器或代理的配置一般会通过在对应拦截器增加埋点代码的方式来实现

  1. 如果三方库对外暴露的接口比较少一般会通过 Hook 或其他方式增加埋点代码或者不支持对应框架的埋点。

这种做法会存在两个主要的问题

  1. 埋点不完全拿 OkHttp 来举例说明三方 SDK 内部也可能存在对 OkHttp 的依赖通过拦截器的方式可能只支持当前业务代码的埋点采集三方 SDK 的网络请求信息无法被采集到会导致埋点信息不完全

  1. 可能需要侵入业务代码为了实现对应框架的埋点需要有一个切入时机这个切入时机往往需要在对应框架初始化时增加代码配置项来实现。

如何解这两个问题

我们使用的方案是实现一个 Gradle Plugin在 Plugin 中对字节码进行插桩处理。我们知道Android App 在打包的过程中有个流程会把 .class 文件转为 .dex 文件在这个过程中可以通过 transform api 对 class 文件进行处理。我们是借助 ASM 的方式来实现 class 文件的插桩处理。在对字节码处理的过程中需要先找到合适的插桩点然后注入合适的指令。

这里拿 OkHttp 的字节插桩进行举例插桩的目标是在 OkHttpClient 调用 newCall 方法时把当前线程的上下文信息关联到 OkHttp 的 Request 中。在 Transform 过程中我们先根据 OkHttpClient 的类名过滤出目标 class 文件然后再根据 newCall 这个方法名过滤要插桩的方法。接下来需要在 newCall 方法开始的地方把上下文信息插入到 request 的 tags 对象中。经过我们的分析需要在 newCall 方法调用开始的时候插入目标代码。为了方便实现和调试我们在扩展库中实现了一个 OkHttp 的辅助工具在目标位置插入调用这个工具的字节码传入 request 对象就可以了。

插入后的字节码会和扩展库进行关联。这样就能解决三方框架数据采集和上下文自动关联的问题。

相对于传统做法使用字节码插桩的方案业务代码侵入性会更低埋点对业务代码和三方框架都能生效同时结合扩展库也能完成上下文的自动关联。

如何确保性能

在可观测数据采集过程中会有大量的数据产生对内存、CPU 占用、I/O 负载都有一定的性能要求。

我们基于 C 对核心部分进行实现确保多平台的性能一致性并从三个方面对性能做了优化

首先是对协议化处理过程进行优化。数据协议方面选择使用 Protocal Buffer 协议Protocal Buffer 相对 JSON 来说不仅速度更快而且更省内存空间。在协议的序列化上我们采用了手动封装协议的实现在序列化的过程中避免了很多临时内存空间的开辟、复制以及无关函数的调用。

其次在内存管理方面我们直接对 SDK 的最大使用内存做了可配置的大小限制。内存的使用可以根据业务情况按需配置避免 SDK 内存占用过大对 App 的稳定性造成影响其次还引入了动态内存管理机制内存空间的使用按需增加不会一直占用 App 的内存空间避免内存空间的浪费。同时还提升了字符串的处理性能。在字符的处理上引入了动态字符串机制它可以记录字符串自身的长度获取字符长度时操作复杂度低而且可以避免缓冲区溢出同时也可以减少修改字符串时带来的内存重分配次数。

最后在文件缓存管理方面我们也限制了文件大小的上限避免对端设备存储空间的浪费。在缓存文件的落盘处理上我们引入了 Ring File 机制把缓存数据存储在多个文件上面以日志文件组的形式对多个文件进行组装。整个日志文件组以环形数组的形式从头开始写写到末尾再回到头重新循环写。通过这种方式写数据可以减少写文件时的随机 Seek而且 Ring File 的机制可以确保单个日志文件不会过大从而尽可能的降低系统 I/O 的负载。除了 Ring File 的机制外还把断点保存、缓存清理的逻辑放到了一起聚合执行减少随机 Seek。checkpoint 的文件大小也做了限制在超出指定大小后会对 checkpoint 文件进行清理避免 checkpoint 文件过大影响文件读写效率。

经过上面的这些优化措施之后最终 SDK 采集数据的吞吐量提升了 2 倍内存和 CPU 占用都有明显的降低。每秒钟最高可支持 400+条数据的采集。

如何确保日志不丢失

性能满足要求还不够还需要确保采集到的数据不能丢失。在 App 的使用过程中app 经常可能会出现异常崩溃手机设备异常重启以及网络质量差网络延时、抖动大的情况。在这类异常场景下如何确保采集到数据不会丢失

在采集数据时我们使用了预写日志WAL机制并结合自建网络加速通道来优化这个问题。

  1. 引入预写日志机制的目的是确保写入到 SDK 的数据在发送到服务器之前不会因为异常原因而丢失。这个过程的核心是在数据成功发送到服务器之前先把数据缓存在移动设备的磁盘上数据发送成功之后再移除磁盘上的缓存数据。如果因为 App 异常原因或者设备重启导致数据发送失败因为缓存的数据还在SDK 会根据记录的断点信息对数据发送进度进行恢复。同时预写日志机制可以确保数据的写入和发送并发执行不会互相阻塞

  1. 在数据发送之前还会对多条数据做聚合处理并通过 lz4 算法进行压缩处理这种做法可以降低数据发送时的请求次数和网络传输流量的消耗。如果数据发送失败还会有重试策略确保数据至少能成功发送一次

  1. 在数据发送时SDK 支持就近接入加速边缘节点并通过边缘节点与 SLS 之间的内部网络加速通道传输数据。

经过这三种主要的方式优化之后数据包的平均大小降低了 2.1 倍整体的 QPS 平均提升 13 倍数据整体的发送成功率达到了 99.3%网络延时平均下降了 50%。

多系统数据关联处理

解决了端侧数据的串联和采集性能问题之后还需要处理多系统之间的数据存储和关联分析问题。

数据存储方面我们直接基于 SLS LogHub 能力把相关的数据统一存储基于 SLS日均可以承载 PB 级别的流量这个吞吐量可以支持移动端可观测数据的全量采集。

解决了数据的统一存储问题之后还需要处理两个主要的问题。

第一个问题不同系统可观测数据之间的上下文关联如何处理

根据 OTel 协议的约束我们可以基于 parent_id 和 span_id 来处理根节点、父节点、子节点之间的映射关系。首先在查询 Trace 数据链路时会先从 SLS 拉取一定时间段内的所有 Trace 数据。然后按照 OTel 协议的约束对每条数据进行节点类型的判定。由于多系统的数据可能存在延时在查询 Trace 数据链路时有些数据可能还没有到达。我们还需要对暂时不存在的父节点进行虚拟化处理确保 Trace 链路的准确性。接下来还需要对节点进行规整处理把属于同一个 parent_id 的节点进行聚合然后再按照每个节点的开始时间进行排序最终就可以得到一条 trace 链路信息基于这个链路信息我们可以还原出系统的调用链路。

第二个问题 在进行 Trace 分析时我们往往还需要从系统视角出发对不同维度的数据进一步分析。比如如果想从设备 ID、App 版本、服务调用等不同维度对 Trace 数据进一步分析该怎么做我们来看一下怎么解决这个问题。

多系统数据拓扑生成

当我们从系统整体视角对问题进行分析时所需要的 Trace 数据规模往往会比较大每分钟可能有数千万条数据而且对数据的时效性要求也比较高。传统的流处理方式在这种场景下很容易遇到性能瓶颈问题。我们采用的方案是把流处理问题转换为批处理问题把传统的链路处理视角转换为系统处理视角。经过视角转化之后从系统视角来看解决这个问题最主要的核心就是如何确定两个节点之间的关系。

我们看一下具体的处理过程。在批处理上我们使用了 MapReduce 框架。首先在数据源处理阶段我们基于 SLS 的定时分析ScheduledSQL能力对数据进行聚合处理按照分钟级从 Trace 数据源中捞取数据。在 Map 阶段先按照 traceID 进行分组对分组之后的数据再按照 spanID、parentID 维度对数据进行聚合。然后计算出相关的统计数据如成功率、失败率、延时指标等基础统计数据。在实际的业务使用中往往还会采集一些和具体业务属性相关的数据这部分数据往往会根据业务的不同有比较大的差异。针对这部分类型的数据在聚合处理的过程中支持按照其他维度对结果进行分组。此时会得到两种中间产物

  • 包含两个节点关系的聚合数据我们把这种类型的数据叫做边信息

  • 以及未匹配到的原始数据

这两种中间产物在 Combine 阶段还会再进行聚合处理最终会得到包含基础统计指标以及其他维度的结果数据。

最终产物会包含几个主要的信息

  • 边信息可以体现调用关系。

  • 依赖信息可以体现服务依赖关系。

  • 还有指标信息以及其他资源信息等。其中业务属性相关的数据会体现在资源信息中。

基于这些产物我们可以通过对资源、服务等信息的多个维度筛选来统计出对应维度的问题分布和影响链路。

自动化问题根因定位探索

接下来向大家介绍下我们在自动化问题根因定位方向的一些探索。

我们知道随着 App 版本的迭代每次 App 的发版可能会涉及到多个业务的代码变更。这些变更有的经过充分测试也有的未经过充分测试或者常规测试方法没有覆盖到对线上业务可能会产生一定的潜在影响导致部分业务不可用。App 规模越大业务模式越多对应的业务数据量请求链路不确定性就越大。出了问题之后往往需要多人跨域参与排查人肉运维成本比较高。

如何在端侧问题排查定位方向通过技术手段进行研发效能的提速 我们基于机器学习技术做了一些探索。

我们目前的方法是先对 Trace 源数据进行特征处理然后再对特征进行聚类分析去找到异常 Trace最后再基于图算法等对异常 Trace 进行分析找到异常的起始点。

首先实时特征处理阶段会读取 Trace 源数据对每个 Trace 链路按照由底向上找 5 个节点的方式生成一个特征并对特征进行编码。然后对编码之后的特征通过 HDBSCAN 算法进行层次聚类分析此时相似的异常会分到同一个组里面接下来再从每组异常 Trace 中找出一条典型的异常 Trace。最后通过图算法找到这条异常 Trace 的起点从而确定当前异常 Trace 可能存在的问题根因。通过这种方式只要是遵循 OTel 标准协议的数据源都能够进行处理。

案例多端链路追踪

经过对数据处理之后我们来看下最终的效果。

这里有一个模拟 Android、iOS、服务端端到端链路追踪的场景。

我们使用 iOS App 来作为指令的发送端Android App 来作为指令的响应端用来模拟远程打开汽车空调的操作。我们从图上可以看到iOS 端“打开车机空调”这个操作触发后依次经过了“用户权限校验”、“发送指令”、“调用网络请求”等环节。Android 端收到指令后依次执行“远程启动空调”、“状态检查”等环节。从这个调用图可以看得到Android、iOS、服务端多端链路被串联到了一起。我们可以从 Android、iOS、服务端的任何一个视角对调用链路进行分析。每个操作的耗时对应服务的请求数错误率以及服务依赖都能体现出来。

整体架构

接下来我们来看下整套解决方案的架构

  1. 最底层是数据源遵循 OTel 协议各个端对应的 SDK 按照协议规范统一实现

  1. 数据存储层是直接依托于 SLS LogHub所有系统采集到的数据统一存储

  1. 再往上是数据处理层对关键指标、Trace 链路、依赖关系、拓扑结构、还有特征等进行了预处理。

最后是上层应用提供链路分析、拓扑查询、指标查询、原始日志查询以及根因定位等能力

后续规划

最后总结下我们后续的规划

  1. 在采集层会继续完善插件、注解等方式的支持降低业务代码的侵入性提升接入效率

  1. 在数据侧会丰富可观测数据源后续会支持网络质量、性能等相关数据的采集

  1. 在应用侧会提供用户访问监测、性能分析等能力

最后我们会把核心技术能力开源共享社区。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6