选型的终点不是“最好用”
技术选型最容易犯的错误,是把评估做成功能对照表:谁的勾多,谁赢。真正决定三年后是否后悔的,往往是三件事:退出要花多少钱、团队能不能维护、供应商停服时怎么办。
这一节给出一套可复用的评估方法:八个统一方向的维度、显式权重与硬约束、总拥有成本公式、POC 验收标准、供应商风险清单,以及参考客户的核实方法。所有金额与分数都是示例值,真实评估必须用本节后面的方法重新采集。
八个维度,统一方向
评分方向必须一致。约定所有维度 5 分最好,因此成本叫“成本友好度”,锁定叫“抗锁定能力”,退出叫“退出便宜度”。
| 维度 | 回答的问题 | 5 分锚点 | 1 分锚点 | 证据 |
|---|---|---|---|---|
| 功能 | 核心场景是否覆盖 | 核心场景全通过 POC | 关键场景缺失 | POC 报告 |
| 性能 | 峰值能否扛住 | 2 倍峰值压测通过 | 峰值即降级 | 压测、容量模型 |
| 可靠性 | 故障时是否可控 | 多可用区,SLA 有赔付 | 单点,无历史数据 | SLA、故障记录 |
| 成本友好度 | 单位经济是否可接受 | 低于预算 30% | 超预算 2 倍 | TCO、账单 |
| 合规 | 数据能否合法使用 | 数据可本地驻留、可删除 | 关键区域无路径 | 法务意见 |
| 抗锁定 | 迁移是否可行 | 标准协议、可导出 | 私有协议、不可导出 | 接口与导出测试 |
| 团队匹配 | 团队能否接手 | 已有经验与值班能力 | 无人会、无文档 | 技能盘点 |
| 退出便宜度 | 离开要付多少 | 迁移 < 1 个月、无罚金 | 迁移 > 6 个月、高罚金 | 合同、迁移演练 |
“团队匹配”不是给团队懒惰找借口。一个需要三个月才能接手的组件,在人员流动率高的团队里就是高风险,不管它在 benchmark 上多漂亮。
“退出便宜度”必须在选型时打分,而不是等要退出时才看合同。很多供应商的低价来自高迁移成本,首年便宜、第三年昂贵。
权重是产品阶段,不是平均分
| 场景 | 功能 | 性能 | 可靠 | 成本 | 合规 | 抗锁定 | 团队 | 退出 |
|---|---|---|---|---|---|---|---|---|
| 新项目 | 0.20 | 0.10 | 0.15 | 0.15 | 0.10 | 0.10 | 0.10 | 0.10 |
| 核心链路替换 | 0.15 | 0.20 | 0.25 | 0.10 | 0.10 | 0.05 | 0.05 | 0.10 |
| 成本压缩 | 0.10 | 0.10 | 0.15 | 0.30 | 0.10 | 0.10 | 0.05 | 0.10 |
表中权重为示例值,实际权重由产品阶段决定。权重之和必须为 1。缺失维度不能当 0:0 是“明确不关心”,缺失是“还没想清楚”。硬约束通常一到两条,例如核心链路要求可靠性 ≥4、合规 ≥4;设得太多会得到“没有可选供应商”的结果。
TCO:把退出成本放进分母
总拥有成本按评估周期计算,而不是只看首年报价:
TCO = 年许可 × 年数 + 年运行 × 年数 + 一次性迁移 + 退出成本
| 供应商 | 年许可 | 年运行 | 迁移 | 退出 | 3 年 TCO | 年化 |
|---|---|---|---|---|---|---|
| A 自建 | 10 | 5 | 20 | 30 | 95 | 31.7 |
| B 托管 | 20 | 10 | 5 | 10 | 105 | 35.0 |
| C 低价 | 8 | 3 | 40 | 50 | 123 | 41.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 架构师的沟通与写作 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。