《互联网产品的服务端架构·规模化与架构演进》13.1 从 MVP 到规模化的路线图

「回声」在增长之后同时列出了分片、多地域和服务化三件事,团队却说不清先做哪个。本节把规模化拆成五个不可跳步的阶段,用可验收的退出条件替代目标口号,并给出阶段校验与下一步计算的 Go 实现和测试,复盘一次跳过可观测性直接分片、故障三天无人定位的事故。

增长之后,最先出现的是一张没有顺序的清单

「回声」从单机 MVP 走到日活三十万,用了两年(案例假设值)。第三年年初,老板把技术负责人叫去,说接下来要“规模化”:要能扛住大促、要开第二个区域、要把现在这个单体拆开。会议结束,白板上留下三行字:分库分表、多地域部署、服务化拆分。

团队按这三行字分别拉了三个人做预研。两周后,三份方案摆上桌,每一份都合理,但没有任何一份说明:这三件事之间是什么关系,先做哪件,做完哪件才算真的做完。负责分片的同学说“随时可以开始”,负责多地域的同学说“等分片完成”,负责服务化的同学说“等团队扩到二十人”。三份方案互相等待,最后谁也没动。

这类停滞不是执行力问题,而是路线图缺失。团队有一张目标清单,却没有一张带顺序、带退出条件的路线图。目标回答“想去哪”,路线图回答“怎么知道已经离开上一站”。没有后者的团队,通常会在某个深夜跳过一站,然后在下一站用事故补票。

这一节不谈具体中间件,而是谈顺序:哪些能力是其他能力的前置,每个阶段的出口应该用什么可验收的事实来定义,以及怎样把这些约束写成一个可以放进 CI 的小函数。

路线图是阶段与退出条件的组合

把「回声」当时能想到的所有事情摊开,会发现它们并不是同一层的东西。可观测性、发布纪律、读写分离、缓存、异步化、服务拆分、分片、多地域、成本治理,有的属于“让系统可被理解”,有的属于“让系统可被扩展”,有的属于“让系统可被信任”。把它们混在一张待办列表里,优先级永远吵不完。

更可操作的拆法是按“离开这一阶段必须证明什么”来分层。下面这张表是「回声」最终采用的版本,阶段编号只表示顺序,不表示重要程度:

阶段进入前提退出条件(全部可验收)典型误区
1 单机可运营有真实用户与真实数据一键部署、一键回滚、每日备份可恢复、核心链路有错误日志把“能跑”当成“可运营”
2 可观测与发布纪律阶段 1 的退出条件全部有证据核心接口有 SLO 与告警、发布可灰度、故障有值班手册先买监控大盘,不定义告警阈值
3 读写分离与缓存能说清读放大来自哪里副本延迟有监控、缓存击穿有预案、数据一致性口径已写文档先加缓存,再补一致性说明
4 拆分与异步化已有按域的调用量数据拆分后的服务有独立发布与回滚、异步任务可重放、跨服务契约有版本按团队人数拆服务,而不是按变化频率
5 分片与多地域单库瓶颈已被数据证明路由版本化、扩容演练完成、跨地域故障切换有演练记录把分片当成性能银弹

这张表最重要的不是内容,而是“进入前提”这一列。阶段 3 的前提不是“觉得数据库慢”,而是阶段 2 已经能回答“慢在哪、慢多少、影响谁”。没有这个前提,读写分离和缓存只是把问题挪到另一个地方。阶段 4 的前提是阶段 3 已经让读路径稳定,否则拆分只会把不稳定的读放大成跨服务调用。阶段 5 的前提是单库瓶颈已经被数据证明,而不是被增长曲线吓出来。

案例里的数字都只是假设值:三十万日活、单库峰值每秒三千次查询、主从延迟高峰期八秒。真实项目应该用自己的监控重新填这张表。数字会变,顺序的逻辑不变:先让系统可被观察,再让它可被扩展,最后才让它分布到更多机器上。

退出条件必须是事实,不是形容词

“监控完善”“性能达标”“架构清晰”都不是退出条件,因为它们无法在评审会上被证伪。可验收的退出条件通常长这样:一条可以在十分钟内执行的命令、一份带时间戳的演练记录、一个在故障注入后自动恢复的测试。

以阶段 2 为例,「回声」把退出条件写成四条:核心接口的 p99 有连续七天的曲线;任何一条告警都能指向具体的负责人;发布可以在五分钟内回滚到上一版本;值班同学在没有原作者帮助的情况下处理过一次真实告警。前三条是系统能力,第四条是人的能力。少了第四条,监控再漂亮也只是装饰。

