第 15 章 兼容性与演进:版本号、扩展点与不变量

HTTP 运行了 30 年,改了无数版,为什么没崩?答案不是”它设计得完美”,而是”它留足了演进的余地,又守住了不能动的地基”。这一章,是全书设计哲学的总纲。


15.0 引子:HTTP/2 上线半年,被迫回滚

老张:老哥,还记得去年我们把 API 网关升级成 HTTP/2 吗?上了半年,被迫回滚到 HTTP/1.1。

你:为什么?HTTP/2 不是更快吗?

老张:老客户端不兼容。我们有一批 2018 年部署的老客户端,它们遇到 HTTP/2 的二进制帧就崩溃。还有几个中间代理,把 h2 的流量当成异常给拦了。

你:那当初怎么没测出来?

墨叔:这背后是一个所有协议都逃不掉的终极问题——你怎么改一个正在被千万人使用的协议,而不炸掉那些”跟不上”的人? 今天这一章,把”兼容性与演进”这件事,给你讲到骨子里。


15.1 先把问题问对:协议演进,到底在解决什么矛盾

墨叔:协议演进,本质是解决一个根本矛盾:

协议需要”变”(因为需求在变、技术在变),但协议的使用者需要”稳定”(因为他们的代码、设备、基础设施都建立在”协议不变”的假设上)。

你:所以是”变的压力”和”稳定的需求”之间的拉扯?

墨叔:对。协议设计的所有”演进机制”,本质都是在回答一个问题:

怎么在不破坏”旧的使用者”的前提下,把”新东西”加进去?

这个问题,比”协议怎么设计”更根本。因为任何协议都会过时,但”演进的能力”决定了它过时后是”平滑升级”还是”崩盘”。


15.2 版本号:一个”看起来有用,其实很失败”的机制

墨叔:先讲最直觉的演进机制——版本号。然后告诉你一个反直觉的结论:HTTP 的版本号,其实是个失败的演进机制。

你:失败?HTTP/1.1、HTTP/2、HTTP/3 不都是靠版本号区分的吗?

墨叔:表面上是。但你想一个问题:

HTTP/1.1 用了多久才升级到 HTTP/2?

你:……1997 年到 2015 年,18 年。

墨叔:18 年!这 18 年里,HTTP 加了多少新功能?——Cache-Control、ETag、Vary、CORS、Retry-After、Range……加了几十个新功能,但版本号一个都没变。

这说明了什么?

你:说明……版本号根本没用?新功能都不靠版本号?

墨叔:对。HTTP 的新功能,绝大多数不是靠”升级版本号”引入的,而是靠”扩展点”(新首部、新方法、新状态码)引入的。 版本号只在”线上格式彻底换代”(文本→二进制→QUIC)这种根本性变革时才动一次。

为什么版本号不能频繁用?因为版本号的代价极高:

  1. 升级版本 = 所有实现都要改。HTTP/2 一来,所有服务器、客户端、代理都要支持二进制帧。
  2. 升级版本 = 碎片化风险。如果大家各自为政地升版本,就会冒出 HTTP/1.3、1.4、2.1 一堆不兼容的变体。
  3. 版本号是”核选项”:它意味着”不兼容”,所以只能”非必要不发射”。

你:所以版本号是”最后的手段”,不是”日常的手段”?

墨叔:精确。这是理解协议演进的关键:

版本号是”重武器”,只能用于”不兼容的根本变革”;日常的、兼容的演进,靠的是”扩展点”。


15.3 扩展点:真正让 HTTP 活 30 年的机制

墨叔:那”扩展点”是什么?就是 HTTP 里那些**“留了位置、可以往里加东西,而不破坏旧实现”**的地方。

回忆前面章节讲过的:

扩展点遇到未知时章节
方法返回 405/50103
状态码按 x00 降级04
首部忽略05
认证方案忽略(WWW-Authenticate 里声明)11

你:所以扩展点就是”方法、状态码、首部、认证方案”这些?

墨叔:对。而扩展点的精髓,不在”怎么加”,而在”旧实现遇到未知时怎么优雅降级”。

