写点什么

苏宁 Nodejs 性能优化实战

  • 2018-05-22
  • 本文字数:3755 字

    阅读完需:约 12 分钟

Nodejs 项目背景介绍

自 2016 年以来,苏宁大规模的使用了基于 Nodejs 渲染的项目,架构使用 Nginx+Nodejs+PM2 组合,其中 Nodejs 版本从最初的 6.0+ 升级到如今的 8.0+,Nodejs 框架从 Express 过度到 Koa2,而 Nodejs 的性能优化作为其中的核心,苏宁在其性能提升上,也从 0 到 1,开始摸索。

初步优化—css、js 注册与合并

ejs 模板相关优化

在苏宁的 nodejs 项目中,刚开始使用 express 框架,后来随着 node.js 8.0 LTS 版本的发布,又开始使用 kos 框架。无论是 express 还是 koa 框架,苏宁在项目开发中都使用 ejs 模板语言(关于 ejs 模板语言这里就不多做介绍,有兴趣的同学可以自行搜索)。

合并 css 和 js 带来的性能损失

在使用 ejs 模板过程中,苏宁把公共部分抽出来为 layout.ejs 文件,页面模板通过 ejs include 方法在 layout.ejs 引入,例如:

复制代码
//layout.ejs
<link type="text/css" rel="stylesheet" href="public.css" />
<script src="public.js"></script>
...
include(page1);
...
//page1.ejs
<link type="text/css" rel="stylesheet" href="page1.css" />
<script src="page1.js"></script>
<h1>hello</h1>

这样做解决了公共部分与页面业务逻辑的分离,但是也带来另一个问题 --layout 模板和 page1 模板中静态资源标签位置的问题,以下是渲染过后返回给客户端的 html 页面:

复制代码
...
<link type="text/css" rel="stylesheet" href="public.css" />
<script src="public.js"></script>
</header>
<body>
<div class="header"></div>
<link type="text/css" rel="stylesheet" href="page1.css" />
<script src="page1.js"></script>
<h1>hello</h1>
</body>
...

我们可以看到 page1 的静态资源引用标签都在 body 内,复杂的页面可能还会有 page2、page3、pageN… 这样会有大量的静态资源引用标签出现在 body 内,这显然不符合我们的预期,我们需要控制静态资源标签在页面中的调用位置,为了解决上面的问题,苏宁引入了 ejs 模板静态资源 register 机制,其注册步骤如下:

a. 使用 getResource() 方法输出占位符。

b. 使用 register() 注册方法注册资源,例如:register(‘a.css’, ‘b.js’)。

c. 将注册的静态资源处理合并后进行字符串 replace 操作。

使用 register 方法后 ejs 模板渲染过后的 html 页面如下:

复制代码
...
{{{CSS_PLACEHOLDER}}}
</header>
<body>
<div class="header"></div>
<h1>hello</h1>
</body>
{{{JS_PLACEHOLDER}}}
...

“{{{CSS_PLACEHOLDER}}}”和“{{{JS_PLACEHOLDER}}} ”就是 getResource() 输出的占位符,在服务器 response 之前进行字符串 replace 操作,将占位符替换成 register() 方法中注册的路径:

复制代码
...
<link type="text/css" rel="stylesheet" href="public.css" />
<link type="text/css" rel="stylesheet" href="page1.css" />
</header>
<body>
<div class="header"></div>
<h1>hello</h1>
</body>
<script src="public.js"></script>
<script src="page1.js"></script>
...

这样就符合了正常的页面静态资源引入位置,同时苏宁在 register() 方法做路径合并的功能,合并后的地址路径如下:

复制代码
<link type="text/css" rel="stylesheet" href="public.css,page1.css " /></header>
<body>
<div class="header"></div>
<h1>hello</h1>
</body>
<script src="public.js, page1.js "></script>
...

这样浏览器中发起的请求就会少很多,减少页面请求也是性能优化的一个点。

缓存机制

使用 register 机制后我们又发现了一个问题,当客户端每一个 request 请求发起,nodejs 服务在响应之前都会进行字符串查找替换, 如果页面够复杂,最终渲染生成的字符串足够大,每一次进行字符串查找替换的过程中也造成了一定的性能损耗。正常在实际的使用中我们多次访问一个路由地址,其页面引用的静态资源并不会发生变化。利用这个特性苏宁引入了静态资源缓存机制。

