在 Node.js 服务端模板渲染中,EJS 渲染变慢的根本原因通常并非模板语言自身,而是缓存未开启、编译结果未复用以及上下文传递不合理。真正的高并发瓶颈九成以上不在 HTML 字符串拼接,而在于模板引擎实例如何复用、缓存开关是否打开、上下文如何传递。如果直接拿 res.render() 接口去压测,日志没关、缓存没开、编译结果没复用,那测出来的数据基本是假象。

EJS 渲染在并发场景下性能下降的原因分析
EJS 的默认设置中,模板缓存是关闭的。这意味着每次调用 res.render() 时,它都会逐一重新读取文件、解析 AST、再生成函数——这一连串操作本质上是同步 I/O 加 CPU 编译,直接抵消了 Node.js 引以为豪的非阻塞优势。因此,以下几项优化必须落实:
- 显式开启缓存:
app.set('view cache', true)(Express 在开发环境下默认是false,不要依赖自动优化) - 警惕热重载工具(如 nodemon)触发的
fs.watch,它们会导致缓存反复失效 - 模板中避免使用同步函数,比如
JSON.parse()、fs.readFileSync()——这些操作会直接阻塞事件循环 - 检查
res.locals中是否传入了庞大的对象,例如整张数据库表,序列化开销会随并发量线性增长,严重影响性能
如何使用 k6 对真实的 EJS 渲染接口进行压力测试
k6 无法直接测量 HTML 渲染结果,它只负责发送 HTTP 请求。你需要测试的是从请求进入直到完整 HTML 返回的端到端延迟,因此后端路由必须真实执行 res.render(),同时排除静态资源的干扰。不妨换个思路:
- 编写一个专用测试路由,例如
GET /test-render,内部仅执行res.render('dashboard', { user: { id: 1, name: 'test' } }),跳过数据库查询和外部 API 调用 - 在 k6 脚本中关闭默认的 cookie 自动管理(
__ENV.K6_NO_COOKIES=1),避免 session 初始化拖慢首字节 - 使用
http.batch()同时请求主页面和关键内联 JS/CSS(如果这些资源也走同一 Express 渲染逻辑),模拟真实首屏加载链路 - 关键断言不应只关注状态码,而要监控
r.timings.duration < 80(P95 延迟)以及r.headers['content-type'] === 'text/html; charset=utf-8'
ab 或 autocannon 测出错误率飙升,应优先查看哪几行日志?
先不要急于调整参数。首先确认是否为模板层的问题,而非连接拒绝或超时。
- 查看 Node 进程的 stdout/stderr:若出现
RangeError: Maximum call stack size exceeded→ 模板递归渲染未设置深度限制 - 查看 Express 日志:大量
Cannot set headers after they are sent→ 模板中使用了异步回调但缺少 await,导致多次res.send() - 查看系统日志:
dmesg | tail检查是否 OOM killer 杀死了 Node 进程(EJS 编译阶段内存易暴涨,尤其当携带大 JSON 数据时) - 使用
node --inspect启动,配合 Chrome DevTools 的 Memory 标签,对比压测前后的堆快照,确认是否存在CompiledFunction实例泄漏——这是模板未被复用的典型信号
pprof 火焰图显示卡在 ejs.render() 内部,该如何处理?
如果火焰图显示大量时间消耗在 String.prototype.replace 或 Function.constructor 上,说明正在高频调用未缓存的模板编译逻辑。好消息是,解决方案并不复杂:
- 首先确认
app.get('view cache')是否为true,启动时打印一行console.log('view cache:', app.get('view cache'))进行验证 - 避免每次请求都新建一个
ejs.Template();改为使用require('ejs').compile(templateString, { cache: true })进行预编译,然后直接调用compiledTemplate(data)执行 - 若使用 Express,确保
app.engine('ejs', require('ejs').__express)是官方绑定,而非自定义封装——第三方封装容易绕过缓存机制 - 将 EJS 升级到 v3.1.9 及以上版本,老版本在多线程场景下
cache键生成存在竞态,缓存 miss 率可高达 40%
真正考验功力的,并非跑出一个漂亮的 TPS 数字,而是确认每一毫秒都花在了它该在的地方。字符串拼接本身极快,但反复编译模板、重复解析 JSON、意外触发垃圾回收——这些才是并发场景下悄无声息吞噬性能的黑洞。
