Puppeteer+RabbitMQ:Node.js 批量加工pdf服务架构设计与落地
<p>智慧作业最近上线「<em><strong>个性化手册</strong></em>」(简称个册)功能,一份完整的个性化手册分为<strong>三部分</strong>:</p><ul>
<li><em><strong>学情分析</strong></em>:根据学生阶段性的学习和考试情况进行学情分析、归纳、总结,汇总学情数据;</li>
<li><em><strong>精准推荐</strong></em>:推荐算法基于学情数据结合知识图谱进行精准练习题推荐;</li>
<li><em><strong>错题回顾</strong></em>:错题的阶段性回顾复习。<br>
第一部分<strong>学情分析的PDF由Node.js加工,与Java后端通过消息队列RabbitMQ进行数据交互</strong>,本文简单记录一下Node.js批量加工PDF服务的架构模式,以及基于现阶段发现的问题,梳理未来的迭代规划和演进方向。</li>
</ul>
<h1 id="业务特征">业务特征</h1>
<p>个册三个部分的PDF数据来源不同,生产逻辑独立由不同的服务生产,最终将三份PDF合并为一份,还要支持班级所有学生批量生产和压缩打包,所以这个功能在技术角度最主要的特征就是<strong>环节多</strong>、<strong>耗时长</strong>:</p>
<ul>
<li><strong>环节多</strong>意味着在各个服务之间存在较多的网络通信和数据交互,核心挑战在于如何设计低耦合、高可用的服务架构;</li>
<li><strong>耗时长</strong>一方面体现在多个环节的总耗时,另一方面体现在三个PDF生产服务各自的加工耗时。<br>
基于以上业务特征,PDF加工服务架构设计的一个大方向就是将<strong>长耗时任务异步处理,各服务之间逻辑解耦,通过消息队列进行数据交互</strong>。</li>
</ul>
<h1 id="技术选型">技术选型</h1>
<p>服务端生成PDF通常有两种方案:</p>
<ul>
<li>第一种是使用 pdfkit 之类的工具通过代码绘制,这种方案最大的问题是可渲染的内容类型有限,定制化不足;</li>
<li>第二种是创建 <em>headless browser</em>用html渲染后截取pdf,这种方案的架构相对复杂,但是可以支持所有web端的内容类型。<br>
个册第一部分学情分析的某一页长这个样子:</li>
</ul>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181447243-369377177.png" alt="" loading="lazy"></p>
<p>仅这一页就涵盖文本、表格、图表以及各种自定义图案,内容类型多样并且后续迭代可能增加更多定制化内容,第一种方案的局限性很难满足需求,所以最终选定 headless browser 方案。</p>
<p>具体到 headless browser 的技术选型就非常有限了,可选的无非就是 Selenium/PhantomJS 这类老招牌,或者 Puppeteer/Playwright 这类新玩家。</p>
<blockquote>
<p>严格来说Selenium只是一种类似按键精灵的工具,可通过代码在浏览器中模拟人的操作,本身并不是浏览器,所以需要搭配第三方浏览器使用,比如PhantomJS。<br>
Selenium/PhantomJS 的最大的优点就是生态健全,支持多种编程语言,有相对繁荣的技术社区;缺点就是稳定性和性能较差,Selenium的稳定性出了名的糟糕,PhantomJS五年前就停止维护了。这哥俩通常用在对稳定性要求不是很高的场景,比如爬虫。</p>
</blockquote>
<p>与之形成鲜明对比的,Puppeteer/Playwright 最大的优点就是稳定性高,性能更优;缺点就是对编程语言的支持有限,生态和技术社区相对没那么健全。</p>
<p>个册的业务特征一是<strong>对稳定性和性能要求很高</strong>;二是<strong>不要求跨浏览器</strong>(Playwright支持浏览器类型更丰富)。最终综合考虑API易用性、稳定性、性能、社区、风险等因素,在 Puppeteer 和 Playwright 之间选择了 Puppeteer。既然选定了 Puppeteer,配套的自然就是 Node.js了。</p>
<blockquote>
<p>Puppeteer 和 Playwright 的对比可以参考这篇文章:Playwright vs Puppeteer: Core Differences。<br>
这个需求是我第一次使用Puppeteer,还没完全摸透,下文涉及到Puppeteer相关的方案<strong>如果有问题,欢迎讨论指点</strong>。</p>
</blockquote>
<h1 id="实现方案">实现方案</h1>
<p>智慧教育的分层架构如下:</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181459381-2020558339.png" alt="" loading="lazy"></p>
<p>Node.js PDF服务是本次需求新增的,为了方便分离部署和优化,PDF服务单独建立一个服务,不涉及Node.js接入层的改动。下图是个册PDF加工的完整流程:</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181508233-1164806655.png" alt="" loading="lazy"></p>
<p>每个环节的具体流程不细讲,Node.js PDF加工服务的细节下文详解。与Node.js PDF服务相关最关键的是与Java后端的数据交互流程。Java后端与Node.js PDF服务通过 RabbitMQ 消息队列进行数据交互,建立<strong>两个队列</strong>:</p>
<table>
<thead>
<tr>
<th style="text-align: left">队列</th>
<th style="text-align: left">生产者</th>
<th style="text-align: left">消费者</th>
<th style="text-align: left">说明</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left">任务队列</td>
<td style="text-align: left">Java后端</td>
<td style="text-align: left">Node.js PDF服务</td>
<td style="text-align: left">Java 向队列中发送个册渲染数据,Node.js 消费</td>
</tr>
<tr>
<td style="text-align: left">回传队列</td>
<td style="text-align: left">Node.js PDF服务</td>
<td style="text-align: left">Java后端</td>
<td style="text-align: left">Node.js 向队列中发送pdf加工结果数据,Java 消费</td>
</tr>
</tbody>
</table>
<p>这部分没啥好讲的,Node.js与Java之间按照约定的数据规范组装数据即可,下面详细介绍一下Node.js加工pdf的具体逻辑。</p>
<blockquote>
<p>这一版个册的第一部分学情分析控制在3页,早期规划的个册PDF大约25页左右,技术调研和架构设计都是基于这个预期进行的,所以现在这套模式多少有点杀鸡用牛刀的意思,不过前期打好基础给后续迭代留些空间也是好事。</p>
</blockquote>
<h2 id="单份pdf加工流程">单份PDF加工流程</h2>
<p>为了更方便理解,在介绍pdf加工流程之前,有必要先简要一下Node.js PDF服务的架构,以及与PDF加工逻辑最相关的 <em><strong>worker</strong></em>角色。</p>
<p>Node.js PDF服务架构最核心的三个角色:</p>
<ul>
<li><em><strong>Scheduler</strong></em>:负责轮询<strong>调度</strong>,发起任务;</li>
<li><em><strong>Executor</strong></em>:负责任务<strong>前置</strong>和<strong>后置</strong>相关逻辑,包括worker pool管理、worker 调度、MQ任务队列消息拉取、MQ回传队列消息发送等;</li>
<li><em><strong>Worker</strong></em>:负责<strong>实质</strong>执行任务,包括pdf渲染、生产、上传OSS;<br>
三者的关系如下所示:</li>
</ul>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181526338-1449224625.png" alt="" loading="lazy"></p>
<p><em><strong>Scheduler</strong></em>和 <em><strong>Executor</strong></em>的具体逻辑以及三个角色之间的调度逻辑下文再详解,PDF文件的实质生产逻辑都集中在 <em><strong>Worker</strong></em>中,流程如下:</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181534884-1236097653.png" alt="" loading="lazy"></p>
<blockquote>
<p>图中「发送消息至MQ回传队列」实质是由 <em><strong>Executor</strong></em>执行,此处画出方便理解完整流程。</p>
</blockquote>
<h3 id="预启动">预启动</h3>
<p>图中虚线部分的预启动是在启动 Node.js 服务之前执行的逻辑,预启动完成之后 Node.js 服务被拉起,所以预启动的耗时是一次性的。</p>
<p>预启动过程执行两个动作:</p>
<ul>
<li>读取磁盘中的html文件内容,写入内存,为后续环节「加载网页」提供数据;</li>
<li>创建 Puppeteer browser 实例。</li>
</ul>
<blockquote>
<p>上图中只画出pdf加工逻辑相关的预启动工作,实际上预启动还包含一些其他逻辑,比如建立 MQ 连接信道。</p>
</blockquote>
<h3 id="冷启动废弃">冷启动(废弃)</h3>
<blockquote>
<p>虽然冷启动在后来开发过程中被废弃,但通过这个事情发现自己的不足,还是值得记录一下的。<br>
最初之所以设想冷启动环节,是因为尝试用 worker <strong>模拟多线程</strong>。每个worker会创建一个browser实例和多个page实例(目前是3个),如下所示:</p>
</blockquote>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181550623-965851572.png" alt="" loading="lazy"></p>
<p>这样做的目的是将<strong>每个worker的负载上限固定,便于服务器资源规模预估,</strong>避免服务器某个节点负载过高,进而也可以避免k8s集群pod的纵向伸缩。</p>
<blockquote>
<p>k8s纵向伸缩的取舍见仁见智,我个人不太建议使用。<br>
<strong>如果任务队列长时间为空会触发缓存清理</strong>逻辑,销毁browser和page实例以节省服务器资源,<strong>再次发起任务会触发冷****启动</strong>。冷启动执行两件事情:</p>
</blockquote>
<ul>
<li>链接/创建browser实例</li>
<li>创建page实例<br>
另外增加一个<strong>标识位_mounted代表冷启动是否完成</strong>,代码如下:</li>
</ul>
<pre><code class="language-typescript">public async run(){
if(!this._mounted){
// 触发冷启动
this._mount();
}
// ...其他逻辑
}
private async _mount(){
if(!this._browser?.isConnected()){
// 链接browser
this._browser = await puppeteer.connect({
browserWSEndpoint: this._wsEndpoint
});
}
// 创建page实例
if(isEmpty(this._pages)){
for(let i =0;i<this._opts.maxPageCount;i++){
const ctx = await this._browser.newPage();
this._pages.push({
ctx,
busy: false
});
}
}
this._mounted = true;
}
</code></pre>
<p>乍看起来似乎没啥问题,但实际跑一跑代码会发现,在<strong>任务调度密集的时候,</strong>run函数短时间内被调用多次(具体的调度策略下文讲解)<strong>,worker会触发多次冷启动</strong>,虽然不影响业务逻辑,但会<strong>引起服务器资源暴涨</strong>,这是因为冷启动会创建新的browser和page实例,但是旧实例并没有被清理,仍然在执行任务。<br>
冷启动被调用多次的根本原因是<strong>Node.js不是多线程</strong>,如下图所示,假设冷启动耗时20ms,在此期间再次调用run函数,标识位_mounted还未被设置为true,就会又触发一次冷启动。</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181603297-1888192327.png" alt="" loading="lazy"></p>
<p>有没有解法?</p>
<p>当然有。多线程编程解决竞态最常用的就是:<strong>加锁</strong>。既然想模拟多线程那就彻底一点,把锁逻辑也加上呗。</p>
<p>worker本身是有“锁”的,<strong>每个worker有3个page实例,只有当存在空闲实例(busy为false)时run函数才可以执行</strong>,但是这个锁机制并不能避免多次冷启动问题,因为冷启动完成之前page实例还未被创建。</p>
<p>可能会有人说,那就加个限制,page实例不存在时也不让run函数执行不就得了?这么做的话run函数永远都不会被执行啊大聪明。</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181613370-1743030181.png" alt="" loading="lazy"></p>
<p>既然worker已有的锁不行,那就再<strong>加个冷启动锁</strong>,冷启动之前锁定,冷启动之后解锁。这么做当然是可以的,但是会增加逻辑复杂度,worker有两种锁,对后期迭代维护无疑是埋雷。</p>
<p>其实之所以有冷启动无非就是为了省点内存,用时间换空间,一个browser实例+3个空白page实例总共100m左右的内存,这年头内存这么便宜,为了省这点空间把逻辑搞那么复杂完全得不偿失。什么叫过度设计,这就是过度设计。</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181622451-641967996.png" alt="" loading="lazy"></p>
<p>所以后来索性把冷启动过程干掉了,browser和page实例的创建放在worker初始化逻辑里。</p>
<pre><code class="language-typescript">public async init() {
/**
* 尽量禁用掉不需要的功能,提高性能
*/
this._browser = await puppeteer.launch({
headless: true,
args: [
'--incognito',
'--disable-gpu',
'--disable-dev-shm-usage',
'--disable-setuid-sandbox',
'--no-first-run',
'--no-sandbox',
'--no-zygote',
'--single-process'
]
});
this._wsEndpoint = this._browser.wsEndpoint();
// _mount函数逻辑不改动,调用_mount函数放在初始化逻辑中
await this._mount();
}
</code></pre>
<h3 id="加载网页">加载网页</h3>
<p>网页通过page.setContent(html)函数加载本地html文件,与通过page.goto(url)加载远程URL相比,既<strong>节省</strong>了部署网页的<strong>服务器资源</strong>,同时<strong>速度也更快</strong>:</p>
<table>
<thead>
<tr>
<th style="text-align: left"></th>
<th style="text-align: left">时间消耗</th>
<th style="text-align: left">执行时机</th>
<th style="text-align: left">性能瓶颈</th>
<th style="text-align: left">其他</th>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left">远程URL</td>
<td style="text-align: left"><ul><li>DNS耗时</li><li>下载耗时</li><li>解析html耗时</li></ul></td>
<td style="text-align: left">运行时</td>
<td style="text-align: left">网络IO</td>
<td style="text-align: left">异步下载html引用的静态资源会增加额外耗时</td>
</tr>
<tr>
<td style="text-align: left">本地html</td>
<td style="text-align: left"><ul><li>读磁盘耗时</li><li>解析html耗时</li></ul></td>
<td style="text-align: left">预启动阶段</td>
<td style="text-align: left">文件IO+常驻内存</td>
<td style="text-align: left"></td>
</tr>
</tbody>
</table>
<p>上文提到过,本地html文件在<strong>预启动</strong>阶段提前从磁盘读取存放于内存,运行时无需实时读取。所以文件IO的耗时不算在pdf加工逻辑总耗时中,而<strong>加载远程URL只能在运行时执行</strong>,会增长pdf加工的总时长。</p>
<p>另外,加载的<strong>本地html文件中不能存在静态资源引用</strong>,比如js和css必须全部以行内<code><script></code>或<code><style></code>标签的形式、图片必须是base64形式嵌入。这是因为预启动阶段从磁盘只读取html文件的文本内容,不会进行解析。所以在工程化方面,要求网页的构建产出只能有一个完完整整的html文件。</p>
<p>等待网页加载完成后再执行下一步的渲染逻辑,代码如下:</p>
<pre><code class="language-typescript">await page.setContent(htmlContent, {
waitUntil: ['load'] // 等待网页loaded
});
</code></pre>
<h3 id="渲染">渲染</h3>
<p>网页中的渲染代码挂载到window对象一个render函数,Node.js层通过调用这个函数注入渲染数据,render函数返回一个Promise,如果渲染成功返回数据的status为1,代码如下:</p>
<pre><code class="language-typescript">// 等待 window.render 函数准备就绪
await page.waitForFunction('!!window.render');
const renderResult = await page.evaluate(`window.render(${renderData})`);
if(renderResult.status === 1){
// 渲染成功
}else{
// 渲染失败
}
</code></pre>
<p>上文提到等待网页loaded之后再执行渲染,但是上述代码中在调用render函数之前,需要等待window.render函数准备就绪,这是因为网页中部分逻辑在window.onload触发之后进行,<strong>为了确保调用render函数不报错,必须等待render函数准备就绪</strong>。</p>
<h3 id="截取pdf上传oss">截取pdf&上传OSS</h3>
<p>调用page.pdf截取pdf数据,注意这一步只拿数据不保存到磁盘,减少不必要的文件IO,代码如下:</p>
<pre><code class="language-typescript">const buf = await page.pdf({
format: 'A4'
});
</code></pre>
<p>上述代码拿到一个二进制Buffer对象,最后将这个Buffer直接上传至OSS,这一步使用的OSS Node.js SDK。<br>
截止到此,一份PDF文件就加工完成了,但还没算完,需要将加工结果通知Java后端,向MQ队列中发送一条约定好的规范数据,这一步是由外层的 Executor 执行,下面就讲一讲其他两个角色,Scheduler 和 Executor, 在完整架构中的承担的具体工作。</p>
<h2 id="批量加工轮询调度策略">批量加工轮询调度策略</h2>
<h3 id="主动-or-被动">主动 or 被动</h3>
<p>RabbitMQ通常有两种使用形式,一种是<strong>发布订阅</strong>,消费者订阅某个队列,MQ服务主动推送消息给消费者,消费者被动接收,这种是最常用的模式。另一种是消费者不订阅,而是从MQ服务<strong>主动拉取</strong>消息,这种模式下MQ其实充当了存储角色,跟数据库、redis缓存服务类似。</p>
<p>Node.js pdf加工服务的业务特征,第一是<strong>耗时长</strong>;第二是<strong>集群消费</strong>,不论是考虑容灾还是效率,生产环境肯定不能是单节点服务器。</p>
<p>如果采用<strong>发布订阅</strong>模式,最大的优点是时效性高,Node.js服务的架构也更简单,只需要订阅任务队列,消息来了就干活,非常适合<strong>实时性高</strong>、<strong>耗时短</strong>的任务处理。但是在长耗时任务场景,会有两个隐患:</p>
<ol>
<li><strong>消费者服务负载不可控</strong>。如果消费者<strong>不控制并行的任务数量上限</strong>无节制地消费,会造成<strong>服务器负载无限增长</strong>,如果是物理机有可能把机器打爆,如果部署在容器集群会触发动态扩容相对还好一些,但同样会存在重复消费、消息丢失以及扩容过程中由于网络延迟造成的稳定性风险;而如果消费者<strong>设定并行的任务数量</strong>上限虽然会避免这个问题,但是这个上限并不会限制RabbitMQ push消息,消费者并行任务当达到上限后,RabbitMQ仍然会不断push消息,就会造成消费者服务产生<strong>消息堆积</strong>;</li>
<li><strong>集群中多个服务节点均衡消费不可控</strong>。有可能会出现某个节点长时间高负载运行甚至产生<strong>消息堆积</strong>,但同时可能其他节点闲的冒烟儿。虽然可以配置RabbitMQ采用相对合理的分配策略,但也不能保证绝对均衡,同时也增强了消费者服务与MQ直接的耦合性。<br>
以上<strong>这些问题</strong>虽然在短耗时任务场景也存在,但是<strong>在长耗时场景会被进一步放大</strong>。比如消息堆积问题,考虑到稳定性,消费者也不能就放任消息排大队不管不顾,好歹你得搞个任务排队和消费调度逻辑吧,相当于在消费者服务上又弄了个微型任务队列,这不是脱裤子放屁吗?</li>
</ol>
<p>如果是<strong>主动拉取</strong>模式,前文讲了每个Node.js服务的worker规模是固定的,进而负载上限也就固定了,Node.js可以根据worker的忙闲情况自主决定是否拉取新消息进行消费。集群中所有服务节点都是相同的负载上限和调度策略,理论上可以实现绝对的<strong>均衡消费</strong>。另外,由于消费者是<strong>基于线程空闲调度消费</strong>,所以<strong>不会产生消息堆积</strong>,消息存放在MQ任务队列中更稳定,总比在消费者服务上排队好。</p>
<p>基于以上考虑,<strong>最终选定主动拉取模式</strong>。</p>
<p>不过主动拉取模式也并非没有问题,<strong>它的优点恰恰也是缺点</strong>。首先是<strong>时效性不高</strong>,Node.js PDF服务根据预先配置的worker规模进行调度消费,可承载的并行任务数量上限是固定的,不管上游多着急,下游的Node.js服务都按照自己的节奏不紧不慢地干活;然后同样因为这个原因,所以在<strong>高并发场景下无法充分发挥出Node.js非阻塞单线程的高并发优势</strong>。</p>
<p>具体到业务场景,<strong>个册功能并非实时任务,短时并发不会很大,耗时瓶颈也并不在Node.js服务,</strong>这套模式在可预见的<strong>中短期</strong>内足够了。</p>
<blockquote>
<p>不过考虑到未来的不确定性,开发时仍然留了个口子,增加了一个开关切换调度模式,后续如果需要改成发布订阅模式只需简单修改即可。<br>
chatGPT大热之后我向chatGPT 3.5提了几个相关问题,结果与我们的方案是一致的,可惜这时候已经开发完了,要是早点问就能省点脑子了。</p>
</blockquote>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181648368-1936394890.png" alt="" loading="lazy"></p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181654983-894046555.png" alt="" loading="lazy"></p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181702078-2066758232.png" alt="" loading="lazy"></p>
<h3 id="基于事件驱动的轮询调度">基于事件驱动的轮询调度</h3>
<p>主动拉取模式下,消费者需要结合自身负载能力设计合理的调度策略,轮询从MQ拉取消息。常见的轮询策略一般按照分工有两类角色:负责发起执行调用的<strong>调度者</strong>和负责执行逻辑的<strong>执行者</strong>,</p>
<p>调度者发起一次调用执行结束后设置定时器发起下次调用,依次循环,如下示意代码:</p>
<pre><code class="language-typescript">// 调度者
function async shcedule(){
await exec();
setTimeout(shcedule, 3000);
}
// 执行者
async function exec(){
await worker1();
await worker2();
}
async function worker1(){
// ...
}
async function worker2(){
// ...
}
</code></pre>
<p>这是前端最常用的轮询方式,比如在轮询请求后端接口查询任务状态。<br>
执行者函数对调度者来说是黑盒,比如上述代码中,schedule函数必须等待exec函数执行完成之后再发起下次调度。exec依次调用worker1和worker2函数,真实场景下worker1和worker2通常在逻辑上存在一定的耦合或者时序依赖,两者的分工必然是不同的。所以这种<strong>常规轮询的局限性在于</strong>,如果worker1和worker2功能完全一样,没有任何关系和依赖,那么每次执行exec函数,worker1都会有一段时间的空闲,<strong>降低了并行处理的效率</strong>。</p>
<p>稍微改一下呢?比如:</p>
<pre><code class="language-typescript">async function exec(){
await Promise.all();
}
</code></pre>
<p>这样会让worker2不用等待worker1完成之后再执行,并行处理效率略有提升,但需要等待两个worker中耗时最长的一个执行完才能发起下次调度,仍然有一定程度的时间浪费,除非两个worker的耗时完全一致才不浪费。</p>
<p>Node.jsPDF 服务就是这种场景,上文提到了三个角色:<em><strong>Scheduler</strong></em>、<em><strong>Executor</strong></em>、<em><strong>Worker</strong></em>。同时还有一个 <em><strong>worker pool</strong></em>的概念,每个worker负责的工作是一样的,互相之间没有任何耦合和依赖,常规的轮询策略显然效率不够高。</p>
<p>改进的思路很简单:<strong>任何一个worker执行结束之后都会通知Scheduler立即发起下次调度,不用等待所有worker都执行完成</strong>。基于这个思路,再稍微加上一点点细节,最终落地的<strong>基于事件驱动的轮询调度策略</strong>如下图所示:</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181715783-459731117.png" alt="" loading="lazy"></p>
<p>介绍几个概念:</p>
<ul>
<li><em><strong>tick</strong></em>:轮询间隔,初始值为0,即发起下次调度的延时。在任务空闲时段每次调度会递增tick,有最大值限制,目前的配置是1分钟;</li>
<li><em><strong>常态轮询</strong></em>:正常情况下的轮询机制,对应图中绿色框部分;</li>
<li><em><strong>异常容错</strong></em>:一些异常情况的处理策略,对应图中红色框部分。<br>
为了架构的整洁性,以上<strong>轮询策略中的所有事件的触发和响应都分别收归到Executor和Scheduler中</strong>,<strong>worker只负责干活</strong>啥都不管,这样每个角色的代码都更易迭代和维护。</li>
</ul>
<h4 id="常态轮询">常态轮询</h4>
<p>常态轮询涉及两个事件:</p>
<ul>
<li><em><strong>worker_idle</strong></em>事件:代表有空闲worker,对应图中绿色六边形。任意worker执行完一次任务后,<strong>不论任务是否成功</strong>都会触发此事件。Scheduler监听到此事件后会<strong>重置tick为初始值</strong>,并在tick之后发起下次调度;</li>
<li><em><strong>queue_empty</strong></em>事件:代表任务队列为空,对应图中灰色六边形。Node.js从MQ任务队列拉取消息为空时触发此事件。Scheduler监听到此事件后会<strong>递增tick</strong>,并在tick之后发起下次调度。<br>
queue_empty事件还会触发一个额外逻辑:缓存清理。<strong>当任务队列长时间为空,tick已经达到最大值,会在整点时刻触发缓存清理动作</strong>,Node.js服务会销毁并重建Puppeteer browser和page实例,这样做的目的主要是为了空闲时段节省内存,同时也避免浏览器长时间运行可能崩溃的风险(概率较低),保障下次任务执行成功。</li>
</ul>
<h4 id="异常容错">异常容错</h4>
<p>异常容错涉及两个事件:</p>
<ul>
<li>
<p><em><strong>mq_svc_err</strong></em>事件:代表RabbitMQ服务错误,一般是挂了,对应图中红色六边形。当Node.js与RabbitMQ服务通信失败时触发此事件;</p>
</li>
<li>
<p><em><strong>no_idle_worker</strong></em>事件:代表无空闲worker是发起了任务调度,对应图中橘色六边形。正常情况下不会触发此事件,除非调度逻辑有bug,为了服务健壮性,即便此事件被触发也不会导致服务崩溃。<br>
完整的调度流程并不复杂,图中已经比较清晰就不再赘述了,需要特别注意的是,<strong>只有常态轮询的事件才会触发调度,异常容错的事件都只会影响 tick 的增减,不会触发调度</strong>。异常容错的事件时并不会打断轮询,相反,<strong>异常容错的目的就是为了在发生异常后能够让逻辑重新回到常态轮询</strong>,所有事件共同配合组成完整<strong>闭环</strong>:</p>
</li>
<li>
<p>no_idle_worker事件虽然不会触发调度,但此事件发生时说明所有worker都忙,只要有一个worker完成任务都会触发worker_idle事件回到常态轮询,触发下次调度。</p>
</li>
<li>
<p>mq_svc_err事件,只有两种场景会触发此事件,一是尝试从任务队列拉取消息,二是尝试向回传队列发送消息。第一个场景失败会触发两个事件,一个是mq_svc_err另一个是queue_empty。第二个场景,按前文所述,发送回传队列本身就是一次pdf加工流程中的一环,不论是否成功都会触发worker_idle事件。所以这两种场景最终都会回到常态轮询,触发下次调度。<br>
以下是所有事件的简要汇总:</p>
</li>
</ul>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181729125-1095523255.png" alt="" loading="lazy"></p>
<h1 id="工程化">工程化</h1>
<p>以上便是Node.js PDF服务的架构设计和落地形态,下面简单说几点工程化方面需要注意的点。</p>
<h2 id="调试模式">调试模式</h2>
<p>Node.js 服务在轮询状态下不方便开发调试,所以增加了一个开关切换轮询模式,关闭状态下有以下不同:</p>
<ul>
<li>Scheduler不监听相关事件</li>
<li>Node.js服务提供一个接口,调用此接口会发起一次任务调度。如下:</li>
</ul>
<pre><code class="language-typescript">/**
* 运行单条任务
*/
@Post('/task/exec')
@HttpCode(HttpStatus.OK)
@DebugOnly()
public async execTask(){
return this._executorService.runSingle();
}
</code></pre>
<p>调试模式的主要目的是方便前后端进行联调数据,只在开发环境下可用。上述代码中的装饰器DebugOnly代码如下:</p>
<pre><code class="language-typescript">import { SetMetadata } from '@nestjs/common';
export const DEBUG_ONLY = Symbol('DEBUG_ONLY');
/**
* 装饰器:禁止记录日志
* @example
* ```typescript
* @DisableLogging()
* ```
*/
export const DebugOnly = () => SetMetadata(DEBUG_ONLY, true);
</code></pre>
<p>然后开发一个guard中间件拦截非开发环境下的接口请求,关键代码如下:</p>
<pre><code class="language-typescript">export class DebugOnlyGuard implements CanActivate{
constructor(
private readonly _configService: ConfigService<IBootstrapOptions>,
private readonly _reflector: Reflector
){}
canActivate(
ctx: ExecutionContext,
): boolean{
const env = this._configService.get<ENV>('env');
const isDebugOnly = this._reflector.get<boolean>(DEBUG_ONLY, ctx.getHandler()) || false;
if(isDebugOnly && env !== ENV.DEVELOPMENT){
return false;
}
return true;
}
}
</code></pre>
<h2 id="构建">构建</h2>
<p>构建方面只需要注意一点:<strong>执行渲染的网页需要把所有静态资源以行内的形式编译进html文件</strong>。</p>
<p>另外,网页的代码与Node.js代码在同一仓库,不用submodule或monorepo,k8s构建镜像时拉取一次即可。</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181741444-1043261030.png" alt="" loading="lazy"></p>
<blockquote>
<p>monorepo一般用在存在逻辑关联的多个模块之间,通过import方式引入,我们的需求中,执行渲染的网页对于Node.js来说是一种资源,而不是逻辑模块,monorepo并不适合。</p>
</blockquote>
<h2 id="部署">部署</h2>
<p>部署模式本身与常规的Node.js服务没啥区别,主要精力花费在<strong>定制容器镜像</strong>上。</p>
<p>我司的k8s集群容器的基础镜像缺少很多Puppeteer依赖的底层库,大概十几个,光安装这些底层库就需要2-3分钟,如果每次构建镜像都临时安装就且得等了。另外,基础镜像中还缺少网页渲染所需的字体,同样需要在构建镜像是拷贝到容器中。</p>
<p>为了减少每次发布时镜像构建的耗时,我们定制了一个Puppeteer专用的镜像,如下:</p>
<pre><code class="language-typescript">FROM node:16
# 拷贝字体
COPY ./fonts/myfont.ttc /usr/share/fonts/myfont.ttc
# 安装 Puppeteer 依赖的底层库
RUN ["apt-get", "update"]
RUN ["apt-get", "install", "-y", "wget", "unzip", "fontconfig", "locales", "gconf-service", "libasound2", "libatk1.0-0", "libc6", "libcairo2", "libcups2", "libdbus-1-3", "libexpat1", "libfontconfig1", "libgcc1", "libgconf-2-4", "libgdk-pixbuf2.0-0", "libglib2.0-0", "libgtk-3-0", "libnspr4", "libpango-1.0-0", "libpangocairo-1.0-0", "libstdc++6", "libx11-6", "libx11-xcb1", "libxcb1", "libxcomposite1", "libxcursor1", "libxdamage1", "libxext6", "libxfixes3", "libxi6", "libxrandr2", "libxrender1", "libxss1", "libxtst6", "ca-certificates", "fonts-liberation", "libappindicator1", "libnss3", "libgbm-dev", "lsb-release", "xdg-utils", "wget"]
</code></pre>
<p>与基础镜像相比,使用定制镜像的构建耗时大约减少了45%,如下图:</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181756382-953086980.png" alt="" loading="lazy"></p>
<h2 id="监控告警">监控告警</h2>
<p>服务上线之后梳理了一些异常事件配置告警,详情如下</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181806132-742194319.png" alt="" loading="lazy"></p>
<p>以上告警会推送至工作群,专人跟进修复。</p>
<h1 id="问题规划">问题&规划</h1>
<p>服务上线至今约半个月时间内主要发现了两个问题:</p>
<ul>
<li>渲染耗时上下限差距巨大;</li>
<li>服务器内存使用率存在异常尖刺。</li>
</ul>
<h3 id="渲染耗时">渲染耗时</h3>
<p>开发初期加工一份pdf的平均耗时约6s,优化之后最佳耗时能达到250ms左右,但最长耗时仍然有接近3s的任务,上下限差距巨大。细节原因还没完全定位清楚,比如渲染数据的体积与渲染耗时成正比,这点可以理解,但渲染只是完整pdf加工任务中的一环,其他环节也存在非常大的耗时波动,比如下面两条任务的对比:</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181819267-1021196392.png" alt="" loading="lazy"></p>
<p>task-fulfilled是任务总耗时,是下面几个事件耗时+一些其他逻辑的耗时总和。通过对比可以看出不仅是渲染耗时(render-success),其他几个环节也存在非常巨大的耗时波动。</p>
<p>所以,后续的一项工作就是尝试<strong>定位每个环节耗时产生波动具体原因,进一步优化总耗时</strong>。</p>
<h3 id="内存尖刺">内存尖刺</h3>
<p>服务器的CPU曲线相对平缓符合预期,但是在某些时刻内存有非常明显的尖刺,比如下图中18点10分左右:</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181829914-324360890.png" alt="" loading="lazy"></p>
<p>每个PDF的渲染数据体可能不同,内存占用率有波动很正常,但这么明显的尖刺就有问题了。通过查日志发现,这个时间段的某条任务触发了create-tmp-page事件,此事件只会在一种情况下发生:<strong>当worker所有page都忙时被调用执行任务,会临时创建一个page实例执行此条任务</strong>。</p>
<p><img src="https://img2023.cnblogs.com/blog/595796/202304/595796-20230419181843134-99997111.png" alt="" loading="lazy"></p>
<p>与上文种的no_idle_worker事件一样,创建临时page实例同样也是<strong>基于服务健壮性考虑</strong>,当出现异常时保障逻辑正常运行,这相当于超过了服务负载理论上限,必然会引起内存增长。理论上这种情况不会发生,既然发生了,说明调度逻辑有bug。</p>
<p>所以,后续的一项工作就是<strong>定位引起create-tmp-page的原因并修复</strong>。</p>
<h1 id="总结">总结</h1>
<p>以上便是Node.js批量加工PDF的服务架构设计和最终落地形态,通过消息队列与其他服务进行数据交互,基于事件驱动的轮询调度策略主动拉取队列消息。这个服务对于前端团队来说实现了两个突破:</p>
<ul>
<li><strong>首次使用了消息队列</strong>。我个人认为,前端工程师虽然写Node.js服务端,但是难以取代后端,最核心的两个点就是<strong>数据库</strong>和<strong>异步任务</strong>。这两点也是我认为后端工程师的核心竞争力。虽然这次Node.js使用了消息队列,但也只停留在使用的层面,对内在机制和技术原理连边都没摸着,所以也并没有抢后端的饭碗。这是好事,专业的人做专业的事,前端的核心工作仍然是聚焦在应用层交互逻辑上;</li>
<li><strong>首个复杂功能零bug</strong>,测试阶段Node.js的PDF加工逻辑零bug(渲染逻辑有少数bug)。不过上文也写了,通过监控日志能看出来调度逻辑肯定是bug的,测试没测出来是因为<strong>有健全的兜底逻辑保障功能正常运行</strong>,这其实也是一种可借鉴的经验:代码没bug不太可能,能够保证出现bug时功能正常就很考验编码能力。</li>
</ul><br><br>
来源:https://www.cnblogs.com/ihardcoder/p/17333926.html
頁:
[1]