Skip to content

微服务拆分方法:先把边界分清,再决定要不要跨网络 ​

订单、库存和支付写在一个仓库里,不一定是坏架构;它们跑在三个进程里,也不一定已经解耦。如果改一个字段要同时改三个服务,发布必须一起发,数据库还互相写,这套系统只是把原来的耦合搬到了网络上。

拆分的目标应是能独立理解、修改、部署和承担责任的一块业务。本文按单体、模块化单体、微服务的演进来讲,重点是如何识别边界、验证拆分收益,以及什么时候应该停下来。代码演示一个进程内的库存预留接口,让边界先在本地成立;它不是完整下单流程,也没有运行远程服务。

三种形态改变的是什么 ​

形态代码和部署关系已获得什么仍要承担什么
单体一个部署单元,内部边界可能松散本地调用简单,事务与调试路径较短依赖容易互相渗透,变更和扩容常一起进行
模块化单体一个部署单元,明确模块接口和数据所有权可以隔离业务知识、限制依赖,低成本验证边界通常仍一起发布,进程资源和故障域仍共享
微服务多个可独立部署的服务,通过网络交互可以独立发布、扩容和组织运行责任网络失败、版本兼容、跨服务一致性、观测和运维成本

Fowler 的 Monolith First 讨论先用单体探索边界的经验和条件,不能把它读成“所有项目必须先写单体”的定理。[1] 模块划分更早的原则来自 Parnas:围绕需要隐藏的设计决策组织模块,而不是机械地沿执行步骤切段。[2]

一个支付模块可以先隐藏支付渠道、重试状态和流水格式。只要外部通过明确契约调用,它今天完全可以与订单模块在同一进程。等到它确实需要不同发布节奏、权限边界或容量策略,再讨论网络化。

先找业务能力,不要先数数据库表 ​

业务能力说的是“企业要完成什么”,例如定价、接单、库存预留、履约、结算,而不是技术层次里的 controller、service、DAO。按能力拆分,使相关规则和数据有一个主要负责者。[3]

候选拆分方式容易得到的结果应如何追问
每张表一个服务一次业务操作要跨多个服务才能完成这些表是否维护同一个业务不变量?
每层一个服务控制层、业务层、数据层都变成远程调用远程层是否有独立业务责任,还是只增加跳数?
按功能页面拆相同规则散落在多个页面后端规则真正属于哪个业务能力?
按业务能力拆规则、状态、接口聚在同一边界容量、团队和一致性是否也支持这个边界?

以库存预留为例,“可售数量不能被同一个订单重复扣减”同时涉及库存计数和预留记录。如果把这两张表分别交给两个服务,这个不变量马上变成跨服务问题。把它们保留在库存边界内,则可以用本地事务或条件更新实现。

按能力也不是先定好一个永久名单。促销可能最初只是定价模块的几个规则,业务复杂后才形成独立生命周期;仓储库存与可售库存也可能属于不同上下文。边界需要根据实际规则和变化频率修正。

限界上下文:同一个词,在不同业务里可以有不同含义 ​

限界上下文(bounded context)给一套业务模型划定适用范围。在电商里,“商品”在商品目录中关心标题、属性和图片,在库存中关心 SKU、仓库和可售量,在结算中关心计价与税务快照。强行共用一个大 Product 结构体,会让每个模块都被别人的字段和规则拖着走。[4]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    C[商品目录上下文<br/>描述、类目、展示状态] -->|SKU 与已发布描述| O[订单上下文<br/>成交快照、订单状态]
    O -->|预留请求:订单 ID、SKU、数量| I[库存上下文<br/>可售、预留、扣减]
    O -->|应付金额与业务单号| P[支付上下文<br/>渠道流水、支付状态]
    P -->|支付结果事件| O
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    C[商品目录上下文<br/>描述、类目、展示状态] -->|SKU 与已发布描述| O[订单上下文<br/>成交快照、订单状态]
    O -->|预留请求:订单 ID、SKU、数量| I[库存上下文<br/>可售、预留、扣减]
    O -->|应付金额与业务单号| P[支付上下文<br/>渠道流水、支付状态]
    P -->|支付结果事件| O

箭头传的是明确契约,而不是共享整个内部对象。例如订单通常需要保留成交时的商品名和价格快照;商品目录后来改名,不一定应该回头改掉历史订单。这个例子说明数据复制有时是业务建模的需要,不是所有重复字段都必须被“消灭”。

