HTTP 语义

综合运用方法、状态码、响应字段、缓存策略、幂等性和连接边界等要素,设计并诊断 HTTP 请求与响应交互。

难度 入门 时长 标准深度约 16分钟
版本 Node 24
what

HTTP 语义定义请求和响应的含义:方法表达意图,状态码报告结果,字段限定消息的处理方式。

trap

传输失败不能说明写操作是否生效,幂等请求也不承诺每次尝试都得到相同响应。

fix

明确定义方法、状态、缓存、验证器和重试约定,再分别从应用边界与连接边界验证它们。

是什么,为什么存在

HTTP 语义是一套赋予交互含义的共同规则。请求说明客户端想对目标资源执行什么操作,响应说明该请求如何被处理,还可以携带资源状态的某种 表示(representation)

这些规则独立于框架的路由语法,也独立于线上编码格式。无论消息经由 HTTP/1.1、HTTP/2 还是 HTTP/3 传输,GET404 Not FoundCache-Control 和验证器的含义都相同。协议版本改变的是帧结构与连接管理,不是方法或状态码的基本意图。

浏览器跟随重定向、缓存复用响应、API 客户端在超时后重试,或代理转发字段时,都会用到这些语义。生产环境中的多数 HTTP 问题并非语法畸形,而是各方对有效消息允许接收方采取什么行为理解不一致。

可以把 HTTP 交互分成四个方面理解:

  1. 意图: 方法描述请求的操作及其安全属性。
  2. 结果: 状态码描述本次请求的处理结果。
  3. 元数据: 字段描述表示、缓存、认证、路由或消息处理方式。
  4. 传输: 某个协议版本在一个或多个连接与流上传递消息帧。

资源并不等于某次响应中的字节。资源是由 URI 标识的概念性目标,表示则是反映资源某种状态的可传输数据。一个 JSON 文档、一个 HTML 页面和一个空的 204 响应,都可以参与对同一资源的操作。

HTTP 提供默认语义,但应用仍要负责自身的领域约定。例如,HTTP 可以规定 PUT 具有幂等性,API 则必须定义请求体代表哪种完整状态、适用哪些前置条件,以及客户端应预期哪些状态码。清晰的 API 会对齐这两个层次,而不是把 HTTP 当作通用信封。

工作原理

一次 HTTP 交互从请求方法和目标开始。请求字段补充条件或偏好,可选的内容主体承载数据。响应以状态码开头,随后是响应字段和可选内容;是否允许内容取决于方法与状态。

方法表达意图

方法名是区分大小写且具有标准语义的标记。服务端或中间节点即使尚未理解应用载荷,也能利用这些语义。因此,即便 GETPOSTPUT 路由调用相似的框架代码,选择不同方法仍会改变缓存和重试行为。

有两个方法属性尤其重要:

  • 安全(safe)方法要求只读语义。日志、指标或请求计费等附带影响仍可能发生,但客户端没有要求改变资源状态。
  • 幂等性(idempotency) 表示多次相同请求的预期效果与一次相同。目标从不存在变成存在时,各次尝试可以返回不同状态码或元数据。
方法安全幂等典型意图
GET获取当前表示
HEAD获取响应元数据,但不获取响应内容
POST默认否要求目标处理随附数据
PUT用给定状态创建或替换目标
DELETE移除目标的当前关联
PATCH默认否应用部分修改
OPTIONS查询通信选项

安全必然意味着幂等,但幂等不必然安全。DELETE 第一次成功时可以改变状态,重复相同删除却不应再产生额外的预期效果。第二次尝试完全可以返回 404,这不会改变该方法的幂等属性。

POST 有意采用宽泛语义。它可以创建下级资源、提交命令、启动任务,也可以执行输入大到不适合放进 URI 的搜索。如果应用通过幂等键对重复 POST 去重,那是额外的 API 约定,并不会让所有 POST 都变成普遍幂等的方法。

状态码报告本次处理结果

