第 10 章 内容协商与压缩:同一个资源的一千种形态

“同一个资源,怎么给不同的人不同的样子?” 这个看似简单的问题,答案里藏着 HTTP 里最优雅也最容易被误用的两套机制。


10.0 引子:移动端和 PC 返回不同内容,缓存全乱了

小美:老哥,移动端要求接口返回精简版字段,PC 要完整版,能不能同一个接口根据设备返回不同内容?

你:行,我在代码里读 User-Agent 判断是不是移动端。

老张:出事了。CDN 缓存全乱了——有的移动用户拿到了 PC 的完整版数据,有的 PC 用户拿到了移动端的精简版。

你:……因为缓存只认 URL,不认”设备”?

墨叔:你撞上了”内容协商”的第一个坑。今天这一章,把”同一个资源,多种形态”这件事彻底讲清楚——包括那个让无数人混淆的 Content-Encoding 和 Transfer-Encoding。


10.1 先把问题问对:什么是”内容协商”

墨叔:一个资源,可以有多种”表示(representation)”:

  • 同一个”用户资料”,可以是 JSON、XML、HTML。
  • 同一个”网页”,可以是英文、中文。
  • 同一个”文件”,可以是 gzip 压缩版、原始版。

“内容协商”就是:客户端和服务器商量好,这次传输用哪种表示。

你:谁来决定用哪种?客户端还是服务器?

墨叔:这正是核心。HTTP 设计了三种协商方式,但最终只有一种活了下来。

三种协商方式

方式一:服务端驱动(Server-driven)—— 赢家。 客户端在请求里声明”我能接受什么”(Accept: application/json),服务器根据这个声明决定返回什么。

方式二:代理驱动(Agent-driven)—— 基本没人用。 服务器返回 300 Multiple Choices,列出所有可选表示,让客户端自己挑。太麻烦,多一次往返,没人用。

方式三:透明协商(Transparent)—— 理论存在。 中间缓存代理替双方协商。太复杂,现实中极少。

你:所以现实中只有”服务端驱动”这一种?

墨叔:对。核心机制就一句话:客户端用 Accept* 系列头声明”我能接受什么”,服务器据此选择,并在响应里用 Content-* 头声明”我这次给了你什么”。

请求:Accept: application/json          <- 客户端说:我要 JSON
请求:Accept-Encoding: gzip, br         <- 客户端说:我支持 gzip 和 br 压缩
请求:Accept-Language: zh-CN            <- 客户端说:我偏好中文

响应:Content-Type: application/json    <- 服务器说:给你 JSON
响应:Content-Encoding: gzip            <- 服务器说:我用 gzip 压了
响应:Content-Language: zh-CN           <- 服务器说:内容语言是中文

10.2 Accept 与 q 值:优先级是怎么算的

墨叔:Accept 头里有个容易被忽略但很精妙的东西——q 值(质量值,quality value)。

Accept: text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8

你:这些 q=0.9、q=0.8 是什么?

墨叔:是优先级。客户端在说:“我最想要 text/html(q 默认是 1.0,最高),其次是 application/xhtml+xml(也是 1.0),再其次是 application/xml(0.9),最后任何格式都行(0.8)。”

匹配算法:服务器遍历自己能提供的格式,选客户端能接受且 q 值最高的那个。如果都不匹配,就返回 406 Not Acceptable。

你:所以 */* 是”兜底,什么都行”?

墨叔:对。*/* 表示”任何 MIME 类型都接受”(q=0.8 表示”不太想要,但实在没有也行”)。

一个实际的坑:很多人在接口里根本不看 Accept 头,直接返回写死的 JSON。这在”只有一种客户端”时没问题,但一旦有别的客户端(比如一个只想拿 XML 的老系统),就会出现”它声明了 Accept: application/xml,你却硬塞给它 JSON”的语义错位。要么尊重 Accept,要么就别声称支持内容协商。


10.3 Content-Encoding vs Transfer-Encoding:全章最大的坑

墨叔:现在讲全章最值得讲的一个辨析,也是面试和工作里高频混淆点:

Content-Encoding 和 Transfer-Encoding 到底有什么区别?

你:这俩我一直稀里糊涂的。都是”编码”?

墨叔:天差地别。记住一句话:

Content-Encoding 是”内容的压缩格式”,是资源表示的一部分;Transfer-Encoding 是”传输的打包方式”,是传输过程的临时手段。

Content-Encoding:把内容压小了存/传

