《互联网产品的服务端架构·规模化与架构演进》1.2 按产品域拆还是按层拆

从「回声」订单与支付的边界争议出发,对比按产品域拆与按技术层拆在所有权、变更频率、事务边界和故障隔离上的差异,说明共享表为什么是最强的耦合信号,并用 Go 实现的 Overlap、Validate 与拓扑排序把模块边界检查变成可执行、可回归的拆分计划。

订单和支付到底该不该在一个服务里

「回声」决定拆分之后,第一个分歧不是拆不拆,而是怎么切。订单团队主张按产品域切:订单、支付、履约各成一个服务,每个服务从接口到数据库自成一体。平台团队主张按层切:接入层、业务层、数据层分开,接入层统一鉴权限流,业务层按无状态扩容,数据层统一管理连接池和分库分表。

两种方案在架构图上都成立,落到发布和故障上却完全不同。按域切,订单团队改履约状态机只需要发订单服务;按层切,改一个业务规则仍然要同时发布业务层的多个实例,而且数据层由平台团队统一维护,订单团队无法独立决定表结构。更关键的是,订单和支付共用 orders、order_items 两张表,按层切之后三个层都能写这两张表,耦合没有减少,只是从“一个进程内”变成“三层之间”。

边界问题不能用架构图回答,只能用所有权回答:谁拥有这张表、谁决定这个接口的契约、谁在故障时负责第一响应。这一节把这三个问题写成可检查的模块定义,再用拓扑排序决定先拆谁。

按域拆与按层拆的分歧

下面这张表是两种切法的对照,数字和场景都是案例假设,用来说明权衡发生在哪里。

维度按产品域拆按技术层拆
所有权一个团队拥有一个域的接口与表一层由平台团队拥有,业务团队共用
变更频率域内变更独立发布,跨域走契约层内变更影响所有业务,发布必须同步
事务边界域内本地事务,跨域用事件或补偿同一请求跨多层,事务边界被拉长
故障隔离一个域故障可降级,其他域继续一层故障通常影响所有域
组织映射与团队边界一致,责任清晰与团队边界交叉,容易互相等待
拆分顺序先拆依赖少的域,再拆核心域通常先拆接入层,业务层仍是一团

按域拆的代价是前期要处理跨域一致性。订单创建要扣库存、要生成支付单,这些动作原来在一个事务里,拆开之后必须用事件、补偿或者状态机保证最终一致。按层拆的代价则是把耦合藏在层与层之间:接入层看起来独立了,业务层仍然是所有域的集合,数据层仍然是所有表的集合。

一个常见的误判是“先拆接入层最安全”。接入层确实容易独立部署,但它不拥有业务数据,拆完之后业务层的发布冲突一点没少。另一个误判是“按团队人数切”:五个团队就切五个服务,结果一个域被切成三段,每段都要跨服务查同一张表。切法的依据应该是数据所有权和变更边界,不是人数或代码目录。

共享表是最强的耦合信号

判断两个模块能不能分开,先看它们是否拥有同一张表。接口可以共享,调用方多一层网关就能解决;表不能共享,因为写入权一旦有两个主人,任何一方都只能看到自己的一半事实。订单服务写 orders.status,支付服务也写 orders.status,两边对“已支付”和“已退款”的优先级理解不同,最终状态就取决于谁后写。

共享表还会把故障放大。支付服务做一次批量对账,锁住 orders 的十万行,订单服务的下单请求开始超时;订单服务为了补救,重试又加重锁竞争。两个服务各自都有熔断和限流,但它们争的是同一份资源,隔离在数据库面前失效。

Overlap 只比较表,不比较 API,原因就在这里。API 重叠往往说明两个模块需要一次契约评审,表重叠说明两个模块根本还没有分开。把共享表列出来,是拆分计划里最便宜、也最有效的一步:如果两个候选模块的共享表不止一张,先别拆服务,先决定这张表归谁。