限界上下文与进程边界没有固定一一对应关系。一个上下文可以先作为单体内模块,也可能因规模拆为多个协作服务;反过来,把互相冲突的模型硬塞进一个上下文,只因为部署方便,也会增加理解成本。DDD 的子领域和限界上下文提供识别边界的语言,部署选择还需要运行和组织方面的证据。[4][5]

外部系统使用另一套术语时,可以设置适配层或防腐层,将对方的模型翻译为自己的契约,避免支付供应商的字段一路流入订单核心逻辑。这个层有维护成本,但能把供应商变化限制在较小范围。

在模块化单体里,把边界真的立起来 ​

一条实用的依赖方向是:订单用例依赖“库存可以预留”的接口,库存模块提供实现;数据库表结构和驱动留在库存内部。Go 里的接口让这个契约足够小,不必为了“以后可能拆”先建立一套通用 RPC 框架。

go
type ReserveRequest struct {
    OrderID  string
    Quantity int
}
type Inventory interface {
    Reserve(context.Context, ReserveRequest) error
}
type OrderService struct {
    inventory Inventory
}

这里只传订单身份和数量,是因为示例只模拟一种库存,没有 SKU;生产契约必须补上它真正需要的身份与语义。context.Context 让调用方传递取消意图,并不使本地函数自动变成远程调用,也不保证取消到达时已经发生的效果会回滚。

模块实现不把 available 和 reservations 直接交给订单模块修改。相同订单 ID、相同数量再次预留成功但不重复扣减;相同 ID 配不同数量返回冲突。实际运行:

go
fmt.Println("first:", o.Place(context.Background(), "o1", 2))
fmt.Println("same request:", o.Place(context.Background(), "o1", 2))
fmt.Println("changed request:", o.Place(context.Background(), "o1", 1))
fmt.Println("stock:", s.available)
text
first: <nil>
same request: <nil>
changed request: id conflict
stock: 1

初始库存为 3,第一次预留 2 后剩 1。重复请求不再扣减,变更 payload 也不能借同一身份混过去。这种契约在单进程内就能测试,将来跨网络面对响应丢失时仍然需要。

模型把业务状态放在内存里并用 mutex 保护,进程退出会丢失;生产实现应把幂等记录和库存修改纳入合适的持久化原子范围。测试发起了 10 个相同请求,最终只留下一个预留。这个测试证明的是给定实现的本地行为,不能证明真实多实例数据库没有超卖。

接口存在,还不等于模块边界存在 ​

一个模块如果仍能直接 import 另一个模块的内部存储包,并绕过接口改它的表,接口只是装饰。可以按业务组织包,让存储实现只通过模块门面暴露;用代码审查、依赖检查、测试和数据库权限逐步约束所有权。

Go 的 internal 目录限制其包只被特定父目录树下的代码导入,但项目根的一个公共 internal 并不会阻止同一树内的兄弟模块相互访问。[6] 若要更严格,需按目录树设计边界或加依赖规则。本文的单文件示例只验证接口契约,没有声称测试了多包导入限制。

共享一个数据库实例,不一定意味着所有模块可以共享写入全部表。模块化单体可以先规定表的唯一写入者,其他模块经接口或明确的只读模型获取信息。这仍需要纪律与工具,不会因为目录名写了 inventory 就自动成立。

什么情况值得把模块移到独立进程 ​

先给拆分提出一个可验证的目标。例如“库存高峰扩容不再同时扩全部后台”“支付模块能独立发布而不等待商品页面”“一个重计算任务不能耗尽用户请求的进程内存”。这些目标对应具体问题,才方便比较收益和成本。

观察到的现象拆分可能带来的收益也应先试的较小改动
一类任务资源需求与其余业务差异很大独立资源限制、扩容与故障域独立 worker/队列、进程隔离,未必立刻拆成完整服务体系
稳定业务边界由独立团队持续维护独立发布和责任范围先验证模块接口及团队能否独立处理变更
一块业务有明确安全或合规隔离要求缩小数据与权限暴露范围同时评估网络、身份、审计和运维要求,不能只拆进程
大多数发布只改一个稳定模块减少发布协调与回归范围先检查构建、测试、发布流水线是否才是瓶颈

这些是工程判断的线索,不是由某篇论文给出的自动阈值。几个人、多少行代码、多少 QPS,都不能单独决定必须拆。一次拆分会增加至少一个远程失败边界,而团队要有能力监控、升级、值班和恢复它。

这些信号出现时,先别拆 ​

