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=600600 秒内直接用本地副本,不发请求
no-cache可以存,但每次使用前必须向服务端验证
no-store完全不存(含中间层),适合含敏感信息的响应
private只允许浏览器存,中间层不许存
must-revalidate过期后不许用旧副本,必须先验证
immutable内容不会变,用户刷新也不重新验证

静态资源带内容哈希的文件名(app.8f1c2d.js)适合 max-age=31536000, immutable;HTML 入口页适合 no-cache,保证用户总能拿到指向新资源的地址。这是"长缓存 + 文件名换版本"的常规做法。

连接与协议版本

HTTP/1.1HTTP/2HTTP/3
传输层TCPTCP(通常配合 TLS)QUIC(UDP)
并发每域名若干条连接单连接多路复用单连接多路复用,丢包互不阻塞
首部压缩无HPACKQPACK
可读性纯文本,能直接看二进制帧,需要工具解码二进制,且加密

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 状态码是给基础设施看的,业务错误码是给业务代码看的,两者不冲突,应该同时用。