58.4 演进路线图

一句话

路线图不是甘特图,而是从当前状态到目标状态的分波迁移计划;每一波都要有可验证的退出标准与回滚点。

先破除四个误区

误区 现实
“路线图 = 项目列表” 项目是“做什么”,路线图是“先变成什么、再变成什么”;顺序错了,做完也不成体系
“大爆炸式替换” 一次性替换风险最高、回滚最难。能分波就不要一次做完
“架构师画图,团队执行” 路线图要按团队容量排,不按架构师的理想排;否则永远延期
“路线图定了一年不变” 业务、容量、依赖都在变;路线图要季度校准,方向稳定、波次可调

路线图的三层

层 回答 更新频率 负责人
目标架构 三年后系统长什么样(边界、数据、部署形态) 年 架构负责人
迁移波次 先迁什么、后迁什么、每波的退出标准 季度 架构 + 团队 lead
团队容量 每波能投入多少人、与业务需求如何分配 季度/月 工程经理

三层缺一:只有目标没有波次,是画饼;只有波次没有容量,是压榨;只有容量没有目标,是打杂。

五种迁移模式

模式 做法 适用 代价
绞杀者(Strangler) 新实现逐步接管流量,旧实现最后下线 单体拆分、替换旧服务 双跑期长,路由/数据要兼容
分支抽象(Branch by Abstraction) 先抽接口,再换实现 替换库/中间件 多一层抽象,短期复杂度上升
并行运行(Parallel Run) 新旧同时处理,比对结果 核心计算、账务 双倍资源,比对逻辑要写
暗发布(Dark Launch) 新链路接流量但不返回给用户 验证性能与正确性 写操作要隔离
Expand-Contract 先兼容扩展、再迁移、最后收缩 数据库/契约变更 多次发布,周期长(见 55.4 数据库变更管理 (Expand-Contract))

选择原则:能绞杀就不重写,能并行就不赌博,能分波就不大爆炸。 大型单体拆到微服务的完整案例与踩坑,见 81.6 大型单体到微服务的演进(待补)。

波次怎么排:依赖 + 容量 + 价值

package main

import (
	"fmt"
	"sort"
)

type item struct {
	name   string
	deps   []string
	effort int // 团队·周
	value  int // 1–5
	risk   int // 1–5
}

// plan 按“依赖 + 容量 + 价值”切波次:同一波内互不依赖,前一波完成才开下一波。
func plan(items []item, capacity int) ([][]item, []string) {
	done := map[string]bool{}
	remaining := append([]item(nil), items...)
	var waves [][]item
	var notes []string
	for len(remaining) > 0 {
		var ready []item
		var blocked []item
		for _, it := range remaining {
			ok := true
			for _, d := range it.deps {
				if !done[d] {
					ok = false
					break
				}
			}
			if ok {
				ready = append(ready, it)
			} else {
				blocked = append(blocked, it)
			}
		}
		if len(ready) == 0 {
			notes = append(notes, "依赖成环或引用了不存在的项,排期中止")
			break
		}
		sort.Slice(ready, func(i, j int) bool {
			if ready[i].value != ready[j].value {
				return ready[i].value > ready[j].value
			}
			return ready[i].effort < ready[j].effort
		})
		var wave []item
		used := 0
		var next []item
		for _, it := range ready {
			if used+it.effort <= capacity {
				wave = append(wave, it)
				used += it.effort
			} else {
				next = append(next, it)
			}
		}
		if len(wave) == 0 { // 单项就超过容量:强制单独一波并提示
			wave = append(wave, ready[0])
			used = ready[0].effort
			next = ready[1:]
			notes = append(notes, fmt.Sprintf("%s 超过单波容量,单独成波", ready[0].name))
		}
		for _, it := range wave {
			done[it.name] = true
		}
		waves = append(waves, wave)
		remaining = append(next, blocked...)
	}
	return waves, notes
}

