全部文章

前端转 Node.js 后端,我花了半年才补上的五件事

会写 JavaScript 不等于会写服务端。事件循环、进程稳定性、事务、可观测性、优雅重启——这五件事我在真实故障里各补了一次课。

10 分钟 · 1796 字

我写前端出身,转去做 Node.js 后端时心态很轻松:同一门语言,同一套工具链,能难到哪去?

半年后回头看,语言确实没构成障碍,真正的成本在于前端和后端对「什么算写完了」的定义完全不同。前端页面崩了,用户刷新一下就能恢复;后端进程崩了,恢复动作发生在你看不见的地方,而且往往伴随着数据不一致。

下面这五件事,每一件我都在真实故障里补过一次课。代码都是 ESM 风格。

一、事件循环:前端可以装作它不存在,后端不行

前端也会遇到长任务卡住页面,但那种卡顿的用户感知是「有点慢」。后端的事件循环被阻塞,表现是所有并发请求一起变慢,而 CPU 和内存监控看上去一切正常——这是最难排查的一类故障。

我踩的第一个坑是密码哈希。当时的登录接口长这样:

import crypto from 'node:crypto';

app.post('/login', (req, res) => {
  // iterations 拉高以后,单次耗时 180ms 左右
  const hash = crypto.pbkdf2Sync(req.body.password, salt, 210000, 32, 'sha256');
  // ...比对逻辑
});

单机 QPS 低的时候完全看不出问题。压测一上来,P99 从 18ms 涨到 900ms,而 top 里 CPU 只跑了 30%——因为阻塞的是事件循环,不是 CPU 峰值。请求排在后面等这个 180ms 的同步调用结束。

修正很直接:换成异步版本,让 libuv 线程池去跑。

import { pbkdf2 } from 'node:crypto';
import { promisify } from 'node:util';

const pbkdf2Async = promisify(pbkdf2);

app.post('/login', async (req, res) => {
  const hash = await pbkdf2Async(req.body.password, salt, 210000, 32, 'sha256');
  // ...比对逻辑
});

但异步化只是把问题往后推了一步:libuv 线程池默认只有 4 个线程,fs、dns、zlib、crypto 共用。压测里我把 UV_THREADPOOL_SIZE=8 加上之后才真正缓解。如果是纯 CPU 计算(大 JSON 解析、图片处理、全文分词),线程池也救不了,得用 worker_threads 或者干脆拆成独立服务。

判断标准:任何单次耗时可能超过 10ms 的同步调用,都不该出现在请求路径上。JSON.parse 一个 20MB 的响应体,大约要 200ms,这跟数据库慢查询是一个量级的问题。

二、错误处理与进程稳定性

前端的错误处理是「别让白屏」,后端的错误处理是「别在状态不确定的时候继续服务」。这两个目标的最优解经常相反。

我犯过的错是写了这么一段:

process.on('uncaughtException', (err) => {
  logger.error(err);   // 记一笔,然后继续跑
});

看起来很稳健,实际上很危险。uncaughtException 触发时,进程的内部状态可能是坏的——半写的缓冲区、没释放的句柄、处于中间态的数据库连接。继续服务意味着用不确定的状态处理后续请求。Node 官方文档的措辞是明确的:此时应该记录日志并退出。

我现在的做法是分层:

class AppError extends Error {
  constructor(message, { status = 500, code = 'INTERNAL', cause } = {}) {
    super(message, { cause });
    this.name = 'AppError';
    this.status = status;
    this.code = code;
    this.expose = status < 500;   // 4xx 可以直接返回给客户端
  }
}

// 业务上可预期的错误(参数错误、余额不足)→ 返回给客户端
// 不可预期的错误(连接池耗尽、断言失败)→ 记日志 + 500,并触发重启
process.on('unhandledRejection', (reason) => {
  logger.fatal({ err: reason }, 'unhandled rejection, exiting');
  process.exit(1);
});

process.on('uncaughtException', (err) => {
  logger.fatal({ err }, 'uncaught exception, exiting');
  process.exit(1);
});

关键在于:这两个 handler 不是用来「兜住」错误的,是用来保证进程能体面地死掉。 恢复工作交给 systemd 的 Restart=always,那是它擅长的领域。

三、数据一致性与事务

这是前端完全没有对应概念的部分。前端有「乐观更新」——先改 UI,请求失败了再回滚界面。那只是视觉上的补偿,后端要是也这么干,就是真的丢数据了。

最典型的例子是余额扣减。我最早写成两步:

// 错误写法:读出来、算一下、写回去
const { rows } = await pool.query('SELECT balance FROM accounts WHERE id = $1', [id]);
const next = rows[0].balance - amount;
await pool.query('UPDATE accounts SET balance = $1 WHERE id = $2', [next, id]);

