生产最佳实践:性能与可靠性
本文讨论了部署到生产环境的 Express 应用程序的性能和可靠性最佳实践。
该主题显然属于“DevOps”范畴,涵盖了传统开发和运维。因此,信息分为两部分
- 代码中要做的(开发部分)
- 环境中/设置中要做的(运维部分)
代码中要做的
以下是您可以在代码中做的一些事情,以提高应用程序的性能
使用 gzip 压缩
Gzip 压缩可以大大减小响应体的大小,从而提高 Web 应用程序的速度。在您的 Express 应用程序中,使用 compression 中间件进行 gzip 压缩。例如
const compression = require('compression')
const express = require('express')
const app = express()
app.use(compression())
对于高流量的生产网站,实现压缩的最佳方式是在反向代理级别进行(参见 使用反向代理)。在这种情况下,您无需使用 compression 中间件。有关在 Nginx 中启用 gzip 压缩的详细信息,请参阅 Nginx 文档中的 ngx_http_gzip_module 模块。
不要使用同步函数
同步函数和方法会阻塞执行进程,直到它们返回。对同步函数的单次调用可能在几微秒或毫秒内返回,但在高流量网站中,这些调用会累积并降低应用程序的性能。在生产环境中避免使用它们。
尽管 Node 和许多模块提供其函数的同步和异步版本,但在生产环境中请始终使用异步版本。唯一可以证明同步函数合理性的情况是在初始启动时。
您可以使用 --trace-sync-io
命令行标志,在您的应用程序使用同步 API 时打印警告和堆栈跟踪。当然,您不会希望在生产环境中使用它,而是为了确保您的代码已为生产做好准备。有关更多信息,请参阅 Node 命令行选项文档。
正确地进行日志记录
一般来说,从您的应用程序进行日志记录有两个原因:用于调试和用于记录应用程序活动(本质上是其他所有内容)。在开发中,使用 console.log()
或 console.error()
将日志消息打印到终端是常见的做法。但是,当目的地是终端或文件时,这些函数是同步的,因此它们不适用于生产环境,除非您将输出管道传输到另一个程序。
用于调试
如果您出于调试目的进行日志记录,那么除了使用 console.log()
之外,请使用一个特殊的调试模块,例如 debug。此模块使您能够使用 DEBUG 环境变量来控制哪些调试消息(如果有)发送到 console.error()
。为了使您的应用程序完全异步,您仍然希望将 console.error()
管道传输到另一个程序。但是,您真的不会在生产环境中进行调试,对吗?
用于应用程序活动
如果您正在记录应用程序活动(例如,跟踪流量或 API 调用),那么除了使用 console.log()
之外,请使用一个日志库,例如 Pino,它是可用选项中最快、最高效的。
妥善处理异常
Node 应用程序在遇到未捕获的异常时会崩溃。不处理异常并采取适当措施将导致您的 Express 应用程序崩溃并离线。如果您遵循下面 确保您的应用程序自动重启 中的建议,那么您的应用程序将从崩溃中恢复。幸运的是,Express 应用程序通常启动时间很短。尽管如此,您还是希望首先避免崩溃,为此,您需要妥善处理异常。
为确保处理所有异常,请使用以下技术
在深入探讨这些主题之前,您应该对 Node/Express 错误处理有一个基本的了解:使用错误优先回调,以及在中间件中传播错误。Node 使用“错误优先回调”约定来从异步函数返回错误,其中回调函数的第一个参数是错误对象,其后是后续参数中的结果数据。要表示没有错误,请将 null 作为第一个参数传递。回调函数必须相应地遵循错误优先回调约定才能有意义地处理错误。在 Express 中,最佳实践是使用 next() 函数在中间件链中传播错误。
有关错误处理基础知识的更多信息,请参阅
使用 try-catch
Try-catch 是 JavaScript 语言构造,您可以使用它来捕获同步代码中的异常。例如,使用 try-catch 来处理 JSON 解析错误,如下所示。
这是一个使用 try-catch 处理潜在进程崩溃异常的示例。此中间件函数接受一个名为“params”的查询字段参数,它是一个 JSON 对象。
app.get('/search', (req, res) => {
// Simulating async operation
setImmediate(() => {
const jsonStr = req.query.params
try {
const jsonObj = JSON.parse(jsonStr)
res.send('Success')
} catch (e) {
res.status(400).send('Invalid JSON string')
}
})
})
然而,try-catch 仅适用于同步代码。由于 Node 平台主要是异步的(尤其是在生产环境中),try-catch 不会捕获很多异常。
使用 Promise
当在 async
函数中抛出错误或在 async
函数内等待被拒绝的 Promise 时,这些错误将像调用 next(err)
一样传递给错误处理程序。
app.get('/', async (req, res, next) => {
const data = await userData() // If this promise fails, it will automatically call `next(err)` to handle the error.
res.send(data)
})
app.use((err, req, res, next) => {
res.status(err.status ?? 500).send({ error: err.message })
})
此外,您可以将异步函数用于中间件,并且如果 Promise 失败,路由器将处理错误,例如
app.use(async (req, res, next) => {
req.locals.user = await getUser(req)
next() // This will be called if the promise does not throw an error.
})
最佳实践是尽可能在靠近站点的地方处理错误。因此,虽然现在在路由器中处理,但最好在中间件中捕获错误并处理它,而无需依赖单独的错误处理中间件。
不该做什么
您不应该做的一件事是监听 uncaughtException
事件,当异常一直冒泡到事件循环时会发出此事件。为 uncaughtException
添加事件监听器将改变遇到异常的进程的默认行为;尽管发生异常,进程仍将继续运行。这听起来可能是一个防止应用程序崩溃的好方法,但在未捕获异常后继续运行应用程序是一种危险的做法,不建议这样做,因为进程的状态变得不可靠且不可预测。
此外,使用 uncaughtException
被官方认为 粗糙。因此,监听 uncaughtException
只是一个坏主意。这就是为什么我们推荐多进程和监督器之类的东西:崩溃和重启通常是从错误中恢复的最可靠方法。
我们也不建议使用 domain 模块。它通常不能解决问题,并且是一个已弃用的模块。
环境中/设置中要做的
以下是您可以在系统环境中做的一些事情,以提高应用程序的性能
将 NODE_ENV 设置为 “production”
NODE_ENV 环境变量指定应用程序运行的环境(通常是开发或生产)。提高性能最简单的方法之一就是将 NODE_ENV 设置为 production
。
将 NODE_ENV 设置为“production”会使 Express
- 缓存视图模板。
- 缓存从 CSS 扩展生成的 CSS 文件。
- 生成更简洁的错误消息。
测试表明,仅仅这样做就可以将应用程序性能提高三倍!
如果您需要编写特定于环境的代码,您可以使用 process.env.NODE_ENV
检查 NODE_ENV 的值。请注意,检查任何环境变量的值都会带来性能损失,因此应谨慎进行。
在开发中,您通常会在交互式 shell 中设置环境变量,例如使用 export
或您的 .bash_profile
文件。但一般来说,您不应该在生产服务器上这样做;相反,请使用操作系统的 init 系统(systemd)。下一节将提供有关使用 init 系统的更多详细信息,但设置 NODE_ENV
对于性能非常重要(且易于操作),因此在此处突出显示。
使用 systemd,在您的单元文件中使用 Environment
指令。例如
# /etc/systemd/system/myservice.service
Environment=NODE_ENV=production
有关更多信息,请参阅 在 systemd 单元中使用环境变量。
确保您的应用程序自动重启
在生产环境中,您永远不希望您的应用程序离线。这意味着您需要确保它在应用程序崩溃和服务器本身崩溃时都能重新启动。尽管您希望这些事件都不会发生,但实际上您必须通过以下方式来应对这两种情况:
- 使用进程管理器在应用程序(和 Node)崩溃时重启它。
- 使用操作系统提供的 init 系统在操作系统崩溃时重启进程管理器。也可以在没有进程管理器的情况下使用 init 系统。
Node 应用程序在遇到未捕获的异常时会崩溃。您首先需要做的是确保您的应用程序经过充分测试并处理所有异常(详见 妥善处理异常)。但作为一种故障保护措施,请设置一个机制,确保您的应用程序在崩溃时能够自动重启。
使用进程管理器
在开发中,您只需通过命令行 node server.js
或类似命令启动您的应用程序。但在生产环境中这样做无异于自寻死路。如果应用程序崩溃,它将离线,直到您手动重启它。为确保您的应用程序在崩溃时自动重启,请使用进程管理器。进程管理器是应用程序的“容器”,它便于部署、提供高可用性并允许您在运行时管理应用程序。
除了在应用程序崩溃时重启它之外,进程管理器还可以让您:
- 深入了解运行时性能和资源消耗。
- 动态修改设置以提高性能。
- 控制集群 (pm2)。
历史上,使用 Node.js 进程管理器如 PM2 曾很流行。如果您希望这样做,请参阅其文档。但是,我们建议使用您的 init 系统进行进程管理。
使用 init 系统
可靠性的下一层是确保您的应用程序在服务器重启时也能重启。系统仍然可能因各种原因而宕机。为确保您的应用程序在服务器崩溃时重启,请使用操作系统内置的 init 系统。目前主要使用的 init 系统是 systemd。
将 init 系统与 Express 应用程序结合使用有两种方式:
- 在进程管理器中运行您的应用程序,并将进程管理器作为服务安装到 init 系统中。进程管理器会在应用程序崩溃时重启应用程序,而 init 系统会在操作系统重启时重启进程管理器。这是推荐的方法。
- 直接使用 init 系统运行您的应用程序(和 Node)。这相对简单,但您无法获得使用进程管理器的额外优势。
Systemd
Systemd 是 Linux 系统和服务管理器。大多数主要的 Linux 发行版已将 systemd 作为其默认的 init 系统。
systemd 服务配置文件称为单元文件,其文件名以 .service
结尾。以下是一个直接管理 Node 应用程序的单元文件示例。请将 <angle brackets>
中包含的值替换为您系统和应用程序的实际值。
[Unit]
Description=<Awesome Express App>
[Service]
Type=simple
ExecStart=/usr/local/bin/node </projects/myapp/index.js>
WorkingDirectory=</projects/myapp>
User=nobody
Group=nogroup
# Environment variables:
Environment=NODE_ENV=production
# Allow many incoming connections
LimitNOFILE=infinity
# Allow core dumps for debugging
LimitCORE=infinity
StandardInput=null
StandardOutput=syslog
StandardError=syslog
Restart=always
[Install]
WantedBy=multi-user.target
有关 systemd 的更多信息,请参阅 systemd 参考(man 手册页)。
在集群中运行您的应用程序
在多核系统中,通过启动进程集群可以多次提高 Node 应用程序的性能。集群运行应用程序的多个实例,理想情况下是每个 CPU 核一个实例,从而在实例之间分配负载和任务。
重要提示:由于应用程序实例作为单独的进程运行,它们不共享相同的内存空间。也就是说,对象是应用程序每个实例的本地对象。因此,您无法在应用程序代码中维护状态。但是,您可以使用内存数据存储(例如 Redis)来存储会话相关数据和状态。此注意事项适用于几乎所有形式的水平扩展,无论是使用多个进程还是多个物理服务器进行集群。
在集群应用程序中,工作进程可以单独崩溃而不会影响其余进程。除了性能优势之外,故障隔离是运行应用程序进程集群的另一个原因。每当工作进程崩溃时,请务必记录事件并使用 cluster.fork() 生成一个新进程。
使用 Node 的 cluster 模块
Node 的 cluster 模块 使得集群成为可能。这使得主进程能够生成工作进程并在工作进程之间分发传入连接。
使用 PM2
如果您使用 PM2 部署应用程序,那么您可以在不修改应用程序代码的情况下利用集群。您应该首先确保您的 应用程序是无状态的,这意味着进程中不存储本地数据(例如会话、websocket 连接等)。
当使用 PM2 运行应用程序时,您可以启用 集群模式 以在集群中运行它,实例数量可由您选择,例如与机器上可用 CPU 的数量相匹配。您可以使用 pm2
命令行工具手动更改集群中的进程数量,而无需停止应用程序。
要启用集群模式,请像这样启动您的应用程序:
# Start 4 worker processes
$ pm2 start npm --name my-app -i 4 -- start
# Auto-detect number of available CPUs and start that many worker processes
$ pm2 start npm --name my-app -i max -- start
这也可以在 PM2 进程文件(ecosystem.config.js
或类似文件)中配置,通过将 exec_mode
设置为 cluster
,将 instances
设置为要启动的工作进程数量。
一旦运行,应用程序可以按如下方式进行扩展:
# Add 3 more workers
$ pm2 scale my-app +3
# Scale to a specific number of workers
$ pm2 scale my-app 2
有关使用 PM2 进行集群的更多信息,请参阅 PM2 文档中的 集群模式。
缓存请求结果
另一种提高生产性能的策略是缓存请求结果,这样您的应用程序就不必重复操作来反复服务相同的请求。
使用像 Varnish 或 Nginx(另请参阅 Nginx 缓存)这样的缓存服务器可以大大提高应用程序的速度和性能。
使用负载均衡器
无论应用程序优化得多么好,单个实例只能处理有限的负载和流量。扩展应用程序的一种方法是运行它的多个实例,并通过负载均衡器分发流量。设置负载均衡器可以提高应用程序的性能和速度,并使其能够比单个实例实现更大的扩展。
负载均衡器通常是反向代理,它协调进出多个应用程序实例和服务器的流量。您可以通过使用 Nginx 或 HAProxy 轻松为您的应用程序设置负载均衡器。
使用负载均衡时,您可能需要确保与特定会话 ID 关联的请求连接到发起它们的进程。这被称为会话亲和性或粘性会话,并且可以通过上述建议来解决,即使用 Redis 等数据存储来存储会话数据(取决于您的应用程序)。有关讨论,请参阅 使用多个节点。
使用反向代理
反向代理位于 Web 应用程序之前,除了将请求定向到应用程序外,还对请求执行辅助操作。它可以处理错误页面、压缩、缓存、文件服务以及负载均衡等。
将不需要了解应用程序状态的任务交给反向代理,可以释放 Express 去执行专门的应用程序任务。因此,建议在生产环境中将 Express 运行在 Nginx 或 HAProxy 等反向代理之后。
编辑此页面