func main() {
	items := []item{
		{"抽取订单域", nil, 6, 5, 2},
		{"订单库拆分", []string{"抽取订单域"}, 5, 5, 4},
		{"统一入口网关", nil, 4, 3, 2},
		{"灰度发布平台", []string{"统一入口网关"}, 4, 3, 3},
		{"消息队列迁移", []string{"抽取订单域", "统一入口网关"}, 4, 4, 2},
		{"旧单体下线", []string{"订单库拆分", "消息队列迁移"}, 3, 5, 4},
		{"可观测性统一", nil, 3, 2, 1},
	}
	const capacity = 10

	waves, notes := plan(items, capacity)
	fmt.Println("== 演进路线图:按波次排 ==")
	totalWeeks := 0
	for i, w := range waves {
		used, value := 0, 0
		var names string
		for _, it := range w {
			used += it.effort
			value += it.value
			names += fmt.Sprintf("%s(%d周) ", it.name, it.effort)
		}
		totalWeeks += used
		fmt.Printf("Wave %d  容量 %d/%d 团队·周  价值 %d\n", i+1, used, capacity, value)
		fmt.Printf("        %s\n", names)
	}
	fmt.Printf("\n共 %d 波、%d 团队·周(若容量允许可并行压缩)\n", len(waves), totalWeeks)
	for _, n := range notes {
		fmt.Println("注意:" + n)
	}

	// 验收:每个依赖都必须在更早的波次里
	pos := map[string]int{}
	for i, w := range waves {
		for _, it := range w {
			pos[it.name] = i
		}
	}
	bad := 0
	for _, it := range items {
		for _, d := range it.deps {
			if pos[d] >= pos[it.name] {
				fmt.Printf("依赖违例:%s 依赖 %s,但没有排在它之前\n", it.name, d)
				bad++
			}
		}
	}
	fmt.Printf("依赖检查:%d 处违例\n", bad)
}

本机运行结果:

== 演进路线图:按波次排 ==
Wave 1  容量 10/10 团队·周  价值 8
        抽取订单域(6周) 统一入口网关(4周) 
Wave 2  容量 9/10 团队·周  价值 9
        订单库拆分(5周) 消息队列迁移(4周) 
Wave 3  容量 10/10 团队·周  价值 10
        旧单体下线(3周) 灰度发布平台(4周) 可观测性统一(3周) 

共 3 波、29 团队·周(若容量允许可并行压缩)
依赖检查:0 处违例

三点解读:

  1. 波次不是按“项目大小”排的,是按依赖与容量排的。订单库拆分必须等订单域抽取完成;旧单体下线必须等订单库与消息队列都完成。
  2. 容量 10 团队·周/波,Wave 2 只用 9,是因为剩下的项放不下,不是浪费;强行塞满会让波次延期。
  3. 依赖检查 0 违例:如果出现“依赖项排在后面”,说明波次计划不可执行,必须重排。

每一波的退出标准

维度 退出标准(示例) 不达标的处理
流量 新链路承接 ≥ 20% 生产流量 延长本波,不加下一波
质量 错误率不高于基线 +0.1pp;P99 不超过目标 回滚或修复后再推进
数据 对账/校验差异 = 0 停写、修数据,禁止继续
依赖 下游已切到新接口;旧接口调用量 = 0 清理遗留调用
债 本波新增债已登记并有还款计划 不许“先欠着再说”
回滚 回滚脚本演练过,5 分钟内可回 没有回滚点不许开波

🔑 路线图的每一波都要回答两个问题:怎么算完成?失败怎么退? 答不上来,就不是一波,是一次冒险。

度量:路线图有没有真的推进

指标 算法 用途
迁移流量占比 新链路流量 / 总流量 判断“真的在用”而不是“代码合并了”
服务/模块迁移数 已下线旧实现数 / 计划数 防止“只建新不拆旧”
债利息变化 见 58.2 技术债管理 的月利息 判断演进是否降低了变更成本
发布 lead time 提交到上线的中位数 判断解耦是否生效
回滚次数 每波回滚次数 判断退出标准是否太松

只看“完成了多少项目”会骗人:项目都“完成”了,旧系统还在跑、流量没迁、债没降,等于没演进。

