electron资源cpu/memory占用过高的处理思路
背景
最近发现客户端(Electron IM)在某些场景下 CPU/Memory 占用较高,所以需要进一步分析定位问题。本文主要记录这次资源占用优化的过程, 以及尝试分析优化效果。
结论
先说结论,在这次优化过程中,其实大部分时间在思考和测试量化优化结果这个事情上。 分两阶段说明: 1、提高性能,提高TPS; 2、控制并发数量,避免高资源占用;
一开始我的评估指标是在同样的QPS上,资源占用的对比。 e.g.
1、在 200QPS 的情况下,未优化前的资源占用情况:
2、在经过第一阶段(主要是数据库查询性能)优化后, 200QPS 的资源占用情况:
可以看到cpu占用降低了一些,执行时间更短,但仍然会有资源占用的飙升。
其实从两个结果对比,很难去量化到底优化了多少,同时仍会存在高并发资源占用飙升的情况。
花了一些时间去思考这个问题,其实在足够大的并发情况下,不管如何优化都会出现高资源的占用(在非常高并发的情况下,我第一阶段的优化只是提高TPS,解决不了高资源占用的问题)。所以对高资源占用的优化思路应该是降低/控制并发数量。e.g. 延迟队列,线程池/进程池限制并发数等。其实对控制并发数量和TPS中取得平衡也是一件比较有意思的事情(后面有场景再写一篇文章说明)。
同时因为在我目前的场景中存在大量幂等的调用,所以通过节流可以有效解决:
第一阶段,提高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调用过程:
从图可以看到主要是一个 query 的方法占用了cpu资源,这个是typeORM框架的sql查询方法。
但这里没办法定位到是哪个sql语句导致,需要用到typeORM的另外一个工具,将超过50ms的慢查询打印出来:
可以看到在高并发的时候出现了大量的慢查询(100-350ms)。
SQL优化
// 200QPS的
// 优化前: 1.2TPS
// 优化后: 1.4TPS
2、减少不必要的查询(这部分主要跟业务逻辑相关,这里不再赘述),限制查询数量(有些sql其实不需要查询全表数据,限制查询的数量,提高sql性能);
// 优化前: 1.4TPS
// 优化后: 6.7TPS
(之所以第二步优化效果比较明显是因为我这里查的是消息表,虽然已经分表,但每个表仍有几万的数据,限制查询数量可以有很高的性能提升。)
经过这两步SQL优化后:
可以看到cpu的调用已经健康了很多,当在高并发的情况下,仍会出现短时间内资源占用过高的问题。
另:其实在我的使用场景下,这里的场景下使用缓存也能非常高效的提高性能,但因为该数据的插入/更新逻辑太多,暂时没有进行缓存(sqlite目前已支持 RETURNING 语法,后面类似的项目在设计的时候应该需要考虑这点)。
第二阶段优化
经过第一阶段的优化后,其实性能已经提升了一大截,但资源占用过高问题仍存在:
理论上,当并发数量设置的非常高时,资源占用问题是一定存在的(在不同的场景可能不太一样,例如我上面这个优化,如果使用缓存,TPS应该可以很高,后面有时间去重构后再补充这部分数据)。
这种时候应该用另外一种处理思路,限制并发数(以时间换空间),限制并发的处理思路很多,e.g.
1、前端限制;
2、延迟队列;
3、线程池/进程池;
4、防抖节流;
...
因为在我的使用场景下该任务中比较消耗资源的都是幂等的调用,所以我是通过防抖/节流来进行调用的。节流后的效果如下:
(其实节流也有个注意的地方,例如我测出来的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/…
转载自:https://juejin.cn/post/7255618015894126653