《后端架构设计·演进与治理》1.2 拆分粒度:业务能力、数据所有权与变更频率

从 arch-lab 订单模块的依赖图出发,用 Ca、Ce 与不稳定度 I=Ce/(Ca+Ce) 区分稳定核心和易变边缘,给出业务能力、数据所有权、变更频率三条拆分粒度判据,并实现两向边界评审、共享表与跨边界边预算检查,附可运行 Go 代码与本机原始测试输出。

边界之争不是画图比赛

arch-lab 的订单域有五个模块:order-api 接单,order-worker 处理订单事件,inventory 管库存,payment 管支付,notify 发通知。团队准备拆服务,白板上出现了两种画法:按技术分层拆成“API 服务”和“Worker 服务”,或者按能力拆成“订单服务、库存服务、支付服务、通知服务”。

两种画法都能画得漂亮,但依赖不一样。order-api 同时调用 order-worker、inventory 和 payment;order-worker 调用 inventory;notify 反向调用 order-worker。按技术分层切,order-api 和 order-worker 被放进两个服务,原本的进程内调用立刻变成跨服务调用;按能力切,order-api 和 order-worker 留在订单边界内,但订单服务仍要跨边界调用库存和支付。

拆分粒度不能只看组织架构图,要看三件事:模块之间谁依赖谁、每张表由谁写、变更是否被迫同步。这三件事分别对应业务能力、数据所有权和变更频率。边界上下文与团队划分的背景可以对照限界上下文与团队边界 ,本节只讨论如何把边界变成可检查的判据。

不稳定度:谁应该依赖谁

Robert Martin 的稳定依赖原则把模块分成两类:被很多人依赖、自己很少依赖别人的模块是“稳定”的;依赖很多、很少被依赖的模块是“不稳定”的。用 Ca 表示流入依赖数,Ce 表示流出依赖数,不稳定度定义为:

I = Ce / (Ca + Ce)

I 越接近 0,模块越稳定;越接近 1,越容易被变更推动。依赖应该从 I 高的一侧指向 I 低的一侧,否则一个易变模块会把稳定核心拖进频繁发布。

对 arch-lab 的五个模块计算,本机读数是:

模块CaCeI解读
order-api031.000纯调用方,变化由下游接口驱动
order-worker210.333被订单 API 和通知依赖,自身依赖库存
inventory200.000被订单链路依赖的稳定核心
payment100.000被订单 API 依赖的稳定核心
notify011.000纯调用方,适合放在边缘

I 不是“好”或“坏”的评分,而是方向提示。order-api 的 I=1 并不说明它该被拆掉,而说明它应该依赖稳定接口,而不是把业务规则塞进下游。inventory 的 I=0 也不说明它必须独立部署;它只说明其他模块依赖它时,应该通过稳定契约,而不是直接读写它的表。孤立模块的 I=0 只是分母为零时的约定,评审时还要同时看 Ca+Ce 是否为 0。

三条粒度判据

判据要回答的问题通过条件失败信号
业务能力边界两侧是否各自拥有完整能力同一 capability 不被切开order-api 和 order-worker 被分到两个服务
数据所有权每张表是否只有一个写入方边界两侧没有共享写表订单表和库存表都被两侧写入
变更频率两侧是否可以独立发布耦合发布比例低于阈值每次订单改动都必须同时改库存

业务能力判据来自限界上下文:一个能力可以由多个模块实现,但这些模块应该落在同一个边界内。把 order-api 和 order-worker 拆开,订单能力就被切成两半,任何订单规则变更都要跨服务协调。

数据所有权判据更硬:一张表只能有一个写入方。共享读可以通过 API、只读副本或事件投影解决,共享写只能带来双重写入和冲突。数据归属的决策方法可以对照数据所有权与存储决策 。

变更频率判据是动态的:即使图上看不出问题,只要两侧每月有 30% 以上的发布必须同步进行,边界就会在发布节奏上失效。这条判据与 1.1 的评分卡共用同一组发布数据;边界检查器不替代发布记录,只负责在结构上先排除明显错误的切法。

边界评审器

下面这个包把模块图、耦合指标和两向边界评审放在一起。它检查五类问题:分区是否完整且不重叠、同一能力是否被切开、同一张表是否被两侧拥有、跨边界依赖是否双向、跨边界边数是否超过预算。实现只用标准库。

// file: internal/platform/split/graph.go
// tier: L1
// verify: go test ./internal/platform/split/ -count=1
package split