当一个新的页面请求进来之后,在执行 register 方法之前,会根据页面请求地址的 pathname 进行缓存查找,如果命中缓存,则 getResource() 直接返回缓存内容,相应的 regsiter 方法也不会去执行。否则执行 register() 流程。引入缓存机制后,非第一次访问代码逻辑中少了注册、替换流程,相应的页面响应时间也缩短了,经过多次测试,页面响应时间大概缩短 4-8ms。

进阶优化—大量路由的优化匹配

在开发苏宁易购香港站过程当中,由于整站页面较多、参数开发人员众多及基于项目安全性的考虑,项目开发中配置了多达 173 条静态路由以及 11 条动态路由,所以路由匹配效率明显下降。究其原因,得从 express 源码入手,express 框架在处理路由配置的方法是,将每一条配置信息转换成一条正则表达式,在请求进入的时候,逐条进行匹配,直到匹配成功为止。

对于动态路由——路由中含有模糊匹配,则必须使用正则表达式来进行匹配,无法优化。而对于静态路由,就是固定的字符串的路由表达式,则可以通过键值对映射进行匹配,复杂度从 O(n) 变成了 O(1) 大大缩短匹配时间,且不会随着路由增加而耗时加长。在实际代码中,由于架构采用了集中路由配置,所以很方便的从配置文件里面就筛选出了静态路由,然后存放在一个 Object 中(HashMap)。然后形成一个中间件形式,相当于把多条路由中间件变成了一条路由中间件。

缺陷:和原来的逻辑相比,优化后的方案缺少了路由匹配的顺序,所以在开发的时候需要额外注意,不过总体来说影响甚微,因为静态路由优先匹配,也是应该优先响应的。

高阶优化—TPS 的提升

在苏宁易购大聚惠系统的前后端分离中,初次提交压力测试结果非常差。怀疑有什么配置没有配好,当时的数据是这样的(16 台 4C4G):

TPS 低的不能忍,而且当时已经配备了 Node.js 8.9.1 这个版本,理论上绝不可能那么差,在观察代码,也没有发现特别消耗性能的地方。最后我们找到了原因,在 ejs 模版配置的时候没有开启模版缓存导致。如果不开启模版缓存那么每次请求渲染的时候,都会从磁盘中读取本地模版文件进行操作,这个磁盘读取的动作消耗了很多 CPU。平时使用不会察觉,只有当压力测试的时候才会体现出来。设置好了参数后,我们得到了 10 倍的性能提升。

但我们的优化并没有止步于此,我们定的目标是 3000TPS,也就是还需要再提高 50% 的渲染性能。这时候我们就必须找到影响 nodejs 性能的点。Nodejs 的特点是单线程异步编程,意味着异步操作对性能的影响不大,而同步操作则会严重影响性能。

所以第一步,是先检查代码中同步操作的逻辑,是否有消耗 CPU 的代码。经过检查,排除了代码部分的嫌疑。只好借助 chrome 提供的 devtools 来进行分析,启动 node 参数—inspect,打开 chrome 的 devtools 插件就可以通过 CPU profile 进行分析了。排除掉不可避免的 CPU 消耗,问题浮出水面,原来还有一部分的 CPU 消耗来自于 ejs 模版引擎的内部。

从图中可以看出来有两部分消耗,一部分是来自 ejs 模版引擎内部的浅拷贝,一部分是来自查找文件是否存在的系统命令。由于大聚会系统的 ejs 里面大量使用 include,导致了这部分消耗凸显了出来。打开 ejs 引擎源码查看,发现虽然缓存了模版,但每次 include 函数依然会去执行 fs.exsitSync 函数。找到罪魁祸首以后,修改起来其实很简单,在执行改函数的判断条件里面加上先判断缓存中是否存在。修改后这部分消耗减少了不少。

浅拷贝的问题,通过 js 的原型链解决,将传入的数据对象作为原型对象,通过 Object.create 函数构造一个派生对象,实现原来浅拷贝达到的目的(模版内部修改对象属性不会影响原始对象,防止污染原始对象传入到其他模版中去)。派生对象修改属性,并不会修改原型中对象的属性,只会在派生对象中新建一个同名的属性,所以不会污染原始对象。新增属性也只会在派生对象中。这一步优化减少了很多赋值操作。

