性能文章>从零开始搞监控系统(3)——性能监控>

从零开始搞监控系统(3)——性能监控原创

2周前
196900

目录:

从零开始搞监控系统(1)——SDK
从零开始搞监控系统(2)——存储和分析
从零开始搞监控系统(3)——性能监控

前言

前端性能监控是个老话题了,各个团队都会对其有所关注,因为关注性能是工程师的本分。

页面性能对用户体验而言十分关键,每次重构或优化,仅靠手中的几个设备或模拟的测试,缺少说服力,需要有大量的真实数据来做验证。

在2016年,我就写过一篇《前端页面性能参数搜集》的文章,当时采用的还是W3C性能参数的第一版,现在已有第二版了。

在2020年,根据自己所学整理了一套监控系统,代号菠萝,不过并没有正式上线,所以只能算是个玩具。

这次不同,公司急切的需要一套性能监控系统,用于分析线上的活动,要扎扎实实的提升用户体验。

整个系统大致的运行流程如下:
  image1.png

一、SDK

性能参数搜集的代码仍然写在前面的监控 shin.js(SDK) 中,为了兼容两个版本的性能标准,专门编写了一个函数。

function _getTiming() {
  var timing =
    performance.getEntriesByType("navigation")[0] || performance.timing;
  var now = 0;
  if (!timing) {
    return { now: now };
  }
  var navigationStart;
  if (timing.startTime === undefined) {
    navigationStart = timing.navigationStart;
    /**
     * 之所以老版本的用 Date,是为了防止出现负数
     * 当 performance.now 是最新版本时,数值的位数要比 timing 中的少很多
     */
    now = new Date().getTime() - navigationStart;
  } else {
    navigationStart = timing.startTime;
    now = shin.now() - navigationStart;
  }
  return {
    timing: timing,
    navigationStart: navigationStart,
    now: _rounded(now)
  };
}

其实两种方式得当的参数类似,第二版中的参数比第一版来的多,下面两张图是官方给的参数示意图,粗看的话下面两种差不多。
image2.png
W3C第一版的性能参数

image3.png
W3C第二版的性能参数

但其实在将 performance.getEntriesByType(“navigation”)[0] 打印出来后,就会发现它还会包含页面地址、传输的数据量、协议等字段。

1)统计的参数