Content-Type: text/html          <- 内容本质是 HTML
Content-Encoding: gzip           <- 但为了省流量,用 gzip 压缩过了

Content-Encoding 是端到端的——它描述的是”这个资源表示本身被压缩成了什么格式”。接收方必须解码(解压),才能得到真正的 HTML。

你:所以 gzip 是”内容的一种形态”?

墨叔:对。Content-Encoding: gzip 意味着”这个表示的字节,是 gzip 压缩后的”。浏览器拿到后先 gunzip,再当 HTML 解析。

Transfer-Encoding:把传输过程打包成段

Transfer-Encoding: chunked        <- 传输时用了分块,跟内容无关

Transfer-Encoding 是逐跳的(第 5 章讲过)——它描述的是”这一跳的传输,用了什么打包方式”(比如 chunked)。接收方解码后得到的是完整的实体,跟传输方式无关。

一张表彻底分清

Content-EncodingTransfer-Encoding
管什么内容的压缩格式传输的分段/打包
端到端/逐跳端到端逐跳
接收方要不要解要(解压)要(去掉分段标记)
典型值gzip / br / zstdchunked
例子一个 gzip 的 HTML一个分块传输的流

你:那”一个 gzip 的 HTML,用 chunked 传输”,两个可以同时有?

墨叔:可以!这正是最好的例子:

Content-Type: text/html
Content-Encoding: gzip        <- 内容被 gzip 压了
Transfer-Encoding: chunked    <- 传输时又用了分块

顺序是这样的:先有”内容”(HTML)→ 用 Content-Encoding 压缩(变成 gzip 字节)→ 用 Transfer-Encoding 分块传输。一个管”内容长什么样”,一个管”怎么送过去”。

为什么这个辨析重要:因为很多人写代码时想”给响应压缩”,结果设置错了头——设了 Transfer-Encoding 以为在压缩,其实只是分了块。或者反过来,在逐跳的地方(代理)处理了 Content-Encoding(端到端),把不该它管的东西给改了。


10.4 压缩:gzip、br、zstd 与安全

墨叔:讲压缩,就讲三个点:选什么算法、代价是什么、以及一个必须知道的安全问题。

算法选择

算法压缩率CPU 消耗现状
gzip中低最通用,兼容性最好
br(Brotli)高(比 gzip 高约 20%)中现代浏览器都支持,静态资源首选
zstd高低(解压快)较新,逐渐普及

你:那直接全用 br 不就完了?

墨叔:不行。因为压缩率和 CPU 是一对矛盾——br 压得小,但压缩时 CPU 更费。对于静态资源(压一次,服务一万次),br 很划算;对于动态内容(每个请求都要现压),br 的 CPU 成本可能扛不住,gzip 更划算。

所以实践中:静态资源用 br(构建时压好),动态响应用 gzip(实时压,CPU 友好)。

安全:BREACH 攻击

墨叔:压缩有一个经典的安全隐患——BREACH 攻击。

原理很精妙:压缩 + 加密 + 反射用户输入 = 泄露秘密。

想象一个页面,它会把用户输入的内容反射到响应里(比如搜索框,你搜”abc”,页面显示”你搜索了 abc”),而且响应是 HTTPS 加密 + gzip 压缩的。

攻击者不断发请求,让你反射不同内容,然后观察加密响应的长度变化:

  • 因为 gzip 压缩,重复的内容会被压得更小。
  • 如果攻击者猜到”秘密”的一部分(比如 CSRF token 的前几个字符),他把它构造进请求里,如果猜对了,响应里出现了重复,压缩后长度就变短。
  • 攻击者通过”响应长度变短了没有”,来一个字符一个字符地猜出秘密。

你:所以压缩把”秘密”的信息,泄露到了”响应长度”里?

墨叔:对。这是一个非常聪明的侧信道攻击。防护办法:对敏感的动态响应(含 CSRF token、含用户反射内容的),禁用压缩;或者给响应加随机长度的填充。


10.5 范围请求:断点续传与分片下载

墨叔:讲一个实用技能——Range 请求,它实现了断点续传。

场景:下载 2GB 的文件,下到一半断了。重新下,如果从头再来,太蠢了。Range 请求让你”只下后半段”。

# 客户端:我要从第 1000 字节开始
GET /big.iso HTTP/1.1
Range: bytes=1000-
 
# 服务器:给你 1000 字节之后的部分
HTTP/1.1 206 Partial Content
Content-Range: bytes 1000-2097151/2097152
Content-Length: 2096152
Accept-Ranges: bytes

