一次“代码太大”的拆分提案
2026 年第三季度,arch-lab 订单系统仍是一个部署单元:Order、InventoryReservation、Payment、Notification 和后台任务跑在同一个进程里,共 9 个模块、约 31 万行 Go 代码。Inventory 模块六个月里从 4 人涨到 12 人,评审会上有人提出:“这个模块已经 6 万行了,应该拆成独立服务,团队自己发版。”
这个理由听起来顺理成章,但它回答的是“模块大不大”,不是“拆分能不能解决问题”。拆分之后,原本一次本地函数调用会变成跨网络调用,原本一个数据库事务会变成两个服务的状态协调,原本一次发布回滚会变成两套部署与数据回退。6 万行代码不会因为换了进程就自动变清晰;如果边界没找对,它只会变成两个互相调用的 3 万行服务。
真正需要先回答的是三个问题:这个模块和别的模块是否被迫一起变更;它是否需要独立的资源与副本;它的故障或合规范围是否必须与其他部分物理隔离。三个问题都答“否”时,正确动作通常是先把模块边界做进单体,而不是拆服务。关于这种路径,可以先看模块化单体 和单体与微服务的取舍 。
拆分的成本要先摆在桌上
| 维度 | 单部署单元 | 两个服务 |
|---|---|---|
| 调用 | 进程内函数调用 | 网络调用,需超时、重试、熔断 |
| 一致性 | 一个本地事务 | 跨服务状态协调,需补偿或事件 |
| 发布 | 一次构建、一次回滚 | 两套制品、契约与兼容窗口 |
| 故障 | 模块崩溃拖垮整个进程 | 故障可隔离,但依赖失败会传导 |
| 运维 | 一套日志、指标、告警 | 两套身份、链路、值班与容量 |
这张表不是劝退拆分,而是说明拆分必须换来可测量的收益。卷三已经讨论过跨服务一致性与对账的实现,这里不再重复;本节只关心“什么时候值得付这笔成本”。部署单元与故障域的划分可以对照部署单元与故障域 。
三个触发条件
把“想拆”翻译成可观测信号,才有讨论基础。下表是 arch-lab 使用的三个触发器。阈值是示例值,不是行业标准;每个系统应当用自己的发布记录和监控数据重新标定。
| 触发条件 | 可测信号 | 示例阈值 | 常见反例 |
|---|---|---|---|
| 变更频率冲突 | 每月发布次数、其中必须同步修改另一模块的比例 | 每月 ≥ 4 次且耦合发布 ≥ 30% | “代码 6 万行”“团队 12 人” |
| 独立扩缩容 | 模块占部署单元峰值 CPU 或峰值流量的比例 | 任一项 ≥ 60% | 平均 CPU 只有 30%,但峰值被均值掩盖 |
| 故障隔离与合规 | 关键级别、监管范围、故障影响用户比例 | 一级链路且受监管,或影响 ≥ 50% 用户 | “想要独立仓库”“想用不同语言” |
变更频率冲突衡量的是协同成本,不是代码体积。如果 Inventory 每两周发一次版,而 Order 每周发三次,且 40% 的 Order 发布必须同时调整 Inventory 的接口,那么两个团队已经被同一根发布节奏绑住。反过来,一个 8 万行但半年只改两次的模块,不构成拆分理由。
独立扩缩容看的是峰值,不是平均。Inventory 的库存扣减在整点促销时可能吃掉整个部署单元 85% 的 CPU,而 Order 查询在其余时间占主导;这时给 Inventory 单独副本才有意义。若两个模块的峰值总是同时到来、资源曲线高度相关,拆开也只是把同一台机器的压力分成两份。
故障隔离与合规是唯一可能“一票通过”的触发器。支付链路属于一级关键系统,又涉及监管数据驻留时,物理隔离可能是硬要求,而不是优化项。但“合规”必须落到具体范围:哪些数据、哪些地区、哪些审计要求;只写“领导要求隔离”不构成可执行边界。
评分卡:触发强度与就绪门分开
评分卡把三个触发器各打 0–3 分,再把五项就绪门作为独立的硬条件。触发强度回答“值不值得拆”,就绪门回答“现在能不能拆”。两者混在一起,最容易出现“分数很高但团队根本没准备好”的误判。
| 触发总分 | 就绪门 | 决定 |
|---|---|---|
| 0 | 任意 | keep_modular,先在单体里修边界 |
| 1–5 | 全部满足 | modular_first,先做进程内模块化并复测 |
| ≥ 6 | 全部满足 | split_candidate,进入拆分设计 |
| 合规触发 3 分 | 全部满足 | split_candidate,物理隔离是硬要求 |
| ≥ 3 | 有缺口 | prepare,先补数据负责人、契约测试等 |
五项就绪门是:数据有明确负责人、跨模块契约测试可运行、服务能独立部署、回滚方案经过演练、观测能区分两个边界。任何一项缺失,拆分都会把问题从代码层搬到组织层。下面这个包只依赖标准库,把信号校验、评分、就绪门和决定放在一起,输出一行可供评审记录的结果。
// file: internal/platform/split/scorecard.go
// tier: L1
// verify: go test ./internal/platform/split/ -count=1
package split
import (
"fmt"
"math"
"sort"
"strings"
)
// Trigger is one observable reason that can justify a service split.
type Trigger string
const (
ChangeFrequencyConflict Trigger = "change_frequency_conflict"
IndependentScaling Trigger = "independent_scaling"
FaultIsolationCompliance Trigger = "fault_isolation_compliance"
)
// Signal is measured from releases, runtime metrics and compliance scope.
// Team size and lines of code are absent: neither is a split trigger.
type Signal struct {
ModuleReleasesPerMonth float64
CoupledReleasesPerMonth float64
PeakCPUConsumption float64
PeakTrafficShare float64
CriticalityTier int
Regulated bool
BlastRadiusShare float64
}
func (s Signal) validate() error {
switch {
case s.ModuleReleasesPerMonth < 0 || s.CoupledReleasesPerMonth < 0:
return fmt.Errorf("split: releases must not be negative")
case s.CoupledReleasesPerMonth > s.ModuleReleasesPerMonth:
return fmt.Errorf("split: coupled releases %.0f exceed total %.0f", s.CoupledReleasesPerMonth, s.ModuleReleasesPerMonth)
case s.PeakCPUConsumption < 0 || s.PeakCPUConsumption > 1:
return fmt.Errorf("split: cpu share must be within [0,1], got %.2f", s.PeakCPUConsumption)
case s.PeakTrafficShare < 0 || s.PeakTrafficShare > 1:
return fmt.Errorf("split: traffic share must be within [0,1], got %.2f", s.PeakTrafficShare)
case s.CriticalityTier < 1 || s.CriticalityTier > 3:
return fmt.Errorf("split: criticality tier must be 1..3, got %d", s.CriticalityTier)
case s.BlastRadiusShare < 0 || s.BlastRadiusShare > 1:
return fmt.Errorf("split: blast radius must be within [0,1], got %.2f", s.BlastRadiusShare)
}
return nil
}
// TriggerScore is the points contributed by one trigger, 0..3.
type TriggerScore struct {
Trigger Trigger
Points int
}
func (s Signal) scores() []TriggerScore {
var out []TriggerScore
// One coupled release in a quiet module is noise; require four releases.
if s.ModuleReleasesPerMonth >= 4 {
switch ratio := s.CoupledReleasesPerMonth / s.ModuleReleasesPerMonth; {
case ratio >= 0.5:
out = append(out, TriggerScore{ChangeFrequencyConflict, 3})
case ratio >= 0.3:
out = append(out, TriggerScore{ChangeFrequencyConflict, 2})
case ratio >= 0.15:
out = append(out, TriggerScore{ChangeFrequencyConflict, 1})
}
}
switch share := math.Max(s.PeakCPUConsumption, s.PeakTrafficShare); {
case share >= 0.8:
out = append(out, TriggerScore{IndependentScaling, 3})
case share >= 0.6:
out = append(out, TriggerScore{IndependentScaling, 2})
case share >= 0.4:
out = append(out, TriggerScore{IndependentScaling, 1})
}
switch {
case s.Regulated && s.CriticalityTier == 1:
out = append(out, TriggerScore{FaultIsolationCompliance, 3})
case s.CriticalityTier == 1 && s.BlastRadiusShare >= 0.5:
out = append(out, TriggerScore{FaultIsolationCompliance, 2})
case s.Regulated || s.BlastRadiusShare >= 0.8:
out = append(out, TriggerScore{FaultIsolationCompliance, 1})
}
return out
}
// Readiness lists hard gates. A candidate without these gates is not ready.
type Readiness struct {
DataOwnerNamed bool
ContractTests bool
IndependentDeploy bool
RollbackPlan bool
Observability bool
}
func (r Readiness) Unmet() []string {
var out []string
for _, gate := range []struct {
ok bool
name string
}{
{r.DataOwnerNamed, "data_owner_named"},
{r.ContractTests, "contract_tests"},
{r.IndependentDeploy, "independent_deploy"},
{r.RollbackPlan, "rollback_plan"},
{r.Observability, "observability"},
} {
if !gate.ok {
out = append(out, gate.name)
}
}
return out
}
func (r Readiness) Ready() bool { return len(r.Unmet()) == 0 }
// Decision is the only action the scorecard allows.
type Decision string
const (
KeepModular Decision = "keep_modular"
ModularFirst Decision = "modular_first"
SplitCandidate Decision = "split_candidate"
Prepare Decision = "prepare"
)
// Assessment is the scorecard result.
type Assessment struct {
Decision Decision
Score int
Triggers []TriggerScore
Unmet []string
}
// Evaluate turns measured signals and readiness gates into one decision.
func Evaluate(s Signal, r Readiness) (Assessment, error) {
if err := s.validate(); err != nil {
return Assessment{}, err
}
scores := s.scores()
total := 0
strongIsolation := false
for _, sc := range scores {
total += sc.Points
strongIsolation = strongIsolation || sc.Trigger == FaultIsolationCompliance && sc.Points >= 3
}
a := Assessment{Score: total, Triggers: scores, Unmet: r.Unmet()}
switch {
case total == 0:
a.Decision = KeepModular
case !r.Ready() && total >= 3:
a.Decision = Prepare
case strongIsolation:
a.Decision = SplitCandidate
case total >= 6:
a.Decision = SplitCandidate
default:
a.Decision = ModularFirst
}
return a, nil
}
// Summary is one review-friendly line.
func (a Assessment) Summary() string {
names := make([]string, 0, len(a.Triggers))
for _, t := range a.Triggers {
names = append(names, string(t.Trigger))
}
sort.Strings(names)
return fmt.Sprintf("decision=%s score=%d triggers=%s unmet=%s",
a.Decision, a.Score, strings.Join(names, ","), strings.Join(a.Unmet, ","))
}
测试覆盖四个决定档位,以及耦合发布数超过总发布数、CPU 份额越界、关键级别越界三类输入错误。
// file: internal/platform/split/scorecard_test.go
// tier: L1
// verify: go test ./internal/platform/split/ -count=1
package split
import (
"strings"
"testing"
)
func ready() Readiness {
return Readiness{DataOwnerNamed: true, ContractTests: true, IndependentDeploy: true, RollbackPlan: true, Observability: true}
}
func TestEvaluateScorecard(t *testing.T) {
cases := []struct {
name string
signal Signal
readiness Readiness
decision Decision
score int
}{
{"no_trigger", Signal{ModuleReleasesPerMonth: 2, CoupledReleasesPerMonth: 1, PeakCPUConsumption: 0.2, PeakTrafficShare: 0.2, CriticalityTier: 3, BlastRadiusShare: 0.1}, Readiness{}, KeepModular, 0},
{"prepare", Signal{ModuleReleasesPerMonth: 8, CoupledReleasesPerMonth: 4, PeakCPUConsumption: 0.7, CriticalityTier: 2, BlastRadiusShare: 0.2}, Readiness{DataOwnerNamed: true}, Prepare, 5},
{"split", Signal{ModuleReleasesPerMonth: 8, CoupledReleasesPerMonth: 4, PeakCPUConsumption: 0.85, CriticalityTier: 2, BlastRadiusShare: 0.2}, ready(), SplitCandidate, 6},
{"compliance", Signal{ModuleReleasesPerMonth: 1, PeakCPUConsumption: 0.1, CriticalityTier: 1, Regulated: true, BlastRadiusShare: 0.2}, ready(), SplitCandidate, 3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
a, err := Evaluate(tc.signal, tc.readiness)
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
t.Logf("summary: %s", a.Summary())
if a.Decision != tc.decision || a.Score != tc.score {
t.Fatalf("decision=%s score=%d, want %s/%d", a.Decision, a.Score, tc.decision, tc.score)
}
})
}
}
func TestEvaluateValidation(t *testing.T) {
cases := []struct {
name string
signal Signal
want string
}{
{"coupled_exceeds_total", Signal{ModuleReleasesPerMonth: 2, CoupledReleasesPerMonth: 3, CriticalityTier: 2, PeakCPUConsumption: 0.1}, "coupled releases"},
{"bad_cpu_share", Signal{ModuleReleasesPerMonth: 0, CriticalityTier: 2, PeakCPUConsumption: 1.2}, "cpu share"},
{"bad_tier", Signal{ModuleReleasesPerMonth: 0, CriticalityTier: 0, PeakCPUConsumption: 0.1}, "criticality tier"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := Evaluate(tc.signal, ready()); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err=%v, want substring %q", err, tc.want)
}
})
}
}
func TestAssessmentSummary(t *testing.T) {
signal := Signal{ModuleReleasesPerMonth: 8, CoupledReleasesPerMonth: 4, PeakCPUConsumption: 0.85, CriticalityTier: 2, BlastRadiusShare: 0.2}
a, err := Evaluate(signal, ready())
if err != nil {
t.Fatalf("Evaluate: %v", err)
}
want := "decision=split_candidate score=6 triggers=change_frequency_conflict,independent_scaling unmet="
if got := a.Summary(); got != want {
t.Fatalf("Summary()=%q, want %q", got, want)
}
}
本机实测
测试模块位于 .local/checks/be-arch-gov-ch01,module 为 archlab,Go 1.26.0,环境为 macOS darwin/arm64。先跑格式与静态检查,再只跑评分卡相关用例,原始输出如下:
$ cd .local/checks/be-arch-gov-ch01
$ gofmt -l .
$ go vet ./...
$ go test ./internal/platform/split/ -run '^(TestEvaluateScorecard|TestEvaluateValidation|TestAssessmentSummary)$' -count=1 -v
=== RUN TestEvaluateScorecard
=== RUN TestEvaluateScorecard/no_trigger
scorecard_test.go:34: summary: decision=keep_modular score=0 triggers= unmet=data_owner_named,contract_tests,independent_deploy,rollback_plan,observability
=== RUN TestEvaluateScorecard/prepare
scorecard_test.go:34: summary: decision=prepare score=5 triggers=change_frequency_conflict,independent_scaling unmet=contract_tests,independent_deploy,rollback_plan,observability
=== RUN TestEvaluateScorecard/split
scorecard_test.go:34: summary: decision=split_candidate score=6 triggers=change_frequency_conflict,independent_scaling unmet=
=== RUN TestEvaluateScorecard/compliance
scorecard_test.go:34: summary: decision=split_candidate score=3 triggers=fault_isolation_compliance unmet=
--- PASS: TestEvaluateScorecard (0.00s)
--- PASS: TestEvaluateScorecard/no_trigger (0.00s)
--- PASS: TestEvaluateScorecard/prepare (0.00s)
--- PASS: TestEvaluateScorecard/split (0.00s)
--- PASS: TestEvaluateScorecard/compliance (0.00s)
=== RUN TestEvaluateValidation
=== RUN TestEvaluateValidation/coupled_exceeds_total
=== RUN TestEvaluateValidation/bad_cpu_share
=== RUN TestEvaluateValidation/bad_tier
--- PASS: TestEvaluateValidation (0.00s)
--- PASS: TestEvaluateValidation/coupled_exceeds_total (0.00s)
--- PASS: TestEvaluateValidation/bad_cpu_share (0.00s)
--- PASS: TestEvaluateValidation/bad_tier (0.00s)
=== RUN TestAssessmentSummary
--- PASS: TestAssessmentSummary (0.00s)
PASS
ok archlab/internal/platform/split 0.283s
gofmt -l . 与 go vet ./... 均无输出,说明没有格式或静态检查问题。四个档位的读数与预期一致:无触发时 keep_modular;有触发但缺少四项就绪门时 prepare,分数 5;两个强触发且就绪门齐全时 split_candidate,分数 6;仅合规触发也能以 3 分直接进入拆分候选。同包全量 go test ./... -count=1 -v 同样通过,输出末尾是 ok archlab/internal/platform/split 0.452s。
这些数字只证明评分卡逻辑在该组输入下按设计工作,不证明 30% 或 60% 这些阈值适合你的系统。阈值必须用你自己的发布记录、峰值监控和合规范围重新标定;本节给出的是可复现的判定结构,不是一套通用常数。
常见错误与边界
- 用代码行数或团队人数当触发条件。它们描述规模,不描述耦合、资源或风险;6 万行但半年不改的模块通常不该拆。
- 用平均资源占用判断独立扩缩容。促销峰值被日均值稀释后,两个模块看起来都很平稳;要看 P95/P99 峰值和资源曲线相关性。
- 把就绪门当成评审勾选项。数据负责人、契约测试、独立部署、回滚演练、观测缺一项,拆分后的故障处理成本就会转嫁给值班团队。
- 把“合规”写成一句口号。必须落到数据分类、驻留地区、审计窗口和访问主体;范围不清时,物理隔离也无法验收。
- 评分卡只负责给出候选与准备状态,不能替代 ADR、容量评估和故障演练;高分不等于可以跳过设计评审。
小结
微服务拆分的第一问不是“模块大不大”,而是“有没有三个可测量触发条件”:变更频率是否被迫耦合、资源峰值是否需要独立副本、故障或合规范围是否必须物理隔离。三者都不成立时,先在单体里修模块边界,成本通常低一个数量级。
评分卡把触发强度与五项就绪门分开,避免“分数够了但团队没准备好”的误判。本节实现只依赖标准库,测试在本机真实运行通过;阈值与分数是示例口径,落地时必须用自己的数据重新标定,并把结论写进 ADR,而不是停留在一次会议纪要里。
阅读导航:上一节:目录与学习路径 · 下一节:1.2 拆分粒度:业务能力、数据所有权与变更频率 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。