我们用第 4 章讲过的一句话,再加深一层:

一个扩展点好不好,不看它加东西多方便,看它面对未知时的行为有多可预测。

你:为什么”降级”比”扩展”更重要?

墨叔:因为扩展是少数人做的事,而降级是所有人都要面对的事。

  • 加一个新首部,只有你(想用这个功能的人)在做。
  • 但”遇到这个新首部”这件事,全世界所有旧实现都会遇到。

如果旧实现遇到新东西就崩溃,那”扩展”就成了”对全世界旧实现的攻击”——没人敢扩展,协议就死了。所以:

“优雅降级”是”可扩展”的前提。没有优雅降级,就没有可扩展性。 HTTP 活 30 年,靠的不是”它加了很多功能”,而是”它加的每一个功能,都不会炸掉旧世界”。


15.4 Postel 法则:救了 HTTP 无数次,也坑了它无数次

墨叔:讲一个贯穿全书的法则——Postel 法则(Robustness Principle,健壮性原则):

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

你:这个我听过,就是”严于律己,宽以待人”?

墨叔:对,但它远比这句话复杂,而且它救过 HTTP,也坑过 HTTP。我们拆开看。

它怎么救了 HTTP

想象 1990 年代:HTTP 刚流行,各种实现质量参差——有的多打个空格,有的少个 \r,有的把首部名写成大写。

如果 HTTP 严格——任何一点不规范都报错——那这些”半吊子实现”就全都无法互通。结果就是:大量网站打不开,协议推广受阻。

Postel 法则说”接收时宽容”:服务器容忍这些”不规范但能猜出来”的报文。于是:

  • 写得烂的客户端也能连上写得严的服务器。
  • HTTP 因为”宽容”而迅速普及。

这和第 1 章那句话是一个意思:一个协议的成功,取决于它允许多少人犯错还活下来。 Postel 法则就是”允许犯错”的具体实现。

它怎么坑了 HTTP

墨叔:但”宽容”是有代价的,而且代价会累积。

问题一:宽容会制造”事实标准”。 你今天”宽容地接受”了某个不规范的写法(比如裸 \n 代替 \r\n),那么这个不规范的写法,就慢慢成了”大家都这么发”的事实。十年后,你想”严格化”,发现全世界的实现都在发裸 \n,你严格化就等于拒绝全世界。宽容把”错误”固化成了”标准”。

问题二:宽容制造”实现不一致”。 “宽容”的边界是模糊的——“哪些不规范可以容忍、容忍到什么程度”,每个实现理解不一样。于是:

  • 实现 A 宽容 X,实现 B 不宽容 X。
  • 同一个报文,A 接受、B 拒绝。
  • 这恰恰是第 2 章”请求走私”的温床——“实现不一致”就是安全漏洞的土壤。

你:所以 Postel 法则是双刃剑?

墨叔:对,而且现代协议设计者对它的态度已经反转了。

现代的观点(尤其是 HTTP/2、TLS 1.3、QUIC 的设计者):

“宽容”已经被证明是错的。 因为”宽容”制造了”实现不一致”,而”实现不一致”是安全漏洞和演进障碍的根源。现代协议倾向于严格——“接收时也严格,不符合规范就报错”。

你:那 Postel 法则被推翻了?

墨叔:更准确的说法是:Postel 法则在”协议普及期”是对的,在”协议成熟期”是危险的。

  • 普及期(1990s):需要”宽容”来降低实现门槛,让协议迅速被采用。
  • 成熟期(现在):需要”严格”来消除”实现不一致”,保证安全和可演进性。

这是一个深刻的教训:

同一个设计原则,在不同阶段,可能是解药,也可能是毒药。 没有”永远正确的原则”,只有”在什么阶段、面对什么问题、该用什么原则”。


15.5 HTTP 的不变量:30 年没动过的地基

墨叔:讲完”变的机制”(版本号、扩展点),再讲”不变的东西”——HTTP 的不变量(Invariants)。

你:不变量?就是”30 年没变过的东西”?

墨叔:对。HTTP 改了无数版,但有一些东西从 0.9 到 3.0 从没变过,RFC 9110 把它们明确列为”不变量”:

