并发管理把任务的启动速度、等待队列和取消边界控制住以后,我们终于可以把视线移到真实后端最外层:HTTP 连接。这里的生产者不只是业务循环,也可能是文件和数据库;消费者则隔着网络,速度由客户端决定。前面的背压、超时和取消,会在请求与响应流里变成可以直接观察的信号。
如果忽略这些信号,即使业务处理已经结束,HTTP 请求也不一定真的完成。
有一次,我盯着一个看似很普通的导出接口发愁:数据库查询只用了两百多毫秒,服务端日志也记着 200,用户却说下载进度停在一半。更怪的是,只要同时导出的人多起来,进程内存就会一路上涨,连 /health 都开始超时。
我最先怀疑数据库。结果慢查询没有增加,连接池也没满。接着我又怀疑是生成 CSV 的循环太吃 CPU,可采样显示 CPU 并不忙。那种“服务没在干重活,却越来越喘不过气”的感觉,通常说明数据正堵在某个缓冲区里。
真正的转折发生在我打印 res.write() 的返回值之后:它早已返回 false,生成程序却还在不管不顾地写。客户端经过一条很慢的网络链路,响应送不出去;服务端又不断生产数据,于是未发送的内容全堆进了内存。访问日志之所以早早显示 200,只是因为响应头已经发出,并不代表客户端收到了完整文件。
修复并不复杂:res.write() 返回 false 时暂停生产,等 drain 再继续;用 finish 和提前发生的 close 区分正常结束与中途断开;客户端离开后,顺手取消仍在执行的查询和生成任务。但这次故障让我重新确认了一件事:Node.js 的 HTTP API 不是几个“收参数、回 JSON”的函数,而是一套围绕报文边界、流和连接生命周期的约定。只要漏掉其中一层,线上表现就会非常反直觉。

一条 TCP 连接只提供有顺序的字节流。它不知道哪几个字节是请求行,也不会替应用标记“一份 JSON 到这里结束”。HTTP/1.1 在字节流上定义了请求行、请求头、空行、请求体等结构,Node 的 HTTP 解析器再把这些结构变成 JavaScript 能操作的对象。
http.createServer(handler) 中的 handler,其实是服务器 request 事件的监听器。解析器读完一份请求的起始行和请求头后,Node 会创建一对新的 req、res 并调用一次处理函数。此时请求体可能还在路上,所以 req 不是一个已经装好所有数据的普通对象,而是一条仍可能继续产出字节的可读流。
const http = require('node:http');
const server = http.createServer((req, res) => {
console.log(req.method, req.url);
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.end('你好,HTTP!\n');
});
server.listen(3000, '127.0.0.1', () => {
console.log('服务已启动:http://127.0.0.1:3000');
});把代码保存为 server.js,运行 node server.js,再访问 http://127.0.0.1:3000,就能观察最小的一次请求—响应周期。listen() 创建的活动句柄会让进程继续运行,直到服务器被关闭或进程退出。
HTTP/1.1 可以通过 keep-alive 在同一条 TCP 连接上依次传输多份请求。因此,建立一次连接不等于处理函数只执行一次;复用的也只是连接,不是 req、res 对象。每识别出一份新请求,Node 都会创建属于它的新对象。
这三个层次最好始终分开看:
很多诡异问题都来自层次混淆。例如,把请求级状态挂在 socket 上,可能污染同一连接上的后续请求;把 res.end() 当成“TCP 已关闭”,则会误判 keep-alive 的行为。
HTTP/1.1 的请求头以空行结束,请求体如何结束则取决于报文规则:
Content-Length 声明请求体的字节数,单位是字节,不是 JavaScript 字符数;Transfer-Encoding: chunked 使用协议层的分块格式,以零长度块收尾;req 发出 end。Node 会剥掉 HTTP 分块编码并识别消息边界,但交给 data 监听器的每个 chunk 只是“此刻可以读取的一段数据”。它既不等于一个 TCP 包,也不对应一个完整 JSON 字段。网络速度、内核缓冲区和流的高水位线都可能改变 chunk 的大小。
所以,下面两种判断都不可靠:收到第一个 data 就解析 JSON;把每一个 data 都当成独立业务消息。正确做法是在接收过程中累计字节并限制大小,等 end 后再解析完整内容。
TCP 保证字节顺序,不提供业务消息边界;HTTP 解析器识别报文边界,Node 流再分批交付正文。应用必须按流的生命周期读取请求体,不能按 chunk 猜测 JSON 边界。
网络数据可读、连接到达等事件由底层通知 Node,JavaScript 回调再由事件循环调度。处理函数等待数据库、文件或下游接口时,只要使用异步 API,就可以把执行权交回事件循环,让其他请求继续推进。
但同步计算仍会独占 JavaScript 主线程。下面这个处理函数会让同一进程里的其他请求一起停顿三秒:
http.createServer((req, res) => {
const deadline = Date.now() + 3000;
while (Date.now() < deadline) {
// 模拟持续占用 CPU 的同步工作
}
res.end('done');
});非阻塞 I/O 解决的是“等待期间别占着主线程”,不是免费增加 CPU,也不是取消内存上限。大量压缩、图片处理或复杂计算,需要 Worker、独立进程或明确的并发限制;大响应还必须服从可写流的背压信号。
服务器端的 req 通常是 http.IncomingMessage,它继承自 stream.Readable;res 是 http.ServerResponse,提供可写流接口。处理函数就像夹在两条流之间的转换站:从左侧按需读取客户端数据,再把结果写向右侧。

