#Next.js#TypeScript

Node.js 后端工程化要点

会写 Express 路由不算会 Node 后端。本文讲清生产级 Node 后端的关键:错误处理(别让进程崩)、日志与可观测、配置与密钥管理、数据库连接池、定时任务与队列、进程管理(PM2/cluster)、以及 Node 单线程并发的真相与误区。

本文要点

  • 主题聚焦:Next.js、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年6月18日·更新于 2026年7月23日

会写几个 Express 路由、能跑通数据库增删改查,就敢说"会后端"了?在本地能跑、压测时 502、上线一周后半夜崩掉的服务,差的从来不是业务代码,是工程化。Node.js 由于单线程 + 默认不重启的运行模型,把传统后端那些"框架替你兜了"的事(进程崩溃、连接池、并发隔离)直接甩到你脸上。下面把生产级 Node 后端绕不开的几件事讲透。

一、会写路由 ≠ 会后端

很多人对"后端"的想象停在 app.get('/users', ...) 这种 demo 上。生产级后端要补的工程大致是:进程不能崩、日志能查、密钥不能泄、数据库不能被你打挂、慢任务不能堵住请求、多核 CPU 不能只用一颗、线上问题能在十分钟内定位。

这些事在小项目里看不见,在中流量项目里是偶发故障,在大流量项目里是日常救火。本文后面每一节,都是"线上踩过坑才会写"的内容。

二、错误处理:别让进程崩

Node 最反直觉的一点:一个未捕获的同步异常或 Promise rejection,默认会让整个进程退出。你的 10000 个并发请求,会因为一个 JSON.parse(undefined) 全部断连。

兜底必须做两层:

js
// 进程级兜底:最后防线,到这里说明已经有 bug 漏出来了
process.on('uncaughtException', (err) => {
  logger.fatal({ err }, 'uncaughtException, 准备退出');
  // 不要假装没事继续跑!状态可能已经不一致,交给进程管理器重启
  server.close(() => process.exit(1));
  // 兜底:0.5s 内强退,防止 connection hang 死
  setTimeout(() => process.exit(1), 500).unref();
});

process.on('unhandledRejection', (reason) => {
  logger.fatal({ reason }, 'unhandledRejection');
  // Node 新版本默认就会因此退出,这里主动控制退出流程
  process.exit(1);
});

为什么 uncaughtException 里要退出而不是继续?因为错误发生时,可能正持有锁、连接、文件句柄的中间状态。继续跑相当于带病作业,数据会越写越烂。正确做法是:记日志 → 优雅关闭 → 让 PM2/systemd 把进程拉起来。

框架级兜底(Express / Koa / Fastify 各有写法)也要做,在错误冒到顶层前统一成 HTTP 响应:

js
// Express,放在所有路由之后
app.use((err, req, res, next) => {
  const status = err.status || 500;
  logger.error({ err, path: req.path }, 'request failed');
  res.status(status).json({
    error: status >= 500 ? 'internal_error' : err.message,
  });
});

最后一条:别用 throw 当流程控制。throw new Error('user not found') 在 Node 里意味着一次栈展开 + 可能漏到进程级 handler。业务上的"预期错误"用返回值或 Result 类型,把 throw 留给真正的意外。

三、日志与可观测:别再 console.log

console.log 在生产等于没日志:没有级别、没有时间戳、没有 trace、不能被 ELK/Loki 检索。最低标准上结构化日志:

js
import pino from 'pino';
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  redact: ['req.headers.authorization', '*.password'], // 密钥别进日志
});

// 用法:每个日志都是一行 JSON,机器可读
logger.info({ userId, durationMs }, 'login_success');

三个不能省的件:

  1. 请求 ID / trace:每个请求注入一个 reqId,所有日志带上,这样一次请求的所有日志能串起来。配合 OpenTelemetry 还能跨服务追踪。
  2. 指标埋点:QPS、p99 延迟、错误率、连接池占用、队列堆积——这些不能靠"日志里 grep",要打到 Prometheus,看 Grafana。
  3. 错误聚合:Sentry 之类的工具,把异常栈自动收口,别靠用户截图反馈 bug。

四、配置与密钥:env 不是万能的

别硬编码。const DB_URL = 'postgres://...' 这种写法上线第一天就该被开掉。但要更进一步:

  • schema 校验:process.env.DB_URL 万一拼错了,启动就报错,别等第一个请求挂了才知道。用 zod / envalid 做校验:
    ts
    const env = z.object({
      DATABASE_URL: z.string().url(),
      PORT: z.coerce.number().default(3000),
      JWT_SECRET: z.string().min(32),
    }).parse(process.env);
    
  • 环境隔离:dev/staging/prod 用不同密钥;prod 密钥放 Vault / AWS Secrets Manager / 阿里云 KMS,而不是写进 .env 提到 git。
  • 密钥轮换:JWT 签名密钥、数据库密码这种东西,要支持双密钥并存,平滑切换,而不是"换了立刻全掉"。

.env 文件请进 .gitignore,这是最基本的。

五、数据库连接池:别每次 new 一个

新手最大的错误之一:每个请求里 mysql.createConnection() → 查询 → connection.end()。TCP 握手 + 鉴权开销,直接把吞吐量打骨折。

正确做法是全局建一个连接池,请求从池里借连接、用完还回去:

js
import { Pool } from 'pg';
const pool = new Pool({
  connectionString: env.DATABASE_URL,
  max: 20,           // 连接数,根据数据库 max_connections 设
  idleTimeoutMillis: 30_000,
});

