三个月没升级的 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判断退役。正好到达退役时间时仍然可用,边界会多服务一个窗口。 - 主版本不同也判兼容。破坏性变更被掩盖,问题会推迟到某个字段真正缺失时才爆发。
- 强制升级没有过渡期。用户可能直接卸载,升级提示需要理由和可操作的路径。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。