状态码的第一位表示所属类别,但客户端必须根据具体状态码决定行为。遇到未知状态码时,可以按类别采取后备行为;缓存验证或重定向方法处理等特殊动作,则需要理解已经定义的具体状态码。

类别含义代表性决策
1xx信息继续处理,之后仍有最终响应
2xx成功按照方法和具体状态使用响应
3xx重定向跟随某个位置,或复用已缓存表示
4xx客户端请求问题修改凭据、输入、前置条件或请求速率
5xx服务端故障保留不确定性,并考虑由策略控制的重试

200 OK 不是万能成功包装。创建操作通常使用 201 Created,并通过 Location 字段标识新建资源。操作成功却没有响应内容时,可以使用 204 No Content;该状态不能携带内容。

重定向状态码编码了不同的后续行为。303 See Other 要求客户端用 GET 获取另一个 URI,适合提交命令之后使用。307 Temporary Redirect308 Permanent Redirect 会保留原方法和内容,这一点对写操作很重要。

304 Not Modified 不是指向新资源的重定向,也不是空的 200。它响应条件式 GETHEAD,告诉客户端可以复用已存储的表示。客户端把已存内容与 304 响应更新的元数据结合起来。

客户端错误状态应保留调用方可以处理的差异。400 表示请求本身无效,401 发起或报告认证质询,403 表示服务端理解却拒绝请求,404 则表示找不到当前表示,或服务端不愿透露它。409 报告与当前资源状态冲突,412 表示给定前置条件求值为假。

服务端错误也有运维含义。500 表示意外的服务端故障,502 表示网关收到无效上游响应,503 表示服务当前无法处理请求。Retry-After 字段可以给出时间建议,但客户端仍要设置重试预算,并考虑重放是否安全。

字段细化消息含义

HTTP 字段是按名称定义语义的元数据。字段名不区分大小写,字段值则遵循各字段自己的语法。把每个值都当作可随意用逗号拆分的字符串,会破坏组合规则不同的字段。

有些字段描述表示。Content-Type 描述本条消息实际携带内容的媒体类型,Content-Encoding 描述压缩等已经应用的编码。请求中的 Accept 字段则说明客户端偏好哪些响应媒体类型。

内容协商(content negotiation) 根据请求偏好和服务端能力选择表示。如果可缓存响应会随 Accept-EncodingAccept-Language 改变,服务端就要发送恰当的 Vary 字段,防止缓存把一个变体复用于不兼容请求。Vary 属于缓存键约定,不只是文档说明。

字段要么是端到端字段,要么只作用于一个连接跃点。HTTP/1.1 的 Connection 字段会列出连接专用选项,中间节点必须消费而不能继续转发。HTTP/2 禁止 Connection 及其他连接专用字段,因为流的帧结构取代了这些 HTTP/1.1 机制。

缓存按策略复用响应

缓存保存响应,并且只在方法、目标、选择字段、新鲜度与认证规则都允许时,才可以为后续请求复用它。复用是一项语义决策,仅仅保存了字节并不代表有权发送。共享缓存还需要额外防护,因为响应可能跨用户传播。

Cache-Control: max-age=60 根据响应的生成时间和年龄元数据,给出六十秒的新鲜度寿命。响应保持新鲜时,缓存通常无须联系源站即可复用。s-maxage 可以为共享缓存设定不同寿命。

no-cache 表示已存响应必须经过成功验证才能复用,并不表示「不要存储」。no-store 要求缓存不存储响应。private 允许浏览器缓存等私有缓存保存,却禁止共享缓存保存该响应。

主缓存键通常包含请求方法和目标 URI。Vary 指定的字段会进一步参与已存响应选择。服务端同时提供 gzip 与 identity 变体却遗漏 Vary: Accept-Encoding 时,可能向不接受该编码的客户端发送元数据错误的字节。

