Node.js下Express结合Puppeteer并行时,如何跨路由传递Page维持并发
跨Express路由复用Puppeteer会话的实现方案
现有实现的核心痛点
- 每个路由单独启动Browser实例,进程开销大、响应慢
- 直接用全局变量存储Browser/Page实例时,多并发请求会互相覆盖实例,出现会话串扰、并行能力失效的问题
- 核心需求是:在不破坏并发能力的前提下,让
/api/auth/check创建的浏览器会话,能被后续调用的/api/auth/register接口复用,完成同一会话下的连续操作
方案1:内存态会话映射表(轻量场景适用)
核心思路是放弃直接挂全局单实例的写法,用唯一会话ID作为键,把对应会话的Browser/Page实例存入键值映射结构,从根本上避免多请求抢同一个全局变量的问题。
落地流程:
/api/auth/check接口创建完Page实例后,生成全局唯一的会话ID,把关联的Browser、Page实例存入全局Map结构- 把生成的会话ID返回给客户端(或写入用户Cookie)
/api/auth/register接口收到请求时,从请求参数/Cookie中取出会话ID,从Map中取出对应的Browser、Page实例直接复用操作
参考实现代码:
const crypto = require('crypto') const puppeteer = require('puppeteer') const express = require('express') const app = express() app.use(express.json()) // 全局会话映射表:键为会话ID,值为对应会话的浏览器实例对象 const puppeteerSessionMap = new Map() app.post('/api/auth/check', async (req, res) => { try { const browser = await puppeteer.launch() const page = await browser.newPage() await page.goto('https://www.google.com') // 生成全局唯一会话ID const sessionId = crypto.randomUUID() // 存入映射表,记录创建时间用于后续过期清理 puppeteerSessionMap.set(sessionId, { browser, page, createTime: Date.now() }) res.json({ message: 'Success', sessionId: sessionId }) } catch (e) { console.log(e) res.status(500).json({ message: 'Error' }) } }) app.post('/api/auth/register', async (req, res) => { try { // 从请求体中取出客户端传入的会话ID const { sessionId } = req.body const session = puppeteerSessionMap.get(sessionId) if (!session) { return res.status(404).json({ message: '会话已过期,请重新发起校验' }) } const { browser, page } = session // 直接复用同一个Page实例执行后续操作 await page.waitForTimeout(1000) // 操作完成后手动关闭实例,从映射表删除,避免内存泄漏 await browser.close() puppeteerSessionMap.delete(sessionId) res.json({ message: '注册操作完成' }) } catch (e) { console.log(e) res.status(500).json({ message: '操作失败' }) } })
方案优化点:
- 必须增加定时清理逻辑:每隔固定时间遍历映射表,把超过过期阈值(比如10分钟无操作)的会话自动关闭Browser、从Map中移除,防止内存泄漏
- 因为Map的键是每个请求独立的唯一ID,不同用户的会话完全隔离,不会出现实例覆盖的问题,并行处理能力不受影响
方案2:Browser进程池 + 会话级Page绑定(高并发生产场景适用)
在方案1的基础上进一步优化资源开销:
- 不需要每个会话单独启动一个独立的Browser进程——Browser进程本身启动开销高、资源占用大,可以在应用启动时预启动固定数量的常驻Browser进程,组成进程池
- 需要用浏览器时,从进程池取出空闲的Browser实例,为当前会话创建独立的Page,再把Page和会话ID绑定存入映射表
这种模式下,Browser启动的开销被全量请求摊薄,并发承载能力比单会话启Browser的模式高一个量级,同时因为Page是会话级隔离的,依然能保证多请求并行不串扰。
多实例部署场景适配
如果后端服务是多副本部署的,单靠内存中的会话映射表会失效(同一用户的check、register请求可能被负载均衡路由到不同的服务实例,无法拿到之前的Page实例),可通过两种方式适配:
- 给负载均衡配置会话粘性策略:把同一个会话ID对应的所有请求,固定路由到同一个后端服务实例上
- 采用远程Browser架构:把Browser进程抽离成独立的常驻服务集群,所有Express实例通过Puppeteer的远程连接能力访问Browser集群,把「会话ID与Browser连接地址、Page标识的对应关系」存在Redis这类分布式缓存里,不管请求落到哪个Express实例,都能定位到对应的Page实例完成操作
常见避坑提示
- 不要尝试把Page/Browser实例存在Express的
req对象中跨路由传递:req的生命周期仅覆盖单次请求,第二次请求的req是全新的对象,无法取到上一次存储的实例 - 禁止不同用户的会话共享同一个Page实例:Page的上下文是全局的,共享会导致操作串扰
- 所有会话实例必须设置过期兜底,防止未被正常关闭的Browser进程长期占用内存
内容的提问来源于stack exchange,提问作者apsenT
相关产品推荐
相关产品推荐