网上有很多种统计性能参数的计算方式,大部分都差不多,我选取了其中较为常规的参数。

 shin.getTimes = function () {
    // 出于对浏览器兼容性的考虑,仍然引入即将淘汰的 performance.timing
    var currentTiming = _getTiming();
    var timing = currentTiming.timing;
    // var timing = performance.timing;
    var api = {}; // 时间单位 ms
    if (!timing) {
      return api;
    }
    var navigationStart = currentTiming.navigationStart;
    /**
     * http://javascript.ruanyifeng.com/bom/performance.html
     * 页面加载总时间,有可能为0,未触发load事件
     * 这几乎代表了用户等待页面可用的时间
     * loadEventEnd(加载结束)-navigationStart(导航开始)
     */
    api.loadTime = timing.loadEventEnd - navigationStart;

    /**
     * Unload事件耗时
     */
    api.unloadEventTime = timing.unloadEventEnd - timing.unloadEventStart;

    /**
     * 执行 on/load 回调函数的时间
     * 是否太多不必要的操作都放到 on/load 回调函数里执行了,考虑过延迟加载、按需加载的策略么?
     */
    api.loadEventTime = timing.loadEventEnd - timing.loadEventStart;

    /**
     * 首次可交互时间
     */
    api.interactiveTime = timing.domInteractive - timing.fetchStart;

    /**
     * 用户可操作时间(DOM Ready时间)
     * 在初始HTML文档已完全加载和解析时触发(无需等待图像和iframe完成加载)
     * 紧跟在DOMInteractive之后。
     * https://www.dareboost.com/en/doc/website-speed-test/metrics/dom-content-loaded-dcl
     */
    api.domReadyTime = timing.domContentLoadedEventEnd - timing.fetchStart;

    /**
     * 白屏时间
     */
     var paint = performance.getEntriesByType('paint');
    if (paint && timing.entryType && paint[0]) {
      api.firstPaint = paint[0].startTime - timing.fetchStart;
      api.firstPaintStart = paint[0].startTime;   // 记录白屏时间点
    } else {
      api.firstPaint = timing.responseEnd - timing.fetchStart;
    }

    /**
     * 解析DOM树结构的时间
     * DOM中的所有脚本,包括具有async属性的脚本,都已执行。加载 DOM 中定义的所有页面静态资源(图像、iframe等)
     * LoadEventStart紧跟在Complete之后。 在大多数情况下,这2个指标是相等的。
     * 在加载事件开始之前可能引入的唯一额外延迟将由onReadyStateChange的处理引起。
     * https://www.dareboost.com/en/doc/website-speed-test/metrics/dom-complete
     */
    api.parseDomTime = timing.domComplete - timing.domInteractive;

    /**
     * 请求完毕至DOM加载耗时
     * 在加载DOM并执行网页的阻塞脚本时触发
     * 在这个阶段,具有defer属性的脚本还没有执行,某些样式表加载可能仍在处理并阻止页面呈现
     * https://www.dareboost.com/en/doc/website-speed-test/metrics/dom-interactive
     */
    api.initDomTreeTime = timing.domInteractive - timing.responseEnd;

    /**
     * 准备新页面耗时
     */
    api.readyStart = timing.fetchStart - navigationStart;

    /**
     * 重定向次数(新)
     */
    api.redirectCount = timing.redirectCount || 0;

    /**
     * 传输内容压缩百分比(新)
     */
    api.compression = (1 - timing.encodedBodySize / timing.decodedBodySize) * 100 || 0;

    /**
     * 重定向的时间
     * 拒绝重定向!比如,http://example.com/ 就不该写成 http://example.com
     */
    api.redirectTime = timing.redirectEnd - timing.redirectStart;

    /**
     * DNS缓存耗时
     */
    api.appcacheTime = timing.domainLookupStart - timing.fetchStart;

    /**
     * DNS查询耗时
     * DNS 预加载做了么?页面内是不是使用了太多不同的域名导致域名查询的时间太长?
     * 可使用 HTML5 Prefetch 预查询 DNS,见:[HTML5 prefetch](http://segmentfault.com/a/1190000000633364)
     */
    api.lookupDomainTime = timing.domainLookupEnd - timing.domainLookupStart;

    /**
     * SSL连接耗时
     */
    var sslTime = timing.secureConnectionStart;
    api.connectSslTime = sslTime > 0 ? timing.connectEnd - sslTime : 0;

    /**
     * TCP连接耗时
     */
    api.connectTime = timing.connectEnd - timing.connectStart;

    /**
     * 内容加载完成的时间
     * 页面内容经过 gzip 压缩了么,静态资源 css/js 等压缩了么?
     */
    api.requestTime = timing.responseEnd - timing.requestStart;

    /**
     * 请求文档
     * 开始请求文档到开始接收文档
     */
    api.requestDocumentTime = timing.responseStart - timing.requestStart;

    /**
     * 接收文档(内容传输耗时)
     * 开始接收文档到文档接收完成
     */
    api.responseDocumentTime = timing.responseEnd - timing.responseStart;

    /**
     * 读取页面第一个字节的时间,包含重定向时间
     * TTFB 即 Time To First Byte 的意思
     * 维基百科:https://en.wikipedia.org/wiki/Time_To_First_Byte
     */
    api.TTFB = timing.responseStart - timing.redirectStart;

    /**
    * 仅用来记录当前 performance.now() 获取到的时间格式
    * 用于追溯计算
    */
    api.now = shin.now();

    // 全部取整
    for (var key in api) {
      api[key] = _rounded(api[key]);
    }

    /**
     * 浏览器读取到的性能参数,用于排查,并保留两位小数
     */
    api.timing = {};
    for (var key in timing) {
      const timingValue = timing[key];
      const type = typeof timingValue;
      if (type === 'function') { continue; }
      api.timing[key] = timingValue;
      if (type === 'number') { api.timing[key] = _rounded(timingValue, 2); }
    }
    return api;
  };

所有的性能参数最终都要被取整,以毫秒作单位。兼容的 timing 对象也会被整个传递到后台,便于分析性能参数是怎么计算出来的。

compression(传输内容压缩百分比)是一个新的参数。白屏时间的计算有两种:

第一种是调用 performance.getEntriesByType(“paint”) 方法,再减去 fetchStart;
第二种是用 responseEnd 来与 fetchStart 相减。

