半年后,只有 12% 的服务迁了过来
「回声」的内部平台叫 Helios,目标是统一发布、配置和可观测性。立项时的承诺很直接:接入之后,业务团队不再需要维护发布脚本,也不用各自搭监控。
上线半年后,平台组做了一次盘点(以下均为案例假设值):
- 全公司 180 个服务,完成迁移的 22 个,覆盖率 12%;
- 这 22 个里,过去四周仍有发布行为的只有 16 个,活跃使用率 9%;
- 迁移过的团队中,仍有 7 个保留着旧的发布脚本,双轨运行;
- 平台组累计投入 18 人月,业务团队累计投入约 26 人月;
- 剩余 158 个服务中,有 40 个明确表示“今年不考虑”,理由是迁移窗口排不开。
季度复盘会上,平台负责人被问到一个很难回答的问题:这个平台到底是成功了 12%,还是失败了 88%?
没有人能立刻回答,因为团队只盯了“覆盖率”一个数字。覆盖率既不区分服务的重要性,也不区分迁移的深浅,更不反映退出成本。一个被迁移过来的边缘服务,和一个仍在旧链路上的核心交易服务,对结论的权重完全不同。
这一节把平台采纳拆成三种成本:迁移成本、双轨成本、退出成本。三者必须在立项时一起算,而不是等使用率停住之后再补账。
使用率不是一个数字,而是一组口径
“12% 的使用率”至少可以对应四种不同的计算方式,每种都在回答不同的问题:
- 覆盖率:迁移过的服务数除以纳入范围的服务数。它回答“铺开了多少”,但不回答“用得多不多”。
- 活跃率:过去一个发布周期内真正通过平台发布过的服务比例。它回答“迁了之后还在不在用”。
- 双轨率:迁移后仍保留旧路径的团队比例。它回答“旧成本有没有真正消失”。
- 退出成本:一个已迁移服务若要迁回旧路径,需要多少人力和时间。它回答“反悔有多贵”。
案例中的 12% 是覆盖率,9% 是活跃率,双轨率大约 32%(7/22)。三个数字放在一起,结论就从“覆盖慢”变成了“迁过来的三分之一还在双轨,平台并没有真正替代旧路径”。
更隐蔽的是分母。180 个服务里,有一部分是定时任务、一次性脚本和即将下线的实验服务。把它们算进分母,覆盖率会被稀释;把它们剔除,又容易变成“只统计容易迁的”。比较稳妥的做法是同时公布两个口径:全量分母和“纳入本期范围”的分母,并写清楚纳入标准。口径可以争论,但不能只有一个。
使用率还要看分布。案例中最早迁移的 22 个服务,大多是平台组自己维护的、依赖少的服务;剩下 158 个里,有 60 多个依赖遗留的虚拟机部署、老版本运行时或特殊网络策略。也就是说,12% 不是线性增长的起点,而是“容易的部分已经做完”的终点。用前 6 个月的速度外推,会严重高估后 6 个月。
三种成本要放在同一张账上
平台立项时,团队通常只算建设成本:多少工程师、多少机器、多少开发时间。真正决定成败的是另外三项,它们分布在平台上线之后,容易被忽略,也最容易在半年后集中爆发。
| 成本项 | 具体内容 | 案例估算(案例假设值) | 主要承担方 |
|---|---|---|---|
| 迁移成本 | 改发布脚本、适配配置、补测试、迁移监控项 | 平均 1.5 人月/服务,核心服务 3 人月 | 业务团队 |
| 双轨成本 | 两套发布路径并存,配置和权限重复维护 | 每月约 0.4 人月/团队 | 业务团队 + 平台组 |
| 学习成本 | 新概念、新流程、新排障方式 | 每人 3 到 5 天,集中在上线首月 | 业务团队 |
| 退出成本 | 迁回旧路径、数据与权限回迁、重新验证 | 平均 2 人月/服务,越高集成越贵 | 业务团队 + 原 Owner |
| 平台运维成本 | 值班、升级、容量、故障处理 | 5 人常驻,另有 20% 机动 | 平台组 |
这张表里最容易被低估的是退出成本。平台推广时,团队往往强调“迁过来很方便”,却很少说明“迁回去要付什么”。一旦业务方发现平台不适合自己,退出成本就成了沉默的锁:不回退,持续忍受;回退,又要再投一次人力。两年后,这笔账会以“技术债”的形式出现在架构评审上。
双轨成本同样被低估。双轨不是“多留一个脚本”那么简单,它意味着两套权限模型、两套告警路由、两套发布窗口,以及每次故障时都要判断“问题出在哪条路径”。案例中有个团队双轨运行了 5 个月,最后一次发布时两套配置不一致,导致灰度比例算错,事故复盘时才发现旧脚本里还留着上一个大促的开关。
一次“先上线再推广”的平台失败
Helios 的立项逻辑是典型的“先上线再推广”:先把平台做出来,用几个内部服务验证,然后号召业务团队迁移。这个逻辑在工具类项目上常常有效,在平台类项目上却容易失败,因为平台的成本由业务团队承担,收益却要等到规模足够大才出现。
时间线(案例假设值):
- 2025 年 11 月,Helios 1.0 上线,平台组自己的 8 个服务首批接入,覆盖率 4%。
- 2026 年 1 月,强推第一波迁移,把“季度目标”压到 30 个服务;业务团队为了达标,优先迁了边缘服务。
- 2026 年 3 月,覆盖率达到 12%,但活跃率只有 9%;两个核心交易服务迁到一半,发现平台不支持它们需要的蓝绿发布,暂停。
- 2026 年 4 月,平台组提出补蓝绿能力,需要 4 个工程师和 3 个月;财务问 ROI,平台组拿不出“节省了多少人月”的数据。
- 2026 年 5 月,已迁移的一个团队尝试迁回旧路径,发现旧脚本已经半年没维护,权限也被回收,回迁花了 3 周。
- 2026 年 6 月,公司决定“两条路径都保留”,平台继续维护,旧路径也不下线。双轨从临时状态变成长期状态。
失败的根因不是技术能力不足,而是采纳曲线被误读。平台组假设覆盖率会按 S 曲线上升,于是把前 12% 当成“早期采用者”,认为只要继续推广就会自然增长。但前 12% 是平台组自己和关系好的团队,属于“非典型样本”;真正的目标用户是那 158 个依赖复杂、发布窗口紧张的服务,他们的迁移成本和前 12% 完全不是一个量级。
第二个根因是收益没有量化。平台的价值主张是“少维护发布脚本”,但节省的人月从未被记账;而迁移成本、双轨成本却真实地落在业务团队的季度目标里。一边是看不见的收益,一边是看得见的成本,采纳停滞几乎是必然结果。
第三个根因是退出成本没有提前设计。平台没有提供“迁移检查清单”和“回迁演练”,业务团队一旦迁入就被锁定。锁定本身不一定是坏事,但必须是明示的交换:平台用更低的长期运维成本,换取业务方放弃一部分自主权。案例中没有人把这句话说清楚。
用覆盖率与成本函数做判断
下面的代码做两件事:计算迁移覆盖率,以及在给定覆盖率和成本收益时判断是否应该继续推进。它不读数据库、不查工单系统,只把判断规则固定下来,方便在复盘和审批时复用。
// file: internal/pipeline/adoption.go
// tier: L1
// verify: go test ./internal/pipeline/
package pipeline
import (
"fmt"
"math"
)
// Adoption 是平台采纳进度的快照。
// Total 是纳入本次迁移范围的服务或工作负载数量,Migrated 是已经切过去的数量。
type Adoption struct {
Migrated int
Total int
}
// Rate 返回迁移覆盖率,取值 [0,1]。
// 它拒绝“没有范围”和“数字互相矛盾”的输入,因为这两种情况都会让
// 使用率报表看起来很好,却无法指导下一步决策。
func Rate(a Adoption) (float64, error) {
if a.Total <= 0 {
return 0, fmt.Errorf("pipeline: total must be positive, got %d", a.Total)
}
if a.Migrated < 0 || a.Migrated > a.Total {
return 0, fmt.Errorf("pipeline: migrated %d out of range [0,%d]", a.Migrated, a.Total)
}
return float64(a.Migrated) / float64(a.Total), nil
}
// ShouldAdopt 判断是否值得继续推进平台采纳。
//
// 两个条件必须同时成立:
// - 覆盖率严格超过一半。低于一半时,双轨成本还在,规模收益尚未出现;
// - 收益严格大于成本。相等意味着这笔投入没有回报,只消耗了迁移窗口。
//
// 非法覆盖率(NaN、负数、大于 1)一律返回 false:宁可停在原地,
// 也不要用一个算错的比例去驱动组织级决策。
func ShouldAdopt(rate float64, cost, benefit int64) bool {
if math.IsNaN(rate) || rate <= 0.5 || rate > 1 {
return false
}
if cost < 0 || benefit < 0 {
return false
}
return benefit > cost
}
Rate 先校验 Total 和 Migrated 的关系。Total <= 0 通常意味着统计范围没配置好,或者查询条件写错;Migrated 越界则说明数据源之间不一致,例如迁移清单和资产清单没有对齐。这两种情况都不应该返回一个“看起来正常”的比例,否则报表会把配置错误包装成业务进展。
ShouldAdopt 要求覆盖率严格大于 0.5,而不是大于等于。等于一半时,双轨成本仍然完整存在,规模收益却还没有出现;此时继续投入,通常只是把更多团队锁进一个尚未证明有效的平台。这个阈值不是自然常数,案例中选择 0.5 是因为平台的主要收益来自“统一路径”,而统一只有在多数服务都走同一条路径时才成立。
收益必须严格大于成本,而不是大于等于。相等意味着这笔投入没有净回报,还占用了业务团队的迁移窗口;在资源紧张的季度,这种投入的机会成本很高。cost 和 benefit 用 int64 表示人月或金额,调用方要保证单位一致;函数本身无法判断单位,这是接口层要约束的事情。
非法覆盖率一律返回 false,包括 NaN、负数、大于 1 和恰好 0.5。NaN 在浮点比较中会让所有大小判断都为假,如果不显式拦截,一个算错的比例可能被当成“满足条件”。宁可停在原地,也不要用坏数据驱动组织级决策。
测试:边界条件比 happy path 更重要
采纳判断的难点不在正常值,而在“刚好一半”“收益等于成本”“比例算错”这些边界。下面的测试把它们逐条固定下来。
// file: internal/pipeline/adoption_test.go
// tier: L1
// verify: go test ./internal/pipeline/
package pipeline
import (
"math"
"testing"
)
func TestRate(t *testing.T) {
cases := []struct {
name string
a Adoption
want float64
}{
{"none", Adoption{Migrated: 0, Total: 10}, 0},
{"half", Adoption{Migrated: 5, Total: 10}, 0.5},
{"three-quarters", Adoption{Migrated: 30, Total: 40}, 0.75},
{"all", Adoption{Migrated: 10, Total: 10}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := Rate(tc.a)
if err != nil {
t.Fatalf("Rate(%+v): %v", tc.a, err)
}
if got != tc.want {
t.Fatalf("Rate(%+v) = %v, want %v", tc.a, got, tc.want)
}
})
}
}
func TestRateRejectsBadCounts(t *testing.T) {
for _, a := range []Adoption{
{Migrated: 0, Total: 0},
{Migrated: 1, Total: -3},
{Migrated: -1, Total: 10},
{Migrated: 11, Total: 10},
} {
if _, err := Rate(a); err == nil {
t.Fatalf("Rate(%+v): want error", a)
}
}
}
func TestShouldAdopt(t *testing.T) {
cases := []struct {
name string
rate float64
cost int64
benefit int64
want bool
}{
{"just-past-half", 0.51, 100, 101, true},
{"exactly-half", 0.5, 100, 1000, false},
{"below-half", 0.4, 100, 1000, false},
{"benefit-equals-cost", 0.8, 100, 100, false},
{"benefit-below-cost", 0.8, 100, 99, false},
{"full-rate", 1.0, 0, 1, true},
{"rate-above-one", 1.2, 0, 1000, false},
{"nan-rate", math.NaN(), 0, 1000, false},
{"negative-cost", 0.9, -1, 1000, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ShouldAdopt(tc.rate, tc.cost, tc.benefit); got != tc.want {
t.Fatalf("ShouldAdopt(%v,%d,%d) = %v, want %v",
tc.rate, tc.cost, tc.benefit, got, tc.want)
}
})
}
}
TestRate 覆盖 0、0.5、0.75 和 1 四个有代表性的比例。选择 0.75 而不是 1/3,是为了避免浮点表示带来的比较误差;比例测试应该用二进制可精确表示的值,或者在断言时使用容差。
TestRateRejectsBadCounts 覆盖总数为 0、总数为负、迁移数为负、迁移数大于总数。这四种输入在真实报表里都出现过:总数为 0 来自空的分组,负迁移数来自回滚后没有更新计数,迁移数大于总数来自两个数据源的口径不一致。
TestShouldAdopt 用表驱动覆盖 9 种组合,包括刚好一半、收益等于成本、收益小于成本、覆盖率为 1、覆盖率大于 1、NaN 和负成本。just-past-half 用 0.51 而不是 0.5001,是为了让测试意图一眼可见;如果实现把阈值改成 >=,这个用例会失败。
测试没有覆盖“覆盖率随时间变化”的序列。采纳是一个动态过程,单点判断只能回答“现在该不该继续”,不能回答“再过三个月会不会自然增长”。后者需要按周记录覆盖率、活跃率和双轨率,做成趋势图,而不是在单元测试里模拟。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/pipeline/ -run 'TestRate|TestShouldAdopt' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestRate
=== RUN TestRate/none
=== RUN TestRate/half
=== RUN TestRate/three-quarters
=== RUN TestRate/all
--- PASS: TestRate (0.00s)
--- PASS: TestRate/none (0.00s)
--- PASS: TestRate/half (0.00s)
--- PASS: TestRate/three-quarters (0.00s)
--- PASS: TestRate/all (0.00s)
=== RUN TestRateRejectsBadCounts
--- PASS: TestRateRejectsBadCounts (0.00s)
=== RUN TestShouldAdopt
=== RUN TestShouldAdopt/just-past-half
=== RUN TestShouldAdopt/exactly-half
=== RUN TestShouldAdopt/below-half
=== RUN TestShouldAdopt/benefit-equals-cost
=== RUN TestShouldAdopt/benefit-below-cost
=== RUN TestShouldAdopt/full-rate
=== RUN TestShouldAdopt/rate-above-one
=== RUN TestShouldAdopt/nan-rate
=== RUN TestShouldAdopt/negative-cost
--- PASS: TestShouldAdopt (0.00s)
--- PASS: TestShouldAdopt/just-past-half (0.00s)
--- PASS: TestShouldAdopt/exactly-half (0.00s)
--- PASS: TestShouldAdopt/below-half (0.00s)
--- PASS: TestShouldAdopt/benefit-equals-cost (0.00s)
--- PASS: TestShouldAdopt/benefit-below-cost (0.00s)
--- PASS: TestShouldAdopt/full-rate (0.00s)
--- PASS: TestShouldAdopt/rate-above-one (0.00s)
--- PASS: TestShouldAdopt/nan-rate (0.00s)
--- PASS: TestShouldAdopt/negative-cost (0.00s)
PASS
ok webapp/internal/pipeline 0.382s
全部用例通过,退出码为 0。输出里每个子测试都单独列了一行,方便在 CI 日志里定位具体失败的是哪一个边界值。
小结
平台采纳不是“做出来就会有人用”的自然过程,而是一笔需要持续记账的投资。覆盖率回答铺开了多少,活跃率和双轨率回答旧成本有没有真的消失,退出成本回答反悔有多贵。三个口径缺一个,结论就会被误读。
“先上线再推广”在平台类项目上尤其危险,因为成本落在业务团队,收益要等规模出现。立项时就应该把迁移、双轨、学习和退出四项成本写进同一张表,并明确谁来承担;推广时先承诺退出路径,再谈锁定,反而更容易获得信任。
下一节进入新的一章,讨论信息流旧链路的问题与基线:当系统从一个产品长成一组产品,最先暴露的往往不是容量,而是链路本身的假设。
阅读导航:上一节:8.2 内部平台的能力边界 · 下一节:9.1 旧链路的问题与基线 。
常见错误与边界
- 只用覆盖率评价平台。覆盖率不区分服务重要性,也不反映迁移后的真实使用;至少同时看活跃率和双轨率。
- 把早期采用者当成代表性样本。平台组自己和关系好的团队,迁移成本通常远低于目标用户。
- 只算建设成本,不算迁移和双轨成本。后两项由业务团队承担,直接决定他们是否愿意继续投入。
- 不设计退出路径。没有回迁演练和检查清单,业务方一旦迁入就被锁定,反悔成本会在两年后集中出现。
- 用
>= 0.5作为采纳阈值。刚好一半时双轨成本仍在,规模收益尚未出现;阈值应严格大于一半。 - 收益等于成本时继续推进。净回报为零的投入仍占用迁移窗口,机会成本不应被忽略。
- 让 NaN 或越界比例进入判断。浮点比较会让坏数据看起来“满足条件”,入口必须显式拒绝。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。