退出条件还要区分“完成”和“持续满足”。备份可恢复是一次性演练,SLO 告警是持续能力。前者可以在阶段评审时打勾,后者一旦失效就应该把阶段状态退回。路线图不是爬楼梯,它允许后退,而且必须允许。

不可跳步不等于不能并行

“不可跳步”常被误解成“必须串行”。实际上,阶段 4 的服务拆分预研可以在阶段 2 就开始,阶段 5 的容量模型也可以在阶段 3 就建立。不能跳的是“进入前提”:你可以提前设计,但不能提前宣布完成。

一个实用的做法是给每个阶段维护三种状态:未开始、进行中、已退出。“进行中”允许跨阶段并行,“已退出”只认退出条件的证据。这样既不会因为流程僵化拖慢团队,也不会出现“做了分片所以规模化完成”的自我安慰。

一次跳过可观测性的分片事故

事故发生在一次大促前。团队觉得阶段 2 的监控“差不多有了”,直接把精力投到分片。十六个库建好,路由改成按用户哈希,写入和读取都切到新逻辑。上线第一周很平静,因为流量还没到峰值。

大促当天下午,客服开始收到“订单列表为空”的反馈。研发登录后台,按用户查订单,返回空数组;再按订单号查,数据在。第一反应是缓存问题,清了缓存,没用;第二反应是读写分离延迟,切了主库,还是没用。由于没有按分片维度的错误率和延迟指标,监控大盘上只有一个总体成功率,而总体成功率只掉了两个百分点,被大促的正常波动掩盖了。

真正的问题是路由版本:一部分历史订单是按旧的分片规则写入的,读路径已经统一用新规则。团队没有在每个分片上记录“最近一次成功读取的时间”,也没有按分片对比写入量与读取量,所以无法快速判断“哪个库的数据从来没被读到”。等定位到原因,已经过去了三天。回滚读路径会让大促期间的新订单读不到,团队只能同时维护两套路由,按订单创建时间分流。

这次事故的代价不只是三天客服压力。它让团队在接下来的半年里都不敢再动分片规则,扩容计划整体推迟。根因不是哈希算法,也不是中间件选型,而是路线图跳过了阶段 2:在系统还不能回答“哪个分片出了问题”的时候,就进入了阶段 5。

反过来看,如果阶段 2 的退出条件真的满足,定位会快得多:按分片的读取量曲线会显示某些库的读请求全部落空;按分片的错误日志会显示“订单不存在”集中在特定路由版本;值班手册会要求先比对路由版本,再动数据。这些能力不产生新功能,但它们决定了故障是“一小时”还是“三天”。

把阶段约束写成可执行的校验

路线图放在文档里,最大的风险是它只被读一次。更稳的做法是把结构约束写成代码:阶段顺序唯一、每个阶段至少有一个退出条件、下一步永远按顺序取第一个未完成阶段。这样路线图可以被测试,也可以被工具消费。

下面的实现刻意不包含排期、负责人和进度百分比。那些是项目管理信息,变化快且依赖组织;阶段与退出条件才是架构约束,应该在代码评审里被固定下来。

// file: internal/scalebase/roadmap.go
// tier: L1
// verify: go test ./internal/scalebase/

package scalebase

import (
	"fmt"
	"sort"
)

// Stage 是规模化路线图上的一个阶段。
// Order 决定先后顺序,Exit 列出离开该阶段必须同时满足的退出条件。
type Stage struct {
	Name  string
	Order int
	Exit  []string
}

// Validate 检查路线图的结构约束。
// 它只关心“路线图是否自洽”,不关心某个团队实际走到了哪一步:
// 顺序必须唯一且为正,阶段名必须唯一,每个阶段至少有一个退出条件。
func Validate(stages []Stage) error {
	if len(stages) == 0 {
		return fmt.Errorf("scalebase: roadmap is empty")
	}
	orders := make(map[int]string, len(stages))
	names := make(map[string]struct{}, len(stages))
	for i, s := range stages {
		if s.Name == "" {
			return fmt.Errorf("scalebase: stage at index %d has empty name", i)
		}
		if _, ok := names[s.Name]; ok {
			return fmt.Errorf("scalebase: duplicate stage name %q", s.Name)
		}
		names[s.Name] = struct{}{}
		if s.Order <= 0 {
			return fmt.Errorf("scalebase: stage %q has non-positive order %d", s.Name, s.Order)
		}
		if other, ok := orders[s.Order]; ok {
			return fmt.Errorf("scalebase: stages %q and %q share order %d", other, s.Name, s.Order)
		}
		orders[s.Order] = s.Name
		if len(s.Exit) == 0 {
			return fmt.Errorf("scalebase: stage %q has no exit condition", s.Name)
		}
		for j, e := range s.Exit {
			if e == "" {
				return fmt.Errorf("scalebase: stage %q exit condition %d is empty", s.Name, j)
			}
		}
	}
	return nil
}

