第 4 章 状态码:一套错误分类学,和一个 418 彩蛋
状态码不是给前端看的,是给全世界的中间设备看的。它是一套用三位数字写成的”错误分类学”。
4.0 引子:监控一片绿,用户骂成一片
老张:老哥,出事了。用户投诉接口大面积失败,但我看监控,错误率是 0。
你:0?不可能啊,用户不是都说报错了吗?
老张:你自己看。Prometheus 里 http_requests_total{code="200"} 占了 99.9%,告警规则里写的是”5xx 比例 > 1% 才告警”。所以——一个告警都没触发。
你:……(打开代码)哦,我们后端所有业务错误都返回 200,错误信息放 body 里的 code 字段。
老张:所以监控永远看不到。这不是第一次了,上个月还有一次,CDN 把我们的错误页缓存了,几万用户看到”系统繁忙”。
墨叔:停。这是第 1 章案例 B 的完整版。今天这一章,我要把”状态码”这件事给你彻底讲透——它是什么、为什么是三位数字、以及你违反了它会付出什么代价。
4.1 先把问题问对:状态码到底在跟谁说话
墨叔:先问一个看起来蠢的问题:状态码是给谁看的?
你:给前端啊,前端根据状态码判断成功失败。
墨叔:只对了一半,而且是最不重要的一半。
状态码首先是给中间设备看的——代理、网关、CDN、负载均衡、监控系统、重试逻辑、熔断器。这些设备里没有任何一个会去读你 body 里的 JSON,它们只看状态码这一位数字。
你:所以……
墨叔:所以,状态码是整个 HTTP 体系里,唯一一个”所有机器都统一理解”的字段。
- 方法,语义有争议(POST 到底干嘛的,大家吵了几十年)。
- 首部,可以随便加,没人认识你加的头。
- body,每个服务各说各话,都是私有格式。
唯独状态码,从浏览器到 CDN 到你的 Go 服务,全世界对”404 是什么”的理解是高度一致的。这是 HTTP 里最成功的标准化成果之一。
所以它的设计,也值得你用最认真的态度去拆。
4.2 为什么是三位数字?—— 一个教科书级的可扩展设计
墨叔:为什么状态码是三位数字?为什么不是”错误字符串”?为什么不是连续递增的整数?
你:字符串不好判断?整数……
墨叔:我们逐个排除,你就明白三位数字的精妙了。
方案一:用错误字符串。
"OK"、"Not Found"、"Internal Error"……
问题:字符串有大小写、拼写、语言问题。"ok" 和 "OK" 和 "Okay" 是不是一回事?机器判断起来又慢又脆。字符串适合给人看,不适合给机器做逻辑判断。
方案二:连续递增的整数错误码。
1、2、3、4…… 每个数字一个含义。
问题:没法分类。你拿到错误码 47,你完全不知道它属于”客户端错”还是”服务端错”,只能查表。而且没法扩展——加一个新错误,插在哪儿?
方案三:三位数字,第一位是分类,后两位是编号。
1xx 信息性
2xx 成功
3xx 重定向
4xx 客户端错误
5xx 服务器错误
这个设计的精妙,拆开看有三层:
第一层:第一位数字,是”可编程的分类”。
机器看到 4xx,不需要查表就知道:“这是客户端的错,我该把锅甩给调用方”。看到 5xx 就知道”这是服务端的错,我该考虑重试”。一个字节(甚至一个 ASCII 字符),就完成了最常用的分类判断——这对中间设备的高性能处理至关重要。
第二层:分类的标准是”能不能重试”。
这是最妙的地方。分类的黄金标准不是”谁错了”,而是——
4xx = 重试一百次也没用(客户端要改);5xx = 换个时间可能就好了(可以重试)。
一个字段,用一位数字,解决了分布式系统里最难的一个决策。第 3 章我们刚讲过重试决策树,现在你看到它的”协议层根基”了。
第三层:每类 100 个槽位,且只用了 5 类。
1xx-5xx 用了,还有 6xx、7xx、8xx、9xx 整整四类空着,留给未来。每类 100 个编号,现在实际用到的每类也就二三十个。这意味着它几乎永远不会”不够用”。
对比一下,如果你用连续整数,用掉 500 个之后,第 501 个错误码该分到哪一类?分类信息就丢失了。三位数字 = 在”编码空间”里内置了”分类结构”。
你:我第一次觉得”404”这个数字这么有道理。
墨叔:记住这个感觉。好的协议设计,从来不是”这个字段存什么”,而是”这个字段的结构本身,就表达了我需要的全部语义”。
4.3 未知状态码的降级规则:协议演进的神来之笔
墨叔:分类结构带来一个必然问题:如果未来引入了新的状态码,老客户端不认识怎么办?
比如,将来出现了一个 599 Network Authentication Required,一个 1995 年的老客户端收到它,会怎样?
你:会崩?报错?
墨叔:不崩。HTTP 规定了一条降级规则:
客户端收到不理解的状态码时,必须把它当成同一个分类里的
x00来处理。
也就是说:收到 599,就当 500 处理(同一类的兜底);收到 429 但你的老客户端不认识它,就当 400 处理。
这意味着什么? 意味着服务端可以放心大胆地引入新状态码,而不必担心老客户端爆炸——老客户端自动把它”降级”成最接近的已知语义。
这是协议演进的教科书级设计。 它和”未知首部要忽略”(第 5 章)、“未知方法返回 405/501”(第 3 章)一起,构成了 HTTP 的可扩展性三件套:
| 扩展点 | 遇到未知时的降级 | 效果 |
|---|---|---|
| 方法 | 返回 405 / 501 | 明确拒绝,不误执行 |
| 状态码 | 按 x00 降级 | 优雅降级,不崩溃 |
| 首部 | 忽略 | 静默忽略,不报错 |
你:这背后的统一原则是什么?
墨叔:很好,你开始问”统一原则”了。这个原则是——
扩展点的设计,核心不是”怎么加新东西”,而是”老实现遇到新东西时,怎么优雅地降级而不是崩溃”。
一个扩展点好不好,不看它加东西多方便,看它面对未知时的行为有多可预测。这一条,会在第 15 章变成全书的一个核心方法论。
4.4 逐类精讲:挑最有信息量的那些
墨叔:现在过一遍五类。我不打算流水账,只挑”最容易被误解、最有设计含量”的讲。
1xx:临时响应,不终结请求
1xx 的共同点是:它们是”中间话”,不是”最终结论”。 客户端收到 1xx 后,继续等真正的响应。
100 Continue:客户端先发一个Expect: 100-continue头,然后先不发 body,等服务端回应。如果服务端回100 Continue,客户端才发 body;如果服务端直接回413(太大)或417,客户端就省下了那 200MB 的上传。这是个”先问后发”的机制,专门对付大 body。101 Switching Protocols:协议升级(第 14 章 WebSocket 的核心)。103 Early Hints:服务端在正式响应前,先发一个Link头,让浏览器提前预加载资源(现代性能优化技巧)。
你:100-continue 这个,“先问后发”,很聪明啊。
墨叔:但现实里它有个尴尬:很多客户端根本不发 Expect: 100-continue,直接就把 body 塞过来了。为什么?因为”先问一句”要等一个 RTT,对小请求不划算。所以 100-continue 主要用在”大 body + 可能被拒”的场景,而且客户端往往设一个”如果服务端 N 毫秒内没回 100,就硬发”的兜底。
这个细节很有启发:再好的协议机制,如果它增加了一个 RTT 的成本,大家就会绕开它。 协议机制的成功,取决于它和”成本”的博弈。
2xx:成功也有很多种
200 OK:通用成功。201 Created:创建成功,且必须带Location头指向新资源。202 Accepted:“我收到了,但活还没干完。” 这是异步任务的标准状态码。第 1 章的导出报表方案里,核心就是它。204 No Content:成功但没有 body。坑:前端很多 HTTP 库对 204 的 body 处理有 bug,会误判。206 Partial Content:配合Range请求,断点续传。
你:202 和 200 的区别,我原来一直没当回事。
墨叔:这个区别,是”同步”和”异步”的分水岭。200 意味着”我现在就把结果给你了”;202 意味着”结果还没好,你过会儿来取”。很多人在做长任务时返回 200,然后 body 里写”任务进行中”,这就是语义混乱——你应该返回 202。
3xx:重定向的语义污染史 —— 本章最精彩的部分
墨叔:这一节是全章最有意思的,因为它展示了**“设计”如何被”现实”污染,最后不得不妥协**。
最早的语义(HTTP/1.0,1996 年):
301 Moved Permanently:永久搬走了,去新地址。302 Found:临时重定向。
本来很清晰。但出了个问题:现实中,几乎所有浏览器实现 302 时,都把原来的 POST 请求改成了 GET 重新发。
为什么?因为这是”历史包袱”——最早的浏览器实现就这么干,后来大家都”兼容”这个行为,于是它成了事实标准。结果是:
- 你 POST 提交一个表单 → 服务器返回 302 让你去另一个页面 → 浏览器把 POST 变成了 GET,你表单里的数据全丢了。
这显然不是设计者的本意,但现实已经这样了。怎么办?
HTTP/1.1 的解决方案,是用新状态码来”承认现实”和”恢复原意”:
303 See Other:**明确承认”POST 之后应该转成 GET”**这个现实,把它正规化。语义变成”你该用 GET 去访问 Location 指向的资源”。这就是著名的 PRG 模式(Post/Redirect/Get)——提交后重定向到结果页,防止刷新重复提交(正好呼应第 3 章)。307 Temporary Redirect:恢复 302 的”原意”——临时重定向,且保留原来的方法和 body。308 Permanent Redirect:永久重定向,且保留方法和 body。
你:所以现在是”302 行为不可靠、303 转 GET、307/308 保留方法”这么个乱局?
墨叔:对。一张表理清:
| 状态码 | 临时/永久 | 是否改方法为 GET | 现在的实际地位 |
|---|---|---|---|
| 301 | 永久 | 历史上会改 | 老代码还在用,行为混乱 |
| 302 | 临时(原意) | 实际都改 | 行为不可靠,新代码尽量别用 |
| 303 | 临时 | 强制改 GET | POST 后重定向的标准选择(PRG) |
| 307 | 临时 | 保留方法 | 语义干净,但浏览器支持曾有坑 |
| 308 | 永久 | 保留方法 | 307 的永久版 |
这个故事的精髓:
协议里的”语义污染”是常态。当实现者们集体”误解”了一个语义,并且这个误解被广泛部署了,你就改不回去了——你只能发明新的状态码,一边”承认误解”(303),一边”恢复原意”(307/308)。
这就是第 15 章”兼容性”的序曲:你无法纠正已经广泛部署的错误,你只能绕着它走。
你:还有”重定向循环”的问题吧?
墨叔:对,浏览器检测到 A→B→A 的重定向循环会报 ERR_TOO_MANY_REDIRECTS。排查时看 Location 头指向哪。
4xx:客户端的错
挑几个最容易被搞混的:
401 Unauthorizedvs403 Forbidden:401= “我不知道你是谁,请出示身份”(配合WWW-Authenticate头,告诉客户端该怎么认证)。403= “我知道你是谁,但你就是不许进”。- 区别的意义:
401意味着”你可以重新认证后再试”;403意味着”再试多少次都一样,认证了也没用”。这个区别决定了客户端要不要重新登录。
404 Not Foundvs410 Gone:404 是”没找到(可能只是暂时不在)“,410 是”曾经有,现在永久没了”。SEO 会据此处理:410 会更快地从索引里移除。405 Method Not Allowed:资源存在,但方法用错了。响应里应该带Allow: GET, POST告诉客户端能用什么。409 Conflict:请求和当前状态冲突(比如乐观锁失败、资源已被改)。这是”并发冲突”的标准状态码。422 Unprocessable Entity:语法没错(所以不是 400),但语义有问题(比如字段值不合法)。这是 WebDAV 引入、现在被 API 广泛使用的状态码。429 Too Many Requests:限流的标准状态码,配合Retry-After头告诉客户端”等多久再来”。第 1 章场景九的核心。
5xx:服务端的错
500 Internal Server Error:兜底。502 Bad Gateway:网关收到了一个无效的响应(比如上游崩了、返回了垃圾)。503 Service Unavailable:服务暂时不可用(过载、停机维护)。配合Retry-After。504 Gateway Timeout:网关等上游等超时了。
你:502、503、504 我一直分不清。
墨叔:记住一句话区分:
502 = 上游”回话”了,但回的是废话;504 = 上游一直”没回话”,等到超时;503 = 上游自己说”我不行了”。
排查时:
- 看到 502:查上游是不是崩了、返回了非 HTTP 的东西(比如端口被别的服务占了,返回了 redis 的协议)。
- 看到 504:查上游是不是太慢、或者请求卡死。
- 看到 503:查上游是不是主动降级/过载保护了。
4.5 状态码 vs body 里的 code:两层错误模型
墨叔:第 1 章案例 B 那个”统一返回 200”的做法,你现在应该能批判得更彻底了。
但我也要给那个做法说一句公道话:它背后有一个合理的需求——“我要区分业务的 100 种失败原因,光靠 HTTP 状态码不够。”
这个需求是对的。错的是实现方式:不该用”统一 200”来牺牲协议层的语义。
正确的做法是两层错误模型:
| 层 | 表达什么 | 用什么 | 给谁看 |
|---|---|---|---|
| 协议层 | 这次交互成功了没有、是什么性质的结果 | HTTP 状态码 | 中间设备、监控、重试逻辑 |
| 业务层 | 为什么是这个结果(余额不足、优惠券过期、风控拦截) | body 里的 code 字段 | 前端、调用方 |
你:所以是”状态码管性质,body 管原因”?
墨叔:精确。举例子:
- 用户没登录 →
401+{"code": "UNAUTHENTICATED"} - 余额不足 →
422+{"code": "INSUFFICIENT_BALANCE"} - 系统过载 →
503+{"code": "OVERLOADED"}+Retry-After: 30
这样,监控能看到 5xx 报警,前端能读到具体的业务原因,中间设备能正确决策重试。三方各取所需。
记住这条原则:
分层系统里,每一层只表达自己那一层的语义。 别用业务层的丰富,去掩盖协议层的贫瘠;也别用协议层的粗粒度,去代替业务层的精确。
4.6 418 的故事:协议也是文化
墨叔:最后讲个轻松的,但它背后是很严肃的道理。
418 I'm a teapot(我是一个茶壶)。
它的来历是:1998 年愚人节,IETF 发布了一份恶搞 RFC 2324,叫「超文本咖啡壶控制协议」(HTCPCP),里面定义了一个状态码 418,用于”当有人要求茶壶煮咖啡时,茶壶拒绝服务”的场景。
它本是个玩笑。 但接下来的事,是谁都没想到的:
- 因为它太有梗了,无数程序员在真实项目里用它——用它做”这个请求在逻辑上不可行”的占位符、用它做彩蛋、用它做测试。
- Node.js、Go、以及一堆 HTTP 框架,都把它实现进了标准库。
- 到了 2017 年前后,IETF 有人想认真了:HTTP 状态码注册表要清理,提议把 418 移除(因为它不是”真”的状态码,只是个玩笑)。
- 结果引发了激烈反对。社区的声音是:“418 是我们的文化。” 最终,提议被拒绝,418 留了下来。
你:一个愚人节玩笑,成了基础设施的一部分,还删不掉了。
墨叔:对。这个故事和第 3 章电梯按钮、第 4 章 302 的语义污染,讲的是同一个道理:
一旦某个东西被真实地实现、部署、使用,它就不再属于”设计者”,而属于”使用者”。设计者的意愿,在”事实”面前,一文不值。
这跟”一个协议的成功取决于它允许多少人犯错还活下来”是同一个哲学。协议不是设计者的艺术品,是几十亿人和几十亿台设备的公共财产。
所以,你现在写下的每一个状态码、每一个接口语义,一旦上线,就有千万个调用方在依赖它。你改它的成本,远超你想象的。
4.7 Go 落地:正确返回状态码,以及那个经典陷阱
墨叔:看一段 Go 代码,里面藏着 Go 程序员最常踩的一个状态码陷阱:
func handler(w http.ResponseWriter, r *http.Request) {
// 假设业务失败,你想返回 500
w.Write([]byte("processing...")) // 先写了 body
w.WriteHeader(http.StatusInternalServerError) // 再写状态码 —— 晚了!
}你:……这有什么问题?
墨叔:问题大了。在 Go 的 net/http 里,WriteHeader 的调用时机有一个不可逆的规则:
一旦你调用
Write写了 body,net/http会”隐式地”先调用WriteHeader(200)。
也就是说,上面的代码实际发出去的是 200 OK + “processing…”,你后面的 WriteHeader(500) 完全无效,还会在标准错误输出里打一条警告:
http: superfluous response.WriteHeader call from ...
你:所以正确顺序是”先 WriteHeader,再 Write”。
墨叔:对:
func handler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError) // 先定状态码
w.Write([]byte("internal error")) // 再写 body
}这个陷阱背后,是一个更深的设计问题:net/http 为什么要”隐式”写 200?
因为它要支持流式响应——你写了第一个字节,状态行和响应头就得先发出去(毕竟首部在 body 前面)。一旦首部发出去了,状态码就”木已成舟”了。所以”先写 body 会自动 200”不是 bug,是流式传输的必然结果。
这也呼应了第 2 章:报文的结构(状态行在前、body 在后)决定了这些看似奇怪的 API 行为。
4.8 排查清单:状态码相关问题的定位
第 1 步:先确认"你看到的状态码是谁的"
→ 是源站返回的,还是网关/LB 改写的?
→ 抓包 + 看 access log,逐跳对比
第 2 步:状态码和 body 里的 code 一致吗?
→ 200 但 body 说失败 = 语义混乱,先修这个
第 3 步:是 502/503/504 吗?
→ 502 查上游是否返回了无效响应
→ 503 查上游是否主动降级
→ 504 查上游是否太慢
第 4 步:是 3xx 吗?
→ 看 Location 指向哪,有没有重定向循环
第 5 步:监控为什么没报警?
→ 是不是所有错误都返回了 200?
思考题
第 1 题(概念)
请写出下面每个场景最合适的状态码,并说明理由:
- 创建订单成功
- 提交了一个异步导出任务,任务还在跑
- 用户没登录就想看订单
- 用户登录了,但访问别人的订单
- 请求的 JSON 字段格式错了(
age传了字符串”abc”) - 服务因为过载临时拒绝服务
- 网关等上游等了 60 秒没等到响应
- 资源永久删除了,而且你希望搜索引擎尽快移除它
第 2 题(概念)
401 Unauthorized 和 403 Forbidden 的区别是什么?请用一个具体的场景说明:如果把 401 误用成 403(或反过来),会引发什么实际问题?
第 3 题(设计)
你要给一个”支付接口”设计状态码方案。要求能区分以下情况,且要让监控、调用方、重试逻辑都能正确工作:
- 支付成功
- 支付处理中(异步,稍后可查)
- 余额不足
- 订单不存在
- 系统过载,需要调用方稍后重试
- 重复提交同一个订单(幂等键重复)
请给出每种情况的状态码 + 关键首部 + body 里的业务 code。
第 4 题(生活类比)
墨叔:餐厅服务员对上菜请求的回应,其实是一套”状态码”:
- “好的,马上来” = ?
- “这个菜今天卖完了” = ?
- “您还没点菜呢” = ?
- “厨师在忙,要等 20 分钟” = ?
- “抱歉,我们后厨着火了” = ?
请映射到 HTTP 状态码。然后回答:为什么餐厅要用”不同的回应方式”而不是”统一说’收到’(200)然后你等着看结果”? 这跟”为什么不能所有接口都返回 200”是同一个道理,请说透。
第 5 题(更深一层的发问)
状态码的降级规则(4.3 节)说:遇到未知状态码,按 x00 降级处理。
现在请你设计一个反过来的问题:
假设你的服务端引入了一个全新的状态码(比如
499,表示”客户端主动断开”),但你的下游调用方里,有一半是 10 年前的老客户端,它们只认识4xx里的 400/401/403/404。
那么:
- 老客户端收到
499会怎么处理?(根据降级规则) - 这个”降级”对你的业务意味着什么?是好事还是坏事?
- 基于这个分析,你在设计新状态码时,应该遵循什么原则,才能既享受扩展的好处,又不坑到老客户端?
参考答案
第 1 题答案
| 场景 | 状态码 | 理由 |
|---|---|---|
| 创建订单成功 | 201 Created | 创建成功,且应带 Location: /orders/123 |
| 异步任务提交 | 202 Accepted | ”收到了,但没做完”,配 Location + Retry-After |
| 未登录 | 401 Unauthorized | ”我不知道你是谁”,配 WWW-Authenticate |
| 登录了但越权 | 403 Forbidden | ”我知道你是谁,但你不许进”,别用 404 掩盖(404 会泄露”资源存在”这个信息,且语义不符) |
| JSON 字段格式错 | 422 或 400 | 语法上 JSON 可能合法但语义错(age 不是数字)→ 422;如果 JSON 本身都解析不了 → 400 |
| 过载拒绝 | 503 | ”服务不可用”,配 Retry-After |
| 网关等超时 | 504 | ”网关等上游超时” |
| 资源永久删除 | 410 Gone | 明确”永久没了”,SEO 会更快移除 |
第 2 题答案
区别:401 是”身份未知”(不知道你是谁),403 是”身份已知但无权限”(知道你是谁,但不许进)。
误用的实际问题,举一个真实的例子:
假设一个管理后台接口,把”未登录”和”权限不足”都返回 403。
- 正确行为:未登录 →
401,前端看到 401 就跳转登录页。 - 错误行为:未登录也返回
403→ 前端以为”这个用户登录了但没权限”,于是不跳登录页,而是显示”你没有权限”。
结果:一个本来应该被引导去登录的用户,被错误地告知”你没权限”,他就一直卡在那儿,甚至以为自己的账号有问题。
反过来,如果把”权限不足”误用成 401,前端会反复地重新登录(因为 401 的语义是”去认证”),但登录了还是没权限,陷入死循环。
更深一层:这两个状态码的区别,本质是**“问题出在认证层还是授权层”。认证(authentication)= 你是谁;授权(authorization)= 你能不能做。协议用两个不同的状态码把这两层分开,是为了让客户端和中间设备能对症下药**。混淆它们,就是把”该去登录”和”该去申请权限”两件事搅在一起。
第 3 题答案
| 情况 | 状态码 | 关键首部 | body code |
|---|---|---|---|
| 支付成功 | 200 OK 或 201 | — | PAY_SUCCESS |
| 支付处理中(异步) | 202 Accepted | Location: /payments/xxx | PAY_PENDING |
| 余额不足 | 422 | — | INSUFFICIENT_BALANCE |
| 订单不存在 | 404 | — | ORDER_NOT_FOUND |
| 系统过载 | 503 | Retry-After: 30 | OVERLOADED |
| 重复提交 | 200 OK(幂等)或 409 | — | DUPLICATE |
设计要点解析:
- 余额不足用 422 而不是 500:余额不足是”客户端的错”(用户钱不够),不是”服务端的错”。用 500 会导致监控误报、调用方盲目重试。这个区分,是”4xx vs 5xx”的黄金标准(能不能重试)在起作用。
- 系统过载用 503 + Retry-After:明确告诉调用方”可重试,但要等 30 秒”。如果不用 Retry-After,调用方可能立即疯狂重试,把过载搞成雪崩(第 3 章的重试风暴)。
- 重复提交的取舍:如果是”幂等成功”(重复请求返回和第一次一样的结果),用 200;如果业务上把”重复”视为一种错误,用 409。但注意:用 409 要小心,因为调用方看到 409 可能会认为”事务失败了”,从而触发不必要的补偿逻辑。所以在支付这种”重复=成功”的语义下,200 更安全。
最关键的洞察:这套方案之所以能让”监控、调用方、重试逻辑”都正确工作,是因为每个状态码都在同时传达三件事——①结果的性质(成功/客户端错/服务端错)②要不要重试 ③重试要等多久。一个设计得当的状态码,等于帮下游把”重试决策”这个最难的活干了。
第 4 题答案
| 餐厅回应 | HTTP 状态码 |
|---|---|
| ”好的,马上来” | 200 OK(或 201 若刚下单) |
| “这个菜今天卖完了” | 404 或 410(没了) |
| “您还没点菜呢” | 400(请求本身有问题)或 401(还没”进入”流程) |
| “厨师在忙,要等 20 分钟” | 503 + Retry-After: 1200 |
| ”后厨着火了” | 500 或 503 |
为什么餐厅不用”统一说’收到’(200),然后你等着看结果”?
因为这个”统一 200”的方案,把三件本该分开传达的信息揉成了一团:
- 要不要继续等(成功了就不用等,失败就不用等,忙就等一会)—— 这对应”结果的性质”。
- 等多久(20 分钟 vs 马上)—— 这对应
Retry-After。 - 能不能通过”再点一次”解决(菜卖完了,再点一次也没用;厨师忙,等会儿再点就行)—— 这对应”能不能重试”。
如果服务员对所有请求都说”收到”,你就得每次都主动追问:“好了没?好了没?“——这就是”统一 200 + 前端轮询”的荒谬之处:你为了省一点协议层的语义,把判断的负担全部甩给了调用方,而且每个调用方都要重新实现一遍同样的判断逻辑。
这和”为什么不能所有接口都返回 200”是同一个道理:
状态码(就像服务员的回应方式)是一套”低成本的、标准化的、人人都懂的”信号。你放弃它,就得用更贵的、私有的、各写各的机制去替代。而标准化信号的价值,恰恰在于它免费、通用、不需要约定。
第 5 题答案
1. 老客户端收到 499 会怎么处理?
根据降级规则,老客户端不认识 499,会把它当成本类的 x00,即 400 Bad Request 处理。
2. 这个降级对你的业务意味着什么?
这是本题的关键,要分情况看:
499 的真实含义是”客户端在服务器响应之前主动断开了连接”(这是 Nginx 常用的一个非标准状态码)。当老客户端把它降级成 400 时:
- 老客户端会认为”这是我的请求错了”(400 = 客户端错误),于是它可能去改请求、或者把这个”错误”报给用户、或者记一条错误日志。
- 但实际上,
499的真实含义是”连接断了”,跟”请求格式错误”完全是两码事。
所以这个降级是有损的:语义从”连接层面的异常”变成了”请求格式错误”。如果调用方根据这个 400 去做”修正请求”的逻辑,就会做无用功,甚至产生误导。
结论:降级规则保证了”不崩溃”(这是底线),但不保证”语义准确”。降级是”兜底”,不是”保真”。
3. 设计新状态码应该遵循的原则:
基于上面的分析,可以提炼出三条原则:
原则一:新状态码的语义,必须落在”同一类”的语义范围内。
因为老客户端会把它降级成 x00。如果你的 499 语义和 400 差太远(比如你想表达”服务器永久关机”),那降级后的理解就会严重跑偏。新状态码应该让”降级到 x00 后的理解”依然大致正确。
原则二:新状态码对”不能理解它的客户端”来说,必须是”无害的”。 也就是说,老客户端”误解”了它,最多是”处理得不那么精确”,不会产生危险的行为(比如不该重试的重试了、不该删除的删除了)。这就要求你在引入新状态码时,想清楚”误解它的那一半老客户端会怎么做”。
原则三:把”关键的新语义”用”老客户端也能理解的机制”来承载。
比如,如果你要表达”限流,请等 30 秒”,与其发明一个老客户端不认识的 429(假设 429 是新的),不如用老客户端认识的 503 + 一个 Retry-After 头。因为 Retry-After 是首部,而首部的降级规则是”忽略”——老客户端即使不认识 Retry-After,也只是忽略它,而 503 本身已经传达了”服务端问题,可重试”的核心语义。
最终升华:
扩展点是有”容错等级”的。状态码的降级是”有损降级”(语义会变),首部的降级是”无损降级”(忽略而已)。 所以设计新特性时,优先用”无损降级”的扩展点(首部)承载新语义,用”有损降级”的扩展点(状态码)承载那些”降级后也无害”的语义。
这一条,是第 15 章”兼容性设计”的核心方法论之一,你在这里提前摸到了它的边。
一页纸小结
状态码是给中间设备看的,是 HTTP 里唯一”所有机器统一理解”的字段。
为什么是三位数字:第一位是分类,后两位是编号。分类结构内置了”可编程的分类”(4xx/5xx 一眼可分)和”可扩展的空间”(6xx-9xx 空着)。分类的黄金标准是能不能重试。
降级规则:未知状态码按 x00 处理。这让你能引入新状态码而不炸掉老客户端——但降级是”有损”的。
3xx 的语义污染史:302 被浏览器集体”误解”(POST 变 GET),于是发明 303 承认现实、307/308 恢复原意。你无法纠正已部署的错误,只能绕着走。
两层错误模型:状态码管”性质”,body 的 code 管”原因”。各层表达各层的语义。
418 的故事:愚人节玩笑被实现、被部署、删不掉。协议是使用者的公共财产,不是设计者的艺术品。
Go 陷阱:先 Write 会自动 WriteHeader(200),之后再 WriteHeader 无效。这是流式传输的必然结果。
下一章:状态码、方法都讲完了,但 HTTP 最骚的操作还没出场——它能让你在不改协议版本的情况下,凭空”长”出新功能。这个机制,就是那一堆 键: 值 的首部。我要讲一个致命的概念:有的首部是”端到端”的,有的首部是”逐跳”的,分不清这个,你的代理会吃掉你一半的头。