Node 接口该写同步还是异步?

0 阅读8分钟

这道题,很多人会凭感觉选

只要写过一段时间 Node,基本都纠结过这个问题。

有的人很怕出事,干脆把接口里能写成异步的全写成异步,觉得这样最稳。
也有人图省事,能同步就同步,反正本地跑得过,先把功能交掉再说。

这两种写法我都写过,而且都出过问题。

前一种的问题是,代码看起来全是 async/await,但很多地方根本没有等待外部资源,纯粹是在把简单逻辑写复杂。后面一层套一层,异常处理、函数签名、调用链都跟着变重。

后一种更直接,问题通常出在线上。单人联调、本地自测都没事,一到并发场景,接口开始卡,RT 变高,前面的请求还没处理完,后面的请求已经排上队了。

所以这里不打算争“同步好”还是“异步好”。

我这里只把这件事说清楚:

Node 接口里,同步和异步到底该怎么判断,才不容易选偏。

先别急着下结论,先把 Node 的运行方式摆出来

很多人讨论同步和异步,第一反应还是停留在语法层面。

比如:

  • 同步是不是更直白
  • 异步是不是更高级
  • async/await 是不是默认就比同步安全

但这些都不是重点。

Node 这件事说到底,绕不开事件循环。你可以不背底层细节,但有一条得记住:

主线程一旦被同步逻辑卡住,别的请求就得一起等。

这也是为什么,同样一段“能跑”的代码,放在脚本里可能没事,放到线上接口里就完全是另一回事。

比如下面这种写法:

const fs = require('node:fs');

app.get('/profile', (req, res) => {
  const text = fs.readFileSync('./profile.json', 'utf8');
  res.send(JSON.parse(text));
});

单看功能,没毛病。

问题在于,只要这个接口有人在调,readFileSync 就会把主线程占住。文件没读完,这次请求不会往下走,别的请求也很难舒服地进来。

再看异步版本:

const fs = require('node:fs/promises');

app.get('/profile', async (req, res) => {
  const text = await fs.readFile('./profile.json', 'utf8');
  res.send(JSON.parse(text));
});

这里真正值钱的,不是多了 async 这几个字符,而是文件读取这件事不会一直堵着主线程。

所以同步和异步的分界线,从来都不是“哪种写法看起来更现代”,而是:

这段逻辑会不会把当前这条执行线占死。

同步不是不能用,但别把它塞进不该待的地方

我不认同“Node 里同步一律有罪”这种说法。

同步当然能用,只是它能活动的范围比很多人想得小。

1. 服务启动阶段,用同步很正常

比如读取本地配置、加载一份静态字典、初始化模板。

这类事情的特点很明确:只做一次,而且通常发生在服务真正开始接请求之前。

const fs = require('node:fs');

const config = JSON.parse(
  fs.readFileSync('./config.json', 'utf8')
);

这种时候你非要绕成异步,收益未必有多大,代码反而更散。

2. 单机脚本、内部工具,同步往往更顺手

如果这是个数据迁移脚本,或者一个批量处理文件的小工具,那同步完全可以接受。

因为这个场景根本不在乎“还能不能同时服务别的请求”。它的目标就是把这件事按顺序做完。

3. 轻量的纯内存逻辑,本来就该同步

像字段整理、对象映射、少量判断,这种逻辑没有 I/O,没有网络等待,本来就是同步语义。

function toUserCard(user) {
  return {
    id: user.id,
    nickname: user.nickname,
    isVip: user.level >= 3,
  };
}

这种函数你硬写成异步,没有任何实际意义。

但这里有个边界要说清楚。

纯内存逻辑不等于永远安全。

如果你在请求链路里做的是大循环、大 JSON 处理、大量排序聚合,它就算不访问数据库,也一样会把主线程拖住。那时候问题已经不是“同步能不能用”,而是“这段计算本来就不该这么放”。

异步真正该上的地方,其实没什么悬念

只要你是在写正式的线上服务,下面这些地方基本就别犹豫了。