沟通与治理

动作 频率 产出
一页纸路线图 季度 目标、波次、容量、风险
波次评审 每波开始前 ADR(决策与代价,见 58.1 ADR 实践)
季度校准 每季度 调整波次与容量,不改长期方向
架构评审 重大变更前 结论与行动项(见 58.3 架构评审)

一页纸要能回答:三年后长什么样、今年走到哪、下季度做什么、需要多少容量、最大风险是什么。写不成一页,说明还没想清。

生产落地:指名 → 可抄 → 可验 → 可退

指名:路线图文档用 Markdown/Confluence;波次任务进 Jira/Linear 的 epic;决策落 ADR;容量用季度排期会确认。工具不是重点,依赖、容量、退出标准才是。

可抄(一页纸模板):

# 订单域演进路线图(2026)

## 目标(三年)
- 订单域独立部署;数据库按 user_id 分片;旧单体只剩报表

## 波次
| 波次 | 内容 | 容量 | 退出标准 | 回滚点 |
|---|---|---|---|---|
| W1 | 抽订单域 + 统一网关 | 10 人周 | 20% 流量、错误率 +0.1pp 内 | 路由切回单体 |
| W2 | 订单库拆分 + MQ 迁移 | 9 人周 | 对账差异 0 | 双写开关 |
| W3 | 旧单体下线 + 灰度平台 | 10 人周 | 旧接口调用 0 | 保留旧实例 7 天 |

## 风险
- 分片键选择错误 → 先做影子读验证

可验:本节排期器真跑过,输出如上;它验证“依赖顺序 + 容量 + 价值”三条约束。人工验证:随机抽一波,检查退出标准是否可量化、回滚点是否演练过、流量是否真的迁了。

可退:每波都必须有回滚点;如果一波失败,退回上一波状态,而不是硬着头皮往下走。长期方向可以调整,但每次调整要更新路线图与 ADR,避免“口头改方向”。

反模式

反模式 后果
大爆炸式替换 风险集中,回滚困难
只建新不拆旧 双份维护,债越滚越大
没有退出标准 波次永远“快完成了”
按架构师理想排期,不看容量 团队被压垮,路线图失信
甘特图精确到人天 一变就全乱,没人再信
路线图只有目标没有波次 团队不知道下周做什么
不度量流量迁移 代码合并了,用户还在旧系统
一年不校准 业务变了,路线图还在原地

自检

Q1 路线图和项目列表的区别是什么?

▸ 答案:项目列表是“要做哪些事”,路线图是“系统先变成什么状态、再变成什么状态”,每一波有退出标准与回滚点。项目可以做完,但系统状态没变(旧系统还在跑、流量没迁),说明路线图没实现。

Q2 为什么波次规划要同时看依赖、容量和价值?

▸ 答案:只看依赖会排出“能执行但没价值”的顺序;只看价值会排出“依赖不满足、执行不了”的顺序;只看容量会平均分配、没有重点。三者一起,才能得到“可执行、有价值、不压垮团队”的波次。

Q3 绞杀者模式和“重写”有什么本质区别?

▸ 答案:绞杀者是逐步接管流量,旧实现继续服务直到新实现验证通过,随时可切回;重写是一次性替换,在新系统上线前旧系统无法验证新系统,失败时回滚代价极大。绞杀者的代价是双跑期长、路由与数据要兼容。

Q4 一波的退出标准至少包含哪些维度?

▸ 答案:流量(新链路承接比例)、质量(错误率/P99 不劣化)、数据(对账/校验差异为 0)、依赖(旧调用清零)、债(新增债已登记)、回滚(演练过且时限内可回)。缺一个,就容易“看起来完成了,实际没迁”。

Q5 路线图为什么要季度校准,而不是一年定死?

▸ 答案:业务优先级、容量、依赖、故障都会变;一年定死会让路线图与现实脱节,团队要么无视它,要么被它拖累。季度校准保持“长期方向稳定、短期波次可调”,并用 ADR 记录每次调整的原因。

关联