HTTP 里很多所谓“常识”其实混在了不同层次:
- HTTP 报文层:这种消息在协议格式上能不能表达?
- HTTP 语义层:RFC 有没有给这种写法定义标准语义?
- Web 平台层:浏览器的 Fetch / XMLHttpRequest 是否允许网页这样发?
- 中间设施层:代理、CDN、WAF、网关、缓存是否正确支持?
- 框架层:Nginx、Spring、Express、NestJS 等最终是否接受并按预期处理?
因此,“HTTP 能不能这么干?”往往不是一个 Yes / No 问题。
1. GET 可以在 HTTP 消息层出现 request body,但没有通用语义
很多教材会直接写:
GET 不能有 body。
这个说法过于绝对。
RFC 9110 明确指出:request message framing 与 method 无关;因此,GET 并不是因为报文格式本身而无法携带 content。问题在于,RFC 对 GET request content 没有定义通用语义,而且客户端 SHOULD NOT 随意发送 GET content,除非直接访问一个已经明确表示支持这种用法的源服务器。
所以更准确的说法是:
HTTP 报文层: 可以表达 GET + body
HTTP 通用语义: body 没有通用定义
RFC 建议: 不应随意发送
中间件兼容性: 可能拒绝,甚至视为 request smuggling 风险
参见 RFC 9110 §9.3.1。
浏览器 Fetch:直接禁止 GET / HEAD + body
fetch("/api/user", {
method: "GET",
body: JSON.stringify({ name: "asuhe" })
});
按照 WHATWG Fetch Standard,构造 Request 时只要 method 是 GET 或 HEAD 且 body 非空,就必须抛出 TypeError。
参见 WHATWG Fetch Standard 的 Request 构造算法。
XMLHttpRequest:可以调用 send(body),但 GET / HEAD 的 body 会被丢弃
const xhr = new XMLHttpRequest();
xhr.open("GET", "/api/user");
xhr.send(JSON.stringify({ name: "asuhe" }));
调用本身可以写,但 XMLHttpRequest 标准规定:
如果 request method 是 GET 或 HEAD:
body = null
也就是说,你传进去的 body 并不会真正成为 GET request body。
参见 WHATWG XMLHttpRequest Standard。
jQuery $.ajax() 的 GET + data 通常也不是 body
$.ajax({
url: "/api/user",
method: "GET",
data: {
name: "asuhe",
age: 27
}
});
jQuery 会把 data 转成 URL query string:
GET /api/user?name=asuhe&age=27
而不是:
GET /api/user
name=asuhe&age=27
curl 则确实能制造 GET + body
例如:
curl -X GET -d 'hello=world' https://example.com
curl 官方 FAQ 直接把这种请求作为一个“少见但可以构造”的例子。
参见 curl FAQ。
一句话总结
GET + body
HTTP framing ✅ 可以
RFC 通用语义 ❌ 没有
RFC 推荐 ⚠️ SHOULD NOT 随便发
fetch() ❌ TypeError
XMLHttpRequest ❌ body 被置为 null
jQuery GET + data ➡️ 通常转 query string
curl ✅ 可以真正构造
代理/CDN/WAF ⚠️ 可能出兼容性问题
2. HEAD 也不是“报文绝对不能有 body”,但 request body 同样没有通用语义
HEAD 与 GET 类似。
RFC 9110 同样说明:
- framing 不因 HEAD 而失去表达 request content 的能力;
- HEAD request content 没有通用语义;
- 客户端
SHOULD NOT随意发送。
浏览器方面:
fetch():HEAD + body →TypeErrorXMLHttpRequest:HEAD 的send(body)→ body 被置为null
参见 RFC 9110 §9.3.2。
3. DELETE 可以带 request body,而且浏览器真的可以发
这和 GET 很容易被错误地混为一谈。
RFC 9110 对 DELETE 的 request content 同样说:
- framing 本身不禁止;
- DELETE content 没有通用定义的语义;
- 客户端
SHOULD NOT随便发送; - 某些实现可能因为 request smuggling 风险而拒绝它。
但 Fetch 并没有像 GET / HEAD 那样禁止 DELETE + body。
所以浏览器里可以:
fetch("/api/users", {
method: "DELETE",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
ids: [1, 2, 3]
})
});
这是真正的 request body。
如果是跨源请求,由于 DELETE 不是 CORS-safelisted method,它会受 CORS preflight 约束。浏览器命中 preflight cache 时可能不会再发一次 OPTIONS 网络请求。
参见 RFC 9110 §9.3.5。
Fetch 中的 CORS-safelisted methods 只有 GET、HEAD、POST,参见 WHATWG Fetch §Methods。
还有一个容易忽略的点:DELETE response 不可缓存
即使:
DELETE /foo
HTTP/1.1 204 No Content
204 这个状态码本身属于“heuristically cacheable”状态码,也不能推出这个 DELETE response 可以缓存。
因为 RFC 对 DELETE method 有更具体的规则:
Responses to the DELETE method are not cacheable.
method 的缓存规则优先约束整个 response。
4. OPTIONS 可以带 body,但 HTTP 没规定这个 body 是干什么的
RFC 9110 对 OPTIONS 很特别:
- OPTIONS request 可以有 content;
- 如果有 content,客户端必须发送合法的
Content-Type; - 但 HTTP 本身没有定义这些 content 的用途。
例如:
OPTIONS /api HTTP/1.1
Content-Type: application/json
{"feature":"foo"}
报文可以成立,但到底是什么意思,需要应用协议自己定义。
参见 RFC 9110 §9.3.7。
5. OPTIONS 不等于 CORS Preflight
这是前端开发中极其常见的概念混淆。
关系应该理解为:
OPTIONS
│
└── HTTP 本身定义的一个 method
CORS preflight
│
└── 浏览器使用 OPTIONS 实现的一种跨域安全检查流程
例如普通 OPTIONS:
OPTIONS /api HTTP/1.1
并不天然就是 CORS preflight。
典型的 CORS preflight 会包含:
OPTIONS /api HTTP/1.1
Origin: https://foo.example
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Content-Type
因此:
所有 CORS preflight 都使用 OPTIONS,但不是所有 OPTIONS 都是 CORS preflight。
6. TRACE 才是 request body 被协议明确禁止的典型例子
和 GET / DELETE 的“没有通用语义”不同,TRACE 是明确的:
A client MUST NOT send content in a TRACE request.
也就是说:
TRACE / HTTP/1.1
hello
不是简单的“不推荐”,而是违反 HTTP 语义要求。
而浏览器 Fetch 又更进一步,把:
CONNECTTRACETRACK
定义为 forbidden methods,网页脚本不能通过 Fetch 构造这些 method。
参见 RFC 9110 §9.3.8 与 WHATWG Fetch §Methods。
7. GET 是 safe,不代表服务器绝对不能发生任何变化
很多人把 safe 理解为:
执行 GET 后服务器不能产生任何变化。
这也是错的。
RFC 对 safe method 的定义关注的是:
客户端没有请求、也不期待服务器状态发生改变。
因此一次 GET 完全可能导致:
- 写访问日志;
- 增加 PV / UV 统计;
- 更新监控数据;
- 更新缓存;
- 广告点击计费;
- 风控记录。
这些都属于实现产生的副作用,并不自动让 GET 失去 safe 属性。
真正错误的是设计成:
GET /user?id=123&action=delete
因为此时客户端实际上是在通过 GET 请求执行破坏性动作。
参见 RFC 9110 §9.2.1。
8. 幂等不等于“每次响应都一样”
RFC 对 idempotent 的定义是:
对服务器而言,多次相同请求的 intended effect 与只执行一次相同。
因此:
DELETE /users/1
第一次:
204 No Content
第二次:
404 Not Found
完全可能仍然是幂等的。
因为最终状态都是:
/users/1 已不存在
HTTP 幂等关注的是预期效果,不是:
- status code 必须相同;
- response body 必须相同;
- 日志不能新增;
- 时间戳不能变化。
参见 RFC 9110 §9.2.2。
RFC 9110 定义的 method 中:
- safe methods 是幂等的;
- PUT 是幂等的;
- DELETE 是幂等的。
9. POST 并不是“绝对不能缓存”
“GET 可缓存,POST 不可缓存”是非常常见的教学简化。
RFC 9110 实际允许 POST response 被缓存,但要求比较严格:
- response 包含显式 freshness 信息;
Content-Location与 POST target URI 相同。
满足这些条件后,一个缓存的 POST response 可以用于以后满足相同 URI 的 GET 或 HEAD。
但注意:
一个新的 POST request 不能直接由先前缓存的 POST response 来满足,因为 POST 本身可能是不安全的。
参见 RFC 9110 §9.3.3。
而 RFC 9111 还指出:
实际中很多常见 HTTP cache 只缓存 GET response。
所以这里又是:
RFC: POST response 在特定条件下可缓存
通用缓存实现: 未必支持
CDN 默认配置: 更未必
参见 RFC 9111 §2。
10. 404 也可能被缓存
HTTP 不仅能缓存成功结果,也能缓存“负结果”。
RFC 9110 中明确列出的 heuristically cacheable 状态码包括:
200
203
204
206
300
301
308
404
405
410
414
501
因此一个普通 GET:
GET /foo
返回:
404 Not Found
即使没有显式 max-age,缓存也可能为它计算 heuristic freshness。
这意味着一种真实问题:
第一次:
GET /foo → 404
404 被缓存
随后服务端创建 /foo
再次 GET /foo → 仍可能命中旧 404
参见 RFC 9110 §15.1 与 RFC 9111 §4.2.2。
但“404 可启发式缓存”不等于任何 404 都能缓存
最终是否允许存储,还要综合:
- request method;
Cache-Control: no-store;- shared cache 下的
private; Authorization;- 其他缓存规则。
所以:
status code 可缓存
只是判断条件之一,不是最终结论。
11. URL 有 ?query=... 并不会自动禁止 heuristic caching
旧版 HTTP 规范曾禁止缓存对带 query component 的 URI 使用 heuristic freshness,例如:
/api/user?id=123
但 RFC 9111 特别指出:
这一限制在实践中并没有得到广泛实现,因此现在不再采用这种通用禁止。
所以:
URL 里有 ?
并不能推导出:
这个 GET 一定不会被缓存
如果服务端明确不想被缓存,应该使用明确的 Cache-Control。
参见 RFC 9111 §4.2.2。
12. HEAD response 没有 body,却可以出现非零 Content-Length
例如:
HTTP/1.1 200 OK
Content-Length: 123456
这是合法的 HEAD response。
这里的:
Content-Length: 123456
不是说 HEAD 真发送了 123456 字节 body。
它表示:
如果相同请求使用 GET,本来会发送 123456 字节 content。
参见 RFC 9110 §8.6。
对 conditional GET 返回的 304 Not Modified 也可以出现 Content-Length,但其值必须等于同一请求如果返回 200 OK 时 content 的字节数;不能把它理解为 304 实际携带了 content。
13. 204 No Content 不能有 body、trailer,甚至不能有 Content-Length
204 No Content 并不是:
body 长度必须为 0。
而是更严格:
- response 不能包含 content;
- 不能包含 trailers;
- server MUST NOT 发送
Content-Length。
因此:
HTTP/1.1 204 No Content
Content-Length: 0
严格按 RFC 9110 也不应该发送 Content-Length。
但 204 可以携带有意义的 metadata,例如:
HTTP/1.1 204 No Content
ETag: "abc123"
参见 RFC 9110 §8.6 与 RFC 9110 §15.3.5。
204 本身可以 heuristically cacheable,但仍要看 method
例如:
GET /foo → 204
状态码层面允许 heuristic caching。
但:
DELETE /foo → 204
不能因为是 204 就缓存,因为 DELETE response 明确不可缓存。
因此正确判断顺序不是:
状态码 → 得出结论
而是:
method
+ status code
+ Cache-Control
+ Authorization
+ cache 类型
+ 其他规则
→ 最终判断
14. 301 / 302 可能把 POST 变成 GET
这是 HTTP 最著名的历史兼容包袱之一。
RFC 9110 允许用户代理在自动跟随:
POST
↓
301 / 302
时将后续请求改成:
GET
所以:
POST /submit
↓
302 Location: /result
↓
GET /result
是合法而且极其常见的行为。
参见 RFC 9110 §15.4.2 与 RFC 9110 §15.4.3。
303 更明确地用于“转成 retrieval request”
303 的核心用途之一是转向 retrieval request:对 HEAD 请求仍使用 HEAD,其他场景中用户代理通常使用 GET。
POST → 303 See Other → GET
HEAD → 303 See Other → HEAD
参见 RFC 9110 §15.4.4。
如果必须保留 method,用 307 / 308
POST → 307 → POST
POST → 308 → POST
因此:
| Status | 是否可能 POST → GET | 常见用途 |
|---|---|---|
| 301 | 是 | 永久重定向,带历史兼容行为 |
| 302 | 是 | 临时重定向,带历史兼容行为 |
| 303 | 是,明确转 retrieval | 操作完成后跳到结果资源 |
| 307 | 否 | 临时重定向且保留 method |
| 308 | 否 | 永久重定向且保留 method |
对于:
- 文件上传;
- 支付;
- RPC;
- 有 request body 的操作接口;
307 / 308 和 301 / 302 不能机械互换。
参见 RFC 9110 §15.4。
15. HTTP Method 大小写敏感,但 Header 名大小写不敏感
Method:大小写敏感
HTTP 规范明确规定 method token 是 case-sensitive。
所以协议语义上:
GET ≠ get
PATCH ≠ patch
标准 method 只是约定使用大写。
参见 RFC 9110 §9。
Header field name:大小写不敏感
下面三种在 HTTP 语义上是同一个 field name:
Content-Type
content-type
CONTENT-TYPE
参见 RFC 9110 §5.1。
16. 但 Fetch 会偷偷帮部分 method 转成大写
WHATWG Fetch 为了历史兼容,会自动 normalize:
DELETE
GET
HEAD
OPTIONS
POST
PUT
例如:
fetch("/", {
method: "get"
});
会被规范化为:
GET
但并不是所有 method 都这样处理。
一个特别有意思的例子:
fetch("/", {
method: "patch"
});
Fetch 并不会把它自动变成 PATCH。
因此服务器可能真的收到:
patch / HTTP/1.1
而不是:
PATCH / HTTP/1.1
Fetch 标准甚至专门提醒:
patch很可能得到 405,而PATCH更可能成功。
这就是:
HTTP:Method 大小写敏感
↓
Fetch:只对部分历史常见 method 做兼容性 normalization
17. HTTP/2 语义上 Header 名仍大小写不敏感,但线上必须使用小写
在 HTTP 通用语义层:
Content-Type
content-type
表示同一个字段。
但 HTTP/2 在编码消息时要求:
Field names MUST be converted to lowercase.
所以:
content-type
cache-control
user-agent
才是 HTTP/2 在线上的形式。
含大写字母的 HTTP/2 field name 会使消息成为 malformed message。
参见 RFC 9113 §8.2。
这也是为什么 Chrome DevTools 里经常看到全部小写的 header。
18. HTTP/2 里 Connection: keep-alive 不是“多余”,而是非法
HTTP/1.1 时代经常看到:
Connection: keep-alive
但 HTTP/2 明确禁止 connection-specific header fields:
Connection
Proxy-Connection
Keep-Alive
Transfer-Encoding
Upgrade
包含这些字段的 HTTP/2 message 必须被视为 malformed。
参见 RFC 9113 §8.2.2。
TE 有唯一例外
HTTP/2 request 可以出现:
TE: trailers
但不能是:
TE: gzip
所以很多 HTTP/1.1 时代背下来的 header 规则不能机械搬到 H2。
19. 405 和 501 都和 method 有关,但含义完全不同
405 Method Not Allowed
表示:
服务器认识并实现这个 method
但 target resource 不允许使用
例如:
DELETE /users
服务器整体支持 DELETE,但是 /users 不允许:
405 Method Not Allowed
Allow: GET, POST
RFC 规定 405 response MUST 包含 Allow。
501 Not Implemented
表示:
服务器不认识 / 没有实现完成这个请求所需的功能
尤其适合:
FOOBAR /users
服务器根本不支持 FOOBAR method。
可以这样记:
405:
“这个动作我会,但这个资源不让你这么干。”
501:
“这个动作我根本不会。”
参见 RFC 9110 §15.5.6 与 RFC 9110 §15.6.2。
20. 2026 年 HTTP 正式有了 QUERY Method
过去复杂查询经常陷入一个尴尬:
GET
GET /search?filters=非常复杂的一大坨数据
优点:
- safe;
- idempotent;
- 很适合缓存。
缺点:
- 查询数据必须塞进 URI;
- 长度和日志暴露等问题;
- GET body 又没有通用语义。
POST
POST /search
Content-Type: application/json
{
"filters": {
"status": "active"
}
}
body 很方便,但从通用 HTTP 语义看 POST:
- 不一定 safe;
- 不一定 idempotent;
- 中间设施不能只看到 method 就知道这是纯查询。
QUERY
RFC 10008 在 2026 年 6 月发布,正式定义:
QUERY /search HTTP/1.1
Content-Type: application/json
{
"age": {
"gte": 18
},
"cities": [
"Shanghai",
"Tokyo"
]
}
QUERY 明确定义为:
Safe ✅
Idempotent ✅
Response 可缓存 ✅(仍受 RFC 9111 与实现支持约束)
Request body / content 有明确意义 ✅
每个 QUERY 请求都必须携带与 request content 一致的 Content-Type。如果字段缺失,或声明的媒体类型与 content 不一致,服务器必须使请求失败。
参见 RFC 10008 §1 与 RFC 10008 §2。
QUERY 的缓存 key 必须包含 request content
这很合理:
QUERY /users
body = {"age":18}
和:
QUERY /users
body = {"age":30}
显然不能命中同一个缓存结果。
因此 RFC 10008 明确要求 QUERY cache key 纳入:
- request content;
- 与 content 相关的 metadata。
参见 RFC 10008 §2.7。
浏览器层面
Fetch 的 method token 和 forbidden-method 规则只明确禁止:
CONNECT
TRACE
TRACK
因此从这一层规则看,new Request(url, { method: "QUERY", body }) 可以被构造;真正发送仍受浏览器实现、CORS 与整条网络栈支持情况影响。
但 QUERY 不属于 CORS-safelisted methods:
GET
HEAD
POST
所以跨源 QUERY 会受 CORS preflight 约束;命中 preflight cache 时不一定重新发送 OPTIONS。
RFC 10008 甚至专门说明了这一点。
参见 RFC 10008 §4。
真正的问题是生态兼容性
RFC 10008 是 2026 年的新规范,因此即使标准已经存在:
Browser
↓
CDN
↓
WAF
↓
API Gateway
↓
Reverse Proxy
↓
Web Framework
↓
Application
其中任何一层都可能还不认识或没有正确支持 QUERY。
所以现阶段不能简单理解成:
“有 RFC 了,生产环境立刻就能放心用。”
总结:判断 HTTP“能不能这么干”的正确方法
以后遇到类似问题,不要只问:
“HTTP 支不支持?”
而是按下面顺序判断:
① 报文格式能否表达?
↓
② RFC 给这种组合定义了什么语义?
↓
③ RFC 是 MUST / SHOULD / MAY 中哪一级?
↓
④ 浏览器 Fetch / XHR 是否进一步限制?
↓
⑤ 是否触发 CORS?
↓
⑥ Proxy / CDN / WAF 是否支持?
↓
⑦ Web Server / Framework 是否支持?
↓
⑧ 最终应用是否自己定义了额外语义?
例如 GET + body:
HTTP framing ✅
RFC 通用语义 ❌
RFC 建议 ⚠️ SHOULD NOT
Fetch ❌
XHR ❌ 实际 body 被丢弃
jQuery Ajax ➡️ data 通常转 query string
curl ✅
Proxy / CDN / WAF ⚠️ 可能拒绝或误处理
Server Framework ⚠️ 看具体实现
这套分层思维比背:
GET 不能有 body
POST 不能缓存
DELETE 一定没 body
OPTIONS 就是 CORS
GET 完全不能改服务器
幂等就是每次响应相同
要准确得多。
官方参考资料
HTTP 核心规范
-
重点章节:
-
重点章节:
-
重点章节:
浏览器 Web 平台规范
-
WHATWG Fetch Standard,其中 Methods 一节包含:
- CORS-safelisted methods:
GET/HEAD/POST; - forbidden methods:
CONNECT/TRACE/TRACK; - method normalization;
- GET / HEAD + body 的
TypeError规则。
- CORS-safelisted methods:
-
WHATWG XMLHttpRequest Standard:
send()对 GET / HEAD 会把 body 置为null。
QUERY Method
工具与库行为
-
jQuery
$.ajax()官方文档:GET 等请求中的data会追加到 URL,而不是作为 request body。 -
curl FAQ:官方示例中展示可以使用:
curl -X GET -d data URL构造带 request body 的 GET 请求。
最后一个最重要的记忆点
“协议格式允许” ≠ “RFC 赋予标准语义” ≠ “浏览器允许” ≠ “中间件支持” ≠ “生产环境值得这么做”。
这句话基本可以解释 HTTP 世界里绝大多数看起来“规范自相矛盾”的现象。