共享表也不是绝对不能拆。可以把表按写入方拆成两张,用事件同步读模型;也可以把表收归一个“记录服务”,其他模块只能通过接口读写。两条路都需要改数据模型和迁移历史数据,成本远高于改代码目录。先承认这笔成本,再决定拆不拆。

一次按层拆分的失败

案例假设:「回声」曾经按层拆过一版。接入层独立成网关,业务层按无状态扩容,数据层由平台团队统一维护。上线后服务数量从 12 个变成 15 个,扩容确实更快了,但发布冲突从“一个仓库内”变成“网关、业务、数据三层之间”。

故障发生在一个退款高峰。业务层的订单实例把退款状态写成“处理中”,数据层的对账任务同时把同一批订单标成“已关闭”;网关层看到两个响应,按超时策略重试了其中一次。三个层都没有违反自己的契约,但 orders 的最终状态取决于写入顺序。用户看到退款失败又成功,客服无法解释,财务对账多出三百多笔差异。

事后复盘,团队发现自己一直在优化“层内”,没有回答“谁拥有 orders”。网关不拥有,业务层有多个实例都认为自己拥有,数据层又认为自己对所有表有最终解释权。按层拆把系统切成了三份,却把所有权留在了模糊地带。

修复分两步。第一步,把 orders 的写入收敛到订单域的一个模块,其他层只能通过接口调用;第二步,按产品域重新划分候选模块,先拆依赖最少的通知和优惠券,再拆订单与支付。拓扑排序在这里不是学术练习,而是用来回答“先拆谁才不会同时改三张表”。

把边界检查写成代码

边界讨论最容易停在白板上。把模块定义、共享表检查、依赖排序写成函数,评审时就能拿真实的表清单和依赖关系跑一遍,而不是凭印象说“这两个模块挺独立的”。

下面这段代码来自本书配套的 webapp-lab。Module 描述一个候选模块拥有的表和对外接口,Overlap 找共享表,Validate 检查重名与共享表,Order 用深度优先后序给出拆分顺序。三个函数都不访问数据库,输入是评审时整理的清单。

// file: internal/split/boundary.go
// tier: L1
// verify: go test ./internal/split/

package split

import (
	"fmt"
	"sort"
	"strings"
)

// Module 是一个候选拆分单元。Tables 是它拥有的表,APIs 是它对外暴露的接口。
type Module struct {
	Name   string
	Tables []string
	APIs   []string
}

// Overlap 返回两个模块共享的表名,按字典序排列并去重。
// API 可以共享,表不能:一张表有两个写入方,边界就没有真正画开。
func Overlap(a, b Module) []string {
	inB := make(map[string]bool, len(b.Tables))
	for _, t := range b.Tables {
		inB[strings.TrimSpace(t)] = true
	}

	seen := make(map[string]bool)
	var shared []string
	for _, t := range a.Tables {
		t = strings.TrimSpace(t)
		if t == "" || !inB[t] || seen[t] {
			continue
		}
		seen[t] = true
		shared = append(shared, t)
	}
	sort.Strings(shared)
	return shared
}

// Validate 检查模块定义能否作为拆分计划:名称非空且唯一,
// 同一模块内表名不重复,跨模块不能拥有同一张表。
func Validate(mods []Module) error {
	names := make(map[string]bool, len(mods))
	owner := make(map[string]string)

	for i, m := range mods {
		name := strings.TrimSpace(m.Name)
		if name == "" {
			return fmt.Errorf("split: module[%d] has empty name", i)
		}
		if names[name] {
			return fmt.Errorf("split: duplicate module %q", name)
		}
		names[name] = true

		local := make(map[string]bool, len(m.Tables))
		for _, t := range m.Tables {
			t = strings.TrimSpace(t)
			if t == "" {
				return fmt.Errorf("split: module %q has empty table name", name)
			}
			if local[t] {
				return fmt.Errorf("split: module %q repeats table %q", name, t)
			}
			local[t] = true
			if prev, ok := owner[t]; ok && prev != name {
				return fmt.Errorf("split: table %q owned by both %q and %q", t, prev, name)
			}
			owner[t] = name
		}
	}
	return nil
}

