增长之后,最先出现的是一张没有顺序的清单
「回声」从单机 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 架构健康度自评 。
常见错误与边界
- 把目标清单当路线图。只有“要做分片”“要做多地域”,没有进入前提和退出条件,团队无法判断何时可以开始下一件事。
- 用形容词定义退出条件。“监控完善”“性能达标”无法验收,应改成带阈值、带时间窗口、带责任人的事实。
- 让序号表达优先级。阶段序号只表示先后,不表示重要程度;混用两种含义会让排序结果自相矛盾。
- 认为不可跳步等于必须串行。可以提前预研和设计,但不能提前宣布阶段完成。
- 只写退出条件,不写进入前提。没有前提的阶段会被“感觉可以了”触发,最终在下一阶段暴露缺口。
- 把路线图锁在文档里。没有校验和测试的路线图会被复制成多个版本,最后没人知道哪份有效。
- 忽略“人的退出条件”。值班同学能否独立处理告警,和监控是否存在同样重要。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。