《互联网产品的服务端架构·产品、流量与基础能力》6.3 版本演进与兼容窗口

从服务端删掉一个字段导致三个月未升级的 App 白屏的事故写起,说明兼容窗口为什么由客户端版本分布决定而不是服务端想象,梳理新增、改名、删除、收紧校验和主版本升级的处理方式;并附版本解析、兼容判断与退役时间检查的 Go 实现、表驱动测试和真实运行输出。

三个月没升级的 App,在启动时白屏

「回声」4.0 重构了商品详情接口。旧响应里有一个字符串字段 coupon_expire_at,新版本把它改成了对象 coupon: {expire_at, amount},旧字段被直接删除。服务端团队的理由很充分:新客户端已经适配,旧字段没有其他调用方,留着只会让响应变胖。

发布当天晚上,客服收到一批奇怪的回访:用户打开商品详情,页面一片空白,底部没有报错,也没有重试按钮。排查发现,这些用户用的是 3.8 版本的 App。3.8 的 JSON 解析器把 coupon_expire_at 标成必填字段,字段缺失时反序列化直接失败,页面渲染层拿不到数据,就停在了白屏。

3.8 版本在当时占日活的 11%(案例假设值)。这个数字在发布前被看到过,但团队的判断是“用户会自己升级”。实际上,应用商店的自动更新有延迟,企业内部分发渠道更慢,还有一批用户关闭了自动更新。发布两周后,3.8 仍然占 7%。

更麻烦的是回滚。4.0 客户端已经开始写新格式的字段,服务端如果回滚到旧响应,新客户端会读不到 coupon 对象。团队只能紧急加回旧字段,让新旧字段并行一段时间。这次“删一个字段”的改动,最终变成了两周的双写和维护两套序列化逻辑。

兼容窗口由版本分布决定

兼容窗口不是一个拍脑袋的时间,比如“保留三个月”。它由客户端版本分布决定:只要还有不可忽略比例的用户停留在旧版本,服务端就不能做破坏性变更。

下面这张表把常见变更按兼容性拆开。窗口的最后一列不是固定天数,而是判断依据。

变更类型是否兼容服务端动作客户端动作窗口依据
新增可选字段是只加不删,给默认值忽略未知字段无需窗口
新增枚举值有条件确认客户端有 default 分支未知值降级展示一个客户端发布周期
字段改名否新旧字段双写,标记 deprecated迁移到新字段旧版本日活占比降到阈值
删除字段否先监控读取量,再下线升级到新版本同上,且需强制升级通道
收紧校验否先告警观察,再拦截按新规则提交同上
新增必填请求参数否服务端提供默认值升级后显式传参同上
提升主版本否新路径与新契约并行强制升级或迁移商店覆盖 + 强制提示

第一行是默认策略:只增不删,只加不改。新增字段对旧客户端是无害的,只要客户端忽略未知字段;这也是为什么 JSON 客户端应该容忍多余字段,而不是把响应结构写死。

枚举值要单独看。新增一个订单状态,对旧客户端可能是灾难:它不认识这个值,switch 没有 default 分支,页面就渲染成空白。兼容的前提是客户端对未知枚举有降级路径,否则“新增”也是破坏性变更。

字段改名和删除是同一类问题。服务端看到的是“字段没了”,客户端看到的是“解析失败”。正确做法是双写:旧字段和新字段同时返回,旧字段标记 deprecated,监控它的读取量;等旧版本占比降到阈值,再下线旧字段。

收紧校验同样危险。以前允许空字符串,现在要求非空;服务端觉得只是“更严格”,旧客户端却可能一直在发空值。这类变更要先以告警模式运行,观察一段时间,确认没有旧客户端受影响,再切换成拦截。

主版本变化是最后的手段。它意味着不兼容,需要新路径、新契约和强制升级提示。强制升级本身也有成本:用户可能直接卸载,所以要有过渡期和明确的升级理由。

版本号本身只需要两段。主版本表示破坏性变更,次版本表示向后兼容的功能新增,补丁号不影响兼容判断。把补丁号放进兼容逻辑,会让“1.2.3”和“1.2.4”这种内部修复也触发一轮客户端适配。

失败场景:删掉一个字段,老版本白屏