1. 数据库操作

查库、写库、事务,全都属于典型 I/O。

app.get('/orders', async (req, res) => {
  const data = await orderService.list(req.user.id);
  res.json(data);
});

这里如果你还想着同步写法,本质上就是拿接口吞吐去换一时省事。

2. Redis、MQ、第三方 HTTP 调用

只要要等网络结果,就老实异步。这里没什么讨论空间。

3. 文件读写

很多人会下意识觉得,本地文件没那么夸张。

但对 Node 来说,文件 I/O 还是 I/O。只要放在请求主路径上,它就有资格把主线程拖慢。

4. 用户接口里的耗时操作

这一条比前面更实用。

你不一定每次都能很快判断某个操作归类到哪里,但你可以先问一句:

这段逻辑是不是在用户请求过程中执行,而且会不会花时间。

只要答案是“会”,就该优先往异步、非阻塞那边靠。

真正容易把人带偏的,不是同步,是“异步绝对正确”

我后来越来越觉得,Node 新手最容易掉进去的坑,其实不是不会写异步,而是把异步想成了默认答案。

误区 1:只要是接口代码,就必须全异步

这是最常见的误区。

接口确实应该避免阻塞,但这不等于接口里的每一层逻辑都必须异步。

参数清洗、结果拼装、轻量校验,这些本来就是同步动作。你非要给它们套一层 async,本质上只是把很短的一段路绕远了。

误区 2:同步写法一定更差

这句话也太粗。

在一次性脚本、启动阶段、低请求量的本地任务里,同步不仅不一定差,有时候还更省心。

Node 里要警惕的,从来不是“同步”这两个字本身,而是同步阻塞出现在并发请求链路里

误区 3:async/await 写满,就代表工程更规范

这几年很容易被带偏的一点,就是把写法外观当成工程质量。

比如下面这种函数:

async function buildUserView(user) {
  return {
    id: user.id,
    nickname: user.nickname,
  };
}

它不是错,只是没必要。

没有异步源,却要假装异步,这种代码写多了,调用方和维护的人都累。

如果你真要在项目里做判断,我建议先看这 4 件事

别再靠感觉选了。感觉最不稳定。

我自己现在看 Node 接口里的同步异步,基本先过这四个问题。

1. 有没有等待外部资源

数据库、Redis、文件、HTTP,只要涉及等待外部返回,优先异步。

2. 它在不在请求主路径上

如果它就在用户接口的执行链路里,那你得天然对阻塞更敏感。

3. 它一旦慢下来,会不会连带拖住别的请求

这点是 Node 和很多后端语言讨论习惯不太一样的地方。

你不能只看“它自己能不能跑完”,还得看“它跑的时候,会不会把别人一起堵住”。

4. 它属于启动期,还是运行期

启动期任务可以放宽,运行期任务要保守。

很多争论说到底,就是把这两个阶段混成了一件事。

最后是总结的一份的场景判断

如果你平时开发节奏快,不想每次都重新分析,那可以先按这个表判断:

场景建议
服务启动时读取本地配置同步即可
单机脚本、构建脚本、内部工具同步优先
轻量对象映射、字段整理、简单计算保持同步
数据库 CRUD必须异步
Redis / MQ / 第三方接口调用必须异步
文件上传、下载、读写必须异步
面向用户的线上接口主路径优先非阻塞设计

这张表不是死规定,但拿来挡掉大部分误判,够用了。

写到最后,我自己的结论其实很简单

Node 里没有“同步永远落后”,也没有“异步天然正确”。

同步适合一次性、短链路、轻逻辑。异步适合 I/O、等待态、并发请求场景。真正该盯住的,不是语法样子,而是这段代码会不会影响整条请求链路的流动性。

最后总结成一句话就是:

先判断这段代码会不会堵住别人,再决定它该不该写成同步。

你们平时写 Node 接口,最常见的误判是哪一种?是把同步放到了不该放的地方,还是把 async/await 用成了默认装饰?