写点什么

苏宁 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:265948

评论

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

模块五作业

老实人Honey

架构训练营

老用户运营从哪里切入?

boshi

运营 私域运营

Java筑基 - JNI到底是个啥

码农参上

Java jni 8月日更

敏捷实践 | 分不清Kanban和看板的只剩你了……

LigaAI

Scrum Kanban 敏捷开发 看板

在线文字图标logo文章封面图生成工具

入门小站

工具

聊一聊这些年看过的动漫

箭上有毒

8月日更

spring 大事务

Rubble

8月日更

前端之算法(七)动态规划

Augus

算法 8月日更

【LeetCode】合并两个排序的链表Java题解

Albert

算法 LeetCode 8月日更

架构实战营第一期 -- 模块五作业

clay

架构实战营

JVM集合之类加载子系统

阿Q说代码

JVM 加载 类加载器 双亲委派 8月日更

有效管理数据安全性—— Pulsar Schema 管理

Apache Pulsar

Apache Pulsar StreamNative schema

AI+云原生,把卫星遥感虐的死去活来

华为云开发者联盟

AI 容器 云原生 k8s 遥感影像

手撸二叉树之单值二叉树

HelloWorld杰少

数据结构与算法 8月日更

Python代码阅读(第12篇):初始化二维数组

Felix

Python 编程 Code Programing 阅读代码

蔚来事故背后,“致命弯道”在辅助驾驶和自动驾驶之间

脑极体

Obsidian一个不错的软件

IT蜗壳-Tango

8月日更

分片上传Minio存储服务的问题集锦[推荐收藏]

liuzhen007

8月日更

【Flutter 专题】64 图解基本 TextField 文本输入框 (一)

阿策小和尚

Flutter 小菜 0 基础学习 Flutter Android 小菜鸟 8月日更

基于AOP和HashMap原理学习,开发Mysql分库分表路由组件!

小傅哥

小傅哥 hashmap 分库分表 aop 数据散列

低代码:时代的选择

华为云开发者联盟

云计算 软件开发 低代码 硬件 IT系统

netty系列之:自定义编码和解码器要注意的问题

程序那些事

Java Netty 程序那些事

架构1期模块五作业

五只羊

架构实战营

如何实现分布式锁,聊聊你的想法?

卢卡多多

redis 分布式锁 8月日更

【设计模式】状态模式

Andy阿辉

C# 编程 后端 设计模式 8月日更

iOS开发:Xcode自带的模拟器常用快捷键的使用

三掌柜

8月日更 8月

vue入门:http客户端axios

小鲍侃java

8月日更

网络攻防学习笔记 Day108

穿过生命散发芬芳

网络安全 8月日更

Linux之ab命令

入门小站

Linux

Ansible 管理 Windows 机器配置过程。

耳东@Erdong

windows ansible 8月日更

三分钟快速了解 Cglib 动态代理

4ye

Java 后端 cglib 代理模式 8月日更

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