// Order 按依赖关系给出拆分顺序:被依赖的模块排在前面。
// deps[m] 列出 m 依赖的模块;同一层没有依赖约束时按模块名字典序排列,
// 保证同一份输入每次得到相同结果。发现环时返回错误。
func Order(mods []Module, deps map[string][]string) ([]string, error) {
	known := make(map[string]bool, len(mods))
	names := make([]string, 0, len(mods))
	for i, m := range mods {
		name := strings.TrimSpace(m.Name)
		if name == "" {
			return nil, fmt.Errorf("split: module[%d] has empty name", i)
		}
		if known[name] {
			return nil, fmt.Errorf("split: duplicate module %q", name)
		}
		known[name] = true
		names = append(names, name)
	}
	for mod, list := range deps {
		mod = strings.TrimSpace(mod)
		if !known[mod] {
			return nil, fmt.Errorf("split: dependency source %q is not a module", mod)
		}
		for _, dep := range list {
			if !known[strings.TrimSpace(dep)] {
				return nil, fmt.Errorf("split: module %q depends on unknown %q", mod, dep)
			}
		}
	}

	sort.Strings(names)
	state := make(map[string]int, len(names)) // 0 未访问,1 访问中,2 已完成
	order := make([]string, 0, len(names))

	var visit func(string) error
	visit = func(name string) error {
		switch state[name] {
		case 1:
			return fmt.Errorf("split: dependency cycle at %q", name)
		case 2:
			return nil
		}
		state[name] = 1
		list := append([]string(nil), deps[name]...)
		sort.Strings(list)
		for _, dep := range list {
			if err := visit(strings.TrimSpace(dep)); err != nil {
				return err
			}
		}
		state[name] = 2
		order = append(order, name)
		return nil
	}

	for _, name := range names {
		if err := visit(name); err != nil {
			return nil, err
		}
	}
	return order, nil
}

几个设计选择需要说明。

  • Overlap 只比较表,并且去重后按字典序返回。共享 API 可能只是调用关系,共享表才意味着两个模块都能写同一份事实。
  • Validate 同时检查模块重名、模块内重复表、跨模块共享表和空表名。空表名如果不拦,两个模块的 "" 会被误判成共享表。
  • Order 用 DFS 后序:先访问依赖,再把当前模块追加到结果,因此被依赖的模块一定排在前面。排序前对模块名和依赖列表排序,保证输出稳定。
  • 环检测靠三色状态:访问中是 1,再次遇到 1 就报环。错误只指出环上的一个节点,定位时还需要沿着依赖表回溯,但这比返回一个顺序错乱的列表安全。
  • Order 不调用 Validate。拓扑排序只回答顺序,共享表是另一个维度的问题;两个检查分开,评审时可以先排序再处理共享表。

这段代码不读取真实 schema。生产环境里,表清单可以从建表语句或元数据服务导出,依赖关系可以从调用链或构建依赖导出。导出之后仍然要人工确认,因为“代码里调用了”不等于“业务上依赖”。

测试:共享表、重名与拓扑环

测试要覆盖三类错误:边界没画开(共享表)、模块定义有问题(重名、空名、重复表)、依赖图不合法(未知依赖、环)。这三类错误如果留到拆分中途才发现,代价远高于评审时跑一次测试。

// file: internal/split/boundary_test.go
// tier: L1
// verify: go test ./internal/split/

package split

import (
	"reflect"
	"strings"
	"testing"
)

