规划会上的三张纸
年度规划会上,平台团队只有 12 人(示例值),却同时被要求做三件事:把账号平台化、把旧战斗服迁到新架构、还掉持续两年的可观测性欠债。三张需求纸都写着“本季度必须启动”,但人力只够做一张半。
这类会议最常见的失败不是吵不出优先级,而是把所有事都排成并行。结果每件事都完成 60%,季度末没有一项能交付。路线图的作用不是把所有愿望排进日历,而是明确依赖、容量与退出标准,让“不做什么”也有依据。
本节讨论如何把能力建设、迁移与偿债排成可执行的季度路线图,如何用技术雷达管理采用节奏,以及如何对管理层与团队说不同的话。文中人数、季度与工期均为示例值,不是项目实测。
路线图的三类工作
先给工作分类,因为三类工作的验收方式不同:
| 类型 | 例子 | 产出 | 退出标准 | 常见误判 |
|---|---|---|---|---|
| 能力建设 | 账号平台、支付对账、匹配框架 | 新能力上线并被消费方接入 | 至少一个真实租户在生产使用 | 上线即宣告完成,没人接入 |
| 系统迁移 | 旧战斗服换轨、存储替换 | 旧路径零流量、旧代码删除 | 旧路径无调用、对账一致 | 新系统上线但旧系统仍在跑 |
| 技术偿债 | 日志规范、告警收敛、依赖升级 | 指标改善且可持续 | 指标连续多个周期达标 | 做了一次大扫除,没有机制 |
路线图要显式标注每项工作的类型,否则“平台化”会被当成一次性项目,“偿债”会被无限推迟;每季度还应保留一部分容量给偿债,不参与业务排期竞争,示例目标是 15% 到 20%。
依赖与关键路径
排期不是把工作填进四个格子,而是先算依赖。每项工作有两个属性:最早可开始季度、预计工时(人周)。依赖关系决定实际开始时间:
- 被依赖项完成后,依赖项才能开始。
- 依赖项的开始时间不早于自己的最早季度。
- 同一季度可以并行多项,但总工时不能超过团队容量。
- 关键路径是工时最长的依赖链,它决定整个路线图的最短完成时间。
示例:账号平台需要 6 人周,支付对账依赖账号、需要 8 人周,客服工具依赖支付、需要 4 人周,可观测性规范独立、需要 5 人周。关键路径是账号、支付、客服,合计 18 人周(示例值)。如果团队每季度可投入 10 人周,这条链至少要跨两个季度,而不是会上说的“一个季度全做完”。
路线图评审时先看关键路径,再看总容量,最后才看单项优先级。依赖图出现环,说明工作拆分有问题;例如“账号平台依赖统一日志,统一日志依赖账号平台的租户模型”,应把共享的租户模型单独拆成前置项,而不是让两个团队互相等。
技术雷达四环与进出标准
技术雷达管理“什么时候采用”,不是“什么技术好”。四环各有明确的进入与退出条件:
| 环 | 含义 | 进入标准 | 退出标准 |
|---|---|---|---|
| 采用 | 新项目默认可用 | 至少两个生产用例、有运维手册、有故障演练 | 出现无法缓解的风险,降级到试验或暂缓 |
| 试验 | 有明确边界的生产试用 | 一个真实团队、明确退出指标、回滚方案 | 达到指标转采用,未达标转暂缓 |
| 评估 | 只做原型与调研 | 有负责人、有评估问题清单、有时间盒 | 产出结论,转试验或暂缓 |
| 暂缓 | 明确不采用或暂停 | 有书面理由与复审时间 | 条件变化后重新进入评估 |
“试验”最容易被滥用。没有退出指标的试验会永久停留在雷达上,既占人力又不产生结论。试验条目必须写清楚:验证什么问题、成功指标是什么、多长时间、失败后怎么退出。“暂缓”也不是“否决”,它需要书面理由和复审时间,否则半年后同一个人会重新提同样的方案;暂缓理由通常包括运维能力不足、社区不活跃、与现有栈冲突、成本高于收益。
雷达条目还需要证据。没有证据的条目会退化成投票:谁声音大谁进采用。证据可以是压测报告、故障演练记录、迁移案例或成本对比,但必须是可复核的链接或文档,不是“大家都说好用”。
状态迁移规则
雷达状态不能任意跳。允许的迁移如下:
| 从 | 可到 | 说明 |
|---|---|---|
| 评估 | 试验、暂缓 | 评估必须有结论 |
| 试验 | 采用、评估、暂缓 | 未达标退回评估,不再试验 |
| 采用 | 试验、暂缓 | 降级需要重新验证 |
| 暂缓 | 评估 | 条件变化后重新评估 |
不允许评估直接跳到采用,也不允许暂缓直接跳到采用。前者跳过了生产验证,后者跳过了重新评估。状态迁移和迁移阶段一样,应该由工具校验,而不是靠会议纪要。
对管理层与团队说不同的话
同一份路线图,两种受众关心的问题不同:
| 受众 | 关心 | 应给的材料 | 不应给的材料 |
|---|---|---|---|
| 管理层 | 风险、成本、什么时候能减少故障 | 关键路径、容量缺口、季度里程碑、风险清单 | 技术选型细节、框架对比 |
| 团队 | 先做什么、接口是什么、怎么验收 | 依赖图、接口契约、退出标准、回滚方案 | 空泛的战略口号 |
对管理层,重点不是“我们要上 Kubernetes”,而是“账号故障目前影响全部游戏,平台化后可以把爆炸半径限制在单游戏,代价是两个人两个季度”;对团队,重点不是“季度目标”,而是“这个季度先交付哪个接口、谁依赖你、什么算完成”。里程碑要可验收:不是“完成账号平台 80%”,而是“账号服务承接第一款游戏 10% 登录流量,错误率与延迟在预算内”。
代码:排期、关键路径与雷达
下面的代码把路线图排期、关键路径和雷达状态迁移做成纯函数。排期按“依赖完成时间”和“最早季度”取最大值,关键路径取工时最长的依赖链,雷达迁移遇到非法跳转直接报错。
// file: internal/techradar/techradar.go
// tier: L1
// verify: go test ./internal/techradar/
package techradar
import (
"errors"
"fmt"
"sort"
)
// Ring is one of the four radar rings.
type Ring string
const (
// RingAdopt is approved for default use on new work.
RingAdopt Ring = "adopt"
// RingTrial is used in production by a named team with exit criteria.
RingTrial Ring = "trial"
// RingAssess is under evaluation with no production dependency.
RingAssess Ring = "assess"
// RingHold is deliberately paused or being retired.
RingHold Ring = "hold"
)
var (
ErrUnknownRing = errors.New("techradar: unknown ring")
ErrIllegalMove = errors.New("techradar: illegal ring move")
ErrSameRing = errors.New("techradar: entry already in that ring")
ErrInvalidEntry = errors.New("techradar: invalid entry")
ErrInvalidItem = errors.New("techradar: invalid roadmap item")
ErrDuplicate = errors.New("techradar: duplicate roadmap item")
ErrUnknownItem = errors.New("techradar: unknown roadmap dependency")
ErrCycle = errors.New("techradar: roadmap dependency cycle")
)
func (r Ring) Valid() bool {
switch r {
case RingAdopt, RingTrial, RingAssess, RingHold:
return true
default:
return false
}
}
func ParseRing(s string) (Ring, error) {
r := Ring(s)
if !r.Valid() {
return "", fmt.Errorf("%w: %q", ErrUnknownRing, s)
}
return r, nil
}
// Entry is one technology on the radar. Evidence is not optional: a radar
// without evidence turns into a popularity poll.
type Entry struct {
Name string
Ring Ring
Owner string
Evidence []string
}
func (e Entry) Validate() error {
if e.Name == "" {
return fmt.Errorf("%w: empty name", ErrInvalidEntry)
}
if !e.Ring.Valid() {
return fmt.Errorf("%w: %s ring=%q", ErrInvalidEntry, e.Name, e.Ring)
}
if e.Owner == "" {
return fmt.Errorf("%w: %s has no owner", ErrInvalidEntry, e.Name)
}
meaningful := false
for _, item := range e.Evidence {
if item != "" {
meaningful = true
break
}
}
if !meaningful {
return fmt.Errorf("%w: %s has no evidence", ErrInvalidEntry, e.Name)
}
return nil
}
// CanMove encodes the allowed ring transitions. Forward movement is gradual;
// Adopt can be demoted; Hold must re-enter through Assess.
func CanMove(from, to Ring) error {
if !from.Valid() || !to.Valid() {
return ErrUnknownRing
}
if from == to {
return ErrSameRing
}
allowed := false
switch from {
case RingAssess:
allowed = to == RingTrial || to == RingHold
case RingTrial:
allowed = to == RingAdopt || to == RingAssess || to == RingHold
case RingAdopt:
allowed = to == RingTrial || to == RingHold
case RingHold:
allowed = to == RingAssess
}
if !allowed {
return fmt.Errorf("%w: %s -> %s", ErrIllegalMove, from, to)
}
return nil
}
// Move validates the entry before and after the transition so a partially
// filled entry cannot slip into a new ring.
func Move(e Entry, to Ring) (Entry, error) {
if err := e.Validate(); err != nil {
return e, err
}
if err := CanMove(e.Ring, to); err != nil {
return e, err
}
moved := e
moved.Ring = to
return moved, nil
}
// Item is one roadmap line. Weeks is effort, not calendar duration.
type Item struct {
ID string
EarliestQuarter int
Weeks int
DependsOn []string
}
func (i Item) Validate() error {
switch {
case i.ID == "":
return fmt.Errorf("%w: empty id", ErrInvalidItem)
case i.EarliestQuarter < 1:
return fmt.Errorf("%w: %s earliest quarter=%d", ErrInvalidItem, i.ID, i.EarliestQuarter)
case i.Weeks < 1:
return fmt.Errorf("%w: %s weeks=%d", ErrInvalidItem, i.ID, i.Weeks)
}
for _, dep := range i.DependsOn {
if dep == "" {
return fmt.Errorf("%w: %s has empty dependency", ErrInvalidItem, i.ID)
}
}
return nil
}
func indexItems(items []Item) (map[string]Item, error) {
index := make(map[string]Item, len(items))
for _, item := range items {
if err := item.Validate(); err != nil {
return nil, err
}
if _, exists := index[item.ID]; exists {
return nil, fmt.Errorf("%w: %s", ErrDuplicate, item.ID)
}
index[item.ID] = item
}
for _, item := range items {
for _, dep := range item.DependsOn {
if _, ok := index[dep]; !ok {
return nil, fmt.Errorf("%w: %s -> %s", ErrUnknownItem, item.ID, dep)
}
}
}
return index, nil
}
// Schedule returns the earliest start quarter of every item. An item starts
// after all dependencies finish and never before its own earliest quarter.
func Schedule(items []Item) (map[string]int, error) {
index, err := indexItems(items)
if err != nil {
return nil, err
}
start := make(map[string]int, len(index))
visiting := make(map[string]bool, len(index))
var resolve func(id string) (int, error)
resolve = func(id string) (int, error) {
if quarter, ok := start[id]; ok {
return quarter, nil
}
if visiting[id] {
return 0, fmt.Errorf("%w: %s", ErrCycle, id)
}
visiting[id] = true
item := index[id]
quarter := item.EarliestQuarter
for _, dep := range item.DependsOn {
depStart, err := resolve(dep)
if err != nil {
return 0, err
}
finish := depStart + index[dep].Weeks
if finish > quarter {
quarter = finish
}
}
delete(visiting, id)
start[id] = quarter
return quarter, nil
}
for id := range index {
if _, err := resolve(id); err != nil {
return nil, err
}
}
return start, nil
}
// CriticalPath returns the longest dependency chain by effort and its total
// weeks. The chain is ordered from the oldest dependency to the final item.
func CriticalPath(items []Item) ([]string, int, error) {
index, err := indexItems(items)
if err != nil {
return nil, 0, err
}
if _, err := Schedule(items); err != nil {
return nil, 0, err
}
memoWeeks := make(map[string]int, len(index))
memoChain := make(map[string][]string, len(index))
var walk func(id string) (int, []string)
walk = func(id string) (int, []string) {
if weeks, ok := memoWeeks[id]; ok {
return weeks, memoChain[id]
}
item := index[id]
bestWeeks := 0
var bestChain []string
deps := append([]string(nil), item.DependsOn...)
sort.Strings(deps)
for _, dep := range deps {
weeks, chain := walk(dep)
if weeks > bestWeeks {
bestWeeks = weeks
bestChain = chain
}
}
chain := append(append([]string(nil), bestChain...), id)
memoWeeks[id] = bestWeeks + item.Weeks
memoChain[id] = chain
return memoWeeks[id], chain
}
ids := make([]string, 0, len(index))
for id := range index {
ids = append(ids, id)
}
sort.Strings(ids)
bestTotal := -1
var best []string
for _, id := range ids {
weeks, chain := walk(id)
if weeks > bestTotal {
bestTotal = weeks
best = chain
}
}
if bestTotal < 0 {
return nil, 0, nil
}
return best, bestTotal, nil
}
代码有三个设计取舍。第一,Schedule 的 Weeks 是人周投入,不是日历周;同一季度并行多项时,容量检查要由上层完成,函数只负责依赖约束。第二,CriticalPath 在有多条等长链时按条目 ID 排序保证确定性,避免每次运行给出不同结果。第三,Move 在迁移前后都校验条目,防止缺负责人或缺证据的条目混进“采用”。
测试覆盖了四环解析、条目证据校验、合法与非法迁移、链式与并行排期、未知依赖、重复 ID、环检测,以及关键路径的分支选择与空路线图。把这些函数接进规划工具,路线图就能在评审前跑一遍,而不是在会上才发现依赖成环。
路线图失败路径
| 失败 | 早期信号 | 根因 | 正确处置 |
|---|---|---|---|
| 路线图变成愿望清单 | 每季度都列十几项,完成率低 | 没有容量约束与退出标准 | 按关键路径裁剪,明确不做什么 |
| 关键路径被插入需求打断 | 主链连续顺延 | 没有保护关键路径 | 关键路径工作单独排期,插入需替换 |
| 对管理层讲技术细节 | 预算被拒或反复追问 | 没有翻译成风险与成本 | 用故障影响、爆炸半径、季度里程碑表达 |
检查表
- 每项工作标注了类型、最早季度、工时与依赖。
- 关键路径已识别,且有保护机制,不被临时需求随意打断。
- 团队季度容量小于等于可投入人周,没有默认加班。
- 试验条目有成功指标、时间盒与退出方案。
- 状态迁移由工具校验,评估不能直接跳采用。
- 对管理层的材料只讲风险、成本与里程碑,对团队只讲依赖、接口与验收。
路线图的价值不在于预测未来,而在于让团队对“先做什么、为什么先做、什么算做完”有同一套说法。技术雷达的价值也不在于给技术排名,而在于给采用节奏设门槛。两者结合,架构演进才从“每年重新吵一次”变成可复核的治理过程。
阅读导航:上一节:10.2 单体到平台、单游戏到多游戏 · 下一节:11.1 AIOps 与异常检测 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。