在实践中发现,每天有大概 2 千条记录中的白屏时间为 0,而且清一色的都是苹果手机,一番搜索后,了解到。

当 iOS 设备通过浏览器的前进或后退按钮进入页面时,fetchStart、responseEnd 等性能参数很可能为 0。

loadTime(页面加载总时间)有可能为0,就是当页面资源还没加载完,触发 load 事件前将页面关闭。

如果这种很多,那就很有可能页面被阻塞在某个位置,可能是接收时间过长、可能是DOM解析过长等。

当这个页面加载时间超过了用户的心理承受范围时,就需要抽出时间来做各个方面的页面优化了。

在上线一段时间后,发现有大概 50% 的记录,load 和 ready 是小于等于 0。查看原始的性能参数,如下所示。

{
        "unloadEventStart": 0,
        "unloadEventEnd": 0,
        "domInteractive": 0,
        "domContentLoadedEventStart": 0,
        "domContentLoadedEventEnd": 0,
        "domComplete": 0,
        "loadEventStart": 0,
        "loadEventEnd": 0,
        "type": "navigate",
        "redirectCount": 0,
        "initiatorType": "navigation",
        "nextHopProtocol": "h2",
        "workerStart": 0,
        "redirectStart": 0,
        "redirectEnd": 0,
        "fetchStart": 0.5,
        "domainLookupStart": 3.7,
        "domainLookupEnd": 12.6,
        "connectStart": 12.6,
        "connectEnd": 33.3,
        "secureConnectionStart": 20.4,
        "requestStart": 33.8,
        "responseStart": 44.1,
        "responseEnd": 46,
        "transferSize": 1207,
        "encodedBodySize": 879,
        "decodedBodySize": 2183,
        "serverTiming": [],
        "name": "https://www.xxx.me/xx.html",
        "entryType": "navigation",
        "startTime": 0,
        "duration": 0
}

发现 domContentLoadedEventEnd 和 loadEventEnd 都是 0,一开始怀疑参数的问题。

旧版的 performance.timing 已经被废弃,新版通过 performance.getEntriesByType(‘navigation’)[0] 得到一个 PerformanceNavigationTiming 对象,它继承自 PerformanceResourceTiming 类,但没查到有什么问题。

还有一种情况,就是 DOMContentLoaded 与 load 两个事件都没有触发。关于两者的触发时机,网上的一篇文章总结道:

load 事件会在页面的 HTML、CSS、JavaScript、图片等静态资源都已经加载完之后才触发。
DOMContentLoaded 事件会在 HTML 加载完毕,并且 HTML 所引用的内联 JavaScript、以及外链 JavaScript 的同步代码都执行完毕后触发。
  在网上搜索一圈后,没有发现阻塞的原因,那很有可能是自己代码的问题。

经查,是调用 JSBridge 的一种同步方式阻塞了两个事件的触发。代码中的 t 就是一条链接。

window.location.href = t

在加载脚本时,就会触发某些 JSBridge,而有些手机就会被阻塞,有些并不会。解决方案就是将同步的跳转改成异步的,如下所示。

var iframe = document.createElement("iframe");
iframe.src = t;
document.body.append(iframe);

值得一提的是,在将此问题修复后,首屏 1 秒内的占比从 66.7% 降到了 48.4%,2 秒内的占比从 20.7% 升到了 25.5%,3、4、4+ 秒的占比也都提升了。

2)首屏时间

首屏时间很难计算,一般有几种计算方式。

第一种是算出首屏页面中所有图片都加载完后的时间,这种方法难以覆盖所有场景(例如 CSS 中的背景图、Image 元素等),并且计算结果并不准。

/**
 * 计算首屏时间
 * 记录首屏图片的载入时间
 * 用户在没有滚动时候看到的内容渲染完成并且可以交互的时间
 */