// Next 返回按 Order 排序后第一个尚未完成的阶段。
// done 以阶段名为键;传 nil 表示一个阶段都没完成。
// 所有阶段都完成时返回错误,因为“没有下一步”本身就是路线图走完的信号,
// 调用方不应该把它和“还没开始”混为一谈。
func Next(stages []Stage, done map[string]bool) (Stage, error) {
	if err := Validate(stages); err != nil {
		return Stage{}, err
	}
	ordered := make([]Stage, len(stages))
	copy(ordered, stages)
	sort.Slice(ordered, func(i, j int) bool { return ordered[i].Order < ordered[j].Order })
	for _, s := range ordered {
		if !done[s.Name] {
			return s, nil
		}
	}
	return Stage{}, fmt.Errorf("scalebase: all %d stages are done", len(ordered))
}

Validate 把“顺序唯一”当成硬约束,而不是建议。两个阶段共用一个序号,意味着“下一步”没有确定答案;这种模糊性在文档里可以靠会议纪要掩盖,在代码里必须报错。要求序号为正,是为了拒绝“用 0 表示还没排期”的临时约定,因为这种约定一旦出现,排序结果就会把未排期阶段排到最前面。

Next 先复制再排序,不修改调用方传入的切片。这个细节看起来无关紧要,但路线图通常来自配置加载,多个团队可能同时读取同一份切片;原地排序会让一个团队的调用改变另一个团队看到的顺序。返回 Stage{} 和错误,而不是返回 nil,是因为 Stage 是值类型,调用方不需要额外判空。

函数没有校验退出条件是否“可验收”。这是有意的:一段文字是否可验收,机器判断不了,只能在阶段评审时由人确认。代码能做的是保证每个阶段至少写了退出条件,并把“没写”的情形挡在评审之前。把机器能做的和只能人做的分开,是这类小工具最重要的边界。

测试:顺序、退出条件与“全部完成”

路线图的测试不需要覆盖业务,只需要锁住三个行为:合法路线图能通过校验;坏结构会被拒绝;下一步永远按顺序取第一个未完成阶段。

// file: internal/scalebase/roadmap_test.go
// tier: L1
// verify: go test ./internal/scalebase/

package scalebase

import "testing"

func sampleRoadmap() []Stage {
	return []Stage{
		{Name: "single-node", Order: 1, Exit: []string{"one deployable binary", "restore tested"}},
		{Name: "split-read", Order: 2, Exit: []string{"replica lag < 2s"}},
		{Name: "sharded", Order: 3, Exit: []string{"routing versioned", "rebalance rehearsed"}},
	}
}

func TestValidate(t *testing.T) {
	if err := Validate(sampleRoadmap()); err != nil {
		t.Fatalf("valid roadmap: %v", err)
	}
	if err := Validate(nil); err == nil {
		t.Fatal("want error for empty roadmap")
	}
	if err := Validate([]Stage{{Name: "a", Order: 1, Exit: []string{"x"}}, {Name: "b", Order: 1, Exit: []string{"y"}}}); err == nil {
		t.Fatal("want error for duplicate order")
	}
	if err := Validate([]Stage{{Name: "a", Order: 1, Exit: []string{"x"}}, {Name: "a", Order: 2, Exit: []string{"y"}}}); err == nil {
		t.Fatal("want error for duplicate name")
	}
	if err := Validate([]Stage{{Name: "a", Order: 0, Exit: []string{"x"}}}); err == nil {
		t.Fatal("want error for non-positive order")
	}
	if err := Validate([]Stage{{Name: "a", Order: 1}}); err == nil {
		t.Fatal("want error for stage without exit condition")
	}
	if err := Validate([]Stage{{Name: "a", Order: 1, Exit: []string{""}}}); err == nil {
		t.Fatal("want error for empty exit condition")
	}
	if err := Validate([]Stage{{Order: 1, Exit: []string{"x"}}}); err == nil {
		t.Fatal("want error for empty stage name")
	}
}