import (
	"fmt"
	"sort"
)

// Edge is one dependency direction between modules.
type Edge struct{ From, To string }

// Module describes one candidate module. Owns lists tables it writes;
// DependsOn lists modules it calls.
type Module struct {
	Name       string
	Capability string
	Owns       []string
	DependsOn  []string
}

// Graph is an immutable module dependency graph.
type Graph struct {
	modules map[string]Module
	names   []string
}

// NewGraph validates names, capabilities and dependency targets.
func NewGraph(modules []Module) (*Graph, error) {
	g := &Graph{modules: make(map[string]Module, len(modules))}
	for _, m := range modules {
		if m.Name == "" || m.Capability == "" {
			return nil, fmt.Errorf("split: module name and capability are required")
		}
		if _, ok := g.modules[m.Name]; ok {
			return nil, fmt.Errorf("split: duplicate module %q", m.Name)
		}
		g.modules[m.Name] = m
		g.names = append(g.names, m.Name)
	}
	for _, m := range modules {
		for _, dep := range m.DependsOn {
			if dep == m.Name {
				return nil, fmt.Errorf("split: module %q depends on itself", m.Name)
			}
			if _, ok := g.modules[dep]; !ok {
				return nil, fmt.Errorf("split: module %q depends on unknown %q", m.Name, dep)
			}
		}
	}
	sort.Strings(g.names)
	return g, nil
}

func unique(items []string) []string {
	seen := make(map[string]bool, len(items))
	out := make([]string, 0, len(items))
	for _, item := range items {
		if !seen[item] {
			seen[item] = true
			out = append(out, item)
		}
	}
	sort.Strings(out)
	return out
}

// Metrics is Martin's coupling pair and instability I = Ce/(Ca+Ce).
type Metrics struct {
	Ca, Ce      int
	Instability float64
}

// Metrics computes afferent and efferent coupling for one module.
func (g *Graph) Metrics(name string) (Metrics, error) {
	m, ok := g.modules[name]
	if !ok {
		return Metrics{}, fmt.Errorf("split: unknown module %q", name)
	}
	ce := len(unique(m.DependsOn))
	ca := 0
	for _, other := range g.names {
		if other == name {
			continue
		}
		for _, dep := range unique(g.modules[other].DependsOn) {
			if dep == name {
				ca++
				break
			}
		}
	}
	instability := 0.0
	if ca+ce > 0 {
		instability = float64(ce) / float64(ca+ce)
	}
	return Metrics{Ca: ca, Ce: ce, Instability: instability}, nil
}

// MaxCrossEdges is the budget above which every extra edge needs a contract
// test; it is a review trigger, not a hard architectural law.
const MaxCrossEdges = 3

// Verdict is the boundary decision.
type Verdict string

const (
	Accept Verdict = "accept"
	Revise Verdict = "revise"
	Reject Verdict = "reject"
)

// BoundaryReport explains whether one partition is a service boundary.
type BoundaryReport struct {
	CrossEdges        []Edge
	CrossEdgeShare    float64
	SharedTables      []string
	SplitCapabilities []string
	Bidirectional     bool
	Verdict           Verdict
}

func bothSides(sides map[string][2]bool) []string {
	var out []string
	for name, flags := range sides {
		if flags[0] && flags[1] {
			out = append(out, name)
		}
	}
	sort.Strings(out)
	return out
}

