《游戏服务端架构·平台化与架构治理》12.2 技术选型与供应商评估

技术选型不是比较功能清单,而是比较三年后的退出成本。本文给出八个评分维度、权重与硬约束、总拥有成本公式、POC 验收标准、供应商风险清单与参考客户核实方法,并用 Go 把评分模型、TCO 计算和确定性排序拆成可测试的包,让选型结论可复现、可审计。

选型的终点不是“最好用”

技术选型最容易犯的错误,是把评估做成功能对照表:谁的勾多,谁赢。真正决定三年后是否后悔的,往往是三件事:退出要花多少钱、团队能不能维护、供应商停服时怎么办。

这一节给出一套可复用的评估方法:八个统一方向的维度、显式权重与硬约束、总拥有成本公式、POC 验收标准、供应商风险清单,以及参考客户的核实方法。所有金额与分数都是示例值,真实评估必须用本节后面的方法重新采集。

八个维度,统一方向

评分方向必须一致。约定所有维度 5 分最好,因此成本叫“成本友好度”,锁定叫“抗锁定能力”,退出叫“退出便宜度”。

维度回答的问题5 分锚点1 分锚点证据
功能核心场景是否覆盖核心场景全通过 POC关键场景缺失POC 报告
性能峰值能否扛住2 倍峰值压测通过峰值即降级压测、容量模型
可靠性故障时是否可控多可用区,SLA 有赔付单点,无历史数据SLA、故障记录
成本友好度单位经济是否可接受低于预算 30%超预算 2 倍TCO、账单
合规数据能否合法使用数据可本地驻留、可删除关键区域无路径法务意见
抗锁定迁移是否可行标准协议、可导出私有协议、不可导出接口与导出测试
团队匹配团队能否接手已有经验与值班能力无人会、无文档技能盘点
退出便宜度离开要付多少迁移 < 1 个月、无罚金迁移 > 6 个月、高罚金合同、迁移演练

“团队匹配”不是给团队懒惰找借口。一个需要三个月才能接手的组件,在人员流动率高的团队里就是高风险,不管它在 benchmark 上多漂亮。

“退出便宜度”必须在选型时打分,而不是等要退出时才看合同。很多供应商的低价来自高迁移成本,首年便宜、第三年昂贵。

权重是产品阶段,不是平均分

场景功能性能可靠成本合规抗锁定团队退出
新项目0.200.100.150.150.100.100.100.10
核心链路替换0.150.200.250.100.100.050.050.10
成本压缩0.100.100.150.300.100.100.050.10

表中权重为示例值,实际权重由产品阶段决定。权重之和必须为 1。缺失维度不能当 0:0 是“明确不关心”,缺失是“还没想清楚”。硬约束通常一到两条,例如核心链路要求可靠性 ≥4、合规 ≥4;设得太多会得到“没有可选供应商”的结果。

TCO:把退出成本放进分母

总拥有成本按评估周期计算,而不是只看首年报价:

TCO = 年许可 × 年数 + 年运行 × 年数 + 一次性迁移 + 退出成本

供应商年许可年运行迁移退出3 年 TCO年化
A 自建10520309531.7
B 托管201051010535.0
C 低价83405012341.0

单位:万元,示例值。C 的报价最低,但迁移和退出把它推到最贵。如果只比年许可,结论会完全相反。

年化值用于和预算比较,总 TCO 用于和“不选”比较。自建方案还要加上人力,托管方案要加上用量超限的阶梯价,这些都应该进入年运行,而不是留在备注里。

POC:先定验收标准,再开始

POC 最常见的失败,是做完之后才发现“好像哪里不对”,然后靠感觉决定。验收标准必须在 POC 开始前写死:

验收项通过标准证据失败动作
功能10 个核心场景全通过脚本输出直接淘汰
性能2 倍峰值 p99 在预算内压测报告降级或淘汰
故障杀掉一个节点,自动恢复 < 60 秒演练记录要求补方案
迁移10% 数据导出并回灌成功校验和重新评估锁定
运维值班同学独立完成一次发布操作记录增加培训成本

POC 只验证“能不能”,不验证“好不好”。好不好要在真实流量里看,所以 POC 通过后还应该有灰度阶段,灰度阶段有明确的回滚条件和观察窗口。

供应商风险:合同里没写的,才是风险

风险项要核实的问题可接受证据兜底
SLA赔付是否覆盖业务损失历史达成率、赔付条款自建降级路径
数据归属数据归谁,能否导出合同条款、导出测试定期全量备份
源码托管停服后能否拿到源码托管协议、触发条件只选可托管供应商
破产/停服有没有延续方案财务公开信息、迁移预案双供应商或自建
合规数据能否落地目标区域法务意见、审计报告区域隔离

“参考客户核实”不能只看供应商给的名单。要问三个问题:上线多久、峰值多大、出过什么故障。愿意回答第三个问题的客户,比只夸产品的客户更有参考价值。

可运行片段:模型、评分、TCO 分开