你:206 Partial Content 是 Range 请求的标志?

墨叔:对。几个要点:

  1. Accept-Ranges: bytes:服务器在第一次响应里声明”我支持范围请求”。没有它,客户端不该发 Range。
  2. 206 + Content-Range:告诉客户端”这是哪一段/总共多长”。
  3. If-Range:一个精妙的细节——客户端在断点续传时,可以带 If-Range: <etag>,意思是”如果资源还是这个 ETag 没变,就给我续传;如果变了,就给我完整的新内容”。防止”资源变了,你却拿旧的后半段拼了个四不像”。

你:这个 If-Range 挺聪明的。

墨叔:对。它是”乐观并发”在传输层的应用——先确认资源没变,再做增量操作,否则退化回全量。这和第 3 章、第 8 章的 ETag 乐观锁是同一个思想。


10.6 分块传输与 SSE:为实时推送铺路

墨叔:内容协商的最后一块,也是为第 14 章 WebSocket 铺路的——SSE(Server-Sent Events,服务器推送事件)。

你:SSE 是什么?跟 WebSocket 啥关系?

墨叔:SSE 是一种**“单向的、纯 HTTP 的”实时推送**:服务器持续往一个 HTTP 响应里”流式”地写数据,客户端持续读。

HTTP/1.1 200 OK
Content-Type: text/event-stream   <- 特殊 MIME 类型
Cache-Control: no-cache
 
data: 用户 123 上线了\n\n
data: 用户 456 下线了\n\n

关键:这个响应是永不结束的(连接一直开着,服务器一直写)。这正是第 2 章”chunked 流式响应”的应用——服务器用 Transfer-Encoding: chunked 或”无限 body”来持续推送。

你:那 SSE 和 WebSocket 的区别?

墨叔:一句话总结(第 14 章会展开):

SSEWebSocket
方向单向(服务器 → 客户端)双向
底层纯 HTTP(复用现有的一切)协议升级后的独立协议
自动重连浏览器原生支持要自己做
复杂度低高

SSE 的定位是:“我只需要服务器推消息,不需要客户端回发,那我干嘛上 WebSocket 这么重的东西?” 这是”选对工具”的思维——不是所有实时场景都需要 WebSocket。


10.7 设计者视角:为什么”协商”是 HTTP 扩展性的又一面

墨叔:最后,把内容协商放到全书的框架里看。

内容协商揭示了一个和”首部扩展”(第 5 章)遥相呼应的设计思想:

HTTP 的很多”多样性”,不是靠”发明新协议”实现的,而是靠”让现有机制可协商”实现的。

  • 多语言?Accept-Language 协商,不用发明”语言版 HTTP”。
  • 多格式?Accept 协商,不用发明”JSON 专用协议”。
  • 压缩?Accept-Encoding 协商,不用发明”压缩版 HTTP”。

你:所以”协商”本质是”同一个东西,声明式地支持多种形态”?

墨叔:精确。而且注意——协商和缓存是深度耦合的(引子里小美的问题就是证据)。这引出一个关键结论:

内容协商改变了”同一个 URL 的响应”,所以它必须用 Vary 告诉缓存”我的内容依赖这些维度”。 否则就会出现引子里的缓存污染。

你:所以 Vary: User-Agent、Vary: Accept-Encoding 这些,是”协商”和”缓存”之间的桥梁?

墨叔:对。协商(让内容多样化)和缓存(让内容复用)是一对天生的矛盾。Vary 就是那个”告诉缓存’多样性按什么维度划分‘“的机制。你理解了”协商 + Vary + 缓存”这个三角,就真正理解了”同一个资源多种形态”这件事的全貌。


10.8 Go 落地:gzip 中间件与两个坑

墨叔:写一个 gzip 压缩中间件,顺便点两个坑:

func GzipMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 坑一:要检查客户端支不支持 gzip(Accept-Encoding)
        if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
            next.ServeHTTP(w, r)
            return
        }
        w.Header().Set("Content-Encoding", "gzip")
        w.Header().Add("Vary", "Accept-Encoding") // 坑二:必须加 Vary!
 
        gz := gzip.NewWriter(w)
        defer gz.Close()
        gzw := &gzipResponseWriter{ResponseWriter: w, Writer: gz}
        next.ServeHTTP(gzw, r)
    })
}

你:两个坑分别是什么?

