第 1 章 场景地图:你的每一个接口,都是一次协议决策

你写的不是接口,你是在跟一个 1991 年定下的契约打交道。它比你想象的要固执得多。


1.0 引子:一个”很简单”的需求

小美:老哥,加个功能呗——后台导出订单报表,点一下按钮下载 Excel。挺简单的吧?

你:行,半小时。

于是你写了个接口:

func ExportOrders(w http.ResponseWriter, r *http.Request) {
    orders := queryAll(r.Context())          // 从库里捞
    buf := renderExcel(orders)               // 生成 xlsx
    w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
    w.Write(buf.Bytes())
}

本地跑通,测试点了两下,Excel 下载下来了。你上线了。

然后一周内,四件事接连发生:

  1. 老张:你这接口一跑,网关全是 504。订单多的时候,一分钟都没返回。
  2. 小美:我明明只点了一次,为什么后台生成了两份报表?
  3. 你:在我电脑上好好的啊……哦等等,Chrome 好像自动重发了一次请求。
  4. 老张:还有,这破接口每次都全量下载 200MB,带宽被打满了。财务那边天天在导同一个表。

墨叔:来了。四个现象,四个不同的协议知识点。而且这四个,恰好覆盖了 HTTP 里最容易踩的四类坑。

先别急着往下看。你猜一下,这四个现象分别对应 HTTP 的哪一块知识?

……

你:第一个肯定是慢,得加索引或者分页。第二个是前端重复点击,加个 loading 遮罩就行。第三个是 Chrome 的锅。第四个……让用户少导几次?

墨叔:你看,你刚才给出的是四个工程侧的答案,而且四个都是打补丁。

我换个问法:为什么”导出”这件事,在 HTTP 里天生就别扭?


1.1 先把问题问对:到底什么是”协议”

墨叔:先问你个最基础的问题——你觉得”协议”是什么?

你:就是……通信双方约定好的数据格式?

墨叔:只对了一半。这只是语法(syntax)。我给你举个例子,你就知道光有语法有多可怕。

一个只有语法的”协议”

假设我跟你约定:咱俩传 JSON,{"action": "...", "params": {...}}。

现在我发你一个:

{"action": "transfer", "params": {"from": "A", "to": "B", "amount": 100}}

墨叔:问题来了——你收到这条消息之后,你该干什么?

你:把 A 的 100 转给 B。

墨叔:好。那你转完之后,要不要告诉我已经转了?如果我不停地发同一条消息给你,你要转几次?如果 A 没钱了,你怎么告诉我?如果我发消息的时候网络断了,我怎么知道你到底转没转?

你:……这些得另外约定。

墨叔:对。你刚才”另外约定”的那堆东西,才是协议的主体。所以——

协议 = 语法(长什么样)+ 语义(每个东西什么意思)+ 时序(谁先谁后)+ 异常(出错了怎么办)

四要素,缺一个都不叫协议,叫数据格式。

第四要素才是分水岭

前三个要素,大部分人都能想到。第四个——异常——是区分”会写代码”和”会设计系统”的那条线。

墨叔:我再问你一句,往深了想:为什么”出错了怎么办”这么难约定?

你:因为……出错的情况太多,列不完?

墨叔:更根本一点。

你:……因为出错的时候,双方看到的世界不一样了。我发完请求网络断了,我不知道你收没收到。

墨叔:漂亮。记住这句话——

协议最难的部分,不是约定”成功长什么样”,而是处理”双方状态不一致”的时刻。

网络世界里,你永远无法通过通信来确认对方的状态,因为确认这件事本身也是一次通信,它也可能失败。 这就是分布式系统里著名的”两军问题”,它是可证明无解的。

所以 HTTP 里所有那些看起来很奇怪的东西——超时、重试、幂等、304 Not Modified、100-continue、 exponential backoff——全都是在跟”双方状态不一致”这件事做斗争。

你现在回头看那四个现象:

现象表面原因协议层真相
网关 504接口太慢没有任何机制告诉调用方”我还在干活”。响应是一次性交付的,要么全有要么全无。
生成了两份前端重复点击这个操作不幂等,而 HTTP 的重试机制默认它是可以重试的
Chrome 自动重发Chrome 的锅协议规定:某些失败场景下,客户端应当自动重试
每次全量 200MB用户不会用没有协商机制让客户端说”我没变的部分别给我”

你:等等,第 2 和第 3 我有点懵。我没写重试啊。

墨叔:你没写,但浏览器写了,操作系统写了,网卡驱动写了,代理写了。你以为你发了一次请求,实际上从你的 Go 代码到对方服务器之间,隔着至少五层可能重试的东西。

这就是为什么我说:写接口的人必须知道,你的接口会被自动重试,不论你愿不愿意。


1.2 场景 → 知识点:全书地图

墨叔:好,你现在知道协议有四要素了。接下来我们干一件这本书最核心的事——把你工作中会遇到的需求,翻译成 HTTP 的知识点。

下面这张表,是这本书的索引。你以后遇到问题,先来这儿查。