回到商品详情的例子,把时间线拆开看。

第一天,4.0 服务端上线,旧字段被删除。新客户端正常,3.8 客户端在商品详情页反序列化失败,白屏。因为错误发生在渲染层之前,页面上没有任何错误提示,用户以为网络卡了,反复下拉刷新。

第二天,客服工单上升。团队先怀疑 CDN 缓存,又怀疑某个机型,直到有人用 3.8 的安装包复现。此时距离发布已经过去 30 小时,期间所有 3.8 用户的商品详情都不可用。

第三天,团队尝试回滚服务端。但 4.0 客户端已经开始写新字段,回滚会让新客户端读不到 coupon。最终方案是加回旧字段,新旧并行,同时在新版本里加一个“检测到旧版本请升级”的提示。

这个事故的代价不只是两天的白屏。它还暴露了两个缺口:发布前没有查版本分布,发布后没有按客户端版本监控错误率。如果错误率按 app_version 拆分,3.8 的解析失败在半小时内就能被发现,而不是等到客服工单。

修复后的规则是:任何删除字段或改名的变更,必须同时提交三样东西——旧字段读取量监控、按版本拆分的错误率面板、以及旧版本的强制升级路径。缺一样就不允许上线。

用 ParseVersion 与 Compatible 表达版本规则

版本判断最容易被写成散落在各处的字符串比较。下面这段代码把解析、兼容判断和下线时间收进 internal/api,让网关和业务代码用同一套规则。

// file: internal/api/version.go
// tier: L1
// verify: go test ./internal/api/

package api

import (
	"fmt"
	"strconv"
	"strings"
	"time"
)

// Version 是产品对外承诺的主次版本。
// 只保留两段:补丁号不应该影响客户端兼容性判断。
type Version struct {
	Major int
	Minor int
}

// String 输出规范形式,用于日志和升级提示。
func (v Version) String() string {
	return fmt.Sprintf("%d.%d", v.Major, v.Minor)
}

// ParseVersion 只接受 "major.minor" 两段十进制整数。
// 前导零、多余段、负数都视为非法,避免 "1.02" 与 "1.2"
// 被当成两个不同版本,导致兼容判断出现分叉。
func ParseVersion(s string) (Version, error) {
	parts := strings.Split(s, ".")
	if len(parts) != 2 {
		return Version{}, fmt.Errorf("parse version %q: want major.minor", s)
	}
	major, err := parsePart(parts[0])
	if err != nil {
		return Version{}, fmt.Errorf("parse version %q: %w", s, err)
	}
	minor, err := parsePart(parts[1])
	if err != nil {
		return Version{}, fmt.Errorf("parse version %q: %w", s, err)
	}
	return Version{Major: major, Minor: minor}, nil
}

func parsePart(s string) (int, error) {
	n, err := strconv.Atoi(s)
	if err != nil || n < 0 || strconv.Itoa(n) != s {
		return 0, fmt.Errorf("invalid segment %q", s)
	}
	return n, nil
}

// Compatible 判断服务端能否继续服务该客户端。
// 同主版本内,服务端次版本不低于客户端即可;
// 主版本变化意味着破坏性变更,必须走新的接入点或迁移。
func Compatible(client, server Version) bool {
	if client.Major != server.Major {
		return false
	}
	return client.Minor <= server.Minor
}

// Sunset 声明某个版本的计划下线时间。
type Sunset struct {
	Version  Version
	RetireAt time.Time
}

// Available 判断版本在 now 是否仍可服务。
// 不可用时返回原因,调用方可以直接放进升级提示。
func Available(v Version, sunsets []Sunset, now time.Time) (bool, string) {
	for _, s := range sunsets {
		if s.Version != v {
			continue
		}
		if !now.Before(s.RetireAt) {
			return false, fmt.Sprintf("version %s retired at %s", v, s.RetireAt.Format(time.RFC3339))
		}
		return true, ""
	}
	return true, ""
}

ParseVersion 只接受 major.minor 两段。多一段、少一段、负数、前导零都会报错。前导零看起来无害,但 1.02 和 1.2 会被字符串比较当成两个版本,最后在兼容判断里分叉。解析阶段拒绝它们,比在比较阶段做归一化更便宜。

