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


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

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

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

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

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

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

      

    一、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)
      };
    }

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

    W3C第一版的性能参数

    W3C第二版的性能参数

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

    1)统计的参数

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

    shin.getTimes = function () {
      //出于对浏览器兼容性的考虑,仍然引入即将淘汰的 performance.timing
      var currentTiming = _getTiming();
      var timing = currentTiming.timing;
      var api = {};     //时间单位 ms
      if (!timing) {
        return api;
      }
      var navigationStart = currentTiming.navigationStart;
      /**
       * 页面加载总时间
       * 这几乎代表了用户等待页面可用的时间
       * loadEventEnd(加载结束)-navigationStart(导航开始)
       */
      api.loadTime = timing.loadEventEnd - navigationStart;
      /**
       * Unload事件耗时
       */
      api.unloadEventTime = timing.unloadEventEnd - timing.unloadEventStart;
      /**
       * 执行 onload 回调函数的时间
       * 是否太多不必要的操作都放到 onload 回调函数里执行了,考虑过延迟加载、按需加载的策略么?
       */
      api.loadEventTime = timing.loadEventEnd - timing.loadEventStart;
      /**
       * 首次可交互时间
       */
      api.interactiveTime = timing.domInteractive - timing.fetchStart;
      /**
       * 用户可操作时间(DOM Ready时间)
       */
      api.domReadyTime = timing.domContentLoadedEventEnd - timing.fetchStart;
      /**
       * 白屏时间
       */
      var paint = performance.getEntriesByType("paint");
      if (paint && timing.entryType && paint[0]) {
        api.firstPaint = paint[0].startTime - timing.fetchStart;
      } else {
        api.firstPaint = timing.responseEnd - timing.fetchStart;
      }
      /**
       * 解析 DOM 树结构的时间
       * 期间要加载内嵌资源
       * 反省下你的 DOM 树嵌套是不是太多了
       */
      api.parseDomTime = timing.domComplete - timing.domInteractive;
      /**
       * 请求完毕至DOM加载耗时
       */
      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 
       */
      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;
      /**
       * 读取页面第一个字节的时间
       * 这可以理解为用户拿到你的资源占用的时间,加异地机房了么,加CDN 处理了么?加带宽了么?加 CPU 运算速度了么?
       * TTFB 即 Time To First Byte 的意思
       * 维基百科:https://en.wikipedia.org/wiki/Time_To_First_Byte
       */
      api.TTFB = timing.responseStart - timing.fetchStart;
      //全部取整
      for (var key in api) {
        api[key] = _rounded(api[key]);
      }
      /**
       * 浏览器读取到的性能参数,用于排查
       */
      api.timing = timing;
      return api;
    };

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

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

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

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

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

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

    2)首屏时间

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

      第一种是算出首屏页面中所有图片都加载完后的时间,这种方法难以覆盖所有场景,并且计算结果并不准。

    /**
     * 计算首屏时间
     * 记录首屏图片的载入时间
     * 用户在没有滚动时候看到的内容渲染完成并且可以交互的时间
     */
    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(用户可操作时间)。

    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);

    二、存储

    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 '浏览器读取到的性能参数,用于排查',
      PRIMARY KEY (`id`)
    ) 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 字符串。 

      

    三、分析

    1)性能看板

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

      

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

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

      

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

      

    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 两张表中的数据清除。

    参考:

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

    如何进行 web 性能监控?

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

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

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

    Navigation_and_resource_timings

    PerformanceNavigationTiming

  • 相关阅读:
    springboot maven
    基于spring http服务远程调用
    java注解
    mongdob-高可用集群
    Mongodb学习笔记--分片集群
    Mongodb学习笔记--springboot中使用mongodb
    Mongodb学习笔记--用户和权限
    Mongodb学习笔记--使用java连接操作mongodb
    mongodb学习笔记--集群配置(二)-副本集配置
    mongodb学习笔记--集群配置(一)
  • 原文地址:https://www.cnblogs.com/strick/p/14578711.html
Copyright © 2020-2023  润新知