场景一:文件上下传

典型现象

  • 小文件正常,大文件上传到一半就失败
  • 下载的文件浏览器直接打开了,不是下载
  • 做不了断点续传,一断就得重来
  • 上传到一半服务端才告诉你”文件太大了”

背后的知识点

知识点解决什么章节
Content-Length vs Transfer-Encoding: chunked接收方怎么知道”这个报文到哪结束”02
Content-Type / Content-Disposition接收方怎么知道”这堆字节是什么、该怎么处理”02、10
Range 请求 / 206 Partial Content断点续传、分片下载04、10
Expect: 100-continue先问一句”你能收吗”,再发 200MB05
413 Payload Too Large服务端拒绝的正确姿势04

你在 Go 里会撞到的:r.ParseMultipartForm 的内存爆炸、http.MaxBytesReader 该在哪用、为什么 io.Copy 到 w 会自动选 chunked。


场景二:登录、会话、鉴权

典型现象

  • 登录成功,一刷新就掉线
  • 本地联调正常,一上线跨域就带不上 Cookie
  • 前端说”我把 token 放 body 里你为什么读不到”
  • 换个域名就登录失效

背后的知识点

知识点解决什么章节
HTTP 是无状态的——这是设计选择,不是缺陷为什么需要 Cookie/Session/Token 这一堆东西09
Set-Cookie 的 Domain / Path / Secure / HttpOnly / SameSiteCookie 的作用域与防线09
Authorization 首部与 Bearer 方案官方的、标准化的身份携带方式11
CSRF / XSS / 中间人为什么 SameSite=Lax 现在成了默认09、11

你会撞到的:为什么 HttpOnly 的 Cookie 前端 JS 读不到(这不是 bug,这是特性);为什么 SameSite=None 必须配 Secure。


场景三:接口慢

典型现象

  • 本地 10ms,线上 3s
  • 加机器没用
  • 压测 QPS 卡在某个数上不去
  • 第一个请求特别慢,后面都快

背后的知识点

知识点解决什么章节
短连接 vs 长连接(Connection: keep-alive)每次请求都要重新握手吗07
TCP 三次握手 + TLS 握手 = 1~3 个 RTT”第一个请求慢”的真相07、11
队头阻塞(Head-of-Line Blocking)为什么 HTTP/1.1 开 6 个连接就够了07、12
连接池与 MaxIdleConnsGo 的 http.Client 为什么必须复用07、17
多路复用(HTTP/2 Stream)一个连接跑一百个请求12

你会撞到的那个经典坑:Go 里 http.DefaultClient 不设超时 + 不读 body + 不关连接 = 连接泄漏,最后 too many open files。这个坑 90% 的 Go 程序员都踩过,但只有知道连接管理原理的人能在踩之前就避开。


场景四:实时推送

典型现象

  • 轮询把服务器打垮了
  • SSE 和 WebSocket 不知道选哪个
  • WebSocket 连上几分钟就断了,也没报错
  • Nginx 后面 WebSocket 死活连不上

背后的知识点

知识点解决什么章节
长轮询 / 分块传输(chunked)在 HTTP 里”流式”返回02、07
SSE(text/event-stream)单向推送,还是纯 HTTP10、14
Upgrade 机制怎么从 HTTP “切换” 到另一个协议14
WebSocket 心跳(ping/pong)与 Sec-WebSocket-Key保活,以及那个防误连的握手14
代理对长连接的超时为什么”几分钟就断”07、14

场景五:重复提交与重复扣款

典型现象

  • 用户双击按钮,下了两单
  • 上游超时重试,导致重复扣款
  • 消息队列重投,数据被处理两次

背后的知识点

知识点解决什么章节
幂等性(idempotency)同一个请求执行 N 次 == 执行 1 次03
方法语义:GET/HEAD/PUT/DELETE 幂等,POST 不幂等谁允许被自动重试03
Idempotency-Key 首部业务层幂等的官方套路03
重试的时序语义(什么时候该重试、什么时候不该)超时 ≠ 失败03、04

墨叔插一句:这是全书最值钱的一章。因为幂等不是 HTTP 的问题,是分布式系统的根本问题,HTTP 只是把它暴露得特别明显。


场景六:缓存不更新

典型现象

  • 发了新版,用户看到的还是旧的
  • CDN 不回源
  • 加了 no-cache 还是被缓存
  • 登录用户 A 看到了用户 B 的数据(缓存污染,这个最要命)

背后的知识点

知识点解决什么章节
Cache-Control / Expires强制缓存08
ETag / Last-Modified + 304协商缓存08
启发式缓存(没有缓存头时的默认行为)为什么”我什么都没配”也会被缓存08
Vary同一个 URL,不同内容 —— CDN 缓存污染的根因08、10
Age / s-maxage / stale-while-revalidate共享缓存(CDN)的专属控制08

场景七:跨域(CORS)

典型现象

  • 本地联调好好的,一上线浏览器报 CORS 错
  • 莫名其妙多出一个 OPTIONS 请求
  • 带了自定义头就报错,不带就好