验证器使陈旧响应无须重新传输完整表示也能继续使用。ETag 中的实体标签是服务端选择的不透明验证器。客户端可以把它放入 If-None-Match;对 GETHEAD 而言,匹配时得到 304,否则服务端照常发送选定表示。

前置条件也能防止更新丢失。读取到 ETag: "v7" 的客户端可以在修改请求中发送 If-Match: "v7"。如果当前表示已有另一个标签,服务端会返回 412 Precondition Failed,而不是覆盖客户端尚未见过的状态。

重试跨越不确定性边界

收到响应可以证明服务端生成了这个响应,没有收到响应却存在歧义。请求可能从未离开客户端,可能到达服务端但尚未提交,也可能已经提交而响应丢失。TCP 重置和超时错误无法区分这些情况。

请求内容可以重现且策略允许时,客户端通常可以重放安全及幂等操作。它仍需限制尝试次数,实施退避与抖动,传递截止时间,并尊重服务端建议。幂等方法防止重复的预期效果,却不能让已经过载的服务从无限重试中获益。

需要自动重放非幂等操作时,必须增加应用机制。稳定的幂等键可以让服务端记住某个操作的完成结果,并在收到重复请求时返回它。约定必须定义键的作用域、请求指纹、保留时间、并发处理,以及第一次尝试仍在进行时的行为。

连接承载消息但不定义消息含义

HTTP/1.1 可以在一个持久连接上依次承载多个交互。消息帧边界由内容长度、传输编码以及禁止内容的状态等规则确定,关闭连接只是可能的分隔方式之一。各方对这些规则的解析分歧可能演变成请求走私漏洞。

HTTP/2 在一个连接上用帧承载并发流。每个流有自己的消息序列,一个流失败时其他流未必失败。HTTP/3 再次改变了传输方式,而方法、状态码、表示、缓存和大多数字段仍保留原有语义。

因此,连接寿命不等于资源或操作寿命。新开连接不会让重复写入变成新的逻辑操作,复用连接也不会把两个请求合成一个事务。诊断时应把传输状态与应用结果当作两个维度。

示例

这些示例使用 Node 24 的 HTTP 服务端和内置 fetch。每个服务端都监听临时回环端口,所以程序无须外部服务,也不会一直占用固定端口。

重复执行幂等的 PUT

这个端点把 PUT /profiles/7 视为完整替换。第一次请求创建目标并返回 201,相同的重复请求用同一状态替换它,并返回 204

method_contracts.mjs
import { createServer } from "node:http";

const profiles = new Map();
const server = createServer(async (request, response) => {
  if (request.method !== "PUT" || request.url !== "/profiles/7") {
    response.writeHead(405, { Allow: "PUT" }).end();
    return;
  }

  let json = "";
  for await (const chunk of request) json += chunk;
  const existed = profiles.has("7");
  profiles.set("7", JSON.parse(json));

  const fields = existed ? {} : { Location: "/profiles/7" };
  response.writeHead(existed ? 204 : 201, fields).end();
});

await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const options = {
  method: "PUT",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ displayName: "Ada" }),
};

for (const label of ["first", "repeat"]) {
  const response = await fetch(`${origin}/profiles/7`, options);
  console.log(label, response.status, response.headers.get("location") ?? "-");
}

const rejected = await fetch(`${origin}/profiles/7`);
console.log("GET", rejected.status, rejected.headers.get("allow"));
console.log("stored", JSON.stringify(profiles.get("7")));
server.close();
first 201 /profiles/7
repeat 204 -
GET 405 PUT
stored {"displayName":"Ada"}

两个不同的成功状态不违反幂等性。无论发送一次还是两次相同请求,资料 7 的预期状态都一样。服务端还返回带有 Allow: PUT405 Method Not Allowed,从而区分「已知资源不支持该方法」与「路由未知」。

生产代码还要验证 Content-Type、限制请求体大小、处理畸形 JSON,并定义并发前置条件。这些要求会加强而不是取代方法语义。只调用 JSON.parse 的生成式处理器还不能安全接收不可信网络输入。

