第 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 下载下来了。你上线了。
然后一周内,四件事接连发生:
- 老张:你这接口一跑,网关全是 504。订单多的时候,一分钟都没返回。
- 小美:我明明只点了一次,为什么后台生成了两份报表?
- 你:在我电脑上好好的啊……哦等等,Chrome 好像自动重发了一次请求。
- 老张:还有,这破接口每次都全量下载 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 | 先问一句”你能收吗”,再发 200MB | 05 |
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 / SameSite | Cookie 的作用域与防线 | 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 |
连接池与 MaxIdleConns | Go 的 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) | 单向推送,还是纯 HTTP | 10、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 |
| 向后兼容的加字段原则 | 什么改动算 breaking | 15 |
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.1 | 13、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 的三种语义契约:
- 案例 A 违反了方法语义 —— GET 被规定为安全方法(safe),不允许有副作用。爬虫、预取、浏览器预加载、CDN 缓存——世界上所有中间设备都被允许对 GET 请求做任何事,包括在你不请求的时候自动发一次。这就是为什么 GET 不能有副作用。
- 案例 B 违反了状态码语义 —— 状态码是给中间层看的,不是给前端看的。你返回 200,就是在告诉全世界”这次交互成功了”。监控、网关、CDN、重试逻辑全都信了你的话。
- 案例 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 的是一个完美主义者,把它设计得特别严谨、特别高效、特别完整——它会成功吗?
你:应该会吧?
墨叔:不会。它会死。
原因有三:
- 严谨 = 复杂 = 实现门槛高 = 没人用。 当年 HTTP 的竞争对手没有一个比它差在功能上,全都是差在”太难实现”。
- 严谨 = 不容错 = 一个实现有 bug 就全崩。 而 HTTP 的”宽容”策略(后面第 15 章讲 Postel 法则)让一堆写错的实现居然也能跑,这救了它。
- 完整 = 没有扩展空间 = 十年后必须换掉。 而 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 | 它决定响应要不要分块传输 | chunked | 02 |
| 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 章的一半内容。