背后的知识点

知识点解决什么章节
同源策略(Same-Origin Policy)浏览器为什么拦你——这是浏览器的规则,不是 HTTP 的05
简单请求 vs 预检请求OPTIONS 从哪来的03、05
Access-Control-* 系列首部服务端怎么”授权”跨域05
为什么 OPTIONS 不能被缓存设计上的坑08

墨叔提醒:CORS 报错 99% 是浏览器在拦,你的 Go 服务根本收到了请求并且正常返回了。分不清这件事的人,会在服务端查半天。


场景八:多版本 API 共存

典型现象

  • v1 和 v2 怎么路由
  • 老客户端不愿意升级
  • 加了个字段,老客户端崩了

背后的知识点

知识点解决什么章节
URL 版本(/v1/xxx)vs 首部版本(Accept: application/vnd.x.v2+json)两种路线之争06、10
内容协商(Accept / Accept-Encoding / Accept-Language)HTTP 原生的版本机制10
向后兼容的加字段原则什么改动算 breaking15
Deprecation / Sunset 首部官方的下线通知机制15

场景九:限流、配额与降级

典型现象

  • 被上游限流了,不知道该等多久再试
  • 重试风暴把下游打挂

背后的知识点

知识点解决什么章节
429 Too Many Requests限流的正确状态码04
Retry-After 首部服务端明确告诉客户端”多久之后再来”04、05
指数退避 + 抖动(jitter)避免重试风暴03
503 + Retry-After vs 429限流和过载的区别04

场景十:抓包看不懂

典型现象

  • tcpdump 抓出来一堆看不懂的东西
  • HTTPS 抓包全是密文
  • HTTP/2 抓包连首部都看不到

背后的知识点

知识点解决什么章节
TLS 在 HTTP 之下,所以 HTTP 报文被整体加密为什么抓不到11
ALPN客户端和服务器怎么协商用 h2 还是 http/1.113、15
HPACK / QPACK 首部压缩为什么 h2 的首部抓出来是乱码12、13

1.3 更深一层:为什么你的接口”能用”但”不对”

墨叔:刚才那张表,是把”现象”翻译成”知识点”。现在我反过来问你一个更狠的问题。

你接口能跑,返回 200,前端能解析——它就”对”了吗?

你:……业务是对的啊。

墨叔:我给你列几个真实世界的翻车案例,你自己判断。

案例 A:某支付接口,用 GET /pay?amount=100 扣款。理由是”方便,前端直接跳转就行”。上线半年后,某个爬虫顺着链接爬了一遍,公司的钱被”付”出去几十万。

案例 B:某订单接口,数据库超时了,返回 200 {"code": 500, "msg": "系统繁忙"}。理由是”HTTP 200 表示请求成功到达,业务错误放 body 里”。结果监控从来没报警过——因为所有 HTTP 监控看到的都是 200,一片绿。

案例 C:某上传接口,前端传了 Content-Length: 10485760,服务端读到一半连接断了,服务端照样返回 200。理由是”反正没崩”。结果引入了半个包的数据,全库脏了。

你:这三个……犯了什么错?

墨叔:它们分别违反了 HTTP 的三种语义契约:

  1. 案例 A 违反了方法语义 —— GET 被规定为安全方法(safe),不允许有副作用。爬虫、预取、浏览器预加载、CDN 缓存——世界上所有中间设备都被允许对 GET 请求做任何事,包括在你不请求的时候自动发一次。这就是为什么 GET 不能有副作用。
  2. 案例 B 违反了状态码语义 —— 状态码是给中间层看的,不是给前端看的。你返回 200,就是在告诉全世界”这次交互成功了”。监控、网关、CDN、重试逻辑全都信了你的话。
  3. 案例 C 违反了报文完整性语义 —— Content-Length 是承诺,不是建议。承诺没收满,就是失败,必须报错。

墨叔:看出共同点了吗?这三个错误,在任何一门编程语言里都不会报错。它们在语法上完全合法,测试也能过。

它们的错误在语义层,而语义层的错误,编译器抓不到,测试也抓不到,只有生产环境能抓到。

这就是你今天坐在这儿的原因。


1.4 设计者视角:如果让你重新设计 HTTP

墨叔:现在换个位置。假设现在是 1991 年,Tim Berners-Lee 找到你:“我要做一个万维网,你帮我设计应用层协议。”

你会怎么开始?

你:先定报文格式吧。请求长什么样,响应长什么样。

墨叔:这是大多数人的第一反应,而且它是错的起点。

墨叔:我给你五分钟,你重新想。在定任何字节之前,你得先回答一个更根本的问题:

这个协议要在什么样的世界里运行?

你:……世界?

墨叔:对。我提示你几个维度:

  • 有多少人会用它?他们互相认识吗?
  • 中间会经过多少台机器?这些机器你管得着吗?
  • 这个协议要活多久?
  • 谁会第一个实现它?他们是天才还是草台班子?

