《互联网产品的服务端架构·规模化与架构演进》1.1 拆分的触发条件

从「回声」一次关于要不要拆服务的评审写起,说明团队数、发布冲突率和故障爆炸半径为什么比代码行数更能判断拆分时机,给出可标定的阈值与强、辅助信号的分工,并用 Go 实现的 Trigger 与 Level 把「该不该拆、拆到什么程度」变成可测试、可复盘的判断。

一次关于「要不要拆」的评审

「回声」日活 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% 用户的故障,和十次只影响内部工具的故障,不是一回事。
  • 命中信号就直接拆服务。先判断该模块化还是服务化,模块化能解决大部分协作问题。
  • 把函数输出当成审批结论。它只能固定口径,不能替代依赖分析、成本评估和回退设计。
  • 统计口径不稳定。团队数和冲突率的定义一变,历史数据就不可比,评审会重新变成各说各话。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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