墨叔:

  1. 不检查 Accept-Encoding 就强行 gzip:会发给”不支持 gzip 的老客户端”一堆乱码。
  2. 压缩了但没加 Vary: Accept-Encoding:CDN 会”缓存压缩版,然后把它发给不支持压缩的客户端”(或反过来)——这就是引子里”缓存全乱”的又一个版本。

记住:只要你的响应会因某个请求头而变化,就必须加对应的 Vary。 这是”协商”和”缓存”之间不能省的桥梁。


10.9 排查清单:内容协商与压缩问题

第 1 步:客户端声明了什么 Accept* 头?
        → 服务器尊重了吗?还是写死了格式?

第 2 步:Content-Encoding 和 Transfer-Encoding 用对了吗?
        → 想压缩却设了 Transfer-Encoding?分清

第 3 步:压缩了,加 Vary 了吗?
        → 没加 Vary: Accept-Encoding = 缓存污染

第 4 步:敏感动态内容,禁用压缩了吗?
        → BREACH 攻击风险

第 5 步:Range 请求,If-Range 处理了吗?
        → 断点续传时资源变了怎么办

思考题

第 1 题(概念)

Content-Encoding 和 Transfer-Encoding 的区别是什么?请用一个具体的报文说明:一个 gzip 压缩的 JSON,用 chunked 传输时,完整的响应头应该是怎样的?

第 2 题(概念)

内容协商有三种方式(服务端驱动/代理驱动/透明协商),为什么最终只有”服务端驱动”活了下来?请从成本的角度分析。

第 3 题(设计)

你要设计一个文件下载接口,要求支持:

  1. 大文件(几个 GB)断点续传
  2. 同一份文件可能被很多用户下载,要省服务器流量
  3. 下载过程中文件被更新了,不能出现”新旧内容拼接”的错误

请设计这个接口,说明要用到的机制(Range、ETag、If-Range、缓存等)和交互时序。

第 4 题(生活类比)

墨叔:餐厅点餐时,“顾客说忌口”和 HTTP 的”内容协商”,有什么相似之处?