你:……人很多,互相不认识。中间经过的机器管不着。要活很久。实现者……什么水平都有。

墨叔:好。现在你拿着这四个答案,重新告诉我,你要怎么设计。

你:那……得简单,不然没人实现得对。得能扩展,因为我管不着中间那些机器,也没法让所有人一起升级。得容错,因为实现者水平参差。得……

墨叔:停。你刚才自己说出来的这四点,就是 HTTP 的设计约束。Roy Fielding 后来把它总结成了 REST 的架构约束,但归根结底就是这四条:

HTTP 的五条元约束

约束通俗说法为什么必须有
简单一个高中生能用手写 telnet 敲出来门槛决定采用率。当年能拼过 Gopher,一半靠的是这个
可扩展不改版本号也能加功能你管不着全球几亿台设备,不可能让它们一起升级
无状态服务器不记得你上一次干了什么记得住 = 有状态 = 挂了就丢 = 扩不了容
可缓存任何响应都要能声明”我能被缓存多久”否则整个互联网的流量成本扛不住
分层客户端不知道对面是源站还是 CDN 还是代理否则 CDN 这种东西根本不可能出现

墨叔:现在,关键问题来了——

这五条约束,是互相打架的。

你:打架?

墨叔:你看:

  • 简单 vs 可扩展:越简单的东西越难扩展。纯文本简单,但纯文本解析慢、有歧义。
  • 无状态 vs 业务需求:无状态好扩容,但业务天生就是有状态的(购物车、登录态)。所以你得在协议外面套 Cookie/Session——HTTP 的每个”补丁”,都是约束打架后的妥协产物。
  • 可缓存 vs 实时性:缓存省流量,但用户要看到最新的。所以你得发明 ETag、校验、过期这套复杂机制。
  • 分层 vs 端到端安全:中间有代理,代理就得看得懂你的报文。所以明文传输了 20 年,直到 HTTPS 普及。

墨叔:所以,协议设计的本质不是”设计最优解”,而是”在互相冲突的约束之间找平衡点”。

这就是这本书后半程要教你的东西。你不是要学 HTTP 长什么样,你要学的是:当年那帮人面对这些冲突时,是怎么权衡的。

一个反直觉的结论

墨叔:我再问最后一个问题,这个问题有点绕,你想清楚再答。

如果当年设计 HTTP 的是一个完美主义者,把它设计得特别严谨、特别高效、特别完整——它会成功吗?

你:应该会吧?

墨叔:不会。它会死。

原因有三:

  1. 严谨 = 复杂 = 实现门槛高 = 没人用。 当年 HTTP 的竞争对手没有一个比它差在功能上,全都是差在”太难实现”。
  2. 严谨 = 不容错 = 一个实现有 bug 就全崩。 而 HTTP 的”宽容”策略(后面第 15 章讲 Postel 法则)让一堆写错的实现居然也能跑,这救了它。
  3. 完整 = 没有扩展空间 = 十年后必须换掉。 而 HTTP 故意留了一堆”未定义”的地方,让后人能往里塞东西。

一个协议的成功,不取决于它有多正确,而取决于它允许多少人犯错误还活下来。

这句话,你在这本书里还会见到好几次。


1.5 Go 落地:解剖你每天都在写的 20 行

墨叔:理论说了一堆,我们用 Go 锚定一下。下面这段代码你肯定写过一千遍:

func main() {
    http.HandleFunc("/api/user", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"name": "laoge"})
    })
    http.ListenAndServe(":8080", nil)
}

十六行。现在我问你:这十六行里,HTTP 帮你做了多少个决定?

你:不就是序列化了个 JSON 吗?

墨叔:我给你数数:

#它替你做的决定对应知识点章节
1读了请求行,判断出这是个 GET报文解析02
2解析了十几个请求头,塞进 r.Header首部是扩展插槽05
3判断 body 长度(要么看 Content-Length,要么看 chunked)报文边界02
4你没写状态码,它默认写了 200 OK状态码语义04
5你没写 Content-Length,它先攒着,写完才知道多长缓冲 vs 流式02、07
6它决定要不要复用这个 TCP 连接连接管理07
7它决定响应要不要分块传输chunked02
8它自己加了 Date 头缓存基础08
9它没加任何缓存头 → 浏览器会走启发式缓存默认行为很危险08
10如果客户端断开,你的 Encode 会返回一个 error,而你没检查异常是协议第四要素01、03

墨叔:十六行代码,十个协议决策。你一个都没显式做过,但它们全都发生了。

这就是为什么我说——

你不是”会用 HTTP”,你是”在用别人帮你做完决策之后的 HTTP”。

而这本书要做的,就是把这十个决策一个一个从框架手里抢回来,交还给你。

我们做个小实验,感受一下”默认行为”有多危险:

// 实验:看看 net/http 到底给你加了什么头
func handler(w http.ResponseWriter, r *http.Request) {
    // 故意什么都不 Set
    w.Write([]byte("hello"))
}
 
