首页 / 文章 / Node.js API性能:一种基于优先级排序的优化框架

Node.js API性能:一种基于优先级排序的优化框架

学习如何将 Node.js API 性能问题的修复工作按投入与收益程度进行分类,从而在追求复杂优化之前先解决连接池和 N+1 查询问题。

1300 词

大多数关于加快 Node.js API 速度的文章都会直接列出十五条建议,比如更快的 JSON 序列化工具与数据库连接池优化并列,仿佛每条建议都值得同等重视。这其实具有误导性。有些改进只需十分钟就能大幅缩短响应时间,而另一些则可能需要数月努力才能获得微不足道的提升。理解这些差异远比记住列表中的每一条建议更重要。

第一级:优先执行这些(效果显著,投入少)

实施这些改动几乎不需要任何成本。它们所需的代码量很少,风险也低,实际上,比起人们试图采用的那些复杂解决方案,它们才是导致性能缓慢的真正原因。

为所有出站HTTP请求启用保持连接功能。默认情况下,Node的HTTP客户端每次调用都会建立新的连接,因此每次向外部服务发起请求都需要重新进行完整的TCP握手和TLS协商。通过在多次请求之间重用同一个连接,可以避免后续对同一主机的调用产生额外的开销。

const agent = new https.Agent({ keepAlive: true, maxSockets: 50 });

始终通过连接池与数据库通信,而非使用单一连接。在真正的并发负载下,单个连接实际上会变成一个所有请求都必须依次等待的队列。适当规模的连接池能够让API以其真正的并行结构来处理并发流量。

让独立的异步调用并行执行,而非依次执行。当两个await语句不依赖彼此的输出时,就没有理由强制它们排队等待。

// costs the sum of both calls
const user = await getUser(id);
const orders = await getOrders(id);

// costs roughly the slower of the two
const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);

为查询中实际用于过滤、连接或排序的列添加索引。在所有建议中,这对于从不断增长的表中读取数据的接口而言可能是收益最大的措施,而且通常只需一行代码即可实现。

消除N+1查询模式。先获取列表,然后在循环中为每个条目再执行一次查询,在测试数据量较少的情况下看似无害,但一旦处理成千上万的真实数据时就会变成严重问题。应用单次批量查询来替代循环以获取相关数据。

对于那些否则可能会返回无限结果集的查询,应使用 LIMIT 来限制结果数量。一个能够返回“该客户所有历史订单”的接口,对于新账户来说尚可正常工作,但对于有多年订单记录的账户而言则会出现问题。

第二层级:值得真正投入(高影响力,需付出较大努力)

这一层级的改进并非微小的调整,而是需要实际的设计工作,往往还涉及新的基础设施,但它们能够解决第一层级解决方案无法处理的各类问题。

为那些成本高昂且频繁发生的读取操作引入缓存层。在缓慢的聚合查询或昂贵的第三方 API 调用之前部署 Redis,可将 200 毫秒的响应时间缩短至约 2 毫秒。难点不在于搭建缓存本身,而在于设计出足够可靠的失效策略,确保缓存不会悄悄返回过时的结果。

将那些耗时且非关键的任务完全从请求-响应循环中分离出来。发送确认邮件、生成报告、更新分析数据,这些操作都无需在回复客户端之前完成。通过将 BullMQ 或 SQS 等队列与专用的工作进程结合使用,原本需要 2 秒才能处理的请求时间可缩短至接近 80 毫秒。

对于规模庞大或层级较深的查询结果集,应从基于偏移量的分页方式改为基于游标的分页方式。基于OFFSET的分页方式在页面越深时速度会逐渐变慢,因为数据库仍需扫描之前所有的行。而基于游标的分页方式无论是在第5页还是第5000页,处理成本大致相同。

通过真正的负载均衡器以及集中式的会话或缓存机制来实现水平扩展。无论如何优化,单个Node进程最终都会达到性能上限。在负载均衡器后运行多个实例,让它们共享同一个Redis缓存和连接池,便能在不要求单个进程速度提升的情况下提高整体性能上限。

在进一步优化之前,请先查看性能概况。一旦明显的問題得到解決,凭猜測来判断哪部分速度慢就不再是一種可靠的策略。使用真正的性能分析工具或APM工具,比如clinic.js這樣的托管式APM產品,或者對可疑查詢執行EXPLAIN ANALYZE命令,才能真正顯示出時間究竟花在了哪裡,而非僅憑猜測。

第三級:通常不值得優先處理(影響較小,常被高估)

這些問題在性能討論中經常被提及,但在真正的生產環境API中卻很少能帶來實質性改變,主要是因為它們針對的往往是本來就非瓶頸部分的代碼。

优化 JSON 序列化过程。确实存在更快的 JSON 库,也能起到一定作用,但仅限于极少数 API 才会达到的规模。如果你的真正问题是 300 毫秒的数据库查询时间,那么试图通过减少几毫秒的序列化时间来解决这个问题是治标不治本。

仅仅为了降低框架开销而更换框架。Express 与所谓更快的替代框架之间的性能差距确实存在,但与未建立索引的表或 N+1 查询所带来的损失相比微不足道。选择框架还有很多其他重要原因,单纯的运行速度通常并非其中之一。

先处理集群相关问题,再考虑其他事项。启动多个节点进程以利用额外的CPU核心,确实有助于提升那些受CPU限制的任务性能。但对于因等待未索引查询而变慢的接口而言,这毫无作用——因为无论有多少进程处于空闲状态等待,等待本身依然存在。

仅为提升性能而用更低级的语言重写高频代码路径。在存在明确的、经验证的CPU瓶颈时,这种做法是合理的。但更多情况下,人们在尚未确认时间消耗确实出在那个环节之前就尝试这么做,结果让本应有效的技巧变成了针对错误目标的徒劳努力。

如何真正运用这一方法

在考虑其他措施之前,先解决所有一级问题,因为这些问题的成本低、风险小,且能解决大多数实际性能问题。只有当性能分析显示一级措施不足以处理某些特定端点时,才应进一步处理二级问题,而非将其当作需要普遍应用的改写方案。在没有来自性能分析工具的明确证据表明某个特定技术确实是瓶颈之前,不要去处理三级问题。大多数运行缓慢的 API 实际上是由于未解决的一级问题,而非缺少从博客文章中找来的某些冷门优化方案。

相关阅读

  • Edge Isolates和Wasm与Node.js的对比:运行时差异及生产环境使用习惯 —— 探讨了V8隔离机制与WebAssembly为何在边缘环境中能优于基于容器的Node.js,随后介绍了使Node.js具备生产环境使用条件的操作实践。
  • process.nextTick()如何悄悄导致Node.js事件循环阻塞 —— 解释了为何递归调用process.nextTick()会完全阻断libuv的轮询阶段,以及如何通过使用setImmediate()来解决事件循环阻塞问题。
  • 防止生产环境服务器宕机的20种Node.js模式 — 了解20种实用的Node.js模式,涵盖错误处理、优雅关闭和连接池管理等内容,帮助在不得不重启之前避免系统崩溃。