提示:想想——

  1. 顾客说”我不吃辣”(类似 Accept)
  2. 服务员端上”不辣的版本”(类似 Content-Type 响应)
  3. 如果顾客说”我都行”(类似 */*)
  4. 如果餐厅做不了不辣的(类似 406)

请映射,并回答:为什么”顾客先说忌口、服务员再决定”比”服务员先做、顾客再抱怨”更高效?

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

内容协商让”同一个 URL 有多种形态”,但这和”缓存”(同一个 URL 应该能复用)产生了根本冲突。解决这个冲突的机制是 Vary。

现在请你深入思考:

  1. Vary 本质上是在做什么?(提示:它是在”URL”这个标识之上,叠加了什么?)
  2. 如果内容依赖的维度太多(Vary: User-Agent, Cookie, Accept, Accept-Encoding, ...),会发生什么?这暴露了”用 Vary 解决多样性与缓存冲突”的什么根本局限?
  3. 基于这个局限,你会怎么设计一个”既要多样性、又要缓存命中率”的系统?

参考答案

第 1 题答案

区别:Content-Encoding 描述”内容被压缩成了什么格式”(端到端,接收方要解压),Transfer-Encoding 描述”传输用了什么打包方式”(逐跳,接收方要去掉分段标记)。

一个 gzip 压缩的 JSON,用 chunked 传输的完整响应头:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: gzip          <- 内容:JSON 被 gzip 压缩了
Transfer-Encoding: chunked      <- 传输:用分块发送
 
<chunked 编码的 gzip 字节流>

处理顺序(接收方的视角):

  1. 先去掉 chunked 分段(处理 Transfer-Encoding),得到完整的”gzip 字节”。
  2. 再解压 gzip(处理 Content-Encoding),得到”JSON 文本”。
  3. 最后按 Content-Type: application/json 解析。

一句话:Transfer-Encoding 管”字节怎么送”,Content-Encoding 管”送的内容是什么格式”,一个在外层、一个在里层,嵌套的。

第 2 题答案

三种方式的核心成本差异,在于**“谁来承担决策的责任和往返成本”**:

  • 代理驱动(300 + 客户端挑):需要两次往返——客户端先发请求,服务器回 300 列选项,客户端再发一次选定。多一次 RTT,在高延迟网络下是灾难。而且客户端要实现”选表示”的逻辑,复杂。

  • 透明协商(缓存代理协商):需要中间缓存代理理解并参与协商,对中间设备要求极高,而现实中中间设备五花八门,无法保证都支持。

  • 服务端驱动(客户端声明、服务器决定):一次往返搞定。客户端在请求里顺手带上 Accept* 头(几乎零成本),服务器直接决定返回什么。成本最低、最简单、对中间设备无要求。

所以服务端驱动赢的本质:

它把”协商”这件事,压进了”已经存在的请求-响应”这个单次往返里,没有新增任何往返或中间设备要求。 而另外两种方式,要么多一次往返(代理驱动),要么多一个”必须聪明的中间设备”(透明协商)。

这和第 7 章”管线化失败”、第 1 章”简单决定采用率”是同一个规律:在协议设计里,“成本最低、对生态要求最低”的方案,几乎总是赢家。优雅但昂贵的方案,会死于现实。

第 3 题答案

机制组合:Accept-Ranges + Range + 206 + ETag + If-Range + Cache-Control。

交互时序:

客户端                                服务器
  |--- GET /files/big.zip ----------->|
  |                                   | 返回:200 OK
  |                                   | Accept-Ranges: bytes
  |                                   | ETag: "hash_v1"
  |                                   | Content-Length: 2147483648
  |                                   | Cache-Control: public, max-age=86400
  |<-- 200 + 部分内容(开始下载)------|
  |
  |(下载到 1GB 时,网络断了)
  |
  |--- GET /files/big.zip ----------->|
  |    Range: bytes=1073741824-       |
  |    If-Range: "hash_v1"            |
  |                                   | 情况 A:文件没变(ETag 还是 hash_v1)
  |                                   | → 206 + Content-Range: bytes 1073741824-2147483647/2147483648
  |<-- 206 + 剩余部分(续传成功)------|
  |
  |                                   | 情况 B:文件变了(ETag 变成 hash_v2)
  |                                   | → 200 + 完整新内容(忽略 Range)
  |<-- 200 + 全部新内容(重新下载)----|

每个机制的作用:

  1. Accept-Ranges: bytes:首次响应声明”支持断点”,客户端才知道能续传。
  2. Range: bytes=1073741824-:客户端声明”从 1GB 处继续”。
  3. 206 Partial Content + Content-Range:服务器返回”这是后半段”。
  4. ETag + If-Range:这是防”新旧拼接”的关键。续传时带上 If-Range: "hash_v1",意思是”如果文件还是 v1 这个版本,就给我续传;如果已经变成 v2,就别续传了,给我完整的新文件”。否则会出现”前半段是 v1 的、后半段是 v2 的”拼接出来的损坏文件。
  5. Cache-Control: public, max-age=86400:文件是共享的、相对不变的,允许 CDN 缓存,省服务器流量。

核心要点:断点续传不是”一个技巧”,而是”Range(增量)+ If-Range/ETag(一致性)+ 缓存(省流量)“三者的协同。 少了 If-Range,续传就可能拼出损坏的文件——这是很多”下载损坏”bug 的根因。

第 4 题答案

餐厅HTTP
顾客说”我不吃辣”Accept(客户端声明能接受什么)
服务员端上”不辣的版本”Content-Type(服务器声明给了什么)
顾客说”我都行”Accept: */*
餐厅做不了不辣的406 Not Acceptable

为什么”先说忌口、再决定”比”先做、再抱怨”更高效?

因为前者的信息流是”提前的、单向的、一次性的”:

  • 先问需求:顾客一句话”我不吃辣”,服务员就知道该做什么、不该做什么,一次做成。
  • 后问抱怨(先做再问):服务员做了一份辣的,顾客说”我不吃辣”,于是倒掉重做——浪费了一份菜、浪费了时间,还可能再来一轮。

翻译成 HTTP:

“客户端先声明 Accept,服务器再决定” = 一次往返、一次成功。而”服务器先固定返回一种,客户端收到后不匹配再报错重来” = 多次往返、反复浪费。

更深一层:这揭示了”协商”的价值本质——

协商是把”双方的偏好”提前对齐,用”一点声明成本”(一个 Accept 头)换掉”反复试错的成本”(多次往返、重复传输)。

在低延迟环境(餐厅里、同机房)下,这个优化不明显;但在高延迟、高成本的网络环境(互联网)里,“提前对齐”省下的每一次往返、每一份重复传输,累积起来就是巨大的收益。协议设计里的很多”声明式”机制(Accept、Vary、Cache-Control),本质都是在”用一点元数据,省掉大量的试错”。

第 5 题答案

1. Vary 本质上在做什么?

Vary 本质上是在**“URL”这个一维标识之上,叠加了额外的维度,把它扩展成一个多维的缓存键。**

  • 默认缓存键:URL(一维)。
  • 加了 Vary: Accept-Encoding 后,缓存键变成:URL × Accept-Encoding(二维)。
  • 加了 Vary: User-Agent 后:URL × Accept-Encoding × User-Agent(三维)。

换句话说:Vary 是在承认”URL 不足以唯一标识内容”,然后在缓存层用额外的维度去”补齐”这个不足。

2. 维度太多会怎样?暴露了什么局限?

维度太多,缓存键就爆炸——每个维度的每个取值组合,都是一个独立的缓存条目。极端情况下:

  • Vary: User-Agent:用户代理成千上万种 → 缓存命中率趋近 0(第 8 章讲过)。
  • Vary: Cookie:每个用户的 Cookie 都不同 → 等于每个用户一个缓存副本,共享缓存彻底失效。
  • Vary: Accept, Accept-Encoding, Accept-Language:三个维度相乘,条目数量是三个维度的笛卡尔积。

根本局限:

Vary 试图用”给缓存键加维度”来解决”内容依赖上下文”的问题,但”上下文”是无限维的,而缓存键的维度是有限的、且维度越多命中率越低。 所以 Vary 只能处理”低维度的、可枚举的”多样性(如压缩编码、语言),无法处理”高维度的、连续的”多样性(如每个用户的个性化内容)。

3. 怎么设计”既要多样性、又要命中率”的系统?

核心思路是**“分层缓存 + 拆分资源”**:

思路一:把”可共享的部分”和”个性化的部分”拆成两个资源。

  • 可共享的(文章正文、公共配置)→ 一个 URL,允许 CDN 缓存,命中率极高。
  • 个性化的(用户信息、收藏状态)→ 另一个 URL,private 缓存,或干脆动态请求不缓存。

这是最有效的办法:不要试图让”一个 URL”同时承载”公共内容”和”个性化内容”,而是把它们拆开。 公共的走共享缓存(省流量),个性的走私有缓存或动态(保证正确)。

思路二:把多样性”固化到 URL 里”,而不是用 Vary。

  • 与其 GET /app + Vary: Accept-Language,不如 GET /app/zh、GET /app/en——把语言变成 URL 的一部分。
  • 这样缓存键天然就是 URL(一维),命中率可控,而且明确。

思路三:理解”缓存只能在某个体积上生效”。

缓存命中率,取决于”内容的可共享度”。 内容越个性化(依赖越多上下文),可共享度越低,缓存越难命中。这不是 Vary 的错,而是”个性化”和”复用”的本质矛盾。要命中率,就要牺牲个性化;要个性化,就要接受命中率下降。

最终结论:

Vary 是”在 URL 上补维度”的权宜之计,适合”低维、可枚举”的多样性。真正高维的个性化,正确解法不是”加更多 Vary”,而是”拆分资源 + 让可共享部分走共享缓存”。 设计一个”既要多样性又要缓存”的系统,本质是仔细划分”什么能共享、什么不能共享”的边界——这个划分能力,就是第 8 章”私有 vs 共享缓存”在更高层次的运用。


一页纸小结

内容协商:客户端 Accept* 声明、服务器 Content-* 决定。三种方式里只有”服务端驱动”活下来,因为它零额外往返、零生态要求。

Content-Encoding vs Transfer-Encoding:前者是”内容压缩格式”(端到端,要解压),后者是”传输打包方式”(逐跳,去分段)。可以嵌套。

压缩:静态用 br(压一次服务万次),动态用 gzip(CPU 友好)。BREACH 攻击:压缩+加密+反射输入 = 泄露秘密。

Range 请求:断点续传。206 + Content-Range + If-Range(防新旧拼接)。

SSE:单向、纯 HTTP 的实时推送,是”不需要双向就别上 WebSocket”的轻量选择。

核心洞察:协商(多样性)和缓存(复用)是天生矛盾,Vary 是它们的桥梁——但 Vary 只适合低维多样性,高维个性化要”拆资源”。


下一章:内容、报文、连接都聊完了,但有一个贯穿始终的问题我一直没正面回答——你的数据,到底在哪一层被保护? 是 TCP?是 TLS?还是那个 Authorization 头?我要讲一个协议设计里最反直觉的结论:那个被寄予厚望的”认证方案” Digest,为什么最后死了? 答案会颠覆你对”技术优劣”的认知。