// 用 curl -i 看真实响应:
// HTTP/1.1 200 OK
// Date: Sun, 30 Aug 2026 06:12:33 GMT
// Content-Length: 5
// Content-Type: text/plain; charset=utf-8
//
// 注意:没有 Cache-Control。
// 浏览器会怎么做?—— 走启发式缓存(heuristic caching),
// 可能把这个响应当成"可以缓存很久"。这就是"发了新版用户看不到"的经典成因。

墨叔:你看,你什么都没干,协议已经替你做了一个可能出事故的决定。


1.6 排查清单:生产环境遇到问题,按这个顺序查

墨叔:给你一个通用的分层排查法。遇到问题,不要猜,从下往上问:

第 1 问:请求到底发出去了吗?
        → 浏览器 DevTools Network 面板,看有没有这条记录
        → 没有 = 前端/浏览器层(CORS 预检失败也会显示在这里)

第 2 问:请求到达服务器了吗?
        → 服务端 access log / tcpdump
        → 没有 = DNS、网络、LB、防火墙

第 3 问:请求长什么样?
        → 抓包看原始报文(curl -v 或 Wireshark)
        → 重点看:方法、URL、Host 头、Content-Length、有没有被代理改过

第 4 问:响应长什么样?
        → 状态码是几?首部有哪些?body 完整吗?
        → 重点看:状态码和 body 里的业务码是否一致

第 5 问:中间有没有"第三者"?
        → 代理、CDN、网关、Service Mesh sidecar
        → 这些家伙会改你的报文,而且改了你不知道

第 6 问:这个请求被重试了几次?
        → 客户端日志、网关日志、服务端日志三边对齐

墨叔:这六问的价值在于——它强迫你在”猜”之前先”看”。我见过太多人花三天改代码,最后发现问题在 Nginx 的一行配置上。


思考题

第 1 题(概念)

HTTP 协议四要素是”语法、语义、时序、异常”。请判断下面这个约定缺了哪一要素,会导致什么后果:

某团队约定:服务间调用统一用 JSON-RPC,格式为 {"method": "...", "params": [...], "id": 1},响应为 {"result": ..., "error": null, "id": 1}。

提示:想想 id 这个字段为什么会被设计出来。

第 2 题(概念)

案例 B 里,团队把所有业务错误都返回 HTTP 200,错误信息放在 body 的 code 字段里。这个做法在很多公司很常见,甚至被写进规范。

请从”HTTP 是分层系统”的角度,列出这个做法会具体破坏哪三种基础设施的能力。

第 3 题(设计)

假设你要设计一个”导出报表”的接口,要求:

  • 数据量大,生成可能要 3 分钟
  • 同一份报表(同样参数)一周内会被重复导出几十次
  • 不能让网关 504
  • 生成失败要能明确告诉用户原因

请你只用 HTTP 已有的机制(不引入 MQ、不引入对象存储、不允许改网关配置),设计这个接口。写出你会用到的:方法、状态码、关键首部、以及交互时序。

第 4 题(生活类比)

墨叔:邮局寄信,和 HTTP 请求,有哪些地方是同构的,有哪些地方是根本不同的?

具体说:信封上的收件人地址、邮票、挂号信的回执、信件内容、平信丢了不赔——分别对应 HTTP 的什么?并且指出至少一个”邮局做不到但 HTTP 做得到”的事情。

第 5 题(更深一层的发问)

我在 1.4 节说:“一个协议的成功,不取决于它有多正确,而取决于它允许多少人犯错误还活下来。”

现在把这个结论推向极端:

如果一个协议无限宽容——对任何畸形输入都尽力猜测意图、从不报错——它会变成什么样?

现实中有这样的例子吗?(提示:想想 HTML。)

这给我们一个什么启示:宽容的边界应该划在哪里?


参考答案

第 1 题答案

缺的是:时序(更准确说是”并发时序 / 请求-响应配对”)中的异步场景,以及异常要素。

JSON-RPC 里的 id 字段,就是为了处理”多个请求同时在路上”时的配对问题。但即使有 id,这个约定仍然缺了两样东西:

(1)缺”超时与重试”的约定。 发送方等多久算超时?超时之后能不能重发?重发了对方收到两次,会不会执行两次?

这就是为什么 gRPC 这样的现代 RPC 框架除了请求 ID,还必须定义 deadline(超时时间戳,随请求一起传) 和 重试策略。注意 deadline 是随请求传的绝对值(比如”2026-08-30T06:15:00Z 之前有效”),而不是”超时 5 秒”——因为每一跳的网络耗时都要算进去,相对值会被中间节点吃掉。

(2)缺”错误分类”的约定。 error 字段里到底放什么?一个字符串?一个错误码?如果是错误码,谁来解释它?服务端说 error: "user_not_found",客户端怎么知道这是”用户输入错了”(不该重试)还是”数据库挂了”(应该重试)?

对比 HTTP 的做法:HTTP 用状态码的第一位数字做分类,而分类的黄金标准就是——这个错误能不能通过重试解决:

  • 4xx:客户端的错,重试一百次也是同样的错 → 别重试
  • 5xx:服务端的错,换个时间可能就好了 → 可以重试

