写点什么

做一个能对标阿里云的前端 APM 工具

作者:光毅
  • 2022 年 5 月 25 日
  • 本文字数:8298 字

    阅读完需:约 27 分钟

做一个能对标阿里云的前端APM工具

APM 全称是 Application Performance Monitor,即性能监控


这篇文章有三个前提:


  • 从产品形态上看这肯定不是一个能够媲美阿里产品的竞品,所以抱歉我碰瓷了。你可以把这里的阿里换成任何一个你在 google 用 APM 搜索到的工具。但是文章最后会我会用阿里的工具对同一网站进行性能测试,看看我们两者的悬殊究竟子啊哪里。很有意思的是,虽然我自己的写的这个工具相比阿里云的监测工具无比简陋,但它依然达到了我的目的帮助我发现了问题在哪。从这个意义上说,这确实是一种胜利

  • 工具起点和终点是 site2share,这是一个我自己给自己写的一个工具网站,我需要知道上线后用户对它的性能感受究竟如何,所以工具因它而造,在完成对它进行性能测试的使命之后也即寿终正寝。

  • 这篇文章其实是对我去年写的《性能指标的信仰危机》一文的回应。在那篇文章中基本都是在阐述这个工具背后的道理和设计,没有一行真实代码的落地。


我还记的很多年前网络上盛传的一道经典前端面试题,大意是请解释从浏览器地址栏敲入 url 之后到看到页面的过程中发生了什么。这类问题的迷人之处在于它给了你一记响亮的大嘴巴子却又让你心服口服——原来我们对眼皮下的诸多事物都熟视无睹,以及漫不经意问题背后存在着这么大的学问题。


在这篇文章里我要回答的问题也简单明了:我怎么知道我网站性能有多慢以及慢在哪?这个问题是网站上线之初我需要首先搞清楚的。

有待解决的问题

确定指标

在大问题下有两个子问题是优先需要搞清楚,


  • 我要用什么指标来衡量快慢?

  • 我怎么排查慢的瓶颈在哪里?


这两个子问题在我去年的《性能指标的信仰危机》一文中已经做出了详细说明,因为篇幅的关系,这里只陈述结论,并且两个问题的答案有千丝万缕的联系,必须放在一起聊才行


简单来说,诸如 onload 或者 DOMContentLoaded 这类技术指标是远远不够的,甚至 First Contentful Paint 距离用户的实际感知依然有距离(在后面我也会证明这件事)。 好的指标应该尽可能的向用户靠拢,甚至是与业务深度定制的。所以我建议将页面上用于承载核心内容 DOM 元素的出现时机作为性能的核心指标。这个时机之所以关键,因为它等同于网站此时此刻才能被称之为可用。


以网站的详情页为例,关键元素便是 .single-folder-container



但着并不意味着一个指标就足够了,因为如果我们发现这个指标数值不够理想,我们无法准确定位问题在哪里。所以好的数据带来的效应应该是双向的:即它既能准确反映当前产品运行状态(从产品到数据),同时通过观察数据我们也应该能得知产品存在何类问题(从数据到产品)


在这个前提下,我们需要从“有潜力”的性能瓶颈中挖掘指标。想当然影响网站加载性能的因素有:


  • 资源加载(脚本,样式等外部资源)的快慢

  • 接口响应时间


那继续记录这两者的加载时间如何?


要回答这个问题,我们又要继续反问自己了,这两类信息足够我们推测出问题出在哪里吗?相比单一指标而言答案是肯定的,但依然还有细化的空间。以资源加载为例,参考 Resource Timing 如下图所示资源加载也分为多个阶段:



我们甚至可以诊断到究竟是在 DNS 解析还是 TCP 连接阶段出现了问题。然而我们不应该事无巨细的收集一切指标,有几个因素需要考虑:


  • 问题暴露之后是不是真的有必要解决?我有没有能力解决?比如上百毫秒的 DNS 解析时间可能是业界的好几倍,但它是否真的是我整个站点的瓶颈?采用已有的 CDN 解决方案是不是会比我煞费苦心的提升几百秒的时间性价比更高?

  • 我的个人经验告诉我,采集指标用的代码是有维护成本的,通常这类代码的维护成本会比业务代码成本高,成本和代码的侵入性成正比。成本高昂之处在于它被破坏之后难以被察觉;单元测试和回归测试更加困难


