扩展 Node.js,枯燥版
大多数讲“如何扩展 Node.js”的文章,作者往往只把一个服务扩展到每天 100万 次请求,就以为这套经验放之四海而皆准。我们运维过好几个日请求量超过 1000万 的系统,得到的经验一点都不光鲜亮丽。
这是我们四年前就希望有人教过我们的实战手册。
别再纠结微服务了
我们见过团队犯的最昂贵的错误,就是在还没到必要阶段就拆分成微服务。一个模块边界清晰的单体应用,能撑住的规模远超大多数工程师的预期,而且运维成本要低一个数量级。
我们的原则是:在以下任何一条成立之前,都保持单体架构。
- 某个特定路径的 扩展特性与其余部分明显不同(例如一个高负载的机器学习推理接口)。
- 某个特定路径的部署节奏不同(例如受合规约束、必须按月发布的代码,而其余部分按天发布)。
- 团队规模超过 25 名工程师,合并冲突已经成为实际问题。
如果以上都不成立,拆分服务只会增加运维成本——服务发现、分布式追踪、部署编排、网络故障模式——却换不来任何干净单体仓库(monorepo)给不了你的东西。
真正的瓶颈是数据库
在我们做过的每一个 Node.js 扩展项目中,瓶颈最终都会转移到数据库上。Node.js 服务的 CPU 和内存通常都没问题,因为 Node 本身擅长处理 I/O 并发。真正的问题在于:
- 无限制的连接池把 Postgres 拖垮
- 单个热点索引成为写入瓶颈
- ORM 中的 N+1 查询,代码审查时看起来毫无问题
- 业务逻辑执行期间,长事务持有锁不放
- 读流量打在主库上,而不是读副本
实际可行的第一步:
- 在 Postgres 前面部署 PgBouncer 或同类工具,使用事务池化模式,连接数上限依据数据库实际 CPU 情况设定,而不是凭 Node.js 的直觉拍脑袋。
- 读副本要显式路由。 不要指望 ORM 自己判断。在代码中准备两个客户端:
db.read和db.write,强制每个开发者自己选择。 - 在生产环境中记录查询耗时。 Pino 加上慢查询日志。当任何查询超过 200ms 时,你需要收到 Slack 告警。
- 在 CI 中对新查询运行 EXPLAIN ANALYZE。 我们把它作为涉及 schema 改动的 PR 检查项。
三层缓存
缓存按这个顺序发挥作用:CDN、内存缓存、Redis。跳过其中任何一层,你都会后悔。
CDN。 任何能在边缘缓存的内容都应该缓存,包括未授权读取的 API 响应。不用耍花招——设置好 Cache-Control 响应头,再配合一个遵守它的 CDN,就能免费解决 60% 的读流量。
内存缓存。 在 Redis 前面加一个小型 LRU 缓存(我们用的是 lru-cache),可以在单个进程内拦截大量重复请求的长尾部分。听起来微不足道,但在我们的实测数据中能减少 30–50% 的 Redis 流量。
Redis。 用于跨进程共享的状态和计算结果。使用时要有明确意图——每个 key 都要有负责人、TTL,以及有文档记录的失效策略。没人追踪的 Redis key,正是生产环境缓存在黑色星期五还在提供 6 个月前旧数据的原因。
能异步的都异步
同步请求路径应该只做最基本的事情:校验、持久化、响应。其他一切——发邮件、触发 webhook、图片处理、第三方 API 调用、搜索索引——都应该放进队列。
我们几乎所有场景都用基于 Redis 的 BullMQ。原因有两个:
- 故障隔离。 如果调用发生在 worker 中,一个不稳定的第三方 API 就无法拖垮你的请求路径。
- 背压控制。 流量突增时,队列会吸收这波冲击,worker 再以可持续的速率消化它。而同步代码在流量突增下只会直接崩掉。
举个实际例子:我们为一个客户重建的结账流程,原来会同步触发 6 个第三方调用(税务、反欺诈、履约、邮件、短信、数据分析)。结账耗时中位数为 1.4 秒,一旦某个服务商变慢,p99 会飙到 8 秒。在把除税务和反欺诈之外的调用全部搬到 BullMQ worker 之后,中位数降到了 220ms,p99 也稳定控制在 600ms 以内。
先做可观测性,再谈精巧架构
在规模化压力下,人的本能是去设计精 巧的系统。但正确的做法是,为你现有的系统打点埋点,直到你能清楚看到时间究竟花在哪里。
我们统一采用以下方案:
- OpenTelemetry 链路追踪,导出到 Honeycomb 或 Tempo。每个请求都有一个 trace ID,每次外部调用都是一个 span。
- 结构化日志,JSON 格式,带上 trace ID。用 Pino 就够了。
- 每个接口的 RED 指标——Rate(速率)、Errors(错误)、Duration(耗时)。用 Grafana 做仪表盘,告警要盯 p99,而不是 p50。
- 从网络外部发起的合成监控,每分钟对关键路径跑一次。
你不可能准确预测出瓶颈在哪,只能靠实测发现它。率先把可观测性做到位的团队,会在接下来六个月的扩展工作中占据优势。
我们反复见过的坑
以下是我们见过的一些拖垮生产环境的常见问题:
- 日志打得太多,结果拖垮了日志管道,最后反而没有日志可查。
- 忘记设置 Node 的
--max-old-space-size。默认值是 1.5GB,而你的容器有 8GB。算一算账就知道问题在哪。 - 在 Promise.all 循环里写数据库却不限制并发数,结果在最糟糕的时刻耗尽连接池。
- 健康检查在服务实际已经故障时依然显示通过。要检测整条依赖链,而不只是进程本身。
- 指望第三方 SDK 自己设置超时。永远要用你自己的超时逻辑包一层。
不酷但真实的结论
在每日 10M 请求的规模下,一个系统能撑住和撑不住之间的差别,很少是因为架构上的天才设计。真正起作用的是:团队会盯着仪表盘看,慢查询一出现当周就修,诚实地做事故复盘,并且在该做监控埋点的时候,克制住重构的冲动。
把这种文化建立起来,Node.js 能撑住的规模会比会议演讲里说的 更高。这正是我们的后端团队和云与 DevOps团队为成长期产品提供的服务。