一个字段,把”重试决策”这个分布式系统里最难的问题,用一位数字解决了。这就是设计的力量。

更深的追问:如果 429(限流)和 503(过载)都是”可以重试”的,那客户端应该多久之后重试?这就是 Retry-After 首部存在的理由(第 4 章详述)。你会发现,协议的每一处细节,都是在补前一个细节留下的洞。


第 2 题答案

统一返回 200 会破坏三类基础设施:

(1)监控与告警失效。 所有基于 HTTP 状态码的监控(Prometheus 的 http_requests_total{code=~"5.."}、Nginx 的 $status、云厂商的 LB 健康指标)全部失灵。你的错误率永远是 0,告警永远不响。 更糟的是:你失去的是”免费”的监控。任何一个 HTTP 组件——Nginx、Envoy、CDN、APM——都原生理解状态码。你放弃它,就得自己从头造一套等价的。

(2)中间层的重试与缓存决策失效。

  • 重试:网关看到 200,认为成功了,不会重试。但你的 body 里写着”系统繁忙,请重试”。
  • 缓存:CDN 看到 200,认为这是一个有效响应,会把它缓存起来。于是一个错误响应被缓存下来,在所有用户面前展示。这是真实事故——某电商曾因把”库存不足”错误页返回 200,导致 CDN 缓存了错误页,几万用户看到”商品已售罄”,而实际有货。
  • 熔断:服务网格(如 Istio)靠 5xx 比例触发熔断。全是 200 = 永不熔断 = 下游已经挂透了,上游还在疯狂打。

(3)客户端的通用处理逻辑失效。 前端的 HTTP 库(axios 拦截器、fetch 的错误分支)、移动端 SDK、第三方调用方——全都靠状态码走不同分支。你返回 200,它们的 catch 不会执行。结果每一家接入方都要写一遍”先判断 HTTP 码,再判断 body 里的 code”的胶水代码,而且每一家写得都不一样。

那正确做法是什么? 两者都要。 HTTP 状态码表达协议层的结果(成功/客户端错/服务端错/限流/需要认证),body 里的 code 表达业务层的结果(余额不足/优惠券已过期/风控拦截)。

分层系统里,每一层只应该表达自己那一层的语义。这就是”分层”约束的直接推论。


第 3 题答案

这是一个真实的系统设计题。核心思路是:把一个”同步的长任务”,拆成”提交任务 + 查询结果”两步,并用 HTTP 的缓存机制消解重复请求。

方案:异步任务 + 302/303 + 缓存

第一步:提交导出任务

POST /exports HTTP/1.1
Host: api.example.com
Content-Type: application/json
Idempotency-Key: 8f14e45f-ea2b-4c9a-b3d1-7a2c5e8f9b01

{"start_date":"2026-08-01","end_date":"2026-08-30","format":"xlsx"}

响应(任务已存在或新建):

HTTP/1.1 202 Accepted
Location: /exports/8f14e45f-ea2b-4c9a-b3d1-7a2c5e8f9b01
Retry-After: 3
Content-Type: application/json

{"id":"8f14e45f...","status":"pending","progress":0}

关键点解析:

  • 202 Accepted(不是 200):明确告诉客户端”我收到了,但活还没干完”。这是为这种场景准备的状态码,可惜 90% 的人不知道它存在。
  • Location:给出”接下来去哪看结果”。这是 HTTP 原生的异步任务模式。
  • Retry-After: 3:告诉客户端至少 3 秒后再来查。避免客户端疯狂轮询。
  • Idempotency-Key:解决导论里”生成了两份报表”的问题。服务端看到相同的 key 就直接返回已有任务,不新建。这才是正解——前端加 loading 遮罩只能挡住用户双击,挡不住浏览器的自动重试和网络层的重传。

第二步:轮询任务状态

GET /exports/8f14e45f-ea2b-4c9a-b3d1-7a2c5e8f9b01 HTTP/1.1
Host: api.example.com
  • 还没好:200 OK + {"status":"running","progress":42},带 Cache-Control: no-store(必须,否则浏览器会缓存这个中间状态)
  • 好了:303 See Other + Location: /exports/8f14e45f.../file,让客户端去拿文件
  • 失败:200 OK + {"status":"failed","reason":"数据库连接超时"}

为什么用 303 而不是 302?因为 303 明确规定客户端必须用 GET 去请求 Location 指向的资源。302 在历史上被各种实现搞得语义混乱(有的实现会把 POST 方法保留下来重定向)。这就是第 4 章要讲的”状态码语义被现实污染”的经典案例。

第三步:下载文件

GET /exports/8f14e45f-ea2b-4c9a-b3d1-7a2c5e8f9b01/file HTTP/1.1

响应:

HTTP/1.1 200 OK
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="orders-20260801-20260830.xlsx"
Content-Length: 209715200
Cache-Control: private, max-age=604800
ETag: "a3f5c8d9e1b2"
Accept-Ranges: bytes

<binary>

