《游戏服务端架构·平台化与架构治理》10.3 架构路线图与技术雷达

季度规划会上,能力建设、系统迁移与技术偿债争同一批人。本节给出路线图的三类工作与退出标准、依赖排序与关键路径算法,以及技术雷达四环的进入退出条件与状态迁移规则,并说明对管理层讲风险成本、对团队讲里程碑接口的沟通口径,附可运行的 Go 排期与雷达代码。

规划会上的三张纸

年度规划会上,平台团队只有 12 人(示例值),却同时被要求做三件事:把账号平台化、把旧战斗服迁到新架构、还掉持续两年的可观测性欠债。三张需求纸都写着“本季度必须启动”,但人力只够做一张半。

这类会议最常见的失败不是吵不出优先级,而是把所有事都排成并行。结果每件事都完成 60%,季度末没有一项能交付。路线图的作用不是把所有愿望排进日历,而是明确依赖、容量与退出标准,让“不做什么”也有依据。

本节讨论如何把能力建设、迁移与偿债排成可执行的季度路线图,如何用技术雷达管理采用节奏,以及如何对管理层与团队说不同的话。文中人数、季度与工期均为示例值,不是项目实测。

路线图的三类工作

先给工作分类,因为三类工作的验收方式不同:

类型例子产出退出标准常见误判
能力建设账号平台、支付对账、匹配框架新能力上线并被消费方接入至少一个真实租户在生产使用上线即宣告完成,没人接入
系统迁移旧战斗服换轨、存储替换旧路径零流量、旧代码删除旧路径无调用、对账一致新系统上线但旧系统仍在跑
技术偿债日志规范、告警收敛、依赖升级指标改善且可持续指标连续多个周期达标做了一次大扫除,没有机制

路线图要显式标注每项工作的类型,否则“平台化”会被当成一次性项目,“偿债”会被无限推迟;每季度还应保留一部分容量给偿债,不参与业务排期竞争,示例目标是 15% 到 20%。

依赖与关键路径

排期不是把工作填进四个格子,而是先算依赖。每项工作有两个属性:最早可开始季度、预计工时(人周)。依赖关系决定实际开始时间:

  1. 被依赖项完成后,依赖项才能开始。
  2. 依赖项的开始时间不早于自己的最早季度。
  3. 同一季度可以并行多项,但总工时不能超过团队容量。
  4. 关键路径是工时最长的依赖链,它决定整个路线图的最短完成时间。

示例:账号平台需要 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 与异常检测 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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