在 Node.js 是怎样的 JavaScript 运行时中,我们知道 http 是 Node.js 提供给 JavaScript 的网络能力;在 事件循环与异步 I/O中,我们讨论了网络事件如何回到 JavaScript;EventEmitter、Buffer 与字符编码以及 Stream 与背压又分别解释了事件通知、字节和持续数据流。
这些知识放到一个 HTTP 服务中会怎样共同工作?当一个请求到达 Node.js 后,socket、请求头、请求体、业务处理和响应写入分别处在什么阶段?req 的 end、res.end()、finish 和连接关闭,哪一个才表示请求真正结束?
本文以 Node.js v24 LTS 的 HTTP API 为背景,使用原生 node:http 和原生 ESM。贯穿案例是一个接收分块文本、统计内容并返回结果的小服务。我们会逐步加入中文和 emoji、较大输入、慢速上传、慢速接收、业务失败和客户端提前断开。示例只连接本机回环地址,不依赖数据库或外部服务。
1. 先建立一条请求生命周期
1.1 一个 TCP 连接可以承载多个 HTTP 请求
服务器先监听一个 TCP 端口。客户端建立连接后,操作系统把到达的字节交给这个 socket;Node.js 的 HTTP 解析器先识别请求行和请求头、创建请求体流,再为其中的每一个 HTTP 请求调用一次 requestListener。请求体随后还会继续解析和交付:
客户端
│ TCP 连接
▼
server.emit("connection", socket)
│ HTTP/1.1 解析器识别一条请求
▼
server.emit("request", req, res)
│ │
│ └─ ServerResponse:服务端准备写回的响应
└─ IncomingMessage:请求行、请求头和请求体的可读流
connection 代表一个底层 TCP 连接,request 代表这个连接上的一条 HTTP 请求。HTTP/1.1 的持久连接允许响应结束后保留 socket,后续请求可以继续使用它。因此,同一个 socket 可能依次触发多次 request;req.socket 也不应被理解为“只属于这一条请求”。
这里的“连接建立”只表示 TCP 层已经有了可用通道。DNS、TCP 握手和 TLS(如果使用 HTTPS)发生在 HTTP 解析之前,本文只把它们当作背景,不展开协议细节。
1.2 req 和 res 是两个方向的流
req 是 http.IncomingMessage,表示客户端发来的这一条 HTTP 消息。请求头解析完成后,Node.js 就可以调用监听器;这时请求体可能只到达了一部分。req 继承自 Readable,正文会通过 data、end 或异步迭代逐块交付。
res 是 http.ServerResponse,表示服务端要写回的响应。它是一个 Writable:应用先设置状态码和响应头,再通过 write() 写入零个或多个响应块,最后用 end() 表示响应消息已经没有更多数据。
因此,request 回调不是“请求已经完成”的通知,而是“请求头已经足够让应用开始处理”的入口。一个最小服务可以这样观察顺序:
// observe-lifecycle.mjs
import http from "node:http";
const server = http.createServer((req, res) => {
console.log("request", req.method, req.url, req.headers);
req.on("data", (chunk) => {
console.log("data", chunk.length, "bytes");
});
req.on("end", () => {
console.log("request end", "complete:", req.complete);
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
res.end("收到\n");
});
req.on("close", () => {
if (!req.complete) console.log("request closed before complete");
});
res.on("finish", () => console.log("response finish"));
res.on("close", () => console.log("response close"));
});
server.listen(3000, "127.0.0.1", () => {
console.log("listening on http://127.0.0.1:3000");
});
保存为 observe-lifecycle.mjs 后运行 node observe-lifecycle.mjs,另一个终端执行:
printf '第一块\n第二块🙂\n' | curl --http1.1 -H 'content-type: text/plain' --data-binary @- http://127.0.0.1:3000/
一次常见的成功路径大致是:
收到请求头
→ 0 个或多个 data
→ req 的 end
→ 业务处理完成
→ res.write() / res.end()
→ res 的 finish
→ 连接保持空闲,或关闭
后面每一项都会说明它由谁触发、能证明什么,以及不能证明什么。
2. 请求体为什么可能还没到齐
2.1 请求头完成,不代表正文完成
HTTP/1.1 请求由请求行、请求头、空行和可选的请求体组成。Content-Length 描述的是正文的字节数;没有它时,HTTP/1.1 也可以使用 Transfer-Encoding: chunked,由协议层标记正文的结束。Node.js 会把 chunked 编码的分块细节解析掉,应用看到的是一串普通的 Buffer chunk。
无论客户端如何发送,TCP 只保证按字节顺序交付。一次 data 事件可能包含半行、几行,或者只包含一个 UTF-8 字符的部分字节。它不等于一条业务消息,也不等于一个完整的字符。
TCP 到达: [E4 B8] [AD E6] [96 87 F0 9F 99 82]
可能的 data: chunk1 chunk2 chunk3
完整文本: 中文🙂
如果直接对每个 chunk 调用 chunk.toString("utf8"),跨 chunk 的多字节字符可能被分别替换成 �。StringDecoder 会暂存不完整的 UTF-8 序列,在下一块到达后再还原字符:
// 代码片段:req 来自当前的 request 监听器,不是独立脚本。
import { StringDecoder } from "node:string_decoder";
const decoder = new StringDecoder("utf8");
async function decodeRequest(req) {
let text = "";
for await (const chunk of req) {
text += decoder.write(chunk);
}
text += decoder.end();
return text;
}
// 在 request 监听器内调用;req 是当前监听器收到的 IncomingMessage。
const text = await decodeRequest(req);
这只解决字符边界,不会自动解决消息边界。JSON、换行记录或自定义协议仍然需要自己的分帧规则。字节数、字符数和 UTF-16 code unit 数也不是同一个概念:
const text = "中文🙂";
console.log(Buffer.byteLength(text, "utf8")); // 10:UTF-8 字节
console.log(text.length); // 4:UTF-16 code unit
console.log([...text].length); // 3:Unicode code point
更详细的编码和分帧背景见 Buffer 与字符编码。
2.2 读取正文时要同时做大小限制
小请求可以暂时收集 Buffer,再在请求结束后解析;但收集数组本身不能成为无限增长的内存容器。限制应该按字节计算,因为网络传输和内存压力首先表现为字节数量,而不是 JavaScript 字符数量。
下面的服务会读取一条文本请求,支持跨 chunk 的 UTF-8 字符,并在发现超过 64 KiB 后停止解析正文,在排空剩余请求体后返回 413。这样连接有机会保持可复用;如果请求仍然很慢或不可信,也可以在超限时直接销毁 socket,代价是放弃这条连接。
// read-text-body.mjs
import { StringDecoder } from "node:string_decoder";
export class BodyTooLargeError extends Error {
constructor(limit) {
super(`request body exceeds ${limit} bytes`);
this.name = "BodyTooLargeError";
this.limit = limit;
}
}
export async function readTextBody(req, { maxBytes, signal }) {
const decoder = new StringDecoder("utf8");
let bytes = 0;
let text = "";
let tooLarge = false;
const onAbort = () => req.destroy(signal.reason);
if (signal?.aborted) throw signal.reason ?? new Error("request aborted");
signal?.addEventListener("abort", onAbort, { once: true });
try {
for await (const chunk of req) {
if (signal?.aborted) {
throw signal.reason ?? new Error("request aborted");
}
if (tooLarge) continue;
bytes += chunk.length;
if (bytes > maxBytes) {
tooLarge = true;
continue;
}
text += decoder.write(chunk);
}
if (tooLarge) throw new BodyTooLargeError(maxBytes);
text += decoder.end();
return { text, bytes };
} finally {
signal?.removeEventListener("abort", onAbort);
}
}
for await...of 会在请求流结束时自然退出,也会在流发生错误或提前关闭时抛出异常。它把读取动作写成了一个有完成边界的异步流程,但背后仍然是 Readable 的暂停、恢复和缓冲机制;不要把异步迭代理解成“请求体已经一次性存在内存中”。
3. Stream、业务异步和背压如何接起来
3.1 处理速度慢时,数据停在哪里
请求体从 socket 进入 Readable 缓冲,再交给应用。应用如果在每个 chunk 上执行异步操作,下一块数据可能已经到达并暂存在缓冲区。缓冲能吸收短暂的速度差,但不能让无限快的生产者永远适应慢消费者。
如果服务要把数据继续写到响应、文件或另一个 socket,就必须观察 Writable 的反馈。res.write(chunk) 返回 true 表示当前缓冲尚未达到阈值;返回 false 表示应该暂缓继续写,等 drain 事件后再继续。这个反馈只描述当前可写端的接收能力,不代表客户端已经收到数据。
function writeChunk(res, chunk) {
if (res.write(chunk)) return Promise.resolve();
return new Promise((resolve, reject) => {
const onDrain = () => {
cleanup();
resolve();
};
const onError = (error) => {
cleanup();
reject(error);
};
const onClose = () => {
cleanup();
reject(new Error("response closed before drain"));
};
const cleanup = () => {
res.off("drain", onDrain);
res.off("error", onError);
res.off("close", onClose);
};
res.once("drain", onDrain);
res.once("error", onError);
res.once("close", onClose);
});
}
Stream 与背压已经详细讨论了 highWaterMark、pipe()、pipeline() 和压力传播。本文只关注它们在 HTTP 生命周期中的接合点:请求体是上游,业务或转换逻辑是处理者,ServerResponse 是下游;任何一个环节变慢,都需要把停止读取、等待 drain 或取消任务的责任传递下去。
这张图描述的是边读边写的代理、转换或大数据处理场景。贯穿案例为了统计完整文本,会先读完请求体再生成很小的响应,因此示例中的 write() 通常不会把响应压力实时传回请求读取;它只用 writeChunk() 展示响应侧应该如何等待 drain。
3.2 一个可运行的统计服务
下面这份服务把前面的阶段放在一起。它只处理 POST /count:读取分块文本,统计字节数、UTF-16 code unit 数、Unicode code point 数和行数,等待一次可取消的异步业务,再逐行写回结果。
// http-lifecycle.mjs
import http from "node:http";
import { StringDecoder } from "node:string_decoder";
const HOST = "127.0.0.1";
const PORT = 3000;
const MAX_BODY_BYTES = 64 * 1024;
const REQUEST_DEADLINE_MS = 5_000;
class BodyTooLargeError extends Error {
constructor(limit) {
super(`request body exceeds ${limit} bytes`);
this.name = "BodyTooLargeError";
this.limit = limit;
}
}
function delay(ms, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(done, ms);
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason ?? new Error("operation aborted"));
};
function done() {
signal?.removeEventListener("abort", onAbort);
resolve();
}
if (signal?.aborted) {
onAbort();
} else {
signal?.addEventListener("abort", onAbort, { once: true });
}
});
}
async function readTextBody(req, { maxBytes, signal }) {
const decoder = new StringDecoder("utf8");
let bytes = 0;
let text = "";
let tooLarge = false;
const onAbort = () => req.destroy(signal.reason);
if (signal.aborted) throw signal.reason;
signal.addEventListener("abort", onAbort, { once: true });
try {
for await (const chunk of req) {
if (signal.aborted) throw signal.reason;
if (tooLarge) continue;
bytes += chunk.length;
if (bytes > maxBytes) {
tooLarge = true;
continue;
}
text += decoder.write(chunk);
}
if (tooLarge) throw new BodyTooLargeError(maxBytes);
text += decoder.end();
return { text, bytes };
} finally {
signal.removeEventListener("abort", onAbort);
}
}
function discardRequest(req) {
if (req.readableEnded) return Promise.resolve();
req.resume();
return new Promise((resolve, reject) => {
const cleanup = () => {
req.off("end", onEnd);
req.off("close", onClose);
req.off("error", onError);
};
const onEnd = () => { cleanup(); resolve(); };
const onClose = () => {
cleanup();
if (req.complete) resolve();
else reject(new Error("request closed while discarding body"));
};
const onError = (error) => { cleanup(); reject(error); };
req.once("end", onEnd);
req.once("close", onClose);
req.once("error", onError);
});
}
function writeChunk(res, chunk) {
if (res.write(chunk)) return Promise.resolve();
return new Promise((resolve, reject) => {
const cleanup = () => {
res.off("drain", onDrain);
res.off("error", onError);
res.off("close", onClose);
};
const onDrain = () => { cleanup(); resolve(); };
const onError = (error) => { cleanup(); reject(error); };
const onClose = () => {
cleanup();
reject(new Error("response closed before drain"));
};
res.once("drain", onDrain);
res.once("error", onError);
res.once("close", onClose);
});
}
function sendText(res, statusCode, text) {
if (res.headersSent || res.writableEnded) return;
res.writeHead(statusCode, {
"content-type": "text/plain; charset=utf-8",
"content-length": Buffer.byteLength(text),
});
res.end(text);
}
const server = http.createServer(async (req, res) => {
const controller = new AbortController();
const deadline = setTimeout(
() => controller.abort(new Error("request deadline exceeded")),
REQUEST_DEADLINE_MS,
);
const abortForDisconnect = () => {
// v24.16 的 req.signal 可能在正常消息完成后也 abort;此时不能取消业务。
if (!req.complete && !controller.signal.aborted) {
controller.abort(req.signal?.reason ?? new Error("client disconnected"));
}
};
if (req.signal) {
req.signal.addEventListener("abort", abortForDisconnect, { once: true });
} else {
// Node.js 24 提供 req.signal;这个回退让实验也能在更早版本运行。
req.once("close", () => {
if (!req.complete) abortForDisconnect();
});
}
res.once("close", () => {
if (!res.writableFinished && !controller.signal.aborted) {
controller.abort(new Error("client disconnected while receiving"));
}
});
res.once("finish", () => {
console.log("finish: response handed to the underlying connection");
});
try {
if (req.method !== "POST" || req.url !== "/count") {
await discardRequest(req);
sendText(res, 404, "not found\n");
return;
}
if (!String(req.headers["content-type"] ?? "").startsWith("text/plain")) {
await discardRequest(req);
sendText(res, 415, "content-type must be text/plain\n");
return;
}
const { text, bytes } = await readTextBody(req, {
maxBytes: MAX_BODY_BYTES,
signal: controller.signal,
});
await delay(40, controller.signal); // 模拟可取消的异步业务
if (text.includes("失败")) throw new Error("business rejected the text");
const lines = [
`bytes: ${bytes}`,
`utf16-units: ${text.length}`,
`code-points: ${[...text].length}`,
`lines: ${text === "" ? 0 : text.split(/\r?\n/).length}`,
];
res.writeHead(200, {
"content-type": "text/plain; charset=utf-8",
"transfer-encoding": "chunked",
});
for (const line of lines) {
await writeChunk(res, `${line}\n`);
await delay(10, controller.signal);
}
res.end();
} catch (error) {
if (controller.signal.aborted || res.destroyed) return;
if (error instanceof BodyTooLargeError) {
sendText(res, 413, "request body too large\n");
} else {
console.error(error);
sendText(res, 500, "internal error\n");
}
} finally {
clearTimeout(deadline);
req.signal?.removeEventListener("abort", abortForDisconnect);
if (controller.signal.aborted && !res.writableEnded && !res.destroyed) {
res.destroy(controller.signal.reason);
}
}
});
server.requestTimeout = REQUEST_DEADLINE_MS;
server.headersTimeout = REQUEST_DEADLINE_MS;
server.keepAliveTimeout = 5_000;
server.listen(PORT, HOST, () => {
console.log(`listening on http://${HOST}:${PORT}`);
});
运行:
node http-lifecycle.mjs
另一个终端用 node:http 客户端故意分三次写入:
// send-slow.mjs
import http from "node:http";
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const response = await new Promise((resolve, reject) => {
const req = http.request({
host: "127.0.0.1",
port: 3000,
path: "/count",
method: "POST",
headers: { "content-type": "text/plain" },
}, resolve);
req.on("error", reject);
(async () => {
for (const part of ["Guang ", "中文", "🙂\n"]) {
req.write(part);
await wait(100);
}
req.end();
})().catch((error) => req.destroy(error));
});
let result = "";
for await (const chunk of response) result += chunk;
console.log(response.statusCode, result);
客户端使用的是 chunked 请求体,服务器仍然通过同一个 req 流逐块读取。慢速上传只会拉长 req 的阶段;它不会让 request 回调等待完整正文后才开始执行。
还可以用几个小实验观察其他分支:
# 超过 64 KiB 限制,预期返回 413
head -c 70000 /dev/zero | curl --http1.1 -i -H 'content-type: text/plain' --data-binary @- http://127.0.0.1:3000/count
# 业务规则拒绝包含“失败”的合法输入,预期返回 500
printf '失败\n' | curl --http1.1 -i -H 'content-type: text/plain' --data-binary @- http://127.0.0.1:3000/count
慢速接收可以让客户端逐块读取响应;这个案例的响应很小,通常不会填满服务端缓冲,但能说明“客户端读得慢”和“服务端 finish”是两个不同阶段:
// receive-slow.mjs
import http from "node:http";
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const req = http.request({
host: "127.0.0.1",
port: 3000,
path: "/count",
method: "POST",
headers: { "content-type": "text/plain" },
}, async (res) => {
console.log("status", res.statusCode);
for await (const chunk of res) {
console.log("received", chunk.length, "bytes");
await wait(250);
}
});
req.on("error", console.error);
req.end("Guang 中文🙂\n");
客户端提前断开则可以只写入半个请求体后销毁请求:
// abort-upload.mjs
import http from "node:http";
const req = http.request({
host: "127.0.0.1",
port: 3000,
path: "/count",
method: "POST",
headers: { "content-type": "text/plain" },
});
req.on("error", (error) => console.log("client", error.code ?? error.message));
req.write("only the first part");
setTimeout(() => req.destroy(), 50);
服务端此时可能先看到 req.close,但 req.complete 仍为 false;具体事件顺序取决于 socket 关闭时机,实验应以阶段日志为准。
4. 响应如何逐步发送
4.1 头部在第一次写入前确定
res.setHeader() 和 res.writeHead() 修改的是尚未发送的响应头。第一次 res.write() 或 res.end() 通常会隐式发送响应头;一旦 res.headersSent 变为 true,再修改普通响应头就太晚了。
res.write(chunk) 做两件事:把一块响应数据交给 ServerResponse 的写入路径,并返回当前缓冲是否还能继续接受更多数据。它的返回值与客户端是否已经读取无关,只是本地背压反馈。
res.end([chunk]) 表示响应消息的最后一块。它可能同时发送最后的数据和结束标记,也可能在没有正文时只结束响应。调用 end() 后,应用不应继续 write();可以观察 res.writableEnded 判断是否已经发出了结束请求。
4.2 finish 不是客户端确认
ServerResponse 的 finish 表示 Node.js 已经把响应数据交给底层连接的写出流程,通常可以理解为服务端的响应写入阶段结束。它不能证明:
- 对端进程已经读取了所有字节;
- 浏览器已经完成了解码或渲染;
- 用户已经看到响应;
- 业务客户端已经根据响应成功提交了后续状态。
传输过程中,内核缓冲、网络设备和对端 TCP 栈都可能仍有在途数据。如果客户端提前断开,close 可能在 finish 前到达;即使 finish 已经触发,也不等于业务意义上的“客户端确认收到”。应用需要把“服务端写完”和“客户端确认”设计成两个不同的事实。
5. 哪一个事件才表示请求结束
“请求结束”至少有八个不同含义,不能用一个事件替代全部含义:
| 信号 | 谁触发 | 能说明什么 | 不能说明什么 |
|---|---|---|---|
req 的 end |
HTTP 请求流 | 请求体已经交付完毕 | 业务处理完成、响应已发送 |
req.complete |
Node.js HTTP 消息解析器 | 整条请求消息已完整解析 | 业务成功、客户端已收到响应 |
| 业务 Promise 完成 | 应用代码 | 业务决定了要返回的结果 | 响应头或正文已经写出 |
res.end() 调用 |
应用代码 | 应用不再提供响应数据 | 数据已经到达客户端 |
res 的 finish |
Node.js 写出流程 | 响应数据已交给底层连接 | 客户端已读取、已确认或已渲染 |
req 的 close |
HTTP 消息对象 | 请求消息已经完成,或连接在完成前终止;结合 req.complete 可判断是否提前结束 |
不能单独证明 TCP socket 已关闭 |
res 的 close |
HTTP 响应对象 | 响应已经完成,或底层连接在完成前终止;成功路径中通常在 finish 后看到,失败路径也可能提前到达 |
不能单独证明客户端已读取完整响应 |
req.socket 的 close |
TCP socket | 这条底层连接已经关闭 | 关闭前的数据是否完整到达 |
在成功路径中,常见顺序是:
请求头解析
→ req data* / req end
→ 业务 Promise 完成
→ res.writeHead / res.write*
→ res.end()
→ res finish
→ keep-alive 空闲或 socket close
下面的图画的是成功路径中的常见位置;失败时 close 可能提前到达,事件之间没有一条对所有请求都成立的固定总顺序。
但失败路径可以在任意阶段截断它。例如客户端在请求体还没发送完时断开,服务端会看到请求对象在 complete 之前关闭,通常不会收到正常的 end;服务端已经开始写响应后,客户端也可能在 finish 前关闭连接。旧代码中常见的 req.aborted 和 aborted 事件在当前 Node.js 文档中已标记为 deprecated,新的代码应结合 close、complete、destroyed 和 socket 状态判断阶段。日志应记录阶段状态,而不是只打印一个“请求完成”。
6. 超时、断开与失败后的资源释放
6.1 超时要区分阶段
一个请求可能在不同地方等待:
等待连接/请求头
→ 接收请求体
→ 业务处理
→ 写出响应
统一设置一个很大的总超时,无法解释到底哪里变慢;只设置业务超时,又可能让慢速上传长期占用连接。服务可以按需要分别配置:
server.headersTimeout:请求头等待时间;server.requestTimeout:请求接收阶段的整体限制;- 应用自己的
AbortController:示例从request回调开始计时,覆盖请求体读取、业务处理和响应写出的总截止时间; - 下游或上游流的空闲超时:持续没有数据时取消。
Node.js 的具体默认值和版本行为应以当前版本文档为准,业务不应依赖“默认值刚好够用”。本文示例显式设置了较短的演示值:requestTimeout 限制请求接收阶段,应用自己的 5 秒截止时间则从收到请求头后开始,覆盖请求体读取、业务等待和响应写出;两者不是按阶段自动续期的独立计时器。
6.2 取消必须沿着任务链传播
Promise.race([work, timeout]) 只会让调用方先看到 timeout;它不会自动停止 work。如果业务仍在运行,数据库查询、文件读取或下游请求仍可能持有资源。可取消的 API 应接收 AbortSignal,在 abort 时真正停止等待和 I/O;不可取消的工作也至少要在完成后检查请求是否已经失效,避免继续写入已关闭的响应。
示例中的三类监听各有责任:
- Node.js v24.16.0 起提供
req.signal,但 v24.20.0 起才不再把正常消息完成报告为 abort;因此示例只在req.complete仍为false时把它当作上传阶段的断开信号。业务阶段还依靠res.close和总截止时间取消。详见 Node.js v24 HTTP API 的message.signal。req.close配合req.complete可帮助记录消息是正常结束还是提前终止。 res.close表示响应完成,或底层连接在响应完成前终止;成功路径中通常在finish后看到,失败路径也可能提前到达。如果res.writableFinished还没变成true,说明响应写出没有正常完成;它不能直接当作 TCP socket 的关闭通知。AbortController把断开和截止时间汇聚成一个取消信号,供readTextBody()、delay()以及其他下游任务共同使用。
处理错误时还要先判断响应是否已经开始发送。响应头发出后,通常不能再把同一个响应改成另一种状态码;此时更安全的做法是停止后续写入、销毁必要的资源,并记录实际失败阶段。
6.3 业务失败不等于网络失败
这几种结果应在日志和监控中分开:
网络失败:连接断开、DNS/TCP/TLS 或 socket 错误
HTTP 失败:收到了 4xx/5xx 响应
解析失败:响应或请求体格式不合法
业务失败:输入合法,但业务规则拒绝
取消:截止时间到达,或客户端主动断开
原生 node:http 不会替应用自动重试。重试前还要回答一个问题:第一次请求的副作用是否已经发生?对写操作盲目重试可能创建重复记录,所以重试策略必须和幂等键、事务边界及业务语义一起设计。这里先停在生命周期边界,数据库事务和服务工程会在后续文章中展开。
7. 连接复用和协议边界
7.1 HTTP/1.1 的复用是顺序复用
HTTP/1.1 keep-alive 让同一个 TCP 连接在一次响应结束后继续存活,后续请求可以复用握手和连接成本。它不表示一条普通 HTTP/1.1 连接可以像 HTTP/2 那样任意交错并行返回多条响应;请求和响应仍然受到 HTTP/1.1 的顺序语义和连接状态约束。
因此,服务端关闭一个 res 并不总是等于关闭 socket。连接可能进入 keep-alive 空闲状态,等待下一个 request;也可能因为 Connection: close、超时、协议错误或服务端主动关闭而结束。res.finish 只描述这一条响应的写出阶段,不能代替 socket.close。
7.2 HTTP/2 是后续专题的协议变化
Node.js 通过 node:http2 提供 HTTP/2 API。HTTP/2 把一条连接中的请求和响应拆成多个带 stream 标识的帧,可以在同一 TCP 连接上复用多个逻辑 stream,减少 HTTP/1.1 顺序等待带来的应用层队头阻塞;但底层 TCP 丢包仍会影响整条连接。HTTP/2 还引入了不同的流控、优先级和 GOAWAY 语义,因此不能只把 node:http 的事件名替换一下。
浏览器通常通过 HTTPS 和 ALPN 协商协议。Node.js 需要使用 node:http2 的 createSecureServer() 才能显式创建 HTTP/2 TLS 服务;也可以在合适的配置下让同一个安全端点兼容 HTTP/1.1。HTTP/2 的 session、stream、窗口和优雅关闭值得单独写一篇,本文只保留这个边界。
7.3 浏览器、fetch 和 Undici 属于另一侧
浏览器网页调用 fetch() 时,浏览器负责 Cookie、缓存、同源策略、CORS、连接复用和协议协商,网页脚本通常看不到具体 socket。Node.js 的全局 fetch() 由 Node 内置的 Undici 提供客户端实现,但它仍然是“发出请求的一方”;本文的 node:http 服务端则是“接收并写回响应的一方”。需要连接池、代理、调度或测试替身等更细控制时,可以再讨论单独安装的 Undici;它不应成为本文服务端生命周期的主线。
8. 用阶段日志定位真正变慢的位置
把一次请求只记录成一个总耗时,很难知道问题是在上传、业务还是下载。可以为同一个请求生成 ID,至少记录以下时间点:
request:收到请求头
body-start / body-end:开始、结束读取请求体
business-start / business-end:业务开始、结束
response-start:第一次写响应头或响应块
response-end:调用 res.end()
finish:Node.js 写出流程结束
req.close / res.close:对应 HTTP 消息对象完成或提前终止;socket close 才表示底层连接关闭
这些时间点能回答不同问题:
request → body-end很长,可能是客户端慢速上传或请求体限制配置不合适;body-end → business-end很长,优先检查业务 I/O、锁和计算;response-end → finish很长,可能是下游背压;finish后客户端仍报告失败,不能直接推断服务端没有写完,要结合客户端日志和协议层证据;close早于预期,说明需要检查断开、超时和代理行为。
请求生命周期的价值,不是记住更多事件名,而是让每一个事件都对应一个可验证的阶段。只有把“输入已经读完”“业务已经决定结果”“服务端已经交给底层发送”和“客户端已经确认”分开,超时、错误和资源清理才有清晰的责任边界。
小结
一条 Node.js HTTP/1.1 请求可以沿着下面的链路理解:
socket 字节
→ HTTP 解析器
→ IncomingMessage(请求头 + 请求体流)
→ 可取消的业务处理
→ ServerResponse(响应头 + 响应体流)
→ finish(服务端写出阶段结束)
→ keep-alive 复用或连接关闭
req 的 end 只表示请求体读完,业务 Promise 的完成只表示应用决定了结果,res.end() 只表示应用不再提供新数据,finish 只表示 Node.js 把响应交给底层连接。它们都不能单独证明客户端已经收到或理解了响应。