经过以上的优化,再进行 CPU profile 分析,发现在 ejs 引擎内部依然有一个函数在消耗 CPU,那就是 getIncludePath。这个函数的目的是在执行 include 的时候讲传入的相对路径转成绝对路径,目的是防止嵌套的 include 中传入相同的相对路径字符串,却是代表不同的模版文件。但是在转换成绝对路径这一步里面会调用文件系统函数造成 CPU 消耗。

解决的思路很快就出来了,就是需要讲相对路径映射成绝对路径,然后缓存起来,这样就不必每次去计算绝对路径了。当然这个缓存不能是全局的,必须每一个 include 创建一个缓存,这样才能避免相同的相对路径有歧义的问题。

原始逻辑:

优化的逻辑:

说明:路径映射 Map 是一个定义在模版函数所在作用域上的,只有该模版函数内部能访问到,每次执行模版函数的时候都会拥有一个独立的 Map。

经过上述优化后,本地进行压测有 50% 的性能提升,故提交测试组对大聚会进行线上压测。

压测结果非常好,从 2000tps 到了 3500 多,提升了 75% 之多。单台机器大约 220tps 左右,而原 java 系统单台大概 150tps 左右。

总 结

Nodejs 系统的性能优势主要体现在异步 IO 上面,所以性能瓶颈基本都是出在同步操作上面,那么优化也是主要尽量减少同步操作,适当使用一些 js 的技巧,另外 npm 包的开源特点也给优化工作带来了便利。

作者介绍

李浩,苏宁易购高级前端技术经理,主要负责苏宁前后端分离 nodejs 项目开发。具有多年的 web 前端从业经历,曾任途牛金服前端负责人。热爱前端,对新技术有学习热情,在 nodejs 前后端分离、koa 等框架方面有独特的见解和丰富的项目实践。

感谢覃云对本文的审校。

2018-05-22 18:265960

评论

发布
暂无评论
发现更多内容

HTTP和HTTPS协议整理

赖猫

https HTTP

架构实战营模块8作业

Vic

架构实战营

我们在学习Spark的时候,到底在学习什么?

王知无

我写过的关于成长/面试/职场进阶的文章

王知无

从HTTP到HTTPS

喵叔

7月日更

模块8作业 消息队列MySQL数据结构

TH

架构实战营

网络攻防学习笔记 Day74

穿过生命散发芬芳

网络攻防 7月日更

模块8学习总结

TH

架构实战营

架构实战营模块二作业

技术是伙伴

架构实战营

索引

卢卡多多

索引 7月日更

浅谈大数据的过去、现在和未来

王知无

架构实战营模块八:课后作业

唐江

架构实战营

架构实战营 模块八作业

夏日

架构实战营

架构实战营 模块八作业

Dylan

架构实战营

数据治理方法论和实践小百科全书

王知无

如何理解领域驱动设计

escray

学习 极客时间 7月日更 如何落地业务建模

架构训练营模块八作业

Geek_e0c25c

架构训练营

Ansible Playbook - 02

耳东@Erdong

ansible 7月日更 ansible Playbook

模块二作业分析微信朋友圈高性能复杂度

kitten

微信朋友圈 模块二

第八次作业

Geek_9cf7b5

大数据方向另一个十年开启 |《硬刚系列》第一版完结

王知无

如何写好一份技术简历

慕枫技术笔记

面试 后端 简历

Linux之tar命令

入门小站

Linux

作业表设计

大肚皮狒狒

【架构实战营】第 8模块作业

swordman

架构实战营

设计消息队列存储消息数据的 MySQL 表格

thewangzl

Spark入门介绍与基础案例(二)

Databri_AI

spark 概念

APISIX 网关--初识

陈靓-哲露

网关 APISIX

【HikariCP技术专题】核心源码分析(为什么那么快?)

洛神灬殇

源码分析 7月日更 数据源连接池 HikraCP

2.1如何设计可拓展的架构

Lemon

架构

模块八 作业

CR

苏宁Nodejs性能优化实战_后端_李浩_InfoQ精选文章