req.url 在服务器端通常是相对地址,直接调用 new URL(req.url) 会因为缺少基准地址而报错。只想做路由解析时,可以提供一个固定基准:
const url = new URL(req.url, 'http://localhost');
console.log(url.pathname); // /tasks
console.log(url.searchParams.get('done')); // true这里的 http://localhost 只是解析相对地址所需的基准,不会改变监听地址,也不会发起网络请求。如果需要根据 Host 或代理头构造对外 URL,必须先校验可信代理和允许的主机名;请求头来自客户端,不能默认可信。
HTTP 响应由状态码、响应头和可选的响应体构成。可以逐项设置,让第一次 write() 或 end() 隐式提交响应头:
res.statusCode = 201;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Location', '/tasks/42');
res.end(JSON.stringify({ id: 42, title: '读懂 HTTP 生命周期' }));也可以用 writeHead() 一次提交:
res.writeHead(404, {
'Content-Type': 'application/json; charset=utf-8',
});
res.end(JSON.stringify({ error: '没有找到该资源' }));我通常在响应头需要经过多个步骤补齐时使用 statusCode 和 setHeader(),在一个集中发送函数中使用 writeHead()。两种写法的关键边界相同:一旦 res.headersSent 为 true,状态行和响应头就已经提交。此后发现错误,也不能把已经发出的 200 改成 500。
固定响应体可以显式设置 Content-Length。长度必须用 Buffer.byteLength() 计算,因为 HTTP 需要字节数,而 JavaScript 的字符串长度按 UTF-16 代码单元计数:
function sendJson(res, statusCode, value, headers = {}) {
const body = JSON.stringify(value);
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
...headers,
});
res.end(body);
}没有显式长度时,Node 会根据 HTTP 版本和当前响应选择合法的结束方式,例如在 HTTP/1.1 中采用分块传输。不要同时手工设置互相冲突的 Content-Length 和 Transfer-Encoding。
end、finish 和 close 记录的是不同事实事故发生时,我的日志在调用 res.end() 后立刻写“请求成功”,这会把应用意图和传输结果混在一起。
res.end() 表示应用声明“当前响应不会再写更多正文”;finish 表示响应数据已经交给底层系统发送,不保证客户端应用已经读取;close 需要结合 res.writableFinished 判断,若响应尚未完成就关闭,通常意味着连接提前中断。res.on('finish', () => {
console.log('响应已经完成写入');
});
res.on('close', () => {
if (!res.writableFinished) {
console.warn('连接在响应完成前关闭');
}
});这一区分直接影响访问日志、成功率和计费:处理函数开始只能证明请求进入应用,状态码发出只能证明协议选择已经提交,finish 才适合记录服务端完成写入;提前关闭则应单独统计。
res.write() 返回 false小 JSON 用一次 res.end(body) 通常就够了。持续生成大响应时,可写流需要一个速度阀门:res.write(chunk) 返回 false,说明内部缓冲区达到或超过高水位线,生产者应该暂停,等待 drain 后再继续。
async function writeLines(res, lines) {
for (const line of lines) {
if (!res.write(`${line}\n`)) {
await new Promise((resolve) => res.once('drain', resolve));
}
}
res.end();
false 不是“本次写入失败”,当前 chunk 已被接受;它表达的是“先别继续塞了”。忽略它,快生产者遇到慢客户端时就会不断扩大内存中的待发送队列。
如果数据来自文件,readable.pipe(res) 或 stream.pipeline() 能协调大部分背压。但生产环境仍要处理源流错误和客户端中断。尤其在等待 drain 时,连接可能已经关闭;完整实现应监听 close/error,停止读取并取消上游工作。
原生 node:http 不提供 app.get('/tasks/:id') 这样的路由表,只把 req.method 和 req.url 交给应用。手写路由不难,难的是让边界语义保持准确。

直接判断 req.url === '/tasks' 会漏掉 /tasks?done=true。先用 URL 拆出 pathname 和 searchParams,再让路径表示资源、查询参数表示筛选或分页条件:
async function handleRequest(req, res) {
const url = new URL(req.url, 'http://localhost');
if (req.method === 'GET' && url.pathname === '/health') {
sendJson(res, 200, { status: 'ok' });
return;
}
if (req.method === 'GET' && url.pathname ===
每个已经结束响应的分支立刻 return,能避免控制流继续向下,最终对同一个 res 再写一次。路径存在但方法不被支持时,405 Method Not Allowed 比 404 Not Found 更准确,还应通过 Allow 告诉客户端可用的方法:
sendJson(
res,
405,
{ error: '该资源不支持此方法' },
{ Allow: 'GET, POST' },
);两三个固定路径用 if 很直观。随着路径参数、鉴权、日志、校验和统一错误处理增加,处理函数才会变得难以维护。路由框架解决的是匹配和组织问题,底下依然是同一对 req、res,也依然受响应只能提交一次、请求体必须消费、连接可能中断等约束。
Node 的 HTTP 层会解析报文结构,但不会因为看到 Content-Type: application/json 就自动生成 req.body。想得到 JavaScript 对象,应用要消费请求流、合并 Buffer,再调用 JSON.parse()。

我见过的另一个隐蔽问题,是开发环境里的 JSON 总在一次 data 事件中到达,于是代码直接解析第一个 chunk。上线后经过代理、弱网或更大的请求体,JSON 被拆开,接口便随机报语法错误。chunk 的拆分方式从来都不属于业务契约。
下面这段代码虽然常见,却还有内存风险:客户端可以一直发送,服务端也会一直收集。
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
});一个可用的 JSON 读取器至少要处理四件事:核对媒体类型、限制真实字节数、拒绝非法 JSON、识别客户端中途断开。
function httpError(statusCode, message) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
function readJson(req, maxBytes = 64 * 1024) {
return new Promise((resolve, reject) => {
const contentType
Content-Length 只能作为提前拒绝的线索,不能成为唯一防线。它可能不存在,例如客户端使用分块传输;应用仍要对每个实际收到的 chunk 累计 chunk.length。这个值是 Buffer 的字节数,正好对应限制真正关心的内存规模。
媒体类型和错误状态也应明确区分:
413 Content Too Large:内容超过服务端愿意处理的上限;415 Unsupported Media Type:当前接口不接受这种媒体类型;400 Bad Request:例如 JSON 语法不合法或参数格式错误。先用 Content-Type 和声明长度做低成本检查,再按实际收到的字节数执行硬限制,最后才解析 JSON。这个顺序既节省资源,也能给客户端稳定、可操作的错误语义。
下面的服务只使用 Node 内置模块。数据保存在内存中,进程退出后会消失;代码重点是把路由、输入限制、状态码、响应结束和异常出口放进同一个可观察的生命周期。
const http = require('node:http');
const tasks = [
{ id: 1, title: '理解 req 与 res', done: true },
{ id: 2, title: '实现原生路由', done: false },
];
function httpError(statusCode, message) {
const error = new Error(message);
error.statusCode =
可以在另一个终端验证成功和失败路径:
curl -i http://127.0.0.1:3000/health
curl -i 'http://127.0.0.1:3000/tasks?done=false'
curl -i http://127.0.0.1:3000/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"检查状态码"}'
curl -i http://127.0.0.1:3000/tasks \
-H 'Content-Type: application/json' \
-d '{bad json}'这四次请求应依次得到 200、200、201 和 400。把 Content-Type 换成 text/plain 可以验证 415;访问 /missing 可以验证 404;对 /tasks 发送 DELETE 可以验证 405 和 Allow 响应头。
代码在不需要正文的分支里调用了 req.resume()。这会把可能仍在到达的请求体读完并丢弃,让流能够进入结束状态,也有利于连接后续复用。如果安全策略要求遇到意外请求体就立即拒绝并断开,也可以显式关闭连接,但应把它当成有意识的协议选择。
HTTP/1.1 的持久连接减少了反复建立 TCP 连接的开销。一份响应结束后,底层连接可以空闲一段时间,再承载另一份请求。Node 每次仍会创建新的 req、res;请求级缓存、用户信息和计时器不该因为方便就挂到 socket 上。

请求头或完整请求未在限制时间内到达时,Node 可以返回 408 并关闭连接,正常处理函数不一定会获得这份请求。keep-alive 超时发生在一份响应已经完成之后,清理的是空闲连接,不会改变已经完成的响应结果。
这三项都不是业务处理时限。假设下游查询最多允许 800 毫秒,需要在查询或 HTTP 客户端调用处单独设置截止时间,并在超时时取消操作。server.requestTimeout 约束的是接收请求,不会自动终止一个请求已经收完、但业务 Promise 一直不结束的处理函数。
部署时还要让应用服务器、反向代理、负载均衡器与客户端的超时关系彼此兼容。若双方对 keep-alive 的空闲时间理解不同,客户端可能尝试复用一条刚被服务端关闭的连接,表现为偶发的 ECONNRESET。默认值会随 Node 版本调整,关键服务应显式配置并用实际运行版本验证。
res.end() 为什么不关闭 TCPres.end() 只结束当前响应。HTTP 解析器知道这一份响应已经完整,底层连接没有错误且双方都允许持久连接时,socket 可以留给后续请求。这正是“响应生命周期”和“连接生命周期”必须分开记录的原因。
同理,不消费请求体也不能简单理解成“反正已经返回了”。当前请求仍对应一条可读流,未处理的字节会影响资源回收和连接管理。要么读完并丢弃,要么按策略明确终止连接,不能把它悬在那里。
修复导出接口时,我一度想在流读取失败后补一个 JSON 格式的 500。很快就发现这条路走不通:文件头和一部分正文已经以 200 发出,HTTP 不能在同一份响应中倒回去重写状态行。

非法请求行或请求头可能触发服务器的 clientError。此时正常的 request 事件尚未建立,应用手里没有常规的 req、res,只有底层 socket。可行的处理通常是写出一份最小的 400 响应并关闭连接。
解析器错误不应继续抛到进程顶层,也不应把堆栈、内部路径或完整错误对象返回给客户端。日志保留错误代码和必要上下文,对外响应保持稳定、简短。
路由未命中、JSON 非法、参数校验失败和大部分业务异常,都可能发生在 res.headersSent === false 的阶段。此时仍能构造完整的错误响应,集中设置状态码、Content-Type 和 JSON 结构。
async 处理函数返回的是 Promise,拒绝不会自动进入外围的同步 try...catch。示例显式调用 handleRequest(req, res).catch(...),就是为了让异步错误进入统一出口。
流式响应一旦调用过 write(),通常已经隐式发送响应头。如果随后读取文件或生成数据失败,再调用 writeHead(500) 只会制造第二个错误。此时应停止生产、记录上下文并销毁响应或 socket,让客户端知道内容没有完整结束。
判断可以压缩为一条分界线:
res.headersSent === false:选择合适的状态码,返回结构化错误;res.headersSent === true:状态码已不可逆,停止后续工作并终止不完整响应。浏览器关闭、代理超时和网络中断都会让连接提前结束。监听 close 只能让我们知道事情发生了;要释放资源,还要把这个信号传给数据库查询、下游请求、文件读取或生成器。
一种常见做法是为每个请求创建 AbortController,在响应提前关闭时调用 abort(),再把 signal 交给支持取消的 API。取消不了的任务也应避免继续启动新的后续步骤。否则,客户端虽然离开,服务器仍会默默消耗连接池、CPU 和内存。
配置了超时不等于业务已经自动停止;监听到断开也不等于资源已经释放。把“检测结束”与“传播取消”连起来,并实际测试慢请求、半途断开和响应写到一半失败的情况。
经历过那次慢客户端下载问题后,我不再从“路由能不能返回 200”开始检查,而是沿着一份请求的生命周期问五个问题:
pathname 是否同时匹配,查询参数是否单独校验?end 后解析?write() 返回 false 后等待 drain?headersSent 前返回结构化错误,之后终止损坏的响应?这五问分别覆盖路由、请求体、响应体、协议状态和资源生命周期。框架可以把代码写得更短,却无法替业务决定最大请求体、下游截止时间、取消方式和错误语义。
回头看,导出接口的症状其实都能从 HTTP 生命周期解释:200 只表示状态行已经发出;慢客户端让响应可写流出现背压;忽略 false 会把未发送数据堆进内存;客户端提前断开后,如果不传播取消,上游仍会继续生产无处可去的数据。
我现在处理 Node HTTP 问题时,会把观测点固定在几条边界上:收到请求头、请求体结束、响应头发送、响应完成写入、连接提前关闭、业务取消完成。每个点都记录耗时和必要的请求标识,但避免记录敏感正文。这样再遇到“日志成功、用户失败”或“CPU 不高、内存却涨”的问题,就能先判断卡在接收、处理、发送还是连接清理,而不是凭感觉轮流怀疑数据库和网络。
真正可靠的原生 HTTP 服务,不是拥有最多的 API 封装,而是每一份输入都有上限、每一次输出都尊重背压、每一个不可逆时刻都被正确识别、每一项失去接收者的工作都能尽快停下来。
HTTP 生命周期正确,只保证信息被可靠地收进来、送出去,却没有规定这些信息在业务上代表什么。POST 超时后能不能重试,空列表应该返回什么状态,错误响应怎样让不同客户端稳定解析,都是协议之上的契约。把这些语义交给每个路由临时决定,最终一定会出现“服务端成功、客户端失败”这样的分裂。