需求模型每天都在大改、同一批人维护全部代码、独立发布需求很弱,或者当前慢点主要是一个缺失索引,这些场景里拆服务可能放大成本而不解决原问题。[1]

更直接的反对信号包括:

  • 每次调用都要先拿一大堆别人的内部状态,才能执行一小步;接口很难描述完整业务动作。
  • 两个候选服务共享一个不能暂时破坏的不变量,又没有可接受的跨边界协调方案。
  • 拆开后仍必须同时上线,版本兼容窗口为零。
  • 发生失败时,没人能说清是继续、查询状态还是补偿,只写了一句“失败重试”。

遇到这些情况,可以先调整模块边界,或者承认这两块业务目前应该放在一起。把它们保留在一个部署单元不妨碍继续改善代码;“微服务数量更多”也不能作为架构收益指标。

渐进迁移:把每一步设计成能停住的状态 ​

绞杀者模式通过一个入口逐步把部分能力路由到新实现,旧系统继续承担未迁出的部分。名称来自 Fowler 的模式描述;其价值在于渐进替换,而不是要求必须多加某种固定网关产品。[7]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    C[客户端] --> F[稳定门面/路由]
    F -->|未迁出功能| O[旧系统]
    F -->|已切换能力| N[新服务]
    O -. 快照与可重放变更 .-> D[新服务的数据所有权准备]
    D --> N
    N -. 校验与观测 .-> V[决定继续切换或恢复旧路径]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    C[客户端] --> F[稳定门面/路由]
    F -->|未迁出功能| O[旧系统]
    F -->|已切换能力| N[新服务]
    O -. 快照与可重放变更 .-> D[新服务的数据所有权准备]
    D --> N
    N -. 校验与观测 .-> V[决定继续切换或恢复旧路径]

一个可审查的迁移顺序如下。

阶段做什么过关条件
明确契约参数、结果、错误、幂等、取消及版本兼容现有调用方能按契约工作,不依赖内部表结构
单体内替换门面后切换实现,仍在同一进程契约测试通过,业务不变量有对应测试
数据准备定义唯一写入者、复制与校验、迁移位点新旧状态对得上;增量和删除不遗漏
引入远程路径配置连接、身份、超时、观测模拟超时、重复、断连,结果未知有查询路径
小范围切流按租户或稳定群组切换并观察错误率、尾延迟和对账满足预先目标
结束迁移关闭旧写路径,保留必要回退/备份窗口旧调用者退出,权限收紧,责任人和恢复手册明确

“影子流量”也要区分读和写。把真实写请求同时发给新旧系统,可能重复扣减、重复发通知。验证写路径时,需要隔离副作用、使用只计算不提交的模式或设计可靠的迁移协议;不能把双发流量当成无风险测试。

最后才能删的是旧契约,最早应明确的是写入权 ​

数据迁移中应尽量维持一个明确的权威写入者。双写会遇到一边成功、一边失败,必须用日志、outbox、版本和恢复流程处理;“两个写都包进同一个 Go 函数”并不产生跨服务原子性。

新服务接管写入后,旧服务的数据可能立刻落后。回滚不再是把路由开关切回去,还需要反向同步或另一种确保数据完整的方案。对数据库字段的变更也可采用扩展—兼容—收缩:先添加兼容结构,让新旧代码都能工作;完成数据和客户端迁移以后,再移除旧结构。不能先删旧字段再要求所有服务同一瞬间升级。

领域事件可以解开时间耦合,也会留下延迟窗口 ​

同步接口适合调用方必须立刻得到结果的业务决策,例如“这次库存预留是否成功”。领域事件表达已经发生的业务事实,例如“订单已取消”,让相关模块各自处理后续工作。事件应表达稳定的业务含义,避免把每次内部表字段变化都广播成公共契约。[3][5]

消费者可能晚到、重复消费或遇到旧版事件。因而事件至少要有身份、类型/版本、关联业务对象、必要的业务版本及可追踪来源。发布者的业务提交与事件记录要保持可靠联系;消费者需要幂等与失败可见性。报表或搜索索引可以接受一定滞后,库存是否能多卖则是另一种要求。

复制到读取模型的数据也有自己的所有权:它可以用于查询,不代表消费者获得了反向改写源业务的权限。这样才能既允许数据为不同用途复制,又不把所有服务拉回共同写一套表的状态。

拆完以后,用结果判断有没有变好 ​