model.go 定义维度、权重、供应商与风险项;score.go 负责硬约束、加权评分、排序与候选名单;tco.go 单独实现总拥有成本与风险负载。分开的原因是 TCO 可以独立于评分被财务复核,评分也可以在不改成本模型的情况下调整权重。

// file: internal/vendoreval/model.go
// tier: L1
// verify: go test ./internal/vendoreval/
package vendoreval

import (
	"errors"
	"fmt"
)

var (
	ErrWeights  = errors.New("vendoreval: invalid weights")
	ErrVendor   = errors.New("vendoreval: invalid vendor")
	ErrNoVendor = errors.New("vendoreval: no vendor passes hard constraints")
)

// Dimension is scored 0..5 in one direction: 5 is better. Cost is "cost fit"
// and lock-in is "lock-in resistance", not raw spend.
type Dimension string

const (
	Function    Dimension = "function"
	Performance Dimension = "performance"
	Reliability Dimension = "reliability"
	CostFit     Dimension = "cost-fit"
	Compliance  Dimension = "compliance"
	LockIn      Dimension = "lock-in-resistance"
	TeamFit     Dimension = "team-fit"
	Exit        Dimension = "exit-cheapness"
)

var Dimensions = []Dimension{Function, Performance, Reliability, CostFit, Compliance, LockIn, TeamFit, Exit}

type Weights map[Dimension]float64

// Validate rejects missing dimensions instead of treating them as zero: a
// missing weight means the evaluation is incomplete, not that it does not care.
func (w Weights) Validate() error {
	total := 0.0
	for _, d := range Dimensions {
		v, ok := w[d]
		if !ok {
			return fmt.Errorf("%w: missing weight for %s", ErrWeights, d)
		}
		if v < 0 || v > 1 {
			return fmt.Errorf("%w: weight for %s is %g, want [0,1]", ErrWeights, d, v)
		}
		total += v
	}
	if total < 0.999 || total > 1.001 {
		return fmt.Errorf("%w: weights sum to %g, want 1", ErrWeights, total)
	}
	return nil
}

// VendorRisk is a supplier-level risk. Severity 3+ requires a written
// mitigation; "we will watch it" is not a mitigation.
type VendorRisk struct {
	Kind       string
	Severity   int
	Mitigation string
}

func (r VendorRisk) Validate() error {
	if r.Kind == "" {
		return fmt.Errorf("%w: risk without kind", ErrVendor)
	}
	if r.Severity < 1 || r.Severity > 5 {
		return fmt.Errorf("%w: risk %s severity %d, want 1..5", ErrVendor, r.Kind, r.Severity)
	}
	if r.Severity >= 3 && r.Mitigation == "" {
		return fmt.Errorf("%w: risk %s severity %d without mitigation", ErrVendor, r.Kind, r.Severity)
	}
	return nil
}

// Vendor carries both scores and cost inputs. Money uses one unit for every
// vendor; callers must not mix currencies.
type Vendor struct {
	Name           string
	Scores         map[Dimension]float64
	LicensePerYear float64
	RunPerYear     float64
	Migration      float64
	ExitFee        float64
	Risks          []VendorRisk
}

func (v Vendor) Validate() error {
	if v.Name == "" {
		return fmt.Errorf("%w: vendor needs a name", ErrVendor)
	}
	for _, d := range Dimensions {
		s, ok := v.Scores[d]
		if !ok {
			return fmt.Errorf("%w: vendor %s has no score for %s", ErrVendor, v.Name, d)
		}
		if s < 0 || s > 5 {
			return fmt.Errorf("%w: vendor %s score for %s is %g, want [0,5]", ErrVendor, v.Name, d, s)
		}
	}
	for _, f := range []struct {
		name string
		val  float64
	}{
		{"license per year", v.LicensePerYear},
		{"run per year", v.RunPerYear},
		{"migration", v.Migration},
		{"exit fee", v.ExitFee},
	} {
		if f.val < 0 {
			return fmt.Errorf("%w: vendor %s has negative %s", ErrVendor, v.Name, f.name)
		}
	}
	for _, r := range v.Risks {
		if err := r.Validate(); err != nil {
			return fmt.Errorf("vendor %s: %w", v.Name, err)
		}
	}
	return nil
}
// file: internal/vendoreval/score.go
// tier: L1
// verify: go test ./internal/vendoreval/
package vendoreval

import (
	"fmt"
	"sort"
)

type Constraint struct {
	Dimension Dimension
	Min       float64
}

func (c Constraint) Validate() error {
	valid := false
	for _, d := range Dimensions {
		if c.Dimension == d {
			valid = true
			break
		}
	}
	if !valid {
		return fmt.Errorf("%w: unknown constraint dimension %q", ErrVendor, c.Dimension)
	}
	if c.Min < 0 || c.Min > 5 {
		return fmt.Errorf("%w: constraint %s min %g, want [0,5]", ErrVendor, c.Dimension, c.Min)
	}
	return nil
}