func TestOverlapReturnsSharedTablesSorted(t *testing.T) {
	a := Module{Name: "order", Tables: []string{"orders", "order_items", "orders"}}
	b := Module{Name: "payment", Tables: []string{"payments", "order_items", "orders"}}

	got := Overlap(a, b)
	want := []string{"order_items", "orders"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Overlap() = %v, want %v", got, want)
	}
}

func TestValidateAcceptsSeparatedModules(t *testing.T) {
	mods := []Module{
		{Name: "order", Tables: []string{"orders", "order_items"}, APIs: []string{"CreateOrder"}},
		{Name: "payment", Tables: []string{"payments"}, APIs: []string{"Pay"}},
	}
	if err := Validate(mods); err != nil {
		t.Fatalf("Validate() = %v, want nil", err)
	}
}

func TestValidateRejectsBadBoundaries(t *testing.T) {
	cases := []struct {
		name string
		mods []Module
		want string
	}{
		{"empty-name", []Module{{Name: " "}}, "empty name"},
		{"duplicate-name", []Module{{Name: "order"}, {Name: "order"}}, "duplicate module"},
		{"shared-table", []Module{
			{Name: "order", Tables: []string{"orders"}},
			{Name: "payment", Tables: []string{"orders"}},
		}, "owned by both"},
		{"repeated-table", []Module{{Name: "order", Tables: []string{"orders", "orders"}}}, "repeats table"},
		{"empty-table", []Module{{Name: "order", Tables: []string{" "}}}, "empty table name"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			err := Validate(c.mods)
			if err == nil {
				t.Fatal("Validate() = nil, want error")
			}
			if !strings.Contains(err.Error(), c.want) {
				t.Fatalf("Validate() = %q, want substring %q", err, c.want)
			}
		})
	}
}

func TestOrderFollowsDependencies(t *testing.T) {
	mods := []Module{{Name: "web"}, {Name: "api"}, {Name: "db"}}
	deps := map[string][]string{
		"api": {"db"},
		"web": {"api"},
	}

	got, err := Order(mods, deps)
	if err != nil {
		t.Fatal(err)
	}
	want := []string{"db", "api", "web"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("Order() = %v, want %v", got, want)
	}
}

func TestOrderBreaksTiesByName(t *testing.T) {
	mods := []Module{{Name: "c"}, {Name: "a"}, {Name: "b"}}
	for i := 0; i < 20; i++ {
		got, err := Order(mods, nil)
		if err != nil {
			t.Fatal(err)
		}
		want := []string{"a", "b", "c"}
		if !reflect.DeepEqual(got, want) {
			t.Fatalf("Order() = %v, want %v", got, want)
		}
	}
}

func TestOrderRejectsBadGraphs(t *testing.T) {
	mods := []Module{{Name: "a"}, {Name: "b"}}
	cases := []struct {
		name string
		mods []Module
		deps map[string][]string
		want string
	}{
		{"unknown-dependency", mods, map[string][]string{"a": {"missing"}}, "unknown"},
		{"unknown-source", mods, map[string][]string{"missing": {"a"}}, "not a module"},
		{"cycle", mods, map[string][]string{"a": {"b"}, "b": {"a"}}, "cycle"},
		{"self-cycle", mods, map[string][]string{"a": {"a"}}, "cycle"},
		{"duplicate-module", []Module{{Name: "a"}, {Name: "a"}}, nil, "duplicate module"},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			_, err := Order(c.mods, c.deps)
			if err == nil {
				t.Fatal("Order() = nil, want error")
			}
			if !strings.Contains(err.Error(), c.want) {
				t.Fatalf("Order() = %q, want substring %q", err, c.want)
			}
		})
	}
}

TestOverlapReturnsSharedTablesSorted 用重复的表名构造输入,确认结果去重且按字典序排列;顺序稳定,评审记录才能逐字比对。TestValidateRejectsBadBoundaries 把空名、重名、共享表、模块内重复表和空表名放在一张表里,每个子用例只验证一条规则。