回到确定指标的问题上,我们必须直面的一个现状是我们无法一次性知道我们需要什么样的数据,这很正常,确定指标就是一个是假设、验证、再假设、再验证的收敛过程。尝试总比停滞能够让我们接近正确答案。我们不妨开始收集上面提到的三类指标


  • 关键元素的出现时机

  • 资源加载时间

  • 接口时间

接口问题

前端工程师一定会落入的陷阱是只用前端的视角看待问题,而忽略了最重要的接口性能。对大部分人来说页面加载可能只是线性的:



但实际上在 API Request 环节上,我们应该用微服务的视角来看待问题。一个请求从发出请求到得到响应,会经由不同的微服务用以获取数据,如果能对请求历经的每一道链路予以追踪。这有益于我们在线上环境中定位问题以及衡量单个微服务的效率。这就是 Distributed Tracing, 目前这项技术已经相当成熟了,jaegertracingZipkin 都是 distributed tracing 解决方案



然而如果你对后端拆分服务层有所了解的话,如果想诊断单个微服务的性能在哪,我们还可以继续下钻到单个微服务中,去对比调用不同服务层方法时的性能(服务层对前端同样适用,详细的介绍可以参考我前年翻译的这篇文章《Angular 架构模式与最佳实践》


我想表达的已经非常明显了,想要完整挖掘应用的性能瓶颈,我们应该同时对上下游进行考察,割裂视角得到的结果是有失偏颇的

解决方案

收集日志

如果你有采集日志的经验,你应该知道日志的采集和输出是两码事。尤其是对于后端程序而言。日志既可以记录在本地文件中,也可以直接输出在控制台上,而到了线上环境则需要记录在专业的日志服务里。


比如 NodeJS 的开源日志类库 winston,它支持集成多种 transport,一种 transport 即为一种用于存储日志的存储方式。它还支持编写自定义的 transport,目前开源社区的的 transport可选项几乎支持市面上所有主流的日志服务。在 .NET CORE 中的 logging providers 也是相同的概念


但这种“主动”收集日志的方式并非是最佳实践,关于构建网络应用的方法论The Twelve-Factor App提出,应用本身不应该考虑日志的存储,而只是保证日志以 stdout 的形式输出,由环境来负责对日志的收集与加工。这项提议是合理的,因为应用程序本不应该知道也无法知道它将要部署的云环境,而不同环境处理日志的方式并不相同


出于 fail fast 的考虑我在开发 site2share 后端时并没有遵循这一理念,在需要进行日志采集时,我直接调用具体平台的采集方法。目前我的日志全部记录在 Azure Application Insights 上,所以在记录时我需要调用 Application Insights 客户端方法:AppInsightsClient.trackTrace(message)


只不过在实现层面借助 winston 代码可以变得更优雅,我们可以创建一个 logger 来达到同时兼容多个日志输出渠道的效果


const logger = winston.createLogger({  transports: [     new AppInsightsTransport(),     new winston.transports.Console()  ]});
复制代码


因为我们测试的是前端性能,且性能数据产生在消费端浏览器的网页上。所以我们依赖的是每个用户在访问之后由植入在页面的脚本主动上传数据

Application Insights

我选择 Azure Application Insights 用于存储和查询日志, 选择的其中一个原因是网站从前端(Azure Static Web App)到后端(Azure Service App)甚至是 DevOps 我使用的都是 Azure 服务,自然官方的 Application Insights 能更好的与这些服务整合;而另一个更重要的原因是,它能为我们解决 distributed tracing 的问题。


你需要在你的应用中植入 Application Insigths 的 SDK 才能进行日志收集,SDK 支持前后端程序。它收集日志的方式有两种,主动收集和被动上报。以 JavaScript 语言的 Web 应用程序为例,在页面上植入 SDK 之后它会自动收集程序运行时的报错、发出的异步请求、console.log(以 monkey patch 的方式)、性能信息(通过 Performance API);你也可以调用 SDK 提供的 trackMetric、trackEvent 等主动上报自定义的指标和事件信息。性能采集时我们同时利用了这两种手段


我们通常将指标、日志等信息称为 telemetry (data / item),通常这些数据会存储在不同的表中并且和其他数据淹没在一起。如何将两者关联起来呢? Application Insights 将 telemetry 相互关联起来的解决方案很简单:为每一则数据提供一个唯一的上下文标识 operation_Id。以用户访问一次页面为例,那么这次访问产生的数据里的 operation_Id 都叫做 xyz,那么在 Application Insights 平台上,我们便可以通过 xyz 将关联的数据(以 Kusto 语法)查询出来


(requests | union dependencies | union pageViews)| where operation_Id == "xyz"
复制代码


我们不仅可以将前端与前端的数据关联起来,还可以将前端与后端的数据做关联,这便是我们做 distributed tracing 的法宝。对于微服务应用而言,Application Insights 甚至可以为我们生成 Application Map,可视化服务间的调用过程和耗时情况。


在过完这一小节的技术细节之后,我们可以可视化的看看我们需要哪些数据以及它们又是如何关联的

资源加载指标

多亏了 Performance API,在现代浏览器中收集指标变得异常简单。无需主动触发,浏览器在每次页面加载时就已经按照时间线将性能指标信息封装在 PerformanceEntry 对象中,事后我们只需将需要的数据筛选出来即可,比如我们关心的脚本:


window.performance.getEntries().filter(({initiatorType, entryType}) => initiatorType === 'script' && entryType === 'resource')
复制代码


根据上一小节的结论,我们也不会事无巨细的记录资源加载每一个环节的数据,在这里我重点采集资源加载的持续时间和资源加载的开始时间,这两者我们从 PerformanceEntry 上都能获取到,分别是 duration 和 fetchStart。因为目前在我看来前置加载以及缩短加载时间都是提升性能的有效手段。如果事后这两项数值无法看出任何异常再考虑收集更多的指标

上报元素出现时间

确认元素出现时刻最简单粗暴的方式就是通过 setInterval 顶起轮询元素是否出现,但在现代浏览器中我们可以使用 MutationObserver API 来监控元素的所有变化,于是问题可以换一种问法:body 标签下什么时候出现 .single-folder-container 元素,关键代码大致如下


const observer = new MutationObserver(mutations => {        if (document.querySelector('.single-folder-container')) {          observer.disconnect();          return;        }    });    observer.observe(document.querySelector('body'), {      subtree: true,      childList: true    });
复制代码


这里出现了一个问题:这段代码极为关键却又难以被测试。


第一个问题是例如在 Jest 环境中并不存在原生的 MutationObserver 对象,如果你只是为了通过测试而单纯 mock MutationObserver 对象测试的意义便荡然无存了;


其次即使在 Headless Chrome 这类支持 MutationObserver 的环境中进行测试,你如果知道它上报给你的元素出现时间是正确的?因为你自己也不知道准确的时机是什么(也就是你测试李的 expect),10 秒一定是不正确的,但是 2.2 秒呢?

额外性能指标

理论上来说上面两者便是所有我们预期想收集的指标。但还是有两个额外指标是我想收集的:First Paint 和 First Contentful Paint,简单来说它们记录的是浏览器在绘制页面的一些关键时机。这两项指标也可以从 Performance API 里获得


window.performance.getEntries().filter(entry => entry.entryType === 'paint')
复制代码


Paint Timing 会比单纯的技术指标更接近用户体验,但是与实际用户看到元素出现实际相比如何,我们拭目以待

后端时间

我猜想可能存在的性能瓶颈有两处:1)Redis 查询 2) MySQL 查询。