不变量含义为什么不能变
方法语义GET 是”取”,DELETE 是”删”全世界的缓存、爬虫、中间设备都依赖这个语义
状态码分类4xx 是客户端错,5xx 是服务端错中间设备靠这个决定”要不要重试”
URI 语法scheme://host/path?query#frag所有解析器都依赖这个结构
首部名语法名: 值所有实现都按这个解析

你:所以”演进”是”在不变量之上,变可变的”?

墨叔:精确。这是协议设计的最高心法:

协议演进 = 守住不变量(不能动的地基)+ 扩展扩展点(可以长的部分)。 地基不动,才能在上面盖楼;扩展点开放,才能一直盖新楼。

你:那怎么判断”什么是不能动的地基,什么是能扩展的部分”?

墨叔:判断标准是——

“哪些东西,是全世界所有实现都依赖来’做决策’的?” 被依赖来做决策的,就是地基(动了,全世界的决策就错了);只是被”传递”的,就是扩展点(可以加新的)。

比如:

  • 状态码分类被依赖来做”重试决策”——这是地基,不能动。
  • 新的首部只是被”传递”(不认识就忽略)——这是扩展点,随便加。

一句话:让中间设备”看不懂也能安全转发”的东西,是可扩展的;让中间设备”必须看懂才能干活”的东西,是动不得的。 这和第 13 章”让中间设备透明”是同一个洞察。


15.6 协议僵化与 GREASE:扩展点为什么会”死”

墨叔:还有一个残酷的现实——扩展点本身会”僵化死亡”。

你:扩展点也会死?

墨叔:会。原因很讽刺:扩展点如果”长期没人用”,中间设备就会”忘记它应该被宽容对待”。

想象:HTTP 有一个扩展点,比如”新的首部”。如果大家 10 年都不往里面加新东西,那么:

  • 中间设备(代理、防火墙)的开发者,就会默认”不会有新的首部了”。
  • 于是他们把代码写成”只认已知的首部,未知的直接丢弃或报错”。
  • 等到你真的想加一个新首部时,发现沿途的中间设备会把它拦下来——扩展点”僵死”了。

你:所以扩展点需要”保持活跃”?

墨叔:对。这正是第 13 章提到的 GREASE 机制的用武之地:

GREASE = 定期往扩展点里塞”随机的、无意义的未知值”,逼中间设备保持”对未知宽容”的习惯。

Google 在 TLS、QUIC 里都用了 GREASE:实现者握手时故意发一些随机未知的扩展值,用来测试中间设备”是不是真的会忽略未知值”。

你:所以 GREASE 是”给扩展点做体检”?

墨叔:精确。它回答了一个协议演进里的深刻问题:

一个”预留了但长期不用”的扩展点,会慢慢失效。要让它保持”可用”,就得定期”使用”它,哪怕是无意义地使用。

这跟”肌肉不用就萎缩”是一个道理:扩展点的活性,靠”持续使用”来维持。 你不用它,中间设备就替你”关掉”它。


15.7 从 HTTP 学到的 10 条协议演进原则

墨叔:把这一章、以及前面 14 章的所有演进智慧,浓缩成 10 条原则。这是全书的”心法总纲”:

  1. 版本号是重武器,非必要不发射:日常演进靠扩展点,版本号只留给”不兼容的根本变革”。
  2. 扩展点比功能重要:预留”能加东西的位置”,比”一次加够所有功能”重要得多。
  3. 优雅降级比扩展重要:旧实现遇到新东西时不崩溃,才是”可扩展”的前提。
  4. 不变量是地基,扩展点是楼:被依赖来做决策的(方法语义、状态码分类)不能动;被传递的(首部)可以加。
  5. 宽容在普及期是解药,在成熟期是毒药:Postel 法则要分阶段看。
  6. 实现不一致是万恶之源:宽容制造不一致,不一致制造安全漏洞。
  7. 扩展点会僵化,需要 GREASE 保持活性:不用就萎缩。
  8. 让中间设备透明:中间设备越”看不懂”,协议越容易演进。
  9. 语义和语法解耦:语义可以几十年不变,语法可以彻底换掉(HTTP/1.1 → 2 → 3)。
  10. 协议的成功取决于它允许多少人犯错还活下来:能落地 > 正确。