doc.addEventListener(
  "DOMContentLoaded",
  function () {
    var isFindLastImg = false,
      allFirsrImgsLoaded = false,
      firstScreenImgs = [];
    //用一个定时器差值页面中的图像元素
    var interval = setInterval(function () {
      //如果自定义了 firstScreen 的值,就销毁定时器
      if (shin.firstScreen) {
        clearInterval(interval);
        return;
      }
      if (isFindLastImg) {
        allFirsrImgsLoaded = firstScreenImgs.every(function (img) {
          return img.complete;
        });
        //当所有的首屏图像都载入后,关闭定时器并记录首屏时间
        if (allFirsrImgsLoaded) {
          shin.firstScreen = _calcCurrentTime();
          clearInterval(interval);
        }
        return;
      }
      var imgs = doc.querySelectorAll("img");
      imgs = [].slice.call(imgs); //转换成数组
      //遍历页面中的图像
      imgs.forEach(function (img) {
        if (isFindLastImg) return;
        //当图像离顶部的距离超过屏幕宽度时,被认为找到了首屏的最后一张图
        var rect = img.getBoundingClientRect();
        if (rect.top + rect.height > firstScreenHeight) {
          isFindLastImg = true;
          return;
        }
        //若未超过,则认为图像在首屏中
        firstScreenImgs.push(img);
      });
    }, 0);
  },
  false
);

第二种是自定义首屏时间,也就是自己来控制何时算首屏全部加载好了,这种方法相对来说要精确很多。

shin.setFirstScreen = function() {
  this.firstScreen = _calcCurrentTime();
}
/**
 * 计算当前时间与 fetchStart 之间的差值
 */
function _calcCurrentTime() {
  return _getTiming().now;
}
/**
 * 标记时间,单位毫秒
 */
shin.now = function () {
  return performance.now();
}

之所以未用 Date.now() 是因为它会受系统程序执行阻塞的影响, 而performance.now() 的时间是以恒定速率递增的,不受系统时间的影响(系统时间可被人为或软件调整)。

在页面关闭时还未获取到首屏时间,那么它就默认是 domReadyTime(用户可操作时间)。

首屏时间(screen)有可能是负数,例如返回上一页、刷新当前页后,马上将页面关闭,此时 screen 的值取自 domReadyTime。

domReadyTime 是由 domContentLoadedEventEnd 和 fetchStart 相减而得到,domContentLoadedEventEnd 可能是 0,fetchStart 是个非 0 值。

这样就会得到一个负值,不过总体占比并不高,每天在 300 条上下,0.3% 左右。

3)上报

本次上报与之前不同,需要在页面关闭时上报。而在此时普通的请求可能都无法发送成功,那么就需要 navigator.sendBeacon() 的帮忙了。

它能将少量数据异步 POST 到后台,并且支持跨域,而少量是指多少并没有特别指明,由浏览器控制,网上查到的资料说一般在 64KB 左右。

在接收数据时遇到个问题,由于后台使用的是 KOA 框架,解析请求数据使用了 koa-bodyparser 库,而它默认不会接收 Content-Type: text 的数据,因此要额外配置一下,具体可参考此处。

/**
 * 在页面卸载之前,推送性能信息
 */
window.addEventListener("beforeunload", function () {
    var data = shin.getTimes();
    if (shin.param.rate > Math.random(0, 1) && shin.param.pkey) {
      navigator.sendBeacon(shin.param.psrc, _paramifyPerformance(data));
    }
  },
  false
);

在上报时,还限定了一个采样率,默认只会把 50% 的性能数据上报到后台,并且必须定义 pkey 参数,这其实就是一个用于区分项目的 token。

本来一切都是这么的顺利,但是在实际使用中发现,在 iOS 设备上调试发现不会触发 beforeunload 事件,安卓会将其触发,一番查找后,根据iOS支持的事件和社区的解答,发现得用 pagehide 事件替代。

以为万事大吉,但还是太年轻,在微信浏览器中的确能触发 pagehide 事件,但是在自己公司APP中,表现不尽如意,无法触发,若要监控关闭按钮,得发一次版本。

无奈,只能自己想了个比较迂回的方法,那就是在后台跑个定时器,每 200ms 缓存一次要搜集的性能数据,在第二次进入时,再上报到后台。

/**
 * 组装性能变量
 */
function _paramifyPerformance(obj) {
  obj.token = shin.param.token;
  obj.pkey = shin.param.pkey;
  obj.identity = getIdentity();
  obj.referer = location.href; //来源地址
  // 若未定义或未计算到,则默认为用户可操作时间
  obj.firstScreen = shin.firstScreen || obj.domReadyTime;
  return JSON.stringify(obj);
}

/**
 * 均匀获得两个数字之间的随机数
 */
function _randomNum(max, min) {
  return Math.floor(Math.random() * (max - min + 1) + min);
}
/**
 * iOS 设备不支持 beforeunload 事件,需要使用 pagehide 事件
 * 在页面卸载之前,推送性能信息
 */