Redis 主要用于 session 的存储,加之后端由 Node.js + ExpressJS 搭建,对于 session 读取性能监控不易。所以我优先优先考察 MySQL 查询性能,比如统计findByFolderId方法的读取性能:


const findFolderIdStartTime = +new Date();await FolderService.findByFolderId(parseInt(req.params!.id))appInsightsClient.trackMetric({  name: "APM:GET_SINGLE_FOLDER:FIND_BY_ID",   value: +new Date - findFolderIdStartTime});
复制代码

总结

最后,为了便于在日志平台上找到对应的指标,以及对指定类型的指标做统计,我们需要对上述指标进行命名,以下就是命名规则,


  • 后端数据库查询单条数据时间—— APM:GET_SINGLE_FOLDER:FIND_BY_ID

  • 浏览器中 first-paint 指标 ——browser:first-paint

  • 浏览器中 first-contentful-paint 指标——browser:first-contentful-paint

  • 前端中异步请求数据——resource:xmlhttprequest:

  • 浏览器请求脚本资源数据——resource:script:

  • 浏览器请求样式文件数据——resource:link:

  • 详情页关键元素可见时间——folder-detail:visible

  • 个人首页关键元素可见时间——dashboard:visible


样本多样性问题


上一小节中的实施方案是微观的,即单次性的、具体的。但是从宏观上看,我需要保证性能测试是公允的,符合大众预期的。为了达到这种效果,最简单的方式就是保证测试的多样性,让足够多人访问产生足够多的样本来,但这对于一个为个人服务的工具网站来说是不现实的。