Compatible 的规则只有两行:主版本必须相同,客户端次版本不能高于服务端次版本。服务端次版本更高,说明服务端包含旧客户端需要的全部能力;客户端次版本更高,说明客户端期望的字段或行为服务端还没有,不能假装兼容。

主版本不同直接返回 false。这里没有“主版本高一点也兼容”的模糊地带;如果确实需要跨主版本服务,应该走独立的接入点或迁移层,而不是放宽这个函数。

Available 把“计划下线”变成可执行的判断。Sunset 记录版本和退役时间,now 由调用方传入,测试可以精确控制边界。到达 RetireAt 的那一刻就返回不可用,而不是等到第二天;原因字符串里带版本和退役时间,可以直接放进升级提示。

这三个函数都不读取全局时间,也不依赖环境变量。时间作为参数传入,版本作为值类型传递,测试不需要 mock,也不会因为时区不同出现“本机通过、线上失败”。

测试:边界日期与非法版本

版本逻辑的 bug 通常出现在边界:正好到达退役时间、客户端次版本比服务端高、字符串带空格或前导零。下面的测试把这些边界固定下来。

// file: internal/api/version_test.go
// tier: L1
// verify: go test ./internal/api/

package api

import (
	"strings"
	"testing"
	"time"
)

func mustVersion(t *testing.T, s string) Version {
	t.Helper()
	v, err := ParseVersion(s)
	if err != nil {
		t.Fatalf("ParseVersion(%q): %v", s, err)
	}
	return v
}

func TestParseVersion(t *testing.T) {
	cases := []struct {
		in   string
		want Version
	}{
		{in: "1.0", want: Version{Major: 1, Minor: 0}},
		{in: "1.12", want: Version{Major: 1, Minor: 12}},
		{in: "12.34", want: Version{Major: 12, Minor: 34}},
		{in: "0.0", want: Version{Major: 0, Minor: 0}},
	}
	for _, tc := range cases {
		got, err := ParseVersion(tc.in)
		if err != nil {
			t.Fatalf("ParseVersion(%q): %v", tc.in, err)
		}
		if got != tc.want {
			t.Fatalf("ParseVersion(%q) = %+v, want %+v", tc.in, got, tc.want)
		}
	}
}

func TestParseVersionRejectsMalformed(t *testing.T) {
	for _, in := range []string{"1", "1.2.3", "1.-1", "01.2", "1.02", "a.b", "", "1.2 "} {
		if _, err := ParseVersion(in); err == nil {
			t.Fatalf("ParseVersion(%q) = nil error, want error", in)
		}
	}
}

func TestCompatibleWithinSameMajor(t *testing.T) {
	cases := []struct {
		client string
		server string
		want   bool
	}{
		{client: "1.2", server: "1.5", want: true},
		{client: "1.5", server: "1.5", want: true},
		{client: "1.5", server: "1.2", want: false},
		{client: "2.0", server: "1.9", want: false},
		{client: "1.9", server: "2.0", want: false},
	}
	for _, tc := range cases {
		client := mustVersion(t, tc.client)
		server := mustVersion(t, tc.server)
		if got := Compatible(client, server); got != tc.want {
			t.Fatalf("Compatible(%s, %s) = %v, want %v", tc.client, tc.server, got, tc.want)
		}
	}
}

func TestAvailableBeforeAndAfterSunset(t *testing.T) {
	retire := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)
	sunsets := []Sunset{{Version: Version{Major: 1, Minor: 2}, RetireAt: retire}}

	ok, reason := Available(Version{Major: 1, Minor: 2}, sunsets, retire.Add(-time.Hour))
	if !ok || reason != "" {
		t.Fatalf("before sunset = %v, %q; want true, empty", ok, reason)
	}

	ok, reason = Available(Version{Major: 1, Minor: 2}, sunsets, retire)
	if ok {
		t.Fatal("version must be unavailable exactly at RetireAt")
	}
	if !strings.Contains(reason, "retired") || !strings.Contains(reason, "1.2") {
		t.Fatalf("reason = %q, want retired version 1.2", reason)
	}
}