var isIOS = !!navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/);
var eventName = isIOS ? "pagehide" : "beforeunload";
window.addEventListener(
  eventName,
  function () {
    sendBeacon();
  },
  false
);

var SHIN_PERFORMANCE_DATA = "shin_performance_data";
var heartbeat; //心跳定时器
/**
 * 发送数据
 */
function sendBeacon(existData) {
  // 如果传了数据就使用该数据,否则读取性能参数,并格式化为字符串
  var data = existData || _paramifyPerformance(shin.getTimes());
  var rate = _randomNum(10, 1); // 选取1~10之间的整数
  if (shin.param.rate >= rate && shin.param.pkey) {
    navigator.sendBeacon(shin.param.psrc, data);
  }
  clearTimeout(heartbeat);
  localStorage.removeItem(SHIN_PERFORMANCE_DATA); //移除性能缓存
}
/**
 * 发送已存在的性能数据
 */
function sendExistData() {
  var exist = localStorage.getItem(SHIN_PERFORMANCE_DATA);
  if (!exist) return;
  setTimeout(function () {
    sendBeacon(exist);
  }, 0);
}
sendExistData();
/**
 * 一个心跳回调函数,缓存性能参数
 * 适用于不能触发 pagehide 和 beforeunload 事件的浏览器
 */
function intervalHeartbeat() {
  localStorage.setItem(
    SHIN_PERFORMANCE_DATA,
    _paramifyPerformance(shin.getTimes())
  );
}
heartbeat = setInterval(intervalHeartbeat, 200);

4)LCP

LCP(Largest Contentful Paint)是指最大的内容在可视区域内变得可见的时间点。于 2022-07-12 新增该指标。

在 MDN 网站中,有一段 LCP 的计算示例,在此基础之上,做了些兼容性判断。

通过 PerformanceObserver.observe() 监控 LCP。entries 是一组 LargestContentfulPaint 类型的对象,它有一个 url 属性,如果记录的元素是个图像,那么会存储其地址。

var lcp;
function getLCP() {
  var types = PerformanceObserver.supportedEntryTypes;
  var lcpType = 'largest-contentful-paint';
  // 浏览器兼容判断
  if(types.indexOf(lcpType) === -1) {
    return;
  }
  new PerformanceObserver((entryList) => {
    var entries = entryList.getEntries();
    var lastEntry = entries[entries.length - 1];
    lcp = lastEntry.renderTime || lastEntry.loadTime;
    // buffered 为 true 表示调用 observe() 之前的也算进来
  }).observe({type: lcpType, buffered: true});
}
getLCP();


在 iOS 的 WebView 中,只支持三种类型的 entryType,不包括 largest-contentful-paint,所以加了段浏览器兼容判断。并且 entries 是一组 PerformanceResourceTiming 类型的对象。

在《Largest Contentful Paint 最大内容绘制》中提到,选项卡和页面转移到后台后,需要停止 LCP 的计算,需要找到隐藏到后台的时间。

let firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity;
// 记录页面隐藏时间 iOS 不会触发 visibilitychange 事件
const onVisibilityChange = (event) => {
  // 页面不可见状态
  if (lcp && document.visibilityState === 'hidden') {
    firstHiddenTime = event.timeStamp;
    // 移除事件
    document.removeEventListener('visibilitychange', onVisibilityChange, true);
  }
}
document.addEventListener('visibilitychange', onVisibilityChange, true);

利用 visibilitychange 事件,就能准备得到隐藏时间,然后在读取 LCP 时,大于这个时间的就直接忽略掉。不过在实践中发现,在 iOS 的 WebView 中并不支持此事件。

公司要监测的 H5 页面主要在移动平台,选项卡的情况比较少,并且页面结构比较简单,一般都不会很久就能加载完成。

largest-contentful-paint 不会计算 iframe 中的元素,返回上一页也不会重新计算。不过,我们的页面中基本不会加载 iframe,并且页面都是以单页的活动为主,跳转也比较少。

有个成熟的库:web-vitals,提供了 LCP、FID、CLS、FCP 和 TTFB 指标,对上述所说的特殊场景做了处理,若要了解原理,可以参考其中的计算过程。

注意,LCP 会被一直监控,这样会影响结果的准确性。例如有个页面首次进入是个弹框,确定后会出现动画,DOM结构都会改变。