关键点:

  • Content-Disposition: attachment:让浏览器下载而不是尝试打开。这是”浏览器直接打开 Excel”的解法。
  • Cache-Control: private, max-age=604800:解决”每次都全量下载 200MB”。财务重复导出同一张表时,浏览器/CDN 直接用缓存,一个字节都不用传。
  • ETag:配合 If-None-Match,下次请求可以直接返回 304 Not Modified。
  • Accept-Ranges: bytes:解决断点续传。客户端可以用 Range: bytes=104857600- 只请求后半段,服务端回 206 Partial Content。200MB 的 Excel 下到一半断了,不用从头再来。
  • private:这个必须加。报表含业务数据,只能存在用户自己的浏览器缓存里,绝不能被 CDN 这种共享缓存存下来——否则 A 用户会下载到 B 用户的报表。这是真实发生过的严重数据泄露事故。

为什么这样就不会 504 了? 因为没有任何一个 HTTP 请求的处理时间超过几秒。3 分钟的生成时间被拆散到了多次轮询之间。网关的 60 秒超时永远不会触发。

更深的追问:能不能不用轮询? 可以,用 SSE(第 10 章)或 WebSocket(第 14 章)推送进度。但你要知道代价:SSE 会占用一个连接 3 分钟,需要网关放行长连接;WebSocket 更重。轮询 + Retry-After + 缓存是”最 HTTP 原生”的解法,而且对中间设备最友好。

选哪个,取决于你的团队能运维多复杂的东西。协议选型从来不是技术题,是运维能力题。


第 4 题答案

同构的部分:

邮局HTTP
信封上的收件人地址请求行里的 URL + Host 首部
寄件人地址请求方的 IP / From 首部
邮票(预付的运费)已经建立好的 TCP 连接 + TLS 握手(代价已经付过了)
平信(丢了不赔)UDP / 无确认的通信
挂号信的回执响应报文(这就是”确认”)
信件内容请求/响应体(body)
信封上注明”内含易碎品”Content-Type / 各种 Content-* 元数据
邮局分拣中心路由器、代理、CDN
你不知道信走了哪条路线分层约束:客户端不知道对面是源站还是 CDN

根本不同的地方(这是重点):

(1)邮局没有”连接”的概念,HTTP 有。 寄信是无连接的——你把信投进邮筒就完了。而 HTTP 是跑在 TCP 上的,必须先建立连接。这意味着:寄一封信的成本是固定的,而发一个 HTTP 请求的第一个字节比后面的字节贵得多(握手成本)。 这就是为什么 HTTP 要搞 keep-alive、连接池、HTTP/2 多路复用——全都是为了摊销这个”建连成本”(第 7、12 章)。

(2)邮局不会”背着你重寄”,HTTP 会。 信丢了就是丢了,邮局不会自动再寄一封。但 HTTP 世界里,重试无处不在且默认开启。所以幂等性在 HTTP 里是头等大事,在邮局里根本不是问题。

(3)邮局没有”缓存”这回事。 你不可能让邮局把同一封信复印一百份送到一百个收件人手里(那就是群发,不是缓存)。而 HTTP 里,中间任何一个节点都被允许缓存响应,这是整个互联网能撑住今天这个流量的根本原因。

“邮局做不到但 HTTP 做得到”的一件事(举例):

HTTP 可以做到”我只告诉你有没有变,不重发内容”。

客户端问:“我手上这份是版本 ETag: abc123,变了吗?” 服务端答:304 Not Modified —— 三个字节,没有正文。

邮局做不到这件事,因为邮局传递的是物理实体,你没法”不寄内容只寄一个’没变’”。

这件事的意义极其深远:它把”传输成本”从”与内容大小成正比”降到了”与内容是否变化有关”。200MB 的报表,第二次请求可能只花 200 字节。这是 HTTP 缓存体系(第 8 章)的核心价值。

顺带一个更妙的:HTTP 还能做到”我只要第 100MB 到第 150MB”(Range 请求),邮局显然做不到让你只寄半封信。


第 5 题答案

这是一个非常好的问题,它的答案会改变你看待”工程妥协”的方式。

(1)无限宽容会导致什么?

三个后果,一个比一个严重:

后果一:没有”正确”了,只有”我的实现认为的正确”。 如果解析器对任何畸形输入都”尽力猜测”,那么不同实现的猜测结果不一样。于是同一份数据,Chrome 渲染成 A,Firefox 渲染成 B。这时候”协议”已经名存实亡了——真正的标准是”市场份额最大的那个实现的行为”。

后果二:错误被静默吞掉,问题在千里之外爆炸。 宽容的解析器不会报错,它会给你一个”看起来还行”的结果。于是错误数据一路向下流,直到三个系统之后的某个环节炸掉,而那时候你已经无法追溯是哪一步开始错的。调试成本呈指数上升。

后果三:无法演进。 这是致命的。如果你想给协议加一个新特性,你怎么知道现实世界里有没有人已经在用这个”畸形语法”表达别的意思?如果有,你一加新特性就破坏了他们的系统。 宽容会吃掉你的未来扩展空间。