func TestAvailableUnlistedAndOtherVersions(t *testing.T) {
	retire := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)
	sunsets := []Sunset{{Version: Version{Major: 1, Minor: 2}, RetireAt: retire}}
	now := retire.Add(24 * time.Hour)

	if ok, _ := Available(Version{Major: 1, Minor: 3}, sunsets, now); !ok {
		t.Fatal("version 1.3 has no sunset entry and must stay available")
	}
	if ok, _ := Available(Version{Major: 1, Minor: 2}, nil, now); !ok {
		t.Fatal("empty sunset list must keep the version available")
	}
}

TestParseVersion 覆盖四个合法输入,包括 0.0 和两位数的次版本。它同时验证 Version 是值类型,比较时不需要额外方法。

TestParseVersionRejectsMalformed 列出八种非法输入:缺次版本、多一段、负数、前导零、非数字、空字符串、带尾随空格。每一种都必须返回错误。前导零和尾随空格是最容易被放过的,因为 strconv.Atoi 对前者宽容,而调用方经常忘记 TrimSpace。

TestCompatibleWithinSameMajor 覆盖五种组合:服务端次版本更高、相同、更低,以及两个方向的主版本差异。它明确固定了“客户端 1.5 对服务端 1.2 不兼容”这条规则,防止有人为了让老客户端“先跑起来”把比较符号写反。

TestAvailableBeforeAndAfterSunset 检查三个时间点:退役前一小时可用、正好到达退役时间不可用、原因里包含版本号。正好到达那一刻的用例最重要,它防的是 now.After 和 now.Before 的边界写错。

TestAvailableUnlistedAndOtherVersions 验证没有配置退役计划的版本保持可用,以及空列表不会误伤任何版本。这两个用例保证 Available 的默认行为是“可用”,而不是“没配置就拒绝”。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/api/ -count=1 -run 'TestParseVersion|TestCompatible|TestAvailable' -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestParseVersion
--- PASS: TestParseVersion (0.00s)
=== RUN   TestParseVersionRejectsMalformed
--- PASS: TestParseVersionRejectsMalformed (0.00s)
=== RUN   TestCompatibleWithinSameMajor
--- PASS: TestCompatibleWithinSameMajor (0.00s)
=== RUN   TestAvailableBeforeAndAfterSunset
--- PASS: TestAvailableBeforeAndAfterSunset (0.00s)
=== RUN   TestAvailableUnlistedAndOtherVersions
--- PASS: TestAvailableUnlistedAndOtherVersions (0.00s)
PASS
ok  	webapp/internal/api	0.230s

-run 的正则只匹配本节的三组测试,避免分类器和错误码测试混进输出。0.230s 是一次本机运行的耗时,会随机器和负载变化,不能当成性能结论。

小结

兼容窗口由客户端版本分布决定,不由服务端的想象决定。默认策略是只增不删、只加不改;删除、改名、收紧校验和新增必填参数都属于破坏性变更,需要双写、监控和强制升级通道配合。

代码层面,ParseVersion 拒绝不规范输入,Compatible 用主版本和次版本表达兼容规则,Available 把退役时间变成可测试的判断。测试覆盖非法版本、主版本差异和退役边界,让版本策略在发布前就能被验证。

阅读导航:上一节:6.2 错误码与用户可感知的失败 · 下一节:7.1 静态与动态的分界 。

常见错误与边界

  • 用“保留三个月”当兼容窗口。窗口应由旧版本日活占比决定,而不是日历。
  • 直接删除字段。旧客户端可能把它当必填字段,解析失败后白屏,且没有错误提示。
  • 只按接口整体看错误率。不按 app_version 拆分,老版本的白屏会被新版本的成功掩盖。
  • 新增枚举值却不检查客户端 default 分支。旧客户端遇到未知值可能渲染空白。
  • 把前导零版本当成合法输入。1.02 与 1.2 会在字符串比较里分叉。
  • 用 now.After 判断退役。正好到达退役时间时仍然可用,边界会多服务一个窗口。
  • 主版本不同也判兼容。破坏性变更被掩盖,问题会推迟到某个字段真正缺失时才爆发。
  • 强制升级没有过渡期。用户可能直接卸载,升级提示需要理由和可操作的路径。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查