你:这 10 条,我记下了。


15.8 Go 落地:一个”向后兼容”的 API 演进

墨叔:落到 Go 上。给一个”如何做向后兼容的 API 演进”的示例:

// 需求:给 /api/user 加一个新字段 "age",但不能破坏老客户端
 
type User struct {
    Name string `json:"name"`
    Age  int    `json:"age,omitempty"`  // omitempty:老客户端请求不带 age
    // 新字段用 omitempty,老客户端拿到时 age 为 0 就省略,不影响它解析
}
 
func GetUser(w http.ResponseWriter, r *http.Request) {
    // 版本策略:URL 版本化(冻结快照,第 6 章讲过)
    if strings.HasPrefix(r.URL.Path, "/v1/") {
        // 老版本:返回旧字段集
        json.NewEncoder(w).Encode(userV1{})
    } else {
        // 新版本:返回新字段集
        json.NewEncoder(w).Encode(userV2{})
    }
}

你:这里体现了哪些原则?

墨叔:

  1. omitempty = “向后兼容加字段”——加字段是安全的,删字段/改字段才是破坏性的(老客户端不认识新字段,忽略即可,呼应”未知忽略”)。
  2. URL 版本化 = “冻结快照”——/v1 和 /v2 是同一个资源的两个不可变快照(第 6 章第 5 题),老客户端永远能依赖 /v1 不变。

核心规则:

API 演进的三条铁律:只增不改(加字段安全)、URL 冻结(版本化)、老版本永不删(或给足迁移期 + Deprecation/Sunset 头)。


15.9 排查清单:兼容性/演进类问题

第 1 步:是"不兼容的变更"吗?
        → 删字段、改字段语义、改状态码含义 = 破坏性

第 2 步:有没有版本化/冻结?
        → URL 版本化了吗?老版本还活着吗?

第 3 步:老客户端遇到新东西会怎样?
        → 未知字段/首部能忽略吗?还是崩溃?

第 4 步:中间设备兼容吗?
        → 新协议流量被老代理拦了吗?

第 5 步:扩展点还"活着"吗?
        → 你要用的扩展点,中间设备还宽容吗?

思考题

第 1 题(概念)

为什么说”HTTP 的版本号是一个失败的演进机制”?HTTP 的日常演进真正靠的是什么?

第 2 题(概念)

Postel 法则(“发送时保守,接收时宽容”)为什么在现代协议设计中被反转了(现代更倾向严格)?请从”普及期 vs 成熟期”的角度解释。

第 3 题(设计)

假设你要给一个已经运行 5 年、有 200 万在线设备的协议,加一个必选字段(所有新功能都依赖它)。

请你设计这个演进方案,并回答:

  1. 直接加一个”必选字段”会发生什么?
  2. 正确的演进路径是什么?(提示:可选字段 → 双写 → 过渡期 → 再变成必选)
  3. 这个过程中,怎么处理”新老设备共存”的问题?

第 4 题(生活类比)

墨叔:给一座”正在使用的大楼”加装电梯,和”给一个正在使用的协议加功能”,有什么深刻的相似之处?

提示:想想——

  1. 大楼里有人住着,你不能拆了重建(对应协议的什么?)
  2. 加装电梯不能影响现有住户的生活(对应什么?)
  3. 大楼的地基不能动(对应什么?)
  4. 电梯井道要预留位置(对应什么?)

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

15.5 节说:协议演进 = 守住”不变量”(被依赖来做决策的地基)+ 扩展”扩展点”(被传递的部分)。

请你深入思考一个边界情况:

  1. 有没有什么东西,曾经是”扩展点”(可以随便加),后来慢慢变成了”不变量”(被依赖来做决策)?举例说明。
  2. 这个”从扩展点变成不变量”的过程,对协议设计者来说意味着什么风险?
  3. 这提醒我们,在”预留扩展点”时,应该刻意注意什么?