// EvaluateBoundary checks a complete two-way partition against capability
// cohesion, data ownership and the cross-edge budget.
func (g *Graph) EvaluateBoundary(left, right []string) (BoundaryReport, error) {
	side := make(map[string]int, len(g.names))
	for _, name := range left {
		if _, ok := g.modules[name]; !ok {
			return BoundaryReport{}, fmt.Errorf("split: unknown module %q on left", name)
		}
		if side[name] != 0 {
			return BoundaryReport{}, fmt.Errorf("split: module %q appears twice or on both sides", name)
		}
		side[name] = -1
	}
	for _, name := range right {
		if _, ok := g.modules[name]; !ok {
			return BoundaryReport{}, fmt.Errorf("split: unknown module %q on right", name)
		}
		if side[name] != 0 {
			return BoundaryReport{}, fmt.Errorf("split: module %q appears twice or on both sides", name)
		}
		side[name] = 1
	}
	if len(side) != len(g.names) {
		return BoundaryReport{}, fmt.Errorf("split: partition covers %d of %d modules", len(side), len(g.names))
	}
	report := BoundaryReport{}
	edges := make(map[string]bool)
	total := 0
	tableSides := make(map[string][2]bool)
	capabilitySides := make(map[string][2]bool)
	for _, from := range g.names {
		index := 0
		if side[from] > 0 {
			index = 1
		}
		for _, table := range g.modules[from].Owns {
			flags := tableSides[table]
			flags[index] = true
			tableSides[table] = flags
		}
		flags := capabilitySides[g.modules[from].Capability]
		flags[index] = true
		capabilitySides[g.modules[from].Capability] = flags
		for _, to := range unique(g.modules[from].DependsOn) {
			total++
			edges[from+"\x00"+to] = true
			if side[from] != side[to] {
				report.CrossEdges = append(report.CrossEdges, Edge{From: from, To: to})
			}
		}
	}
	sort.Slice(report.CrossEdges, func(i, j int) bool {
		if report.CrossEdges[i].From != report.CrossEdges[j].From {
			return report.CrossEdges[i].From < report.CrossEdges[j].From
		}
		return report.CrossEdges[i].To < report.CrossEdges[j].To
	})
	if total > 0 {
		report.CrossEdgeShare = float64(len(report.CrossEdges)) / float64(total)
	}
	report.SharedTables = bothSides(tableSides)
	report.SplitCapabilities = bothSides(capabilitySides)
	for _, edge := range report.CrossEdges {
		if edges[edge.To+"\x00"+edge.From] {
			report.Bidirectional = true
			break
		}
	}
	switch {
	case len(report.SharedTables) > 0 || report.Bidirectional:
		report.Verdict = Reject
	case len(report.SplitCapabilities) > 0 || len(report.CrossEdges) > MaxCrossEdges:
		report.Verdict = Revise
	default:
		report.Verdict = Accept
	}
	return report, nil
}

正文只列核心的边界用例;不稳定度读数由同包的 TestInstability 产生,共享表、能力切分、双向依赖和构造校验用例也放在完整测试文件中。

// file: internal/platform/split/graph_test.go
// tier: L1
// verify: go test ./internal/platform/split/ -count=1
package split

import "testing"

func archLabGraph(t *testing.T) *Graph {
	t.Helper()
	g, err := NewGraph([]Module{
		{Name: "order-api", Capability: "order", Owns: []string{"orders"}, DependsOn: []string{"order-worker", "inventory", "payment"}},
		{Name: "order-worker", Capability: "order", Owns: []string{"order_events"}, DependsOn: []string{"inventory"}},
		{Name: "inventory", Capability: "inventory", Owns: []string{"stock"}},
		{Name: "payment", Capability: "payment", Owns: []string{"payments"}},
		{Name: "notify", Capability: "notification", Owns: []string{"notifications"}, DependsOn: []string{"order-worker"}},
	})
	if err != nil {
		t.Fatalf("NewGraph: %v", err)
	}
	return g
}

func TestEvaluateBoundary(t *testing.T) {
	g := archLabGraph(t)
	wide, err := g.EvaluateBoundary(
		[]string{"order-api", "order-worker"},
		[]string{"inventory", "payment", "notify"},
	)
	if err != nil {
		t.Fatalf("EvaluateBoundary: %v", err)
	}
	t.Logf("wide: verdict=%s cross=%d share=%.2f", wide.Verdict, len(wide.CrossEdges), wide.CrossEdgeShare)
	if wide.Verdict != Revise || len(wide.CrossEdges) != 4 {
		t.Fatalf("wide: verdict=%s cross=%d, want revise/4", wide.Verdict, len(wide.CrossEdges))
	}
	narrow, err := g.EvaluateBoundary(
		[]string{"order-api", "order-worker", "payment"},
		[]string{"inventory", "notify"},
	)
	if err != nil {
		t.Fatalf("EvaluateBoundary: %v", err)
	}
	t.Logf("narrow: verdict=%s cross=%d share=%.2f", narrow.Verdict, len(narrow.CrossEdges), narrow.CrossEdgeShare)
	if narrow.Verdict != Accept || len(narrow.CrossEdges) != 3 {
		t.Fatalf("narrow: verdict=%s cross=%d, want accept/3", narrow.Verdict, len(narrow.CrossEdges))
	}
}

本机实测