// 请求里
const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);

ORM(Prisma / TypeORM)还是查询构建器(kysely / Knex)?取舍很现实:

  • Prisma:DX 顶级、类型安全好,但复杂查询会被它的抽象拖累,慢查询排查地狱。
  • TypeORM:功能全、装饰器风格,但类型推导和质量被吐槽多年。
  • kysely:纯类型安全的 SQL 构建器,接近原生 SQL,掌控力强,适合对性能敏感的服务。

如果业务复杂度高、CRUD 占大头、团队想要快,选 Prisma;如果是高并发、需要精细 SQL、团队愿意写 SQL,选 kysely。我个人在新项目里默认 kysely。

六、定时任务与后台队列:和请求处理分离

发邮件、生成报表、清理过期数据——这些慢任务如果同步跑在请求里,用户等十秒才返回,体验崩,网关也会超时。

两种工具:

  • node-cron:进程内定时任务,适合简单、单实例、轻量场景:

    js
    import cron from 'node-cron';
    cron.schedule('0 3 * * *', () => cleanupExpiredSessions());
    

    单实例时没问题,多实例部署时同一个任务会被跑多遍,需要加分布式锁。

  • BullMQ:基于 Redis 的队列,适合生产环境:

    js
    import { Queue, Worker } from 'bullmq';
    const emailQueue = new Queue('email', { connection: redis });
    await emailQueue.add('send', { to, subject }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } });
    
    new Worker('email', async (job) => sendMail(job.data), { connection: redis });
    

    重试、延迟、优先级、并发控制、死信队列,BullMQ 全给你了。

原则:API 进程只做编排,重活扔给 Worker。这样 API 永远快,Worker 出问题也不会影响请求响应。

七、进程管理:PM2 / cluster,榨干多核

Node 默认一个进程一个线程,跑在 16 核机器上等于浪费 15 个核。两种方案:

  1. cluster 模块:Node 内置,主进程 fork 多个子进程,共享端口:
    js
    import cluster from 'node:cluster';
    if (cluster.isPrimary) {
      for (let i = 0; i < os.cpus().length; i++) cluster.fork();
    } else {
      startServer();
    }
    
  2. PM2:帮你做 cluster + 日志切割 + 自动重启 + 0 秒重载:
    bash
    pm2 start dist/main.js -i max   # 按核数起进程
    pm2 reload all                  # 集群热重启,无停机
    

容器时代还要不要 PM2?如果用 Kubernetes,通常 1 容器 1 进程,多副本交给 K8s,重启交给 K8s。PM2 适合"我就一台机器,跑 Node 服务"的中小规模。

内存泄漏监控:Node 默认堆上限约 1.5GB(64 位)。长跑服务如果内存一直涨,通常是闭包、Map 缓存、未清理的 listener。监控 process.memoryUsage().heapUsed,或在 PM2 / Docker 里设内存上限,超了自动重启:

bash
pm2 start app.js --max-memory-restart 1G

优雅关闭:收到 SIGTERM 时(部署、缩容),别立刻 exit,先停止接收新请求 → 处理完在途请求 → 关闭数据库连接池 → 再退出。否则会丢请求、断连接:

js
process.on('SIGTERM', async () => {
  logger.info('SIGTERM received, shutting down');
  server.close();
  await pool.end();
  await emailQueue.close();
  process.exit(0);
});

八、Node 单线程的真相

最大的误区:"Node 是单线程,所以并发不行"。准确说法是:

  • JS 代码运行在主线程,一次只能做一件事。
  • I/O(网络、磁盘、数据库)是异步非阻塞的,底层由 libuv 用线程池处理,主线程不用等,可以同时挂上万个 I/O。

所以 Node 特别适合 I/O 密集型:API 网关、聊天室、爬虫调度、代理服务器,这种"90% 时间在等"的场景,Node 一台机器扛几万连接很轻松。

真正怕的是 CPU 密集:大 JSON 解析、图片处理、加解密、复杂算法。这种活如果跑在主线程,会阻塞所有请求,p99 延迟直接爆炸。解决方案:

  • worker_threads:同进程内开 worker 跑 CPU 密集代码,共享内存(SharedArrayBuffer)。
  • 子进程:更彻底,跑独立脚本,通过 IPC 通信。
  • 挪出去:让专门的 Go / Rust 服务做,Node 调它的 HTTP / gRPC。
js
import { Worker } from 'node:worker_threads';
const worker = new Worker('./heavy-calc.js', { workerData: input });
worker.on('message', (result) => res.json(result));

不要用 Node 跑需要纯 CPU 高密度计算的服务(机器学习推理、视频转码)。选错工具比写错代码更要命。

小结

  • 错误处理是 Node 后端的第一门必修课:uncaughtException / unhandledRejection 兜底 + 框架层统一错误响应 + 别用 throw 做流程控制。
  • 日志与可观测三件套:结构化日志(pino)+ 请求 trace(reqId / OpenTelemetry)+ 指标埋点(Prometheus / Sentry)。
  • 配置 schema 校验 + 密钥进 Vault,上线第一天就别把数据库密码写进 git。
  • 数据库连接池 + 慢任务进队列(BullMQ),API 进程只做编排,重活交给 Worker。
  • 多核用 cluster / PM2 + 优雅关闭 SIGTERM,理解 Node 单线程的真相:I/O 异步是强项,CPU 密集要 worker。

会写路由只是入口,工程化才是后端的护城河——线上不出事,不是因为运气好,而是因为那些"看不见的兜底"在工作。

Related

相关推荐