订单和支付到底该不该在一个服务里
「回声」决定拆分之后,第一个分歧不是拆不拆,而是怎么切。订单团队主张按产品域切:订单、支付、履约各成一个服务,每个服务从接口到数据库自成一体。平台团队主张按层切:接入层、业务层、数据层分开,接入层统一鉴权限流,业务层按无状态扩容,数据层统一管理连接池和分库分表。
两种方案在架构图上都成立,落到发布和故障上却完全不同。按域切,订单团队改履约状态机只需要发订单服务;按层切,改一个业务规则仍然要同时发布业务层的多个实例,而且数据层由平台团队统一维护,订单团队无法独立决定表结构。更关键的是,订单和支付共用 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的输出当成发布顺序。它只给依赖顺序,灰度批次、回退窗口和容量验证要另外设计。 - 共享表长期不处理。拆得越多,写同一张表的服务越多,最终状态越难解释。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。