于是我打算借助机器的力量,在世界各地建造机器人程序来模拟访问。机器人程序原理非常简单,借助 headless chrome 来模拟用户的访问:


const url = 'https://www.site2share.com/folder/20020507';const browser = await puppeteer.launch();const page = await browser.newPage();await page.goto(url);await page.waitForSelector('.single-folder-container');await page.waitForTimeout(1000 * 30);browser.close();
复制代码


注意程序会等到 .single-folder-container 元素出现之后才进入关闭流程,在关闭前会等待 30 秒钟来保证有足够的时间将指标数据上传到 Application Insights。


为了达到重复访问的效果,我给机器人制定的执行策略非常简单,每五分钟执行一次。这种轻量级的定时任务应用非常适用于部署在 Azure Serverless 上,同时 Azure Serverless 也支持在部署时指定区域,这样就能达到模拟全球不同地区访问的效果



虽然每一个 Serverless Function 都能配置独立的执行间隔,但考虑到可维护性,比如将来希望将 5 分钟执行间隔提高到 2 分钟时不去修改 27 里的每一个 function,我决定将所有 function 交给 Azure Logic App 进行管理


Azure Logic App 在我看来是一款可视化低代码工具。它能够允许非编程人员以点击拖拽的形式创建工作流。初始化变量、分支判断、循环、响应或者发送网络请求,都可以仅用鼠标办到。


我们的场景总结下来就两句话:


  • 距离上次进行性能测试是不是已达五分钟

  • 如果是的话再次发送性能测试请求


那么我们可以依次创建一套工作流


  • 工作流的触发器为一个定时任务,每十分钟执行一次

  • 定时任务执行时连带执行所有的 Azure Function



Azure Logic App 还会将每次的执行情况记录下来,甚至每个函数的输入和输出,某种意义上这也起到了监控执行的作用



审视数据,发现问题

工具开发完成之后不间断的运行了七天,这七天时间内共产生了 219613 条数据。看看我们能从这二十万数据钟能发现什么


首先我们要看一个最重要的指标:关键元素的出现时机


customMetrics| where timestamp between (datetime(2022-02-01) .. datetime(2022-02-06))| where name has 'folder-detail:visible'| extend location =strcat(client_City, ":", client_StateOrProvince, ":", client_CountryOrRegion)| summarize metric_count=count(), avg_duration=round(avg(valueMax)) by location| where metric_count > 100| order by avg_duration asc 
复制代码


指标数据我按照国家地区排序,在我看来地区会是影响速度的关键因素,最终结果如下。抱歉我使用的名称是 avg_duration 这个有误导的名称,实际上这个指标应该是一个 startTime,即从浏览器开始加载页面的开始为起点,到看到元素的时间。下面的 first-contentful-paint 同理



从肉眼上我们可以感知到,大部分用户会在 3 秒左右才会看到实质性内容。接下来我们要做的就是探索 3 秒钟的时间去哪了。


顺便也可以查询一下浏览器提供的 first-contentful-paint 数据如何。上面的查询语句在之后会频繁被用到,所以我们可以提取一个函数出来


let queryMetricByName = (inputName: string) {    customMetrics    | where timestamp between (datetime(2022-02-01) .. datetime(2022-02-06))    | where name has inputName    | extend location =strcat(client_City, ":", client_StateOrProvince, ":", client_CountryOrRegion)    | summarize metric_count=count(), avg_duration=round(avg(valueMax)) by location    | where metric_count > 100    | order by avg_duration asc};
复制代码


接着用这个函数查询 first-contentful-paint 指标


queryMetricByName('browser:first-contentful-paint')
复制代码



浏览器认为用户在 1.5 秒左右就看到了一些有用的内容了,但是从我们刚刚查询到的关键元素出现时机看来并非如此


我们先看看脚本的资源加载情况,以 runtime 脚本为例,我们看看它的平均加载时间


queryMetricByName('resource:script:https://www.site2share.com/runtime-es2015.ffba78f539fb511f7b4b.js')
复制代码