不要只统计服务数量。可以在拆分前后用同样口径观察:一次功能修改需要几个团队同步、发布等待时间、单模块回滚范围、尾延迟、跨服务调用数、故障定位时间和运行成本。本文没有这类实测数据,不能承诺拆分后一定提速。

如果大部分需求仍跨越全部服务、调用链更长、故障更难定位,边界就值得重新检查。合并某些服务、保留模块化单体、让少数高差异 worker 独立运行,都是正常的设计选择。

先把一个候选边界的契约、写入所有权和故障恢复说清楚,再决定它需不需要网络。这一步做不好,换多少框架都很难得到独立演进的服务。

依据与验证范围 ​

  1. Martin Fowler:Monolith First:先探索边界再拆分的经验、条件与反面情况,不是普遍定理。
  2. David Parnas:On the Criteria To Be Used in Decomposing Systems into Modules:信息隐藏与按设计决策划分模块。
  3. Chris Richardson:Decompose by business capability:按业务能力组织服务。
  4. Martin Fowler:Bounded Context:模型的适用边界和上下文关系。
  5. Chris Richardson:Decompose by subdomain:按 DDD 子领域识别服务边界。
  6. Go 1.27.2 cmd/go:Internal packages:internal 包的导入限制。
  7. Martin Fowler:Strangler Fig Application:渐进替换模式。

这些资料分别提供原始模块化原则、模式作者的说明和工具规则;本文订单/库存案例与迁移检查表是据此组织的教学设计,不是假称来自某公司的生产复盘。

实际环境为 Go 1.27.2、Linux/amd64。运行、普通测试、race、vet 均通过。实验验证单进程库存预留契约:重复请求不重复扣减、不同 payload 拒绝、库存不足拒绝、调用开始前已取消的 context 被拒绝。它只在入口检查取消,不能保证后来取消会撤销效果。没有数据库或真实服务迁移,也没有验证多包依赖规则、RPC 兼容、网络故障和团队协作收益。

完整可复现实验 ​

把以下三个文件放在同一空目录。只依赖标准库,不需要外部服务。

sh
go run .
go test -count=1 -v ./...
go test -race -count=1 ./...
go vet ./...

go.mod ​

mod
module example.com/distributed/decomposition

go 1.27.2

main.go ​

代码块 · 61 行 · 点击收起 / 展开
go
package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
)

type ReserveRequest struct {
	OrderID  string
	Quantity int
}
type Inventory interface {
	Reserve(context.Context, ReserveRequest) error
}
type Stock struct {
	mu           sync.Mutex
	available    int
	reservations map[string]int
}

func (s *Stock) Reserve(ctx context.Context, r ReserveRequest) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	if r.OrderID == "" || r.Quantity <= 0 {
		return errors.New("invalid reservation")
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if n, ok := s.reservations[r.OrderID]; ok {
		if n != r.Quantity {
			return errors.New("id conflict")
		}
		return nil
	}
	if r.Quantity > s.available {
		return errors.New("out of stock")
	}
	s.available -= r.Quantity
	s.reservations[r.OrderID] = r.Quantity
	return nil
}

type OrderService struct{ inventory Inventory }

func (o OrderService) Place(ctx context.Context, id string, n int) error {
	return o.inventory.Reserve(ctx, ReserveRequest{id, n})
}
func main() {
	s := &Stock{available: 3, reservations: map[string]int{}}
	o := OrderService{s}
	fmt.Println("first:", o.Place(context.Background(), "o1", 2))
	fmt.Println("same request:", o.Place(context.Background(), "o1", 2))
	fmt.Println("changed request:", o.Place(context.Background(), "o1", 1))
	fmt.Println("stock:", s.available)
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	fmt.Println("cancelled:", o.Place(ctx, "o2", 1))
}

main_test.go ​

go
package main

import (
	"context"
	"sync"
	"testing"
)

func TestModuleContract(t *testing.T) {
	s := &Stock{available: 3, reservations: map[string]int{}}
	o := OrderService{s}
	var wg sync.WaitGroup
	for range 10 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if e := o.Place(context.Background(), "o1", 2); e != nil {
				t.Error(e)
			}
		}()
	}
	wg.Wait()
	if s.available != 1 || len(s.reservations) != 1 {
		t.Fatal("non-idempotent")
	}
	if o.Place(context.Background(), "o1", 1) == nil {
		t.Fatal("payload conflict")
	}
	if o.Place(context.Background(), "o2", 2) == nil {
		t.Fatal("oversold")
	}
}

保留各书来源与许可 · 示例运行条件以正文为准