func TestNextFollowsOrder(t *testing.T) {
	stages := []Stage{
		{Name: "sharded", Order: 3, Exit: []string{"routing versioned"}},
		{Name: "single-node", Order: 1, Exit: []string{"restore tested"}},
		{Name: "split-read", Order: 2, Exit: []string{"replica lag < 2s"}},
	}
	got, err := Next(stages, nil)
	if err != nil {
		t.Fatalf("Next(nil done): %v", err)
	}
	if got.Name != "single-node" {
		t.Fatalf("first stage = %q, want single-node", got.Name)
	}
	got, err = Next(stages, map[string]bool{"single-node": true})
	if err != nil {
		t.Fatalf("Next(after single-node): %v", err)
	}
	if got.Name != "split-read" {
		t.Fatalf("second stage = %q, want split-read", got.Name)
	}
}

func TestNextAllDone(t *testing.T) {
	done := map[string]bool{"single-node": true, "split-read": true, "sharded": true}
	if _, err := Next(sampleRoadmap(), done); err == nil {
		t.Fatal("want error when every stage is done")
	}
}

func TestNextRejectsInvalidRoadmap(t *testing.T) {
	if _, err := Next(nil, nil); err == nil {
		t.Fatal("want error for empty roadmap")
	}
	if _, err := Next([]Stage{{Name: "a", Order: 1}}, nil); err == nil {
		t.Fatal("want error for stage without exit condition")
	}
}

TestNextFollowsOrder 故意把阶段按 3、1、2 的顺序传入,再断言返回的是 1 号阶段。这个用例能抓住“直接遍历输入顺序”的实现错误,而按顺序传入的测试很难发现这种问题。真实配置来自 YAML 或 JSON,书写顺序并不保证等于执行顺序。

TestNextAllDone 把“全部完成”当成错误,而不是返回零值阶段。这个选择会影响调用方:如果返回零值,调用方可能把一个空阶段名当成正常结果继续执行;返回错误则强迫调用方显式处理“路线图走完”这个状态。对于工具函数,早失败通常比隐式默认值更安全。

测试没有覆盖“阶段数量很多”的性能场景。路线图通常只有个位数阶段,排序开销可以忽略;如果未来阶段数量增长到需要性能优化,应该先确认调用频率,而不是提前把实现复杂化。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/scalebase/ -run 'TestValidate|TestNext' -count=1 -v

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

=== RUN   TestValidate
--- PASS: TestValidate (0.00s)
=== RUN   TestNextFollowsOrder
--- PASS: TestNextFollowsOrder (0.00s)
=== RUN   TestNextAllDone
--- PASS: TestNextAllDone (0.00s)
=== RUN   TestNextRejectsInvalidRoadmap
--- PASS: TestNextRejectsInvalidRoadmap (0.00s)
PASS
ok  	webapp/internal/scalebase	0.230s

四个用例全部通过,退出码为 0。-run 只筛选了路线图相关的用例,同包的健康度与发现汇总测试没有执行;需要全量确认时去掉 -run 再跑一次。

小结

规模化的难点不是列出要做的事,而是承认有些事情必须等另一些事情完成。路线图的价值在于把这种“等待”写清楚:每个阶段有进入前提,有可验收的退出条件,有明确的下一步。跳过阶段不会让进度变快,只会把成本推迟到一次更难排查的故障里。

「回声」那次三天的事故,本质上是把阶段 5 当成了阶段 2 的替代品。系统能分片,不代表团队能看见分片;能看见分片,才谈得上在分片之上做容量和故障决策。顺序不是官僚流程,它是故障排查能力的累积。

下一节把这张路线图变成一次自评:用哪些维度打分、权重怎么定、分数应该用来排序还是排名。

阅读导航:上一节:目录与学习路径 · 下一节:13.2 架构健康度自评 。

常见错误与边界

  • 把目标清单当路线图。只有“要做分片”“要做多地域”,没有进入前提和退出条件,团队无法判断何时可以开始下一件事。
  • 用形容词定义退出条件。“监控完善”“性能达标”无法验收,应改成带阈值、带时间窗口、带责任人的事实。
  • 让序号表达优先级。阶段序号只表示先后,不表示重要程度;混用两种含义会让排序结果自相矛盾。
  • 认为不可跳步等于必须串行。可以提前预研和设计,但不能提前宣布阶段完成。
  • 只写退出条件,不写进入前提。没有前提的阶段会被“感觉可以了”触发,最终在下一阶段暴露缺口。
  • 把路线图锁在文档里。没有校验和测试的路线图会被复制成多个版本,最后没人知道哪份有效。
  • 忽略“人的退出条件”。值班同学能否独立处理告警,和监控是否存在同样重要。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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