并发一上来就会丢更新,两个请求读到同一个旧值,后一次把前一次覆盖掉。修法有两个层次:单条 SQL 里做原子运算,或者显式加锁。

const client = await pool.connect();
try {
  await client.query('BEGIN');
  const { rows } = await client.query(
    'SELECT balance FROM accounts WHERE id = $1 FOR UPDATE',
    [fromId],
  );
  if (rows[0].balance < amount) throw new AppError('余额不足', { status: 409 });
  await client.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, fromId]);
  await client.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, toId]);
  await client.query('COMMIT');
} catch (err) {
  await client.query('ROLLBACK');
  throw err;
} finally {
  client.release();   // 必须放在 finally,漏掉就是连接泄漏
}

再往外一层还有跨服务的一致性问题:先写库、再发消息通知下游,两步之间进程挂了怎么办。我的选择是本地消息表(outbox)而不是分布式事务——把「写业务数据」和「写待发消息」放进同一个事务,再用一个轮询任务发出去,下游按消息 ID 幂等。妥协点在于延迟从毫秒变成了百毫秒级,但换来的是不需要引入额外的事务协调者。

四、日志、可观测性与链路追踪

前端的日志基本等于 console.log,看的是浏览器控制台。后端的日志是给机器读的,而且一次请求会穿过若干个函数甚至若干个服务。

我改用结构化日志之后,排查效率的变化非常明显:

import crypto from 'node:crypto';
import pino from 'pino';
import { AsyncLocalStorage } from 'node:async_hooks';

export const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' });
export const ctx = new AsyncLocalStorage();

// 中间件:为每个请求生成 traceId,之后所有日志自动带上
app.use((req, res, next) => {
  const traceId = req.headers.traceparent ?? crypto.randomUUID();
  const reqLogger = logger.child({ traceId, path: req.path, method: req.method });
  ctx.run({ traceId, logger: reqLogger }, () => {
    res.setHeader('traceparent', traceId);
    next();
  });
});

// 业务代码里直接取,不用一层层传参
ctx.getStore().logger.info({ orderId }, 'order created');

配套的三件事:

  • RED 指标:每个路由的请求速率、错误率、耗时分布。平均值没意义,要看 P95 和 P99。
  • traceparent 透传:哪怕暂时没有接入完整的追踪系统,先把请求头传下去,以后接 OpenTelemetry 时不用改业务代码。
  • liveness 与 readiness 分开:/healthz 只回答「进程是否活着」,/readyz 才检查数据库和下游依赖。这两个混在一起,依赖抖动会导致整个集群被重启。

五、部署、进程管理与优雅重启

发版那一刻是事故高发期。我经历过的两次线上 502,都是因为进程在还有在途请求时被直接杀掉。

正确顺序是:先从负载均衡摘掉流量 → 发 SIGTERM → 停止接收新连接 → 等在途请求结束 → 关闭连接池 → 退出。

const server = app.listen(3000);

async function shutdown(signal) {
  logger.info({ signal }, 'shutting down');
  server.close();                     // 不再接受新连接
  server.closeIdleConnections?.();    // Node 18.2+:关掉空闲 keep-alive 连接
  await pool.end();                   // 关连接池
  setTimeout(() => process.exit(1), 10_000).unref();   // 兜底强退
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

systemd 那侧配合好这几行就够了:

[Service]
ExecStart=/usr/bin/node --enable-source-maps /srv/app/dist/server.js
Restart=always
RestartSec=2
KillSignal=SIGTERM
TimeoutStopSec=20
MemoryMax=512M
Environment=NODE_ENV=production

两个提醒:TimeoutStopSec 要略大于代码里的兜底超时,否则 systemd 会先发 SIGKILL,优雅退出就白写了。MemoryMax 让内存泄漏表现为进程被重启,而不是把整台机器拖垮——这不优雅,但足够安全。

至于 PM2 的 cluster 模式,我现在只在需要多核利用且应用完全无状态时使用。它有两个容易忽略的代价:模块级的内存状态在不同 worker 之间不共享(本地缓存、限流计数器都会失真),定时任务会被每个 worker 各跑一遍。WebSocket 场景还需要 sticky session。如果用 systemd,多开几个实例配反向代理是更透明的做法。

小结

前端直觉后端现实
卡顿只是变慢阻塞会放大成全局延迟
错误捕获后继续跑状态不确定就该退出重启
乐观更新,失败回滚 UI事务与幂等,回滚在数据层
console.log 追问题结构化日志 + traceId + 指标
刷新页面即恢复摘流量、优雅退出、可回滚

这五件事没有一件需要多高深的知识,但它们决定了服务是「能跑起来」还是「能一直跑」。前端的经验在这里帮不上忙,只能一件一件补。

本文由 Kyne 撰写,采用 CC BY-NC-SA 4.0 许可,转载请注明出处。