响应、状态码与内容
理解最终响应、重定向、错误、表示元数据与消息定界。
本页沿 HTTP 的具体接口和通信行为展开。沿用归档中的消息与代码示例,归档所称的实测仅作为原作者历史记录;2026-10-05 整理未向外部站点发送实验请求,也未操作浏览器。命令输出随服务、网络、版本和代理条件变化。
上一章我们学会了怎么"开口"发请求,这一章学着"听懂"回答。你递给餐厅一张点菜单,服务员的回应从来不只是一盘菜:她会先说"来了"还是"卖完了、隔壁家有"。服务器(server)的回应(response)也一样,是一封结构完整的回信——开头一句总评语(状态码),中间一排标签(响应头),最后才是货物(响应体)。
回应长什么样:报文的四段结构
先看一份真实回应,是 2026 年 10 月用 curl -i https://example.com 抓到的(HTTP/2,节选):
HTTP/2 200
date: Sat, 03 Oct 2026 18:05:56 GMT
content-type: text/html; charset=utf-8
server: cloudflare
<html>……正文共 577 字节……</html>从上到下恰好四段:
- 状态行(status line):一句话报告结果;
- 响应头(response headers):一行一个
名字: 值,是关于回应的元数据; - 一个空行:在 HTTP/1.x 中是一个 CRLF 空行,表示"元数据到此为止";
- 响应体(response body):真正的数据,比如这份 HTML。
为什么要专门留一个空行?机器不像人眼能扫一眼就分清"面单"和"货物",它需要一条明确的分界线,才知道头部在哪结束、正文从哪开始——就像快递箱外面贴的面单,撕线以内才全是货。
还有个对照的记法:两行都是三段式、都含协议版本,但内容不同——请求行 POST /users HTTP/1.1 是"方法+路径+版本",状态行是"版本+状态码+原因短语",唯一的对称点是协议版本从末尾挪到了开头。一个说"我要做什么",一个说"结果如何"。
状态行:程序只该看数字
状态行由三部分组成:协议版本、状态码(status code)、原因短语(reason phrase),例如 HTTP/1.1 201 Created。这里要主动澄清一个常见误解:Created 这类短语是可选的、纯粹给人看的描述,服务器可以自定义措辞甚至省略;程序判断结果只应依据数字状态码,把逻辑写在"短语是否等于 OK"上是靠不住的。人读短语,机器读数字,各取所需。
状态码五大家族:看首位数字
状态码按首位数字分成五个家族(定义见现行标准 RFC 9110《HTTP Semantics》第 15 节):
| 区间 | 家族 | 一句话 |
|---|---|---|
| 100–199 | 信息性响应(Informational) | 中间信号 |
| 200–299 | 成功响应(Successful) | 成了 |
| 300–399 | 重定向消息(Redirection) | 资源在别处 |
| 400–499 | 客户端错误(Client error) | 你这边的问题 |
| 500–599 | 服务器错误(Server error) | 我这边的问题 |
排障时先看首位:2 开头收货,3 开头去新地址,4 开头检查自己,5 开头找服务器。
1xx:先探个路
100 Continue 是临时响应:客户端打算发一个很大的请求体时,可以先带 Expect: 100-continue 头探路,服务器回 100 表示"继续发";若请求已经发完,忽略它即可。
2xx:成功各有各的样
200 OK 的"成功"含义取决于请求方法:GET 表示资源已取回、就在正文里;POST/PUT 表示描述操作结果的资源在正文中传输。204 No Content 是另一种成功:没有内容可发、正文为空,但响应头仍有用——它可以携带目标资源的最新元数据(例如 PUT 修改成功后的新 ETag),浏览器会把它们应用到当前正在使用的那份资源上(比如你正看着的文档视图)。并非所有回应都有正文:204、304 以及 HEAD 请求的回应都没有;反过来,错误回应完全可以带给人看的正文——原调研记录 https://example.com/no-such-page 返回 404,正文仍是一个完整 HTML 页面。
3xx:转向其他位置与使用已存表示
常见误解:3xx 是出错。恰恰相反,重定向(redirection)是服务器的正常指示——"资源在别处,请去新地址再请求一次"。新地址写在 Location 头里,浏览器会自动跟随,用户通常毫无察觉。实测用未加密的 http://(俗称"明文"传输,与加密的 https 相对)访问 github.com:
HTTP/1.1 301 Moved Permanently
Content-Length: 0
Location: https://github.com/301 是永久搬家,浏览器和搜索引擎会记住新地址;302 Found 是临时挪窝,今后仍应先请求原地址。两者不能随便换着用,选错会影响浏览器缓存与搜索引擎行为。
重定向还有个易踩的坑:到了新地址,用户代理(user agent,泛指浏览器这类替你发请求的程序)该沿用原来的方法,还是换成 GET?各状态码的约定不同:
303 See Other:一律改用 GET;307 Temporary Redirect/308 Permanent Redirect:保持原方法不变(307 临时、308 永久);301/302:按规范应保持原方法,但旧的用户代理不保证——这正是历史上 POST 被悄悄改成 GET 的根源。
需要严格保持方法时,用 307/308。
304:缓存的成功配合
常见误解:304 是故障。它其实是缓存(cache)机制的一次成功握手。条件请求(conditional request)的标准玩法:
先交代 curl 的三个开关:-s 是安静模式,-I 只发 HEAD 请求并显示响应头,-i 连响应头带正文一起显示——文末"动手观察"一节还会集中讲。
# ① 首次请求,响应头里拿到 etag: abc123
# (httpbin 实际返回不带引号;按 RFC 9110 §8.8.3 的文法,规范形态应写作 "abc123")
curl -sI https://httpbin.org/etag/abc123
# ② 带着上次拿到的 ETag 再问一次:"资源变了吗?"
curl -si -H 'If-None-Match: "abc123"' https://httpbin.org/etag/abc123
# → HTTP/2 304(只有头,没有正文)服务器比对后确认资源没变,回 304 且不带正文——省下的正是那份正文:客户端继续用本地缓存,又快又省流量;同时,304 响应头里带来的新元数据也会被拿来刷新缓存中已存的那个条目。除 ETag/If-None-Match 外,另一对常用条件是 Last-Modified/If-Modified-Since(按修改时间问"变了吗")。304 没有正文是设计使然,不是出错。
4xx:问题多半在你这边
401 Unauthorized 最容易被望文生义成"没有权限"。它的真实语义是未认证(unauthenticated):服务器不知道你是谁,请先出示凭证;它通常带 WWW-Authenticate 头提示期望的认证方式:
HTTP/2 401
www-authenticate: Basic realm="Fake Realm"403 Forbidden 才是"没有权限":服务器已经知道你是谁,但仍然拒绝。好比门卫:401 是"请先出示工牌",403 是"我认识你,但这层楼你不能进"。404 Not Found 在浏览器场景是 URL 不被识别,在 API 场景还可能表示端点(endpoint,即 API 里某个功能对应的请求地址)本身有效、但你要的那个资源不存在。429 Too Many Requests 表示被限流(rate limiting):正确做法是按 Retry-After 头指示的时间等待后再试——立刻更频繁地重试,只会继续被限流。
5xx:服务器那边的问题——但要分清哪一个
常见误解:5xx 都等于"服务器代码崩了"。几个常见码分工不同:
500 Internal Server Error:应用遇到不知道如何处理的情况,是"找不到更合适的 5XX"时的通用码;502 Bad Gateway:作为网关(gateway)或代理的服务器从上游拿到了无效响应,常见于后端进程挂了、反向代理(reverse proxy)配错端口;503 Service Unavailable:停机维护或过载,暂时不可用。规范允许(MAY)服务器带Retry-After告知预计恢复时间——不是强制要求,但实践中很多服务器会带;504 Gateway Timeout:作为网关的服务器等上游回应,一直等到了超时。
现代网站前面普遍站着 CDN(content delivery network,内容分发网络——散布在各地、替源站就近回应的服务器群)和反向代理,你看到的很多 5xx 是代理层替故障的后端发出的——排查时别只盯着应用日志。
响应头:贴在包裹上的标签
响应体是货物,响应头就是箱子外面那张面单:不占货舱,却决定了货物"是什么、多重、怎么处理、去哪儿找"。先认识四大件。
Content-Type:货物是什么
Content-Type: text/html; charset=utf-8 同时声明两件事:媒体类型(media type,曾称 MIME type)是 HTML,字符编码(charset)是 UTF-8。媒体类型形如 type/subtype(如 text/html、application/json),标准化于 RFC 6838,官方注册表由 IANA 维护。
关键认知:浏览器依据 Content-Type 里的 MIME 类型决定怎么处理回应,而不是文件扩展名——扩展名只在本地文件场景有意义。CSS 若以 text/plain 发送,浏览器不会把它当样式表;application/octet-stream 是未知二进制数据的默认类型,浏览器通常直接触发下载。一个历史遗留:JavaScript 的类型曾有 application/javascript 等多种写法,现代规范(RFC 9239)只认 text/javascript——它是现在和将来唯一保证可用的类型。
charset 与乱码的本质
charset 不是给文件"起个名字",它声明的是把字节流还原成文字所用的那张解码表。字节还是那些字节,查表方式对不上,解出来的就是乱码——这就是乱码的本质。浏览器判定 HTML 编码的优先级是:UTF-8/UTF-16 BOM > HTTP 头的 charset > 文档前 1024 字节内的 <meta charset>(规范称其置信度仅为"暂定"——浏览器先按它试着解,中途发现解错了还可能换编码重解一遍)。HTTP 头一旦声明了编码,meta 标签就被忽略;反过来,回应若完全不声明 charset,浏览器只能靠检测和偏好去猜——这正是乱码的常见来源,所以服务器必须显式标注。
Content-Length:消息内容的字节数
Content-Length 表示消息体的大小,单位是字节(octet,8 位字节),不是字符数。归档实验记录一目了然:
printf '你好' | wc -m # 2:字符数
printf '你好' | wc -c # 6:UTF-8 编码下的字节数
# 同样内容转成 GBK 编码后再数:4 字节同样两个字,UTF-8 下占 6 字节、GBK 下占 4 字节——编码不同,字节数就不同,所以协议只能按无歧义的"字节"计数。
它还有个重要作用:HTTP/1.1 允许在一条 TCP 连接上接连收发多次请求与回应,它们首尾相接排成一串,Content-Length 就像快递单上的重量栏,标出"这一件到哪里为止"。
它并不永远在场:HTTP/1.1 的动态或流式内容可用 Transfer-Encoding: chunked(分块传输编码)替代,两者不并用。
到了 HTTP/2,报文被拆成一帧一帧(frame)传输,靠 DATA 帧加结束标记定界,不再依赖 Content-Length——但只要带了它,数值就必须与 DATA 帧载荷的总长一致,否则按 RFC 9113 §8.1.1 视为协议错误;而对 HEAD、204、304 这类没有正文的回应,它还允许携带非零值,用来告知资源的实际长度。
Location 与 Set-Cookie:去哪儿找、下次凭什么认出你
Location 指示重定向的目标 URL:常见的 301/302/303/307/308 按规范"应当(SHOULD)"携带它(300 Multiple Choices 属可选;304 虽同是 3xx 却与它无关);值可为相对或绝对 URL。它也配合 201 Created 使用,指向新建资源的地址:
HTTP/1.1 201 Created
Content-Type: application/json
Location: http://example.com/users/123
{"message": "New user created", "user": {"id": 123}}Set-Cookie 是服务器下发的响应头——初学者常把它误记成请求头。它像商家发给你一张会员卡:服务器发出、浏览器收好,之后每次请求用 Cookie 请求头回传;发多个 cookie 要用多个 Set-Cookie 头(原调研记录 httpbin.org 一次下发两个,Path=/ 表示全站路径都回传)。另外,浏览器里的 JavaScript 读不到 Set-Cookie。属性速览:
Expires(绝对时刻)/Max-Age(秒数):同设时 Max-Age 优先;都不设则是会话 cookie(session cookie);Domain/Path:限定哪些主机与路径回传(规范明确说明 Path 不是安全机制);Secure:仅在 HTTPS 下发送;HttpOnly:禁止 JavaScript 经document.cookie访问,缓解 XSS(cross-site scripting,跨站脚本攻击:恶意脚本混入你访问的页面)偷走 cookie;SameSite=Strict/Lax/None:控制跨站请求是否携带,防 CSRF(cross-site request forgery,跨站请求伪造:恶意网站冒你的身份悄悄发请求);设None必须同时设Secure。
其他值得认识的响应头
一句话认识它们:Server(服务器软件名片)、Date(回应生成时间)、Cache-Control(缓存策略)、Content-Encoding(正文所用压缩编码)、Vary(见下节)、WWW-Authenticate(期望的认证方式)、Retry-After(请多久后再来)。
内容协商:先问口味,再上菜
同一个 URL 常有多种"表示"(representation):同一篇文章的中文版和英文版、同一份文件的压缩版与原文。客户端得先声明"能接受什么",服务器才能挑对版本——这就是内容协商(content negotiation)。机制一来一回:请求端发 Accept(媒体类型)、Accept-Language(语言)、Accept-Encoding(压缩编码),服务器把它们当作提示(hint),用自己的内部算法选择(标准并未规定算法),再用 Content-Type、Content-Language、Content-Encoding 对应回应。列表每项可带权重(quality factor,q 值 0 到 1,越大越优先,缺省为 1):
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: de, en;q=0.7
Accept-Encoding: br, gzip;q=0.8两个误解要澄清。其一,Accept: */* 不是"命令服务器随便给"——Accept 只是偏好声明,选什么由服务器算法决定;协商不下去时,服务器可以回 406 Not Acceptable,也可以干脆忽略偏好头、返回默认版本。(别把 415 Unsupported Media Type 与它混为一谈——415 拒绝的是客户端发来的请求体格式,属于另一个方向的失败。)其二,gzip 压缩不是服务器"想压就压":实测对 example.com 发 Accept-Encoding: identity 时原文返回,发 gzip 才得到 content-encoding: gzip——先声明,才压缩。
最后是 Vary。缓存要记住一份回应,靠的是一把"缓存键"(cache key)——通常就是"哪个 URL"这条索引。但协商之下,同一个 URL 可能有多个版本,光记 URL 分不清该端给谁。Vary 正是告诉缓存:除了 URL,还要把哪些请求头一起算进这把钥匙——例如 Vary: Accept-Encoding 时,gzip 版和未压缩版会被存成两条不同的条目,各回各的用户。Vary: * 则表示协商依据了请求头之外的信息,缓存无法复现当时的选择,只好不缓存这份回应。
动手观察一条回应
HTTP 的基本模型就是请求与回应一来一回,读十遍不如亲手抓一次:
curl -i https://example.com # 状态行 + 响应头 + 正文
curl -sI https://example.com # 只要状态行与响应头(实际发的是 HEAD 请求)
curl -sv https://example.com # 连 TLS 握手(https 建立加密连接前的准备步骤)等底层过程都看得到-sI 用的正是 HEAD 请求——它的语义就是"只问头部,不要货物",所以回应有头无体。浏览器里则打开开发者工具(DevTools)的 Network 面板:Status 列看状态码,Response Headers 看标签,Response 看正文。重定向链在这里一目了然:浏览器自动跟随 Location 一次次再请求,直到拿到非 3xx 的结果(如 200)或达到次数上限。
下次页面异常,先抓一条回应看看:首位数字告诉你方向,响应头告诉你细节,正文告诉你人能读懂的原因。
最后更新于