Libx

Puppeteer优化实录

Word count: 1,809Reading time: 7 min
2019/12/12 Share

最近在写关于Puppeteer的服务,其中一些思路大概梳理一下。

截图服务的大致流程

  1. Puppeteer打开Browser,准备好对应的Tab。
  2. 调用Puppeteer服务,Puppeteer处理来源的原始HTML或者跳转到相应的页面
  3. 截图
  4. 把结果返回,如果中间有错误,把错误返回。
  5. 关闭这个Tab。
  6. 关闭这个Browser。

看起来流程非常的简洁美好,启动一个简单的Node服务,我们就可以按照这6个步骤完成开发,但是现实其实并不是这么美好:

  1. 频繁的开关browser和tab,效率很低。
  2. Puppeteer异常一次后,browser就不受控制,无法关闭,导致内存泄露,QPS高时,内存暴涨,QPS低时,内存不释放。
  3. 在Page中evaluate脚本的时候,线上极度难以调试,调试需要case by case,上线后JS报错很难追踪和复现。
  4. Puppeteer自身很慢,并发非常低,处理任务一秒一个都做不到。
  5. 截图服务中间的流程均是低效率流程,其中更是包含了上传tos的操作,非常耗时

在使用最原始的方式,不包含任何调度的时候:截取本地模版 接口调用平均时延大概在1.2s左右(本地测试)(由于截取线上网站很大程度取决于线上网页的加载速度,对服务来说不可控,所以暂时不讨论线上地址截图)

下面就要开始我们的一些优化了:

连接池设计

生产Page资源

连接到一个页面大概有以下几种方法:

  1. 一个请求->打开一个浏览器->打开页面->截图->关闭页面->关闭浏览器
    1. 这当然是最简单的方法,如果可以接受长达几秒的响应时间,那其实这也是最可靠的,不会有任何crash、内存泄漏、浏览器崩溃之类的风险。但这显然得不偿失。
  2. 一个浏览器 => 对应多个tab
    1. 复用tab,但一个浏览器的crash风险较高。
  3. 多个浏览器 => 每个浏览器都对应多个tab
    1. 不需要每次请求都进行打开浏览器、打开页面的操作,响应时间大幅缩短
    2. 相对规避了浏览器崩溃之后app完全不可用的风险

按照上面的思路,Puppeteer自身非常慢,所以考虑把一些比较耗时的操作尽可能在每次请求前置进行,目前可以前置做的事情有:

  1. 启动浏览器
  2. 启动多个tab

先按照这个思路实现一个可用版本:

async createAll() {
for (let i = 0; i < this.browserMax; i++) {
let browser = await this.createBrowser();
this.browsers[i] = { browser, pages: [] };
let [defaultPage] = await browser.pages();
defaultPage = await this.setPage(defaultPage);
this.browsers[i].pages[0] = {
page: defaultPage,
};
for (let k = 1; k < this.pageMax; k++) {
let page = await this.createPage(browser);
this.browsers[i].pages[k] = {
page
};
}
}
return this.browsers;
}

按照这个数据结构,每个browser对应了多个page

看起来已经是一个可用版本了,但是还有一些细节:

  1. 在启动Browser的时候,只需要启动启动一个最小化可用的浏览器实例,不需要的功能都禁用掉,具体的实践之后:
    1. 部分功能disable掉,比如GPU、Sandbox、插件等,减少内存的使用和相关计算。

在启动参数优化之后,应用启动速度大概提升200ms,收益非常可观。

连接Page资源

可用的page生产完了,接下来需要思考如何高效的拿到可用的page:
获取产生的page,需要有一定的调度,这里用到的规则是:

  1. 先找到一个空闲的page单位,然后给这个page单位加锁,
  2. 使用次数累加
  3. 返回可用page,执行使用
  4. 使用完之后归还page给资源池,如果使用次数超过5次,就关闭这个page(这里也是为了降低page崩溃的几率)创建新的page实例,补上这个page单位。
  5. 最后解锁page。

在分配page的时候也进行了一定的设计:也比较简单,首先从我们存储的browsers对象中进行查找,找到下面没有被used的page,判断是否有异常被关闭了,如果异常被关闭了,我们需要重新建立补上,然后使用次数归0,最后我们这个选中的page的存储排序挪到数组最后,让其他的没有被used的page能在下次更快被拿到。
获取page的时候还会遇到一个问题就是资源不够用了。目前在项目中每个请求会给10s的等待调度时间,如果5s之后依然没有可用page,返回超时 (后续可以根据机器的内存情况来动态增加浏览器和page池)。2C4G的机器大概可以支撑的qps待测试(需要注意在机器内存占用80%以上的时候是不太安全的,内存空间需要留足够的buffer)

Page的维护

实际测试之后发现使用tab方式渲染后请求速度提升了200ms左右,带来的收益也非常可观。不过这里要注意,官方并不建议这样做,因为一个tab页阻塞或者内存泄露会导致整个浏览器阻塞并Crash。但是我们为了追求速度,只能在可控的情况尽可能的实现复用,虽然我们用了多个浏览器来规避服务完全不可用的情况,但万全的解决办法是定期重启程序。

重启

重启很简单,把所有浏览器关了就行了,但是需要注意的是当页面在使用的时候我们把它给关了那就GG了,所以需要加一步检查页面是否在用:this.browsers[i].pages[k].used 如果在使用就sleep1s之后再来检查。在执行关闭前是先生产一组新的browser list,把旧的替换,然后去关旧的。

一些问题

目前的重启调度逻辑其实和Node的内存回收中所用到的新生代垃圾回收算法非常像:新生代中的对象主要通过Scavenge算法进行垃圾回收,这是一种采用复制的方式实现内存回收的算法。Scavenge算法将新生代的总空间一分为二,只使用其中一个,另一个处于闲置,等待垃圾回收时使用。在进行浏览器重启的时候其实会造成一定的空间浪费;

一些思路

  1. 存在部分同样的图片,可根据请求参数生成hash 当作key放到redis,当下次有相同的请求时直接返回redis里存的图片。并且如果两次page的内容是完全一致的话,截图来的ImageArray也会是完全相同的,这其实也不难理解。

  2. 既然要把耗时的操作前置,那其实可以直接把模版内容提前set到page里,请求来的时候只需要渲染数据。

CATALOG
  1. 1. 截图服务的大致流程
  2. 2. 连接池设计
    1. 2.1. 生产Page资源
    2. 2.2. 连接Page资源
    3. 2.3. Page的维护
      1. 2.3.1. 重启
  3. 3. 一些问题
  4. 4. 一些思路