如果在关闭页面时上报,那么 LCP 将会很长,所以需要选择合适的上报时机,例如 load 事件中。

window.addEventListener("load", function () {
    sendBeacon();
  }, false
);

优化后还有 5、6 千条记录中的 load 是 0。查看参数记录发现 loadEventEnd 是 0,而 loadEventStart 有时候是 0,有时候有值。

可以在 load 事件中加个定时器,避免在上报性能参数时,loadEventEnd 为 0,因为此时事件还没执行完毕。

window.addEventListener("load", function () {
    setTimeout(function () {
      sendBeacon();
    }, 0);
  }, false
);

优化后,白屏 1 秒内的占比从 74.2% 提升到了 92.4%,首屏 1 秒内的占比从 48.6% 提升到了 78.8%。

二、存储

1)性能数据日志

性能数据会被存储到 web_performance 表中,同样在接收时会通过队列来异步新增。

CREATE TABLE `web_performance` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `load` int(11) NOT NULL DEFAULT '0' COMMENT '页面加载总时间',
  `ready` int(11) NOT NULL DEFAULT '0' COMMENT '用户可操作时间',
  `paint` int(11) NOT NULL DEFAULT '0' COMMENT '白屏时间',
  `screen` int(11) NOT NULL DEFAULT '0' COMMENT '首屏时间',
  `measure` varchar(1000) COLLATE utf8mb4_bin NOT NULL COMMENT '其它测量参数,用JSON格式保存',
  `ctime` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
  `day` int(11) NOT NULL COMMENT '格式化的天(冗余字段),用于排序,20210322',
  `hour` tinyint(2) NOT NULL COMMENT '格式化的小时(冗余字段),用于分组,11',
  `minute` tinyint(2) DEFAULT NULL COMMENT '格式化的分钟(冗余字段),用于分组,20',
  `identity` varchar(20) COLLATE utf8mb4_bin NOT NULL COMMENT '身份',
  `project` varchar(20) COLLATE utf8mb4_bin NOT NULL COMMENT '项目关键字,关联 web_performance_project 表中的key',
  `ua` varchar(600) COLLATE utf8mb4_bin NOT NULL COMMENT '代理信息',
  `referer` varchar(200) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源地址',
  `timing` text COLLATE utf8mb4_bin COMMENT '浏览器读取到的性能参数,用于排查',
  `resource` text COLLATE utf8mb4_bin COMMENT '静态资源信息',
  PRIMARY KEY (`id`),
  KEY `idx_project_day` (`project`,`day`),
  KEY `idx_project_day_hour` (`project`,`day`,`hour`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='性能监控'

表中的 project 字段会关联 web_performance_project 表中的key。

2)性能项目

性能项目就是要监控的页面,与之前不同,性能的监控粒度会更细,因此需要有个后台专门管理这类数据。