TestOrderFollowsDependencies 构造 web -> api -> db 的依赖链,期望顺序是 db、api、web。TestOrderBreaksTiesByName 在没有依赖时重复运行二十次,确认结果始终按模块名排序,不会因为 map 遍历顺序而变化。TestOrderRejectsBadGraphs 覆盖未知依赖、未知来源、二元环、自环和重复模块。

测试没有覆盖的部分也要说清楚:它不验证表清单是否完整,不验证依赖关系是否真实,也不验证拆分后的数据迁移是否可行。函数只负责把评审输入变成一致的结论,边界是否合理仍然要靠领域知识和成本评估。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/split/ -run '^(TestOverlapReturnsSharedTablesSorted|TestValidateRejectsBadBoundaries|TestOrderFollowsDependencies)$' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestOverlapReturnsSharedTablesSorted
--- PASS: TestOverlapReturnsSharedTablesSorted (0.00s)
=== RUN   TestValidateRejectsBadBoundaries
=== RUN   TestValidateRejectsBadBoundaries/empty-name
=== RUN   TestValidateRejectsBadBoundaries/duplicate-name
=== RUN   TestValidateRejectsBadBoundaries/shared-table
=== RUN   TestValidateRejectsBadBoundaries/repeated-table
=== RUN   TestValidateRejectsBadBoundaries/empty-table
--- PASS: TestValidateRejectsBadBoundaries (0.00s)
    --- PASS: TestValidateRejectsBadBoundaries/empty-name (0.00s)
    --- PASS: TestValidateRejectsBadBoundaries/duplicate-name (0.00s)
    --- PASS: TestValidateRejectsBadBoundaries/shared-table (0.00s)
    --- PASS: TestValidateRejectsBadBoundaries/repeated-table (0.00s)
    --- PASS: TestValidateRejectsBadBoundaries/empty-table (0.00s)
=== RUN   TestOrderFollowsDependencies
--- PASS: TestOrderFollowsDependencies (0.00s)
PASS
ok  	webapp/internal/split	0.277s

输出里的 shared-table 子用例就是订单与支付共用 orders 的最小复现。它通过并不代表拆分方案可行,只代表这两个模块不能被当成两个独立单元;真正的修复仍然是先决定写入权。

小结

按产品域拆还是按层拆,不是架构风格之争,而是所有权之争。按域拆把接口、表和责任放在同一个团队手里,代价是跨域一致性;按层拆让每一层独立扩容,代价是业务耦合被藏进层与层之间。共享表是最强的耦合信号:API 可以共享,写入权不能。

Overlap 找出共享表,Validate 拦住重名和重复定义,Order 用拓扑排序回答先拆谁。三个函数都不能替你做领域判断,但能把白板上的争论变成一组可复现的输入和输出。下一节讨论拆分真正开始之后的问题:如果拆到一半失败,怎么按顺序退回去。

阅读导航:上一节:1.1 拆分的触发条件 · 下一节:1.3 一次拆分的回退方案 。

常见错误与边界

  • 按团队人数切服务。人数反映组织规模,不反映数据所有权,切完仍然共享表。
  • 先拆接入层就以为完成了服务化。接入层不拥有业务数据,业务层和数据层的耦合原样保留。
  • 把共享 API 当成共享表。API 重叠可以靠契约评审解决,表重叠必须先定写入权。
  • 只检查表名,不检查写入路径。两个模块表名不同、却通过同一张中间表写入,仍然是耦合。
  • 拓扑排序有环就直接拆。环通常说明两个模块还在互相调用,先合并或抽公共层,再谈顺序。
  • 忽略模块名和依赖列表的排序。map 遍历顺序不稳定,测试会随机失败,评审记录也无法复现。
  • 把 Order 的输出当成发布顺序。它只给依赖顺序,灰度批次、回退窗口和容量验证要另外设计。
  • 共享表长期不处理。拆得越多,写同一张表的服务越多,最终状态越难解释。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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