重新验证已缓存表示

下一个服务端为固定的目录表示分配实体标签。第二次请求发送该验证器,于是服务端通过 304 传输元数据,不再重复传输 JSON 主体。

conditional_get.mjs
import { createServer } from "node:http";

const body = JSON.stringify({ items: ["tea", "coffee"] });
const etag = '"catalog-v3"';
const server = createServer((request, response) => {
  const fields = { ETag: etag, "Cache-Control": "max-age=60" };
  if (request.headers["if-none-match"] === etag) {
    response.writeHead(304, fields).end();
    return;
  }

  response.writeHead(200, {
    ...fields,
    "Content-Type": "application/json",
  });
  response.end(body);
});

await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const url = `http://127.0.0.1:${server.address().port}/catalog`;

const first = await fetch(url);
console.log("first", first.status, first.headers.get("etag"), await first.text());

const second = await fetch(url, { headers: { "If-None-Match": etag } });
console.log("validated", second.status, "body bytes", (await second.text()).length);
server.close();
first 200 "catalog-v3" {"items":["tea","coffee"]}
validated 304 body bytes 0

304 结果只对已经拥有选定表示的客户端有用。直接调用方不能把它的空内容当成新的空目录。真实缓存还会用验证响应中允许的字段更新已存元数据。

这个小型服务端直接比较一个强标签,足以满足固定示例。生产实现需要处理完整的实体标签语法、列表与通配符行为、规范指定场景下的弱比较,以及各条件字段之间的既定优先级。应由框架或 HTTP 库完成这类解析。

区分消息边界与 EOF

最后一个程序检查从同一字节流读取的两个 HTTP/1.1 响应。这个刻意缩小范围的解析器用 Content-Length 确定第一个响应的长度,再利用第二个状态禁止内容的规则解析它;两个消息之后剩余零字节。

message_framing.mjs
const wire = Buffer.from(
  "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello" +
  "HTTP/1.1 204 No Content\r\n\r\n",
);

function readMessage(buffer) {
  const headerEnd = buffer.indexOf("\r\n\r\n");
  const head = buffer.subarray(0, headerEnd).toString();
  const lines = head.split("\r\n");
  const lengthField = lines.find((line) =>
    line.toLowerCase().startsWith("content-length:"),
  );
  const length = lengthField ? Number(lengthField.split(":", 2)[1]) : 0;
  const bodyStart = headerEnd + 4;
  const bodyEnd = bodyStart + length;
  return {
    status: lines[0].slice("HTTP/1.1 ".length),
    body: buffer.subarray(bodyStart, bodyEnd).toString(),
    rest: buffer.subarray(bodyEnd),
  };
}

const first = readMessage(wire);
const second = readMessage(first.rest);
console.log(`${first.status} -> ${first.body}`);
console.log(`${second.status} -> ${second.body || "<empty>"}`);
console.log("remaining bytes", second.rest.length);
200 OK -> hello
204 No Content -> <empty>
remaining bytes 0

两个响应之间并未关闭连接。前五个内容字节恰好结束于下一个状态行之前,而 204 在字段段之后结束,因为该状态不能包含内容。如果调用方在第一个响应后等待 EOF,就会在持久连接上一直挂起。

不要把这个教学解析器改成生产代码。它省略了请求帧结构、传输编码、临时响应、畸形输入检查、字段大小限制和冲突长度防护。应使用维护良好的 HTTP 栈,由其解析器执行单一且无歧义的帧边界策略。

陷阱

每逢超时都重试

修复方法: 按方法与应用语义对操作分类。只在总截止时间和尝试次数受限的情况下重试可重现且可安全重放的请求;非幂等写入则要定义稳定的幂等键约定,并测试响应丢失场景。

所有领域结果都返回 200

修复方法: 选择最准确描述 HTTP 处理结果的标准状态码,再把领域细节放入大小受限的响应表示。记录每项操作可能返回的状态集合,并测试中间节点能否保留它们。