参考答案

第 1 题答案

为什么版本号失败:HTTP/1.1 从 1997 年用到 2015 年,18 年间加了大量新功能(缓存、压缩、CORS、Range…),但版本号一个都没变。说明新功能根本不靠版本号。

版本号是”重武器”——升级版本意味着”不兼容”,需要所有实现同步更新,代价极高、极易碎片化,所以只能”非必要不发射”。

日常演进真正靠的是”扩展点”:方法、状态码、首部、认证方案这些”留了位置、可以往里加东西、而旧实现遇到未知能优雅降级”的地方。

一句话:版本号是”革命”(格式换代),扩展点是”改良”(日常加功能)。HTTP 活 30 年,靠的是无数个”改良”,而不是几次”革命”。

第 2 题答案

Postel 法则为什么被反转:

  • 普及期(1990s)需要宽容:HTTP 刚流行,实现质量参差(多空格、少 \r、大小写混乱)。如果严格,这些”半吊子实现”全都无法互通,HTTP 推广受阻。宽容 = 降低实现门槛 = 快速普及。

  • 成熟期(现在)需要严格:当 HTTP 已经无处不在,宽容的代价开始反噬:

    1. 宽容制造”事实标准”——你宽容地接受的不规范写法,会慢慢变成”大家都这么发”,你想严格化都严格不了。
    2. 宽容制造”实现不一致”——“哪些能宽容、宽容到什么程度”每个实现理解不同,导致同一个报文 A 接受、B 拒绝。而”实现不一致”是请求走私等安全漏洞的根源。

结论:同一个原则,在不同阶段是解药还是毒药。 普及期,宽容降低门槛;成熟期,宽容制造漏洞。现代协议(HTTP/2、TLS 1.3、QUIC)因此倾向于”严格”——从源头上消除”实现不一致”。

第 3 题答案

1. 直接加”必选字段”会发生什么:

灾难。200 万设备里有大量的老设备不知道这个新字段。如果你把它设为”必选”,老设备发的报文没有这个字段,服务器就会拒绝——200 万设备瞬间掉线。更糟的是,老设备可能永远无法升级(IoT 设备、已售出的硬件),你等于”把自己锁死”。

2. 正确的演进路径(渐进式):

阶段 1:把新字段设为【可选】,老设备不带也能工作
        → 服务器:没这个字段时,用默认值兜底

阶段 2:新设备【双写】——同时发新字段和旧字段(如果有旧字段)
        → 保证过渡期两边都读得到

阶段 3:过渡期——服务器同时接受"有字段"和"没字段"
        → 统计老设备占比,等它降到可接受范围

阶段 4:新字段才【升级为必选】,但只对"声明支持新版本"的设备
        → 用版本协商(老设备走老逻辑,新设备走新逻辑)

3. 新老设备共存的处理:

关键是**“版本协商”或”能力协商”——让服务器知道每个设备支持到什么程度**,分别对待:

  • 老设备 → 走老逻辑(不依赖新字段)。
  • 新设备 → 走新逻辑(依赖新字段)。

核心原则:

任何”必选”的变更,都不能一次性强制。必须走”可选 → 双写 → 过渡 → 升级”的渐进路径。 因为”必选”意味着”不兼容”,而对 200 万无法升级的设备,不兼容就是灾难。

第 4 题答案

给大楼加电梯给协议加功能
大楼有人住,不能拆了重建协议被千万人用,不能”不兼容地改”
加电梯不能影响现有住户加功能不能破坏旧实现(优雅降级)
地基不能动不变量不能动(方法语义、状态码分类)
电梯井道要预留位置扩展点要预留(方法、首部、状态码)

深刻的相似之处:

  1. “在用”决定了一切:大楼和协议,都因为”正在被使用”而无法推倒重来。任何改动都必须在”不中断现有使用”的前提下进行。这是”演进”和”设计”的根本区别——设计是白纸作画,演进是带人施工。

  2. “预留”是远见:有远见的建筑设计师,会预留电梯井道(哪怕现在不装电梯)。有远见的协议设计者,会预留扩展点(哪怕现在用不上)。预留的成本,远低于事后改造的成本。

  3. “地基 vs 可改部分”的划分是核心智慧:大楼的地基动不得,协议的不变量动不得。知道”什么不能动”,比知道”什么能改”更重要——因为动了地基,楼会塌;动了不变量,协议会崩。