type Score struct {
	Name     string
	Weighted float64
	Pass     bool
	Failed   []Dimension
}

func scoreAll(w Weights, cs []Constraint, vs []Vendor) ([]Score, error) {
	if err := w.Validate(); err != nil {
		return nil, err
	}
	for _, c := range cs {
		if err := c.Validate(); err != nil {
			return nil, err
		}
	}
	if len(vs) == 0 {
		return nil, fmt.Errorf("%w: no vendors", ErrVendor)
	}
	out := make([]Score, 0, len(vs))
	for _, v := range vs {
		if err := v.Validate(); err != nil {
			return nil, err
		}
		s := Score{Name: v.Name, Pass: true}
		for _, c := range cs {
			if v.Scores[c.Dimension] < c.Min {
				s.Pass = false
				s.Failed = append(s.Failed, c.Dimension)
			}
		}
		for _, d := range Dimensions {
			s.Weighted += w[d] * v.Scores[d]
		}
		out = append(out, s)
	}
	return out, nil
}

// Assessment is a report row, not a single recommendation: the review still
// decides whether a cheap vendor with a high exit cost is acceptable.
type Assessment struct {
	Score
	TCO       TCO
	RiskScore int
	TopRisk   string
}

// Evaluate returns every vendor, passing ones first, then by weighted score,
// then annualized TCO, then name. The last tie-breaker keeps output stable
// without pretending the evaluation found a real preference.
func Evaluate(w Weights, cs []Constraint, vs []Vendor, years int) ([]Assessment, error) {
	scores, err := scoreAll(w, cs, vs)
	if err != nil {
		return nil, err
	}
	byName := make(map[string]Vendor, len(vs))
	for _, v := range vs {
		byName[v.Name] = v
	}
	out := make([]Assessment, 0, len(scores))
	for _, s := range scores {
		v := byName[s.Name]
		t, err := TotalCost(v, years)
		if err != nil {
			return nil, err
		}
		r, top := RiskLoad(v)
		out = append(out, Assessment{Score: s, TCO: t, RiskScore: r, TopRisk: top})
	}
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].Pass != out[j].Pass {
			return out[i].Pass
		}
		if out[i].Weighted != out[j].Weighted {
			return out[i].Weighted > out[j].Weighted
		}
		if out[i].TCO.Annualized != out[j].TCO.Annualized {
			return out[i].TCO.Annualized < out[j].TCO.Annualized
		}
		return out[i].Name < out[j].Name
	})
	return out, nil
}

// Shortlist returns at most n passing vendors. It never promotes a vendor that
// failed a hard constraint just to fill the list.
func Shortlist(as []Assessment, n int) []Assessment {
	var pass []Assessment
	for _, a := range as {
		if a.Pass {
			pass = append(pass, a)
		}
	}
	if n <= 0 || n > len(pass) {
		n = len(pass)
	}
	return pass[:n]
}

三个取舍值得说明。第一,权重缺失直接报错,不默认 0,避免“漏填一个维度”被当成“不关心”。第二,硬约束在加权之前过滤,防止高分但不合规的供应商靠其他维度翻盘。第三,排序在分数相同时比较年化 TCO,再比较名称;年化 TCO 是真实偏好,名称只是确定性兜底。

Shortlist 不会为了凑数把被硬约束淘汰的供应商放回来。如果没有供应商通过,正确动作是改需求或改架构,而不是降低门槛后假装还有选择。tco.go 把退出费固定计入总成本,即使当前没有退出计划,也不允许它在比较中消失。

失败模式与检查表

失败模式表现兜底
只比功能演示很全,迁移很难把退出成本纳入评分
权重事后调整先看结果再改权重评分前冻结权重与硬约束
TCO 只算首年第三年账单翻倍统一按评估周期计算
POC 没有标准做完靠感觉开始前写死验收项
忽略团队能力选完没人会维护团队匹配单独打分
  • 八个维度都有评分锚点,而不是临时拍脑袋。
  • 权重在打分前冻结,变更记录理由与批准人。
  • 至少一条硬约束,且每条都有证据与所有者。
  • TCO 包含迁移与退出成本,按同一周期比较。
  • POC 验收标准在开始前写成可执行脚本。
  • 供应商风险逐项有证据,高危项有书面缓解。
  • 参考客户至少核实两家,且问过故障经历。
  • 结论记录了落选原因,而不只是赢家名字。

小结

技术选型是把功能、性能、可靠性、成本、合规、锁定、团队与退出放在同一张表上,用显式权重和硬约束排序,再用 TCO 把时间维度的代价摊开。评分不是客观真理,它的价值是让“为什么选它”和“为什么淘汰它”都可复现。

下一节换一个对象:不是选外部供应商,而是把架构师的判断写成管理层、团队和跨团队都能读的文档。

阅读导航:上一节:12.1 决策记录、风险台账与路线图 · 下一节:12.3 架构师的沟通与写作 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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