HTTP 基础
够用来读日志、写接口、排查"为什么浏览器拿到的不是我发的版本"。重点是状态码的语义、缓存的三组首部,以及 curl 那几个真正能定位问题的参数。
一条请求长什么样
GET /api/items?page=2 HTTP/1.1
Host: example.com
Accept: application/json
Accept-Encoding: gzip, br
User-Agent: curl/8.5.0
Connection: close
四部分:请求行(方法、目标、版本)、首部、一个空行、可选的消息体。Host 在 HTTP/1.1 里是必需的,因为同一个地址上的不同站点靠它区分;少了它,服务器只能返回默认站点或者直接 400。
响应结构对称,第一行换成状态行:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 84
Cache-Control: max-age=60
ETag: "8f1c2d"
{"items":[{"id":1,"name":"alpha"},{"id":2,"name":"beta"}],"page":2}
方法
| 方法 | 用途 | 安全 | 幂等 | 通常带请求体 |
|---|---|---|---|---|
GET | 读取资源 | 是 | 是 | 否 |
HEAD | 只要首部,不要正文 | 是 | 是 | 否 |
POST | 创建资源、提交数据 | 否 | 否 | 是 |
PUT | 整体替换 | 否 | 是 | 是 |
PATCH | 局部修改 | 否 | 不一定 | 是 |
DELETE | 删除 | 否 | 是 | 否 |
"幂等"的意思是重复执行多次,结果与执行一次相同。它不保证每次返回的响应一样,只保证服务端状态一样。这个区分很实用:客户端和中间层可以放心重试幂等请求,而 POST 重试可能导致重复下单。
PUT 与 PATCH 的区别常被搞混:PUT 要求提交完整表示(没传的字段视为删除或置空),PATCH 只提交要改的部分,具体语义由服务端定义。
状态码
第一位数字定义类别:2xx 成功、3xx 重定向、4xx 客户端问题、5xx 服务端问题。下面这些是实际会遇到、且容易用错的:
| 码 | 含义 | 常见误用 |
|---|---|---|
200 | 成功 | 用 200 包一个 {"code":500},让所有监控失效 |
201 | 已创建 | 创建成功却返回 200,客户端无法区分 |
204 | 成功但无正文 | 同时返回了正文,客户端按协议会忽略它 |
301 | 永久重定向 | 临时跳转用 301,浏览器会长期缓存 |
302 | 临时重定向 | 把 POST 跳成 GET,丢了请求体 |
304 | 未修改,用缓存 | 手写 304 却没带 ETag |
307 / 308 | 临时 / 永久重定向,保持方法与正文 | 需要保方法时仍用 302 |
401 | 未认证 | 和 403 混用(401 是"你是谁",403 是"你不能") |
404 | 不存在 | 用 200 返回错误页,搜索引擎会收录 |
409 | 冲突 | 并发写冲突却返回 400,客户端无法针对性重试 |
429 | 请求过多 | 不带 Retry-After,客户端只能瞎猜 |
502 | 上游返回了无效响应 | 和 504 混用(504 是上游超时) |
503 | 服务暂不可用 | 永久性故障用 503,客户端会一直重试 |
302 的语义在历史上被浏览器"纠正"过:绝大多数客户端会把 POST 之后的 302 当成 GET 处理,并且丢掉请求体。需要保持方法和正文的重定向,明确用 307(临时)或 308(永久)。
首部字段
| 首部 | 作用 | 注意 |
|---|---|---|
Content-Type | 正文的媒体类型与字符集 | application/json; charset=utf-8;缺了 charset 中文可能乱码 |
Content-Length | 正文字节数 | 与 Transfer-Encoding 互斥 |
Transfer-Encoding: chunked | 分块传输,长度未知时用 | 不能同时给 Content-Length |
Cache-Control | 缓存策略 | no-store 最严格,no-cache 仍会存但要验证 |
ETag | 资源版本的指纹 | 配合 If-None-Match,比时间戳精确 |
Last-Modified | 最后修改时间 | 精度只到秒,同一秒内多次修改会误判 |
Vary | 缓存键还要考虑哪些请求首部 | 按语言或编码返回不同内容时必须写 |
Location | 重定向目标 / 新建资源地址 | 相对地址也合法,但绝对地址更省事 |
Retry-After | 多久之后可重试 | 配合 429 / 503,单位秒或 HTTP 日期 |
Content-Length 与 chunked 为什么互斥
两者都是"正文到哪里结束"的声明方式。同时出现时,不同实现的选择不一致,历史上导致过请求走私一类的安全问题。所以协议规定二者只能有其一,服务端遇到两个都带的请求应当拒绝。
缓存与条件请求
一次带缓存的往返大致是这样:
GET /app.js HTTP/1.1
If-None-Match: "8f1c2d"
HTTP/1.1 304 Not Modified
ETag: "8f1c2d"
Cache-Control: max-age=3600
客户端手里有副本,带着上次的 ETag 去问"还是这个吗"。服务端说 304,客户端直接用本地副本——省掉的是正文,不是往返。想要连往返都省掉,靠的是 max-age 内的新鲜期。
| 指令 | 含义 |
|---|---|
max-age=600 | 600 秒内直接用本地副本,不发请求 |
no-cache | 可以存,但每次使用前必须向服务端验证 |
no-store | 完全不存(含中间层),适合含敏感信息的响应 |
private | 只允许浏览器存,中间层不许存 |
must-revalidate | 过期后不许用旧副本,必须先验证 |
immutable | 内容不会变,用户刷新也不重新验证 |
静态资源带内容哈希的文件名(app.8f1c2d.js)适合 max-age=31536000, immutable;HTML 入口页适合 no-cache,保证用户总能拿到指向新资源的地址。这是"长缓存 + 文件名换版本"的常规做法。
连接与协议版本
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| 传输层 | TCP | TCP(通常配合 TLS) | QUIC(UDP) |
| 并发 | 每域名若干条连接 | 单连接多路复用 | 单连接多路复用,丢包互不阻塞 |
| 首部压缩 | 无 | HPACK | QPACK |
| 可读性 | 纯文本,能直接看 | 二进制帧,需要工具解码 | 二进制,且加密 |
HTTP/1.1 的 Connection: keep-alive 让连接复用,但同一连接上请求是串行的,所以浏览器会对同一域名开多条连接。HTTP/2 把请求切成帧交错发送,解决了队头阻塞;HTTP/3 换到 QUIC,进一步解决 TCP 层的丢包阻塞。注意 HTTP/2 的"多路复用"只解决应用层,TCP 层丢一个包仍会拖慢所有流,这正是 HTTP/3 的动机。
用 curl 排查
curl -sS -D - -o /dev/null http://example.com/ # 只打印响应首部
curl -sS -o /dev/null -w '%{http_code} %{time_total}\n' URL
curl -sSI http://example.com/ # HEAD 请求
curl -sS -v http://example.com/ 2>&1 | head -40 # 看握手与首部
curl -sS -H 'If-None-Match: "8f1c2d"' -o /dev/null -w '%{http_code}\n' URL
curl -sS --resolve example.com:80:127.0.0.1 http://example.com/ # 绕过解析,直连本机
curl -sS --http2 -I http://example.com/
curl -sS -X POST -H 'Content-Type: application/json' -d '{"a":1}' URL
-D - 把响应首部打到标准输出,配合 -o /dev/null 丢掉正文,是看状态码与缓存首部最快的方式。-w 可以取时间分段:%{time_connect}、%{time_starttransfer}、%{time_total},分别对应建连、首字节、总耗时。
排查"域名解析对不对"时用 --resolve,比改 /etc/hosts 干净,测完自动失效。
常见坑
1. 301 被永久缓存
浏览器会把 301 记很久,服务端改回来之后用户仍然被跳到旧地址。调试跳转时先用 302,确认无误再改 301;已经踩了的,只能让用户清缓存或用无痕窗口验证。
2. 表单刷新导致重复提交
POST 之后直接返回 200 页面,用户刷新就会再发一次。标准做法是 POST 成功后 303 跳到结果页(POST/Redirect/GET),刷新就变成一次无害的 GET。
3. 缺少 Content-Type 导致解析错误
浏览器和很多客户端会按 Content-Type 决定怎么解析正文。返回 JSON 却标成 text/html,前端 response.json() 会直接抛错;反过来,把用户上传的内容标成 text/html 则可能造成脚本注入。默认给 application/octet-stream 更安全。
4. 跨域:预检失败与凭证
跨域请求带自定义首部或非简单方法时,浏览器会先发 OPTIONS 预检。服务端必须回 Access-Control-Allow-Origin,且不能用 * 配合 Access-Control-Allow-Credentials: true——这两个组合会被浏览器直接拒绝。预检响应还要带 Access-Control-Allow-Methods 和 Access-Control-Allow-Headers,否则正式请求不会发出。
5. 日期格式
HTTP 日期必须是 IMF-fixdate 格式:Sun, 02 Nov 2026 10:12:33 GMT。用本地时区或 ISO 8601 写进 Expires,缓存会直接失效。
6. 200 掩盖了所有错误
接口永远返回 200,用响应体里的 code 表示成败,会让日志、监控、重试策略、浏览器缓存全部失效。HTTP 状态码是给基础设施看的,业务错误码是给业务代码看的,两者不冲突,应该同时用。