平均时间不过 100ms


而 http 请求指标数据呢


queryMetricByName('resource:xmlhttprequest')
复制代码



相比资源加载而言,平均 1s 的请求时长已经是资源加载时长的好几倍了,它有很大的嫌疑。接着我们继续看后端 SQL 查询数据性能


queryMetricByName('APM:GET_SINGLE_FOLDER:FIND_BY_ID')
复制代码



首先要理解一下为什么这里只有一类地理位置的数据,因为之前所有的前端数据都又不同地区的机器人感知产生。因为我的后端服务器只有一台。虽然机器人从世界各地访问,但是查询总发生在这一台服务器上


这里就很有意思了。也就是说平均请求时间我们需要花上一秒钟,但是实际的 SQL 查询时间只需要 100 毫秒


为了还原犯罪事实,我们不妨从选取一次具体的请求,看看从后端到前端的时间线是怎么样的。这个时候 Application Insights 的 Telementry Correlation 的功能就体现出来了,我们只需要指定一个 operation_id 即可


(requests | union dependencies | union pageViews | union customMetrics)| where timestamp > ago(90d)| where operation_Id == "57b7b55cda794cedb9e016cec430449e"| extend fetchStart = customDimensions.fetchStart| project timestamp, itemType, name, valueSum, fetchStart, id, operation_ParentId, operation_Id, customDimensions| order by timestamp asc
复制代码


我们于是得到了所有的结果



我们只需要 fetchStart 和 valueSum (也就是 duration) 就可以把整个流程图画出来



脚本资源在 1s 钟之内就加载完毕了。很明显,瓶颈在于接口的 RTT(Round-Trip Time)太长。抱歉没钱在世界各地部署后端节点。

总结


我自己写到这里觉得这篇文章有炫技或者是多此一举的嫌疑。因为即使没有这套工具。凭借工程师的经验,你也应该大致能猜到问题出在哪。


首先肯定不会是前端资源,一方面在多次访问的场景中浏览器的缓存机制不会让资源加载成为瓶颈;其次前端我使用的是 Azure Static Web App 服务,在 Azure CDN 的加持下即使是首次访问静态资源也不会是问题


至于 SQL 性能,你一定要相信商用的 MySQL 性能绝对比你本地开发环境的 MySQL 性能还要好。对这种体量的应用和查询来说,你的代码想把查询性能变得很差都很难。


所以问题只可能出现在接口的 RTT 上。


但我不认为这个方案无价值可言,对于我个人来说一个切实可行且能够落地的代码会比所谓摘抄自教科书上所谓的业界方案更重要;另一方面我在这个方案上看到了很多种可能性,比如它可以支持更多种类的指标采集,又比如利用 Headless Chrome 自带的开发者工具我们可以洞见更多网站潜在的性能问题,也许者几十万条数据还能够帮助我们预测某时某刻的性能状况。

对比阿里

阿里云有两个工具我们可以拿来对比,一个是阿里云的前端前端性能监控工具



我没法将整个页面截图给你,但是总体看来,统计的信息有• JS 错误信息• API 请求信息• PV/UV• 页面性能(首次渲染耗时,完全加载时间)• 访问的各个维度(地理位置、网络、终端分布)


从上图的左侧子菜单可以看出,对每一类信息它都已经给出了预订制的报告详情。你可以把它理解为对于 Application Insights 数据进行加工后显得对人类更友好的产品。因为 Application Insights 是非常底层存储于表中的数据,你需要自己编写查询语句然后生成报表


另一个和我们功能很像的工具是云拨测,在这个工具内你可以选择测试发起的城市,来看看不同人群对于你网站的性能体验如何。比如我选择了美国和北京



甚至对于每一次访问,我们都能看到它的详细数据,甚至包括我们之前说的 DNS 的情况



如此强大的功能不是我个人开发出的工具能够匹敌的。


但是面对这些无所不能的工具,假设它们能把成千上百个指标准确的呈现在你面前,我想问的是,你真的需要它们吗?或者说,你关心的究竟是什么?


感谢阅读,原文请访问这里:

发布于: 刚刚阅读数: 6
用户头像

光毅

关注

还未添加个人签名 2018.07.05 加入

还未添加个人简介

评论

发布
暂无评论
做一个能对标阿里云的前端APM工具_阿里云_光毅_InfoQ写作社区