在 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
  → 连接保持空闲,或关闭

一条 HTTP/1.1 请求从 TCP socket、请求体流、业务处理到响应写出的完整生命周期;响应结束后连接可能进入 keep-alive,也可能关闭

后面每一项都会说明它由谁触发、能证明什么,以及不能证明什么。

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);

HTTP 请求中的三种边界:TCP 字节块由网络交付,StringDecoder 处理字符边界,业务代码决定消息分帧和大小限制

这只解决字符边界,不会自动解决消息边界。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 或取消任务的责任传递下去。

请求数据从 socket 经过 req 和业务处理流向 res;当响应缓冲变满时,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 可能提前到达,事件之间没有一条对所有请求都成立的固定总顺序。

请求体结束、业务完成、res.end、finish、响应 close 和 socket close 位于不同生命周期阶段;finish 不表示客户端确认

但失败路径可以在任意阶段截断它。例如客户端在请求体还没发送完时断开,服务端会看到请求对象在 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 把响应交给底层连接。它们都不能单独证明客户端已经收到或理解了响应。

参考资料