一句话:协议演进 = 带人施工。你要在”不塌楼(守不变量)、不扰民(优雅降级)、留井道(预留扩展点)“的三重约束下,把新功能加进去。

第 5 题答案

1. “扩展点变成不变量”的例子:

  • Host 首部:HTTP/1.0 时代,Host 是可选的(因为一个 IP 只对应一个网站)。后来虚拟主机普及(一个 IP 挂多个域名),Host 成了必选——HTTP/1.1 强制要求,且中间设备(CDN、反向代理、路由)依赖 Host 来做路由决策。它从”扩展点”变成了”不变量”。

  • Content-Length/Transfer-Encoding:早期是”可选的新特性”,现在中间设备(代理、网关)依赖它来定位报文边界,已经成了”动不得的地基”。

2. “扩展点变不变量”的风险:

这是协议演进里最隐蔽的风险——“你当初随手预留的、以为可以随便改的扩展点,某天发现自己已经’被依赖’了,改不动了。”

具体风险:

  1. 回不去了:一旦中间设备开始”依赖”某个扩展点做决策,你想改它、废弃它、甚至调整它的语义,都会破坏这些中间设备。
  2. 设计欠债:扩展点当初是”随便预留的”,没经过”不变量”级别的严谨设计。等它变成不变量,它的设计缺陷(考虑不周、语义含糊)就成了永远的包袱。

3. 预留扩展点时应刻意注意什么:

从第一天起,就要假设”这个扩展点将来可能变成不变量”,然后以”不变量”的标准来设计它。

具体三条:

  1. 语义要清晰:扩展点的语义要足够清晰、无歧义,这样即使将来”被依赖”,也不会因为”语义含糊”造成实现不一致。
  2. 留好升级路径:预留时就考虑”如果它将来要升级/废弃,怎么平滑过渡”(比如字段加版本、加弃用标记)。
  3. 谨慎地”被依赖”:当你发现某个扩展点开始”被中间设备依赖”时,要意识到——它正在从”可改”变成”不可改”,此刻要格外谨慎地”定死”它的语义。

最终升华:

协议设计里,“扩展点”和”不变量”不是静态的分类,而是动态的演化。一个扩展点,会随着”被依赖的程度”加深,而不可逆地”凝固”成不变量。 所以真正的高手,不是”多预留扩展点”(数量),而是**“对每个扩展点,都按’它将来会变成不变量’来谨慎设计”(质量)**。因为——你预留的每一个扩展点,都可能是下一个你改不动的”地基”。


一页纸小结

版本号是重武器:HTTP/1.1 用了 18 年没升级,日常演进靠”扩展点”(方法/状态码/首部)。

扩展点的精髓在”降级”:旧实现遇到新东西时优雅降级(405/501、x00、忽略),才是”可扩展”的前提。

Postel 法则的分阶段:普及期”宽容”是解药(降门槛),成熟期”宽容”是毒药(制造实现不一致→安全漏洞)。现代倾向严格。

不变量 vs 扩展点:被依赖来做决策的(方法语义、状态码分类)是地基,不能动;被传递的(首部)是扩展点,随便加。

扩展点会僵化:长期不用就萎缩,GREASE 靠”定期塞未知值”保持中间设备”对未知宽容”的习惯。

10 条演进原则:版本号重武器、扩展点比功能重要、降级比扩展重要、不变量是地基、宽容分阶段、实现不一致是万恶之源、GREASE 保活性、让中间设备透明、语义语法解耦、能落地大于正确。


下一章:理论讲完了,现在到了全书最有意思的部分——动手。我要带你从零设计一个协议,走完”从需求到字节”的完整推演。你会第一次体会到:那些你在这 15 章里学到的每一个概念(边界、幂等、错误码、扩展点、不变量),在你亲手设计时,是怎么一个个跳出来逼你决策的。