likes
comments
collection
share

electron资源cpu/memory占用过高的处理思路

作者站长头像
站长
· 阅读数 16

背景

最近发现客户端(Electron IM)在某些场景下 CPU/Memory 占用较高,所以需要进一步分析定位问题。本文主要记录这次资源占用优化的过程, 以及尝试分析优化效果。

结论

先说结论,在这次优化过程中,其实大部分时间在思考和测试量化优化结果这个事情上。 分两阶段说明: 1、提高性能,提高TPS; 2、控制并发数量,避免高资源占用;

一开始我的评估指标是在同样的QPS上,资源占用的对比。 e.g. 1、在 200QPS 的情况下,未优化前的资源占用情况: electron资源cpu/memory占用过高的处理思路

2、在经过第一阶段(主要是数据库查询性能)优化后, 200QPS 的资源占用情况: electron资源cpu/memory占用过高的处理思路 可以看到cpu占用降低了一些,执行时间更短,但仍然会有资源占用的飙升。

其实从两个结果对比,很难去量化到底优化了多少,同时仍会存在高并发资源占用飙升的情况。 花了一些时间去思考这个问题,其实在足够大的并发情况下,不管如何优化都会出现高资源的占用(在非常高并发的情况下,我第一阶段的优化只是提高TPS,解决不了高资源占用的问题)。所以对高资源占用的优化思路应该是降低/控制并发数量。e.g. 延迟队列,线程池/进程池限制并发数等。其实对控制并发数量和TPS中取得平衡也是一件比较有意思的事情(后面有场景再写一篇文章说明)。 同时因为在我目前的场景中存在大量幂等的调用,所以通过节流可以有效解决: electron资源cpu/memory占用过高的处理思路

第一阶段,提高TPS

首先需要一个工具来测量TPS,因为不像平时的后端开发,可以借助测试工具进行请求返回,目前也找不到类似的工具,所以自己实现了一个简单的TPS统计方法:

// 主要功能是在第一个开启事务的地方标识时间
// 最后完成所有事务后统计TPS
class TPSCalculator {
    constructor() {
      this.transactions = new Map(); // 用于存储事务的开始时间
      this.completedTransactions = new Set(); // 用于存储已完成的事务
      this.startTime = null; // 记录开始时间
    }
  
    static getInstance() {
      if (!TPSCalculator.instance) {
        TPSCalculator.instance = new TPSCalculator();
      }
      return TPSCalculator.instance;
    }
  
    startTransaction(key) {
      if (this.transactions.size === 0) {
        this.startTime = Date.now(); // 第一个任务开始时记录开始时间
      }
      const startTime = Date.now();
      this.transactions.set(key, startTime);
    }
  
    endTransaction(key) {
      if (this.transactions.has(key)) {
        const startTime = this.transactions.get(key);
        const endTime = Date.now();
        const transactionTime = endTime - startTime;
        console.log(`Transaction '${key}' took ${transactionTime}ms`);
  
        this.transactions.delete(key);
        this.completedTransactions.add(key);
  
        if (this.transactions.size === 0) {
          this.calculateTPS();
        }
      } else {
        console.log(`Transaction '${key}' not found`);
      }
    }
  
    calculateTPS() {
      const endTime = Date.now();
      const totalTime = endTime - this.startTime;
      const transactions = this.completedTransactions.size;
      const tps = (transactions / totalTime) * 1000; // 计算 TPS,乘以 1000 转换为每秒
  
      console.log('Current TPS:', tps);
    }
}

测试结果在目前 200QPS 的情况下,只有1.2左右的TPS.

定位耗时计算

将Electron通过调试模式(--inspect-brk)启动, 记录并发时的cpu调用过程: electron资源cpu/memory占用过高的处理思路 从图可以看到主要是一个 query 的方法占用了cpu资源,这个是typeORM框架的sql查询方法。 但这里没办法定位到是哪个sql语句导致,需要用到typeORM的另外一个工具,将超过50ms的慢查询打印出来: electron资源cpu/memory占用过高的处理思路 可以看到在高并发的时候出现了大量的慢查询(100-350ms)。

SQL优化

// 200QPS的
// 优化前: 1.2TPS
// 优化后: 1.4TPS

2、减少不必要的查询(这部分主要跟业务逻辑相关,这里不再赘述),限制查询数量(有些sql其实不需要查询全表数据,限制查询的数量,提高sql性能);

// 优化前: 1.4TPS
// 优化后: 6.7TPS

(之所以第二步优化效果比较明显是因为我这里查的是消息表,虽然已经分表,但每个表仍有几万的数据,限制查询数量可以有很高的性能提升。) 经过这两步SQL优化后: electron资源cpu/memory占用过高的处理思路 可以看到cpu的调用已经健康了很多,当在高并发的情况下,仍会出现短时间内资源占用过高的问题。

另:其实在我的使用场景下,这里的场景下使用缓存也能非常高效的提高性能,但因为该数据的插入/更新逻辑太多,暂时没有进行缓存(sqlite目前已支持 RETURNING 语法,后面类似的项目在设计的时候应该需要考虑这点)。

第二阶段优化

经过第一阶段的优化后,其实性能已经提升了一大截,但资源占用过高问题仍存在: 理论上,当并发数量设置的非常高时,资源占用问题是一定存在的(在不同的场景可能不太一样,例如我上面这个优化,如果使用缓存,TPS应该可以很高,后面有时间去重构后再补充这部分数据)。 这种时候应该用另外一种处理思路,限制并发数(以时间换空间),限制并发的处理思路很多,e.g. 1、前端限制; 2、延迟队列; 3、线程池/进程池; 4、防抖节流; ... 因为在我的使用场景下该任务中比较消耗资源的都是幂等的调用,所以我是通过防抖/节流来进行调用的。节流后的效果如下: electron资源cpu/memory占用过高的处理思路 (其实节流也有个注意的地方,例如我测出来的TPS目前是6.7TPS左右,大概就是150ms处理一个任务,考虑到不同的客户端的性能差异,我设置的是300ms)

Memory

其实主进程在处理完高cpu占用后,内存占用也跟着下去了。但这次对内存占用的进一步了解,给我提供了一些比较清晰的思路反馈到开发过程中,但内容较多,下一篇文章再写一下。

问题记录

1、如何根据参数进行防抖/节流? 有点麻烦,需要针对不同的参数创建自己的防抖函数,e.g.

const _ = require('lodash')
// 根据唯一key进行防抖
// 需要存储节流函数
const debounceMap = {}
function argumentDebounce(key, fn) {
    if(!debounceMap[key]) {
        debounceMap[key] = _.throttle(fn, 300, {leading: true, trailing: true})
    }

    return debounceMap[key]
}

2、nodejs中是否需要手动触发内存回收,以及如何手动触发内存回收? 通过 --expose-gc 参数启动应用,在需要进行手动触发的地方执行 global.gc() 即可,但在electron中有点特殊, 关联issue.

参考文档

1、RETURNING: www.sqlite.org/lang_return… 2、Avoiding Memory Leaks in Node.js: Best Practices for Performance: blog.appsignal.com/2020/05/06/…