混淆表示字段

修复方法: 验证请求的 Content-Type,根据 Accept 字段进行协商,设置响应的实际 Content-Type,并把所有参与选择的请求字段写入 Vary。测试不同变体以及共享缓存行为。

304 当作空表示

修复方法: 只在标准定义的条件式获取路径中返回 304。缓存代码应保留已存内容、合并允许的元数据;如果没有可用的已存响应,就退回无条件获取。

意外共享敏感响应

修复方法: 用户专属响应应标记为 private;如果连存储都不可接受,则使用 no-store。共享响应需要明确设计认证与 Vary 规则,并用两个身份经由实际 CDN 或代理测试。

依靠关闭连接解析消息

修复方法: 使用维护良好的协议实现,拒绝有歧义的帧结构,并在每个中间节点边界移除逐跃点字段。分别记录流或请求标识符与连接标识符。

深入 分离消息语义与连接

分离消息语义与连接

HTTP 能够工作,是因为发送方和接收方无须共享框架状态也能推理消息。困难通常出现在应用结果、缓存表示和传输事件无法一一对应的地方。诊断必须保持这些层次分离,才能保留真实的不确定性。

幂等性针对预期效果

假设 PUT /profiles/7 保存完整资料。第一次请求可以创建资源并返回 201,重复请求则可能发现无须可见修改并返回 204。不同响应与同一个预期最终状态完全相容。

附带影响通常不会改变方法分类。服务端可以记录每次尝试、增加指标,或计入内部请求核算单位。重要限制是客户端重复同一幂等操作时,并没有要求产生更多资源状态效果。

幂等性还取决于怎样判断「相同请求」。复用方法和 URI 却更换主体,不是重复同一请求。随时间变化的服务端规则可能让旧请求日后变得无效,后续响应可以报告这种变化,而不会让方法失去幂等性。

幂等键把请求身份问题移入应用协议。稳健的服务端会把键绑定到认证主体、操作作用域与请求指纹。如果相同键携带不同内容,静默返回第一次结果会隐藏调用方缺陷;返回冲突通常更安全。

并发重复请求需要原子的所有权规则。两个工作进程不能都观察到键不存在,再各自执行副作用。记录可以经历「进行中」「已完成」「已过期」等状态,并在约定的重放窗口内保留原始状态码和响应数据。

前置条件让写入带上条件

验证器不只是节省带宽的手段。If-Match 在 HTTP 层把写入变成比较后设置操作。服务端会先对选定的当前表示求值前置条件,再应用请求方法。

只要与相等性相关的表示数据变化,强实体标签就应变化。带有 W/ 前缀的弱标签可以把字节不同但语义等价的表示归为一组。If-Match 使用强比较,因为弱等价不足以防止覆盖未见过的修改。

If-None-Match 有两个常见用途。对于 GETHEAD,标签匹配会产生 304 并复用已存内容。在状态修改请求上使用 If-None-Match: *,则可要求当前表示不存在,防止「不存在才创建」行为意外覆盖数据。

实体标签不可用时,日期验证器仍有价值,但 HTTP 日期精度有限,也依赖可信的修改时间。在规范同时定义两者的地方,实体标签前置条件具有优先级。如果标准前置条件已能向中间节点和通用客户端表达相同并发约定,就不要另造 JSON version 字段。

缓存复用是一次新的响应决策

已存响应并非保持冻结直到被逐出。它的当前年龄会增长,新鲜度寿命决定何时变得陈旧,请求指令可能限制复用,成功验证则可以更新元数据。缓存会按照当前请求的规则,用已存信息构造响应。

新鲜不等于真实。即使源站状态已经改变,响应仍可能处于新鲜期,因为新鲜度允许在有限时间内不经验证直接复用。反过来,陈旧响应也可以在明确允许陈旧的控制下提供,或在成功验证后继续使用。

