一次关于「要不要拆」的评审
「回声」日活 30 万时,后端仍然是一个仓库、一次发布。九个团队共用一条发布列车,每周二、周四各发一次。订单团队要改履约状态机,支付团队要改对账字段,两边都要动 orders 和 order_items;合并前互相 rebase,合并后一起等回归。最近四周的 40 次发布里,有 7 次因为跨团队冲突回滚,冲突率 17.5%。
上个月的一次故障更能说明问题。履约团队改了一个状态判断,把“已支付未发货”误判成“已取消”,检查范围覆盖了所有下单用户。故障持续 25 分钟,期间约 40% 的活跃用户看到订单消失。事后复盘,大家同意的不是“谁写错了”,而是“一个团队的改动不该影响这么大比例的用户”。
评审会上有人提议:订单模块有 18 万行代码,支付模块只有 3 万行,先把订单拆出去。技术负责人反问:代码行数能说明订单和支付之间的发布冲突吗?能说明一次改动的爆炸半径吗?如果不能,按行数拆只是在切代码目录,不是在切组织边界。
三个信号,而不是代码行数
拆分的目的是降低协作成本和故障影响面,所以信号也应该从这两个方向取。下面这张表是案例假设的判断口径,阈值只是起点,不是行业标准。
| 信号 | 怎么量 | 起点阈值 | 命中说明什么 | 长期忽略的后果 |
|---|---|---|---|---|
| 团队数 | 最近四周有提交的团队数 | 4 | 超过一条发布列车能协调的规模 | 发布排队,评审变成跨团队谈判 |
| 发布冲突率 | 因跨团队冲突回滚的发布占比 | 15% | 代码边界和团队边界不重合 | 大家开始降低发布频率,交付变慢 |
| 故障爆炸半径 | 单次故障影响的活跃用户占比 | 50% | 一个模块的失误能拖垮主链路 | 稳定性投入永远追不上故障面 |
| 周发布次数 | 全站每周生产发布次数 | 20(辅助) | 变更密度高,协调成本被放大 | 小冲突累积成发布冻结 |
| 构建时长 | 从提交到可部署产物的分钟数 | 20(辅助) | 反馈变慢,团队倾向批量提交 | 问题定位变难,回滚粒度变粗 |
前三个是强信号,任意一个达到阈值就值得启动拆分评估;后两个是辅助信号,只有两个同时命中才计入触发。这样设计的原因是:发布多可能只是业务节奏快,构建慢可能只是缓存没做好,单独一条都不足以证明“必须拆服务”。但两个同时出现,通常意味着变更密度已经超过了当前结构的承载能力。
阈值必须可标定。团队数 4 对应“一条发布列车最多能协调多少个团队”的经验值;冲突率 15% 对应“每七次发布就有一次因冲突回滚”;爆炸半径 50% 对应“一次故障影响一半活跃用户”。换了业务、换了发布方式,这些数字都要重算。代码里把它们写成常量,是为了让评审有共同起点,不是为了给出标准答案。
代码行数为什么不算信号
代码行数衡量的是产出,不是协调成本。一个 30 万行的报表模块,如果只有一个团队维护、半年发布一次,拆它没有收益;一个 2 万行的账户模块,如果六个团队都要改、每次发布都冲突,它才是候选。行数还会被生成代码、vendored 依赖和历史迁移放大,删掉一万行死代码不会让协作变好。
更危险的是,按行数拆很容易沿着代码目录切,而不是沿着数据所有权切。目录可以随便分,表却只有一份;两个新服务仍然写同一张 orders,发布冲突只是从“同一个仓库”变成“两个服务同时改表”,故障半径甚至更大。
一次按代码行数拆分的失败
案例假设:「回声」曾经把订单域按代码量拆成“订单查询服务”和“订单写入服务”。查询侧代码多、读流量大,先独立;写入侧保留在单体里。拆分当天看起来很顺利:两个进程、两套部署、两条告警,服务数量从 12 个涨到 13 个,架构图上多了一个框。
问题出在数据上。查询服务为了减少跨服务调用,自己缓存了订单状态,并在取消场景下“顺手”回写了一张状态表;写入服务仍然认为自己是 orders 的唯一写入方。两边对“已取消”和“已退款”的优先级判断不一致,一次促销退单高峰里,查询侧把写入侧刚写的“已退款”覆盖成“已取消”。用户看到退款成功又变回取消,客服工单在半小时内涨了三倍。
后果分三层。第一层是用户:订单状态来回跳,退款进度无法解释。第二层是数据:需要按时间窗口核对两边写入,人工修复持续了两天。第三层是组织:两个团队开始互相加校验、加锁、加告警,发布冲突没有减少,反而多了一层跨服务协议。复盘结论很直接:拆的是代码目录,不是数据边界;共享表还在,拆分就没有完成。
修复不是再加一个同步任务,而是先把 orders 的写入权收归一个模块,查询侧只能读,缓存失效由写入侧发事件。写入权明确之后,两个服务才真正可以独立发布。这个顺序在后两节会反复出现:先定边界,再谈拆分。
把阈值写成可测试的函数
评审里最常见的争论是“我觉得还没到”和“我觉得早就该拆了”。把口径写成函数,至少能让争论落在同一组输入上:团队数多少、冲突率多少、爆炸半径多少,命中哪条规则,建议停在单体、模块化还是服务化。
下面这段代码来自本书配套的 webapp-lab。Signals 是最近四周的统计输入,Trigger 返回是否该拆以及命中的原因,Level 给出形态建议。阈值全部写成常量,并在注释里说明它们是起点。
// file: internal/split/trigger.go
// tier: L1
// verify: go test ./internal/split/
package split
import "fmt"
// 以下阈值是评估拆分的起点,不是行业标准。每个产品应按自己的发布节奏、
// 故障成本和团队结构重新标定,并在评审记录里写明依据。
const (
TeamsLimit = 4
WeeklyDeploysLimit = 20
DeployConflictRatePct = 15
BlastRadiusPct = 50
BuildMinutesLimit = 20
ServiceBlastRadiusPct = 80
)
// Signals 是一次拆分评估的输入。统计窗口建议取最近四周,
// 比率用百分比整数表示,避免浮点数在边界上产生歧义。
type Signals struct {
Teams int
WeeklyDeploys int
DeployConflictRate int
IncidentBlastRadius int
BuildMinutes int
}
// Trigger 判断是否应该开始拆分,并返回命中的原因。
// 强信号是团队数、发布冲突率、故障爆炸半径:任意一个达到阈值就返回 true。
// 周发布次数与构建时长是辅助信号,只有两个同时达到阈值才算命中,
// 避免把“发布多”或“构建慢”单独当成拆分理由。
func Trigger(s Signals) (bool, []string) {
var strong, weak []string
if s.Teams >= TeamsLimit {
strong = append(strong, fmt.Sprintf("teams=%d>=%d", s.Teams, TeamsLimit))
}
if s.DeployConflictRate >= DeployConflictRatePct {
strong = append(strong, fmt.Sprintf("deploy-conflict=%d%%>=%d%%", s.DeployConflictRate, DeployConflictRatePct))
}
if s.IncidentBlastRadius >= BlastRadiusPct {
strong = append(strong, fmt.Sprintf("incident-blast=%d%%>=%d%%", s.IncidentBlastRadius, BlastRadiusPct))
}
if s.WeeklyDeploys >= WeeklyDeploysLimit {
weak = append(weak, fmt.Sprintf("weekly-deploys=%d>=%d", s.WeeklyDeploys, WeeklyDeploysLimit))
}
if s.BuildMinutes >= BuildMinutesLimit {
weak = append(weak, fmt.Sprintf("build-minutes=%d>=%d", s.BuildMinutes, BuildMinutesLimit))
}
if len(strong) > 0 {
return true, append(strong, weak...)
}
if len(weak) == 2 {
return true, weak
}
return false, nil
}
// Level 给出拆分形态的建议:monolith 继续单体,modular 先做模块化,
// service 才进入服务化拆分。两个及以上强信号、团队数达到阈值两倍,
// 或爆炸半径达到 80%,说明问题已经不是模块边界能解决的了。
func Level(s Signals) string {
split, _ := Trigger(s)
if !split {
return "monolith"
}
strong := 0
if s.Teams >= TeamsLimit {
strong++
}
if s.DeployConflictRate >= DeployConflictRatePct {
strong++
}
if s.IncidentBlastRadius >= BlastRadiusPct {
strong++
}
if strong >= 2 || s.Teams >= 2*TeamsLimit || s.IncidentBlastRadius >= ServiceBlastRadiusPct {
return "service"
}
return "modular"
}
几个设计选择需要说明。
- 强信号和辅助信号分开收集。强信号命中直接返回 true;辅助信号必须两个同时命中,避免把“发布频繁”或“构建慢”单独当成拆分理由。
- 返回原因而不是只返回布尔值。评审记录里可以直接写“命中 teams=9>=4 与 deploy-conflict=17>=15”,下次复盘能对上同一口径。
Level不重新定义阈值,而是复用Trigger的判断。两个及以上强信号、团队数达到阈值两倍、爆炸半径达到 80%,才建议进入服务化;否则先做模块化。- 负数输入一律不命中。统计缺失时传 0 比传一个猜出来的值更安全,函数不会因为缺数据而给出“该拆”的结论。
- 阈值是起点,不是结论。函数只负责把口径固定下来,是否真的拆、先拆哪一块,仍然要结合依赖关系和回退成本判断。
这段代码不读取监控,也不做统计。真实系统里,团队数来自代码仓库的提交者去重,冲突率来自发布系统的事故标签,爆炸半径来自故障复盘的影响面估算。输入怎么来可以换,判断口径应该保持稳定。
测试:单信号、辅助信号与边界
测试要证明三件事:任一强信号单独出现就触发;辅助信号必须成对;形态建议不会把“该模块化”误判成“该服务化”。下面这组表驱动测试把这三类边界固定下来。
// file: internal/split/trigger_test.go
// tier: L1
// verify: go test ./internal/split/
package split
import (
"strings"
"testing"
)
func TestTriggerNoSignalStaysMonolith(t *testing.T) {
ok, reasons := Trigger(Signals{})
if ok {
t.Fatalf("Trigger(zero) = true, reasons %v", reasons)
}
if len(reasons) != 0 {
t.Fatalf("Trigger(zero) reasons = %v, want none", reasons)
}
if got := Level(Signals{}); got != "monolith" {
t.Fatalf("Level(zero) = %q, want monolith", got)
}
}
func TestTriggerStrongSignalAloneIsEnough(t *testing.T) {
cases := []struct {
name string
s Signals
want string
}{
{"teams", Signals{Teams: TeamsLimit}, "teams="},
{"deploy-conflict", Signals{DeployConflictRate: DeployConflictRatePct}, "deploy-conflict="},
{"incident-blast", Signals{IncidentBlastRadius: BlastRadiusPct}, "incident-blast="},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ok, reasons := Trigger(c.s)
if !ok {
t.Fatalf("Trigger(%+v) = false, want true", c.s)
}
if len(reasons) != 1 {
t.Fatalf("reasons = %v, want exactly one", reasons)
}
if !strings.HasPrefix(reasons[0], c.want) {
t.Fatalf("reason = %q, want prefix %q", reasons[0], c.want)
}
})
}
}
func TestTriggerNeedsBothAuxiliarySignals(t *testing.T) {
if ok, _ := Trigger(Signals{WeeklyDeploys: WeeklyDeploysLimit}); ok {
t.Fatal("weekly deploys alone triggered a split")
}
if ok, _ := Trigger(Signals{BuildMinutes: BuildMinutesLimit}); ok {
t.Fatal("build minutes alone triggered a split")
}
s := Signals{WeeklyDeploys: WeeklyDeploysLimit, BuildMinutes: BuildMinutesLimit}
ok, reasons := Trigger(s)
if !ok {
t.Fatal("both auxiliary signals did not trigger a split")
}
if len(reasons) != 2 {
t.Fatalf("reasons = %v, want two", reasons)
}
if got := Level(s); got != "modular" {
t.Fatalf("Level(%+v) = %q, want modular", s, got)
}
}
func TestTriggerCollectsReasonsInOrder(t *testing.T) {
s := Signals{
Teams: 6,
WeeklyDeploys: 30,
DeployConflictRate: 25,
IncidentBlastRadius: 70,
BuildMinutes: 35,
}
ok, reasons := Trigger(s)
if !ok {
t.Fatal("Trigger() = false, want true")
}
want := []string{"teams=", "deploy-conflict=", "incident-blast=", "weekly-deploys=", "build-minutes="}
if len(reasons) != len(want) {
t.Fatalf("reasons = %v, want %d", reasons, len(want))
}
for i, prefix := range want {
if !strings.HasPrefix(reasons[i], prefix) {
t.Fatalf("reasons[%d] = %q, want prefix %q", i, reasons[i], prefix)
}
}
}
func TestTriggerIgnoresNegativeInputs(t *testing.T) {
s := Signals{Teams: -1, WeeklyDeploys: -1, DeployConflictRate: -1, IncidentBlastRadius: -1, BuildMinutes: -1}
if ok, reasons := Trigger(s); ok {
t.Fatalf("Trigger(negative) = true, reasons %v", reasons)
}
}
func TestLevelPromotesOnlyOnSeverity(t *testing.T) {
cases := []struct {
name string
s Signals
want string
}{
{"one-strong", Signals{Teams: TeamsLimit}, "modular"},
{"two-strong", Signals{Teams: TeamsLimit, DeployConflictRate: DeployConflictRatePct}, "service"},
{"double-teams", Signals{Teams: 2 * TeamsLimit}, "service"},
{"wide-blast", Signals{IncidentBlastRadius: ServiceBlastRadiusPct}, "service"},
{"auxiliary-pair", Signals{WeeklyDeploys: WeeklyDeploysLimit, BuildMinutes: BuildMinutesLimit}, "modular"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := Level(c.s); got != c.want {
t.Fatalf("Level(%+v) = %q, want %q", c.s, got, c.want)
}
})
}
}
TestTriggerStrongSignalAloneIsEnough 分别用团队数、冲突率、爆炸半径各构造一个刚好达到阈值的输入,确认每个强信号都能单独触发。TestTriggerNeedsBothAuxiliarySignals 先验证周发布次数或构建时长单独出现时不触发,再验证两者同时出现时触发,并且形态建议仍是 modular。
TestTriggerCollectsReasonsInOrder 把所有信号拉满,检查原因的顺序固定为强信号在前、辅助信号在后。顺序稳定不是为了好看,而是为了让评审记录和测试断言可以逐字比较。TestLevelPromotesOnlyOnSeverity 则覆盖了“一个强信号是模块化、两个强信号才是服务化”的分界。
测试没有覆盖的部分也要说清楚:它不验证统计窗口是否合理,不验证团队数去重口径,也不验证爆炸半径的估算方法。这些属于数据采集和复盘流程,函数只负责在给定输入下给出一致判断。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/split/ -run '^(TestTrigger.*|TestLevel.*)$' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestTriggerNoSignalStaysMonolith
--- PASS: TestTriggerNoSignalStaysMonolith (0.00s)
=== RUN TestTriggerStrongSignalAloneIsEnough
=== RUN TestTriggerStrongSignalAloneIsEnough/teams
=== RUN TestTriggerStrongSignalAloneIsEnough/deploy-conflict
=== RUN TestTriggerStrongSignalAloneIsEnough/incident-blast
--- PASS: TestTriggerStrongSignalAloneIsEnough (0.00s)
--- PASS: TestTriggerStrongSignalAloneIsEnough/teams (0.00s)
--- PASS: TestTriggerStrongSignalAloneIsEnough/deploy-conflict (0.00s)
--- PASS: TestTriggerStrongSignalAloneIsEnough/incident-blast (0.00s)
=== RUN TestTriggerNeedsBothAuxiliarySignals
--- PASS: TestTriggerNeedsBothAuxiliarySignals (0.00s)
=== RUN TestTriggerCollectsReasonsInOrder
--- PASS: TestTriggerCollectsReasonsInOrder (0.00s)
=== RUN TestTriggerIgnoresNegativeInputs
--- PASS: TestTriggerIgnoresNegativeInputs (0.00s)
=== RUN TestLevelPromotesOnlyOnSeverity
=== RUN TestLevelPromotesOnlyOnSeverity/one-strong
=== RUN TestLevelPromotesOnlyOnSeverity/two-strong
=== RUN TestLevelPromotesOnlyOnSeverity/double-teams
=== RUN TestLevelPromotesOnlyOnSeverity/wide-blast
=== RUN TestLevelPromotesOnlyOnSeverity/auxiliary-pair
--- PASS: TestLevelPromotesOnlyOnSeverity (0.00s)
--- PASS: TestLevelPromotesOnlyOnSeverity/one-strong (0.00s)
--- PASS: TestLevelPromotesOnlyOnSeverity/two-strong (0.00s)
--- PASS: TestLevelPromotesOnlyOnSeverity/double-teams (0.00s)
--- PASS: TestLevelPromotesOnlyOnSeverity/wide-blast (0.00s)
--- PASS: TestLevelPromotesOnlyOnSeverity/auxiliary-pair (0.00s)
PASS
ok webapp/internal/split 0.267s
输出里的每个子用例对应一条规则。PASS 表示包内测试通过,ok 后面的时间是本次运行耗时,不是性能承诺。真正上线前,还应该拿过去四个季度的真实统计跑一遍,看看阈值和你所在业务的发布节奏是否匹配。
小结
拆分不是成熟度标志,而是一项有成本的手段。判断该不该拆,先看协作是否被结构卡住、故障是否被结构放大:团队数、发布冲突率、故障爆炸半径是三个强信号,周发布次数和构建时长只在同时命中时作为辅助证据。代码行数不在这张表里,因为它衡量产出,不衡量协调成本。
Trigger 把“该不该拆”写成可测试的判断,Level 把“拆到什么程度”分成单体、模块化、服务化三档。阈值是起点,评审记录要写清楚为什么用这组数字、下次什么时候复算。函数不能替你做决定,但能让同一个决定在半年后仍然可解释。
下一节讨论更具体的问题:决定要拆之后,边界应该按产品域画,还是按技术层画。
阅读导航:上一节:目录与学习路径 · 下一节:1.2 按产品域拆还是按层拆 。
常见错误与边界
- 按代码行数拆。行数多可能只是生成代码或历史包袱,和发布冲突没有直接关系。
- 把周发布次数当成唯一信号。发布多可能只是业务节奏快,不能单独证明需要拆服务。
- 只看团队数,不看冲突率。九个团队各自独立发布、互不改同一模块时,未必需要拆。
- 阈值拍脑袋定完就再也不改。业务节奏、发布方式和故障成本都会变,阈值要定期复算。
- 用故障次数代替爆炸半径。一次影响 80% 用户的故障,和十次只影响内部工具的故障,不是一回事。
- 命中信号就直接拆服务。先判断该模块化还是服务化,模块化能解决大部分协作问题。
- 把函数输出当成审批结论。它只能固定口径,不能替代依赖分析、成本评估和回退设计。
- 统计口径不稳定。团队数和冲突率的定义一变,历史数据就不可比,评审会重新变成各说各话。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。