CREATE TABLE `web_performance_project` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `key` varchar(20) COLLATE utf8mb4_bin NOT NULL COMMENT '唯一值',
  `name` varchar(45) COLLATE utf8mb4_bin NOT NULL COMMENT '项目名称',
  `ctime` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
  `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '1:正常  0:删除',
  PRIMARY KEY (`id`),
  UNIQUE KEY `name_UNIQUE` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='性能监控项目';

目前做的也比较简单,通过名称得到 16位MD5 字符串。
image4.png

三、分析

1)性能看板

在性能看板中,会有四张折线图,当要统计一天的数据时,横坐标为小时(0~23),纵坐标为在这个小时内正序后处于 95% 位置的日志,也就是 95% 的用户打开页面的时间。

这种写法也叫 TP95,TP 是 Top Percentile 的缩写,不用性能平均数是因为那么做不科学。
image5.png

过滤条件还可以选择具体的小时,此时横坐标为分钟,纵坐标为在这个分钟内正序后处于 95% 位置的日志。

点击图表的 label 部分,可以在后面列表中显示日志细节,其中原始参数就是从浏览器中得到的计算前的性能数据。

image6.png
  后面又增加了对比功能,就是将几天的数据放在一起对比,可更加直观的展示趋势。
image7.png

2)定时任务

在每天的凌晨 3点30 分,统计昨天的日志信息。

本来是计划 web_performance_statis 表中每天只有一条记录,所有性能项目的统计信息都塞到 statis 字段中,并且会包含各个对应的日志。

但奈何数据量实在太大,超出了 MySQL 中 TEXT 类型的范围,没办法塞进去,后面就只存储 id 并且一个项目每天各一条记录。

数据结构如下,其中 loadZero 是指未执行load事件的数量。

{
  hour: {
    x: [11, 14],
    load: ["158", "162"],
    ready: ["157", "162"],
    paint: ["158", "162"],
    screen: ["157", "162"],
    loadZero: 1
  },
  minute: {
    11: {
      x: [11, 18, 30],
      load: ["157", "159", "160"],
      ready: ["156", "159", "160"],
      paint: ["157", "159", "160"],
      screen: ["156", "159", "160"],
      loadZero: 1
    },
    14: {
      x: [9, 16, 17, 18],
      load: ["161", "163", "164", "165"],
      ready: ["161", "163", "164", "165"],
      paint: ["161", "163", "164", "165"],
      screen: ["161", "163", "164", "165"],
      loadZero: 0
    }
  }
}

还有个定时任务会在每天的凌晨 4点30 分执行,将四周前的 web_performance_statis 和 web_performance 两张表中的数据清除。

3)资源瀑布图

2022-07-08 新增了资源瀑布图的功能,就是查看当时的资源加载情况。

在上报性能参数时,将静态资源的耗时,也一起打包。getEntriesByType() 方法可返回给定类型的 PerformanceEntry 列表。

const resources = performance.getEntriesByType('resource');
 const newResources = [];
 resources && resources.forEach(value => {
   const { name, duration, startTime, initiatorType} = value;
   // 过滤 fetch 请求
   if(initiatorType === 'fetch') return;
   // 只存储 1 分钟内的资源
   if(startTime > 60000) return;
   newResources.push({
     name,
     duration: Math.round(duration),
     startTime: Math.round(startTime),
   })
 });
 obj.resource = newResources;

代码中会过滤掉 fetch 请求,因为我本地业务请求使用的是 XMLHTTPRequest,只在上报监控数据时采用了 fetch() 函数。

并且我只会搜集 1 分钟内的资源,1 分钟以外的资源我都会舍弃,只记录名称、耗时和开始时间。

最终的效果如下图所示,包含一个横向的柱状图和查询区域,目前只开放了根据 ID 查询。
image8.png

4)堆叠柱状图

先将所有的性能记录统计出来,然后分别统计白屏和首屏 1 秒内的数量、1-2 秒内、2-3 秒内、3-4 秒内、4+秒的数量,白屏的 SQL 如下所示。

SELECT COUNT() FROM web_performance WHERE ctime >= ‘2022-06-12 00:00’ and ctime < ‘2022-06-13 00:00’;
SELECT COUNT(
) FROM web_performance WHERE paint <= 1000 and ctime >= ‘2022-06-12 00:00’ and ctime < ‘2022-06-13 00:00’;
SELECT COUNT() FROM web_performance WHERE paint > 1000 and paint <= 2000 and ctime >= ‘2022-06-12 00:00’ and ctime < ‘2022-06-13 00:00’;
SELECT COUNT(
) FROM web_performance WHERE paint > 2000 and paint <= 3000 and ctime >= ‘2022-06-12 00:00’ and ctime < ‘2022-06-13 00:00’;
SELECT COUNT() FROM web_performance WHERE paint > 3000 and paint <= 4000 and ctime >= ‘2022-06-12 00:00’ and ctime < ‘2022-06-13 00:00’;
SELECT COUNT(
) FROM web_performance WHERE paint > 4000 ctime >= ‘2022-06-12 00:00’ and ctime < ‘2022-06-13 00:00’;
  算出后,分母为总数,分子为上述五个值,组成一张堆叠柱状图,类似于下面这样,每种颜色代码一个占比。

image9.png

这样就能直观的看到优化后的性能变化了,更快的反馈优化结果。

5)阶段时序图

在将统计的参数全部计算出来后,为了能更直观的发现性能瓶颈,设计了一张阶段时序图。

描绘出 TTFB、responseDocumentTime、initDomTreeTime、parseDomTime 和 loadEventTime 所占用的时间,如下所示。

橙色竖线表示白屏时间,黑色竖线表示首屏时间。移动到 id 或来源地址,就会提示各类参数。

image10.png

TTFB 的计算包括 redirectTime、appcacheTime、lookupDomainTime、connectTime 以及 requestDocumentTime 的和。

并且因为 requestStart 和 connectEnd 之间的时间(即 TCP 连接建立后到发送请求这段时间)没有算,所以会这个和大。

responseDocumentTime 就是接收响应内容的时间。initDomTreeTime 是构建 DOM 树并执行网页阻塞的脚本的时间,在这个阶段,具有 defer 属性的脚本还没有执行。

parseDomTime 是解析 DOM 树结构的时间,DOM 中的所有脚本,包括具有 async 属性的脚本也会执行,还会加载页面中的静态资源,例如图像、iframe 等。

parseDomTime 是 domComplete 和 domInteractive 相减得到的差。loadEventStart 会紧跟在 domComplete 之后,而在大多数情况下,这 2 个指标是相等的。

loadEventTime 就是执行 on/load 事件的时间,一般都比较短。

观察下来,如果是 TTFB 比较长,那么就是 NDS 查询、TCP 连接后的请求等问题。

initDomTreeTime 过长的话,就需要给脚本瘦身了;parseDomTime过长的话,就需要减少资源的请求。

参考:

前端性能监控及推荐几个开源的监控系统

如何进行 web 性能监控?

蚂蚁金服如何把前端性能监控做到极致?

5 分钟撸一个前端性能监控工具

10分钟彻底搞懂前端页面性能监控

Navigation_and_resource_timings

PerformanceNavigationTiming

请先登录,感受更多精彩内容
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

字节跳动如何系统性治理 iOS 稳定性问题
本文是丰亚东讲师在2021 ArchSummit 全球架构师峰会中「如何系统性治理 iOS 稳定性问题」的分享全文。
使用 Lighthouse CI 进行性能监控
Lighthouse CI是一套用于在持续集成期间使用 Lighthouse 的工具。Lighthouse CI 可以通过多种不同方式整合到开发人员工作流程中。本指南涵盖以下主题: 使用 Lighthouse CI CLI。配置 CI 提供程序以运行 Lighthouse CI。
4000字全解Web 前端性能
导语也许你有听过一个问题,你这款 web 应用性能怎么样呀?你会回答什么呢?是否会优于海量 web 应用市场呢?本文就来整理下如何进行 web 性能监控?包括我们需要监控的指标、监控的分类、performance 分析以及如何监控。但是,如何进行 web 性能监控本身是一个很大的话题,文中只会侧
使用 nmon 来排查与监控 Linux 系统的性能
导读影响linux性能的主要是cpu、内存、磁盘O/I和网络,本篇文章主要介绍了一个系统性能监控工具来帮助大家快速定位性能问题。当然监控linux性能的工具远远不止这些,希望大家学以致用~正文Nigel's Monitor (nmon) 是一个系统性能监控工具,其最初由 IBM 为 AIX 操
从零开始搞监控系统(1)——SDK
目前市面上有许多成熟的前端监控系统,但我们没有选择成品,而是自己动手研发。这里面包括多个原因:填补H5日志的空白节约公司费用支出可灵活地根据业务自定义监控回溯时间能更长久反哺运营和产品,从而优化产品质量一次难得的练兵机会前端监控地基本目的:了解当前项目实际使用的情况,有哪些异常,在
从零开始搞监控系统(2)——存储和分析
一、存储在将数据传送到后台之前,已经做了一轮清洗工作,如果有需要还可以再做一次清洗。日志表如下所示,自增的 id 直接偷懒使用了 bigint,没有采用分表等其他技术。CREATE TABLE `web_monitor` ( `id` bigint(20) NOT NULL AUTO_IN
从零开始搞监控系统(3)——性能监控
前端性能监控是个老话题了,各个团队都会对其有所关注,因为关注性能是工程师的本分。页面性能对用户体验而言十分关键,每次重构或优化,仅靠手中的几个设备或模拟的测试,缺少说服力,需要有大量的真实数据来做验证。在2016年,我就写过一篇《前端页面性能参数搜集》的文章,当时采用的还是W3C性能参数的第一版
Web优化躬行记(6)——优化闭环实践
在遇到一个页面性能问题时,我理解的优化闭环是:分析、策略、验证和沉淀。分析需要有分析数据,因此得有一个性能监控管理。策略就是制订针对性的优化方案,解决当前遇到的问题。验证的对象上述策略,判断方案是否有效,同样需要数据支撑。沉淀就是将解决过程文档化、通用化,能够总结成一套实际方案、优化规则等
0
0