命令与环境同 1.1: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 '^(TestInstability|TestEvaluateBoundary|TestBoundaryRejections|TestNewGraphValidation)$' -count=1 -v
=== RUN   TestInstability
    graph_test.go:42: module=order-api Ca=0 Ce=3 I=1.000
    graph_test.go:42: module=order-worker Ca=2 Ce=1 I=0.333
    graph_test.go:42: module=inventory Ca=2 Ce=0 I=0.000
    graph_test.go:42: module=payment Ca=1 Ce=0 I=0.000
    graph_test.go:42: module=notify Ca=0 Ce=1 I=1.000
--- PASS: TestInstability (0.00s)
=== RUN   TestEvaluateBoundary
    graph_test.go:58: wide: verdict=revise cross=4 share=0.80
    graph_test.go:69: narrow: verdict=accept cross=3 share=0.60
--- PASS: TestEvaluateBoundary (0.00s)
=== RUN   TestBoundaryRejections
=== RUN   TestBoundaryRejections/shared_table
    graph_test.go:105: verdict=reject tables=[orders] capabilities=[] bidir=false
=== RUN   TestBoundaryRejections/split_capability
    graph_test.go:105: verdict=revise tables=[] capabilities=[order] bidir=false
=== RUN   TestBoundaryRejections/bidirectional
    graph_test.go:105: verdict=reject tables=[] capabilities=[] bidir=true
--- PASS: TestBoundaryRejections (0.00s)
    --- PASS: TestBoundaryRejections/shared_table (0.00s)
    --- PASS: TestBoundaryRejections/split_capability (0.00s)
    --- PASS: TestBoundaryRejections/bidirectional (0.00s)
=== RUN   TestNewGraphValidation
=== RUN   TestNewGraphValidation/duplicate
=== RUN   TestNewGraphValidation/unknown_dep
=== RUN   TestNewGraphValidation/self_dep
=== RUN   TestNewGraphValidation/empty_capability
--- PASS: TestNewGraphValidation (0.00s)
    --- PASS: TestNewGraphValidation/duplicate (0.00s)
    --- PASS: TestNewGraphValidation/unknown_dep (0.00s)
    --- PASS: TestNewGraphValidation/self_dep (0.00s)
    --- PASS: TestNewGraphValidation/empty_capability (0.00s)
PASS
ok  	archlab/internal/platform/split	0.243s

读法分三层。耦合层,inventory 和 payment 的 I=0,说明它们是被依赖的稳定核心;order-api 和 notify 的 I=1,说明它们处在调用边缘。边界层,宽切法留下 4 条跨边界边、占全部 5 条边的 80%,被判 revise;把 payment 移到订单侧后,跨边界边降到 3 条、占比 60%,被判 accept。拒绝层,共享表直接 reject,能力被切开是 revise,双向依赖是 reject,三种失败信号都能被识别。

这些读数只证明检查器在该组手工构造的模块图上按规则工作。真实系统的依赖边来自调用链和数据库访问日志,往往比白板图多得多;上线前应把实际依赖导入同一结构重跑,而不是把本节的 5 个模块当成真实规模。跨边界边预算 3 也只是本例的示例值,边越多,契约测试与故障处理的成本越高,阈值应随团队能力调整。

常见错误与边界

  • 把不稳定度当成服务好坏。I=1 只说明模块依赖别人多,不说明它该被合并或删除;方向比数值更重要。
  • 用模块图代替数据所有权。图上没有共享表,代码仍可能让两个服务写同一张表;必须把 Owns 从建表脚本和写路径里核实。
  • 只看静态依赖,不看变更频率。结构上干净的边界,如果两侧发布仍要同步,实际仍是单体;变更频率要用发布记录验证。
  • 边界检查器只做结构与预算判断,不验证调用延迟、事务边界和故障传播;这些必须由契约测试与演练补齐,相关门禁思路可对照架构适应度函数 。

小结

拆分粒度不是“切得越细越好”,而是让边界同时满足三条判据:业务能力完整、数据只有一个写入方、两侧可以独立发布。不稳定度提供了一个结构化的方向检查:依赖应从易变边缘指向稳定核心,而不是反过来。

本节实现把图构造、Ca/Ce 计算、两向分区校验、共享表与能力切分检测、跨边界边预算放在同一个包里。本机测试显示宽切法 4 条跨边界边被判 revise,窄切法 3 条被判 accept;这些数字来自手工构造的 arch-lab 模块图,真实依赖必须重新采集。通过检查器只说明结构及格,是否最终拆分仍要回到变更频率和 1.1 的就绪门。

阅读导航:上一节:1.1 微服务拆分不是默认答案:三个触发条件 · 下一节:1.3 订单域的一次拆分演练:边界、共享数据与回退 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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