共享缓存与私有缓存处于不同信任边界。浏览器缓存的作用域通常是一个用户代理配置,而 CDN 可能服务多个身份。认证请求的响应和标记为 private 的响应都有特殊共享缓存约束,需要和显式指令一起考虑。

Vary 记录哪些请求字段影响表示选择。Vary: * 表示不把请求转发给源站就无法匹配该响应进行复用。即使只用一种区域或编码的首次缓存测试能够通过,遗漏选择字段仍是正确性错误。

默认情况下,失效操作并非瞬间完成。经过缓存的不安全请求成功后,可以让相关 URI 的已存响应失效,但应用在其他位置发生的变化可能需要显式清除或带版本的资源设计。没有测量实际失效路径,就不要承诺即时全局缓存一致性。

消息帧结构影响安全

HTTP/1.1 接收方根据一套优先级规则确定内容长度,而不是读取到某个方便的分隔符。有些方法和状态隐含响应无内容,传输编码可以划分内容帧,合法的 Content-Length 则在允许时提供十进制长度。关闭连接只是某些响应的最后一种帧边界手段,并非通用规则。

冲突或畸形的长度信息很危险,因为两个接收方可能选择不同消息边界。边缘代理可能把一段字节当作一个请求,而源站却把后缀当成第二个请求。这种分歧就是 HTTP 请求走私的基本形态。

中间节点必须一致地解析、规范化并转发消息。它应消费连接专用字段而不是端到端转发,执行大小限制,并拒绝歧义而非自行猜测。在解析器不一致之后增加 Web 应用防火墙,并不能修复它们的边界分歧。

HTTP/2 使用与流标识符关联的类型化二进制帧,取代文本行帧结构。它仍有消息规则:响应从字段块开始,可以携带数据,并在自己的流上结束。连接错误和流错误的作用域不同,因此重试逻辑需要知道哪些流可能已经被处理。

协议版本转换属于语义工作。网关不能把 ConnectionTransfer-EncodingKeep-AliveConnection 点名的字段盲目复制到 HTTP/2。它必须保留端到端含义,同时独立应用两端各自的帧结构规则。

可观测性必须保留两类身份

连接标识符回答哪些字节经过了哪个传输会话。请求或追踪标识符回答某次逻辑尝试经过了哪些服务。幂等键回答哪些尝试属于同一预期操作。这些标识符会在追踪中重叠,却不能互换。

记录方法、规范化目标、状态、协议版本、重试次数、缓存结果、验证器结果、可用时的流标识符和计时,同时避免记录秘密。对写操作,还要记录应用操作标识符和最终提交证据。系统暴露足够证据时,调查者便能区分「提交后响应丢失」与「处理前请求被拒绝」。

指标不应把所有 4xx5xx 压成一个失败计数。412 增多可能表示并发保护正常工作,429 可以说明限流策略生效,502 则指向上游边界。应按可执行的语义分组,同时控制标签基数。

有纪律地审查交互

从 API 约定开始,而不是从控制器实现开始。写出资源、方法意图、给定内容属于完整表示还是部分表示,以及成功与失败状态集合,再说明调用方必须理解哪些响应字段。

接着,独立于顺利路径编写重试决策。覆盖发送内容前后的传输失败、响应读取不完整、可重试状态、主体可重放性、截止时间预算和重复抑制。如果无法得知结果,应暴露「未知」状态,而不是把它改写成失败。

把缓存作为独立状态机审查。找出私有与共享缓存、缓存键与 Vary、新鲜度指令、验证器、失效触发条件,以及验证失败时的行为。要使用两个变体和两个身份测试,不能只让一个客户端重复请求。

最后检查每个协议边界。确认 HTTP 库负责帧结构,代理会移除逐跃点字段,而且 HTTP/2 流失败没有被报告成数据库状态证据。即使部署更换框架或协商出另一个 HTTP 版本,得到的约定仍然有效。

延伸阅读

检查点

5个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?