(2)现实中的例子:HTML。

HTML 是”无限宽容”最典型的案例,而且它真的付出了上述全部三个代价:

  • 你可以写一个 <p>xxx 不闭合,浏览器照样渲染。你可以把 <div> 和 <table> 嵌套得乱七八糟,浏览器照样猜。
  • 代价一:早期 IE 和 Netscape 的渲染差异成了前端工程师的噩梦,“兼容 IE6”这件事养活了一代程序员。
  • 代价二:HTML 解析错误几乎不报错,页面”看起来能显示”,但 DOM 结构已经跟你想的完全不同,于是 CSS 选择器失效、JS 拿不到元素——问题现象和根因隔了十万八千里。
  • 代价三:HTML5 标准化时,工作组必须先去调查现实世界里几十亿个网页到底写了什么畸形 HTML,然后把这些畸形行为反过来写成标准。这就是著名的 “HTML5 是把浏览器的 bug 标准化”。

对比 XML:XML 选择了严格——一个标签没闭合,解析器直接报错,页面白屏。结果呢?XML 在文档领域被 HTML 打得落花流水,但在配置文件和机器间通信领域活得好好的。

这说明什么? 严格 vs 宽容,不是对错问题,是场景问题。

(3)宽容的边界应该划在哪里?

我给你三条判断准则:

准则一:对人宽容,对机器严格。 如果输入来自人(浏览器地址栏、用户上传、手写配置),宽容一点——因为人犯错的代价很高,而纠正人的成本也很高。 如果输入来自机器(另一个服务、另一个程序),严格一点——因为机器不会”手滑”,它写错了说明上游有 bug,你要让它立刻炸,而不是帮它掩盖。

准则二:对”收到的”宽容,对”发出的”严格。 这是 Postel 法则(第 15 章详述)的正确表述:

“Be conservative in what you send, be liberal in what you accept.” (发送时保守,接收时开放。)

注意它只对了一半被记住。大多数人只记住了”接收时开放”,忘了”发送时保守”。如果所有人都只开放不保守,系统整体会迅速腐烂。

准则三:宽容的地方,必须是你愿意永久承诺的地方。 这是最关键的一条。你今天”善意接受”的畸形输入,十年后就变成了事实标准。所以:

只在”你愿意永远支持它”的地方宽容。

如果一个畸形输入你只打算”暂时兼容一下,下个版本就删”——从第一天起就报错。因为你一旦放过去,就再也删不掉了。

一个具体的边界划法:

情况建议理由
未知的首部字段忽略(宽容)这是 HTTP 可扩展性的核心机制
未知的方法报错(405/501)方法决定语义,猜错了后果严重
首部值多余空格容忍(宽容)历史包袱,成本极低
body 长度与 Content-Length 不符必须报错(严格)这是完整性问题,容忍 = 数据污染
状态码第一位不认识(如 6xx)按 x00 处理(宽容但有规约)HTTP 明确规定了这条降级规则
请求行畸形必须报错(400)解析不下去了,猜不了

最后一句,送给你记一辈子:

宽容是一种债务。你今天接受的每一个畸形输入,都是你未来必须永远偿还的利息。

真正的高手不是”宽容的高手”,是**“知道在哪里必须严格”的高手**。


一页纸小结

协议四要素:语法(长什么样)+ 语义(什么意思)+ 时序(谁先谁后)+ 异常(出错了怎么办)。 第四要素最难,因为网络世界里你永远无法通过通信确认对方状态,因为确认本身也是通信。

场景 → 知识点地图:文件传输看报文边界与 Range;登录看无状态与 Cookie;慢看连接与队头阻塞;实时看长连接与 Upgrade;重复提交看幂等;缓存不更新看 Cache-Control 与 Vary;跨域看同源策略与预检;多版本看内容协商;限流看 429 + Retry-After;抓包看 TLS 与 ALPN。

“能用”≠“对”:三个真实案例分别违反了方法语义(GET 不能有副作用)、状态码语义(状态码是给中间层看的)、完整性语义(Content-Length 是承诺)。

HTTP 五条元约束:简单、可扩展、无状态、可缓存、分层。它们互相打架,HTTP 的每一个”补丁”都是冲突后的妥协。

核心结论:一个协议的成功不取决于它有多正确,而取决于它允许多少人犯错误还活下来。 但宽容是债务——只在”你愿意永远支持它”的地方宽容。

Go 落地:十六行 http.HandleFunc 里藏着十个协议决策,你一个都没显式做过,但它们全都发生了。

排查六问:发出去了吗 → 到达了吗 → 请求长什么样 → 响应长什么样 → 中间有没有第三者 → 被重试了几次。


下一章:我们要钻进报文里面去。我会问你一个听起来很蠢的问题——为什么 HTTP 是纯文本的? 以及一个更蠢但更关键的问题——接收方怎么知道这个报文到哪儿结束?

这两个问题的答案,决定了后面 17 章的一半内容。