主题
微服务拆分方法:先把边界分清,再决定要不要跨网络
订单、库存和支付写在一个仓库里,不一定是坏架构;它们跑在三个进程里,也不一定已经解耦。如果改一个字段要同时改三个服务,发布必须一起发,数据库还互相写,这套系统只是把原来的耦合搬到了网络上。
拆分的目标应是能独立理解、修改、部署和承担责任的一块业务。本文按单体、模块化单体、微服务的演进来讲,重点是如何识别边界、验证拆分收益,以及什么时候应该停下来。代码演示一个进程内的库存预留接口,让边界先在本地成立;它不是完整下单流程,也没有运行远程服务。
三种形态改变的是什么
| 形态 | 代码和部署关系 | 已获得什么 | 仍要承担什么 |
|---|---|---|---|
| 单体 | 一个部署单元,内部边界可能松散 | 本地调用简单,事务与调试路径较短 | 依赖容易互相渗透,变更和扩容常一起进行 |
| 模块化单体 | 一个部署单元,明确模块接口和数据所有权 | 可以隔离业务知识、限制依赖,低成本验证边界 | 通常仍一起发布,进程资源和故障域仍共享 |
| 微服务 | 多个可独立部署的服务,通过网络交互 | 可以独立发布、扩容和组织运行责任 | 网络失败、版本兼容、跨服务一致性、观测和运维成本 |
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
箭头传的是明确契约,而不是共享整个内部对象。例如订单通常需要保留成交时的商品名和价格快照;商品目录后来改名,不一定应该回头改掉历史订单。这个例子说明数据复制有时是业务建模的需要,不是所有重复字段都必须被“消灭”。
限界上下文与进程边界没有固定一一对应关系。一个上下文可以先作为单体内模块,也可能因规模拆为多个协作服务;反过来,把互相冲突的模型硬塞进一个上下文,只因为部署方便,也会增加理解成本。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[决定继续切换或恢复旧路径]
一个可审查的迁移顺序如下。
| 阶段 | 做什么 | 过关条件 |
|---|---|---|
| 明确契约 | 参数、结果、错误、幂等、取消及版本兼容 | 现有调用方能按契约工作,不依赖内部表结构 |
| 单体内替换 | 门面后切换实现,仍在同一进程 | 契约测试通过,业务不变量有对应测试 |
| 数据准备 | 定义唯一写入者、复制与校验、迁移位点 | 新旧状态对得上;增量和删除不遗漏 |
| 引入远程路径 | 配置连接、身份、超时、观测 | 模拟超时、重复、断连,结果未知有查询路径 |
| 小范围切流 | 按租户或稳定群组切换并观察 | 错误率、尾延迟和对账满足预先目标 |
| 结束迁移 | 关闭旧写路径,保留必要回退/备份窗口 | 旧调用者退出,权限收紧,责任人和恢复手册明确 |
“影子流量”也要区分读和写。把真实写请求同时发给新旧系统,可能重复扣减、重复发通知。验证写路径时,需要隔离副作用、使用只计算不提交的模式或设计可靠的迁移协议;不能把双发流量当成无风险测试。
最后才能删的是旧契约,最早应明确的是写入权
数据迁移中应尽量维持一个明确的权威写入者。双写会遇到一边成功、一边失败,必须用日志、outbox、版本和恢复流程处理;“两个写都包进同一个 Go 函数”并不产生跨服务原子性。
新服务接管写入后,旧服务的数据可能立刻落后。回滚不再是把路由开关切回去,还需要反向同步或另一种确保数据完整的方案。对数据库字段的变更也可采用扩展—兼容—收缩:先添加兼容结构,让新旧代码都能工作;完成数据和客户端迁移以后,再移除旧结构。不能先删旧字段再要求所有服务同一瞬间升级。
领域事件可以解开时间耦合,也会留下延迟窗口
同步接口适合调用方必须立刻得到结果的业务决策,例如“这次库存预留是否成功”。领域事件表达已经发生的业务事实,例如“订单已取消”,让相关模块各自处理后续工作。事件应表达稳定的业务含义,避免把每次内部表字段变化都广播成公共契约。[3][5]
消费者可能晚到、重复消费或遇到旧版事件。因而事件至少要有身份、类型/版本、关联业务对象、必要的业务版本及可追踪来源。发布者的业务提交与事件记录要保持可靠联系;消费者需要幂等与失败可见性。报表或搜索索引可以接受一定滞后,库存是否能多卖则是另一种要求。
复制到读取模型的数据也有自己的所有权:它可以用于查询,不代表消费者获得了反向改写源业务的权限。这样才能既允许数据为不同用途复制,又不把所有服务拉回共同写一套表的状态。
拆完以后,用结果判断有没有变好
不要只统计服务数量。可以在拆分前后用同样口径观察:一次功能修改需要几个团队同步、发布等待时间、单模块回滚范围、尾延迟、跨服务调用数、故障定位时间和运行成本。本文没有这类实测数据,不能承诺拆分后一定提速。
如果大部分需求仍跨越全部服务、调用链更长、故障更难定位,边界就值得重新检查。合并某些服务、保留模块化单体、让少数高差异 worker 独立运行,都是正常的设计选择。
先把一个候选边界的契约、写入所有权和故障恢复说清楚,再决定它需不需要网络。这一步做不好,换多少框架都很难得到独立演进的服务。
依据与验证范围
- Martin Fowler:Monolith First:先探索边界再拆分的经验、条件与反面情况,不是普遍定理。
- David Parnas:On the Criteria To Be Used in Decomposing Systems into Modules:信息隐藏与按设计决策划分模块。
- Chris Richardson:Decompose by business capability:按业务能力组织服务。
- Martin Fowler:Bounded Context:模型的适用边界和上下文关系。
- Chris Richardson:Decompose by subdomain:按 DDD 子领域识别服务边界。
- Go 1.27.2 cmd/go:Internal packages:internal 包的导入限制。
- 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.2main.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")
}
}