1.2 拆分边界:按业务而不是按技术切

确定要拆之后,第二天大家就围在白板前画框。第一个分歧立刻冒出来:有人说按技术层切,数据库一层、缓存一层、业务逻辑一层,每层一个服务;有人说按业务切,订单、商品、用户各一个服务。争了一上午,最后选了后者,而这个决定省掉了后来三个月的返工。这一节把两种切法的差别、我们划边界用的方法、以及踩过的循环依赖坑讲清楚。

先说我们最初的想法有多么朴素:以为服务边界就是按目录划线,一天就能定稿。结果白板上反复擦了三版,中间还有一版被否掉重画。边界决定的是未来三年每次改动的走向,值得多花时间。

按技术切为什么诱人

按技术切特别符合程序员的直觉,因为它看起来“复用性最强”:所有数据库访问收成一个数据服务,所有缓存收成一个缓存服务,业务层只管调接口。听起来很整洁,我们第一版方案差点就这么干。

问题出在哪:把技术层抽成服务,等于把同一份业务逻辑换了个地方写。下一个促销活动要改价格规则,你依然要同时改订单服务、商品服务、缓存服务三个地方,因为“价格”这个业务概念本身就被技术分层切碎了。模块间通信从函数调用变成了跨进程 RPC,耦合一点没少,延迟倒是翻了好几倍。

具体画一下它的调用链就更能看出荒谬:前端要展示一个促销价,订单服务算价,要去缓存服务查命中,没命中去数据服务查表,数据服务再回调订单的服务算规则。一个最普通的价格展示,进程内一次调用变成了四次跨网络往返,而且“促销规则”这段逻辑到底归谁,没人答得上来。

更糟的是性能。商品详情页一次请求可能要查缓存、查数据库、查推荐,本来一次进程内调用搞定,拆成三个服务后变成三次跨网络往返。我们推演过一版方案,压测预估的首屏 RTT 从 80ms 涨到 250ms,直接被否。

按业务切:依照用户旅程

最后定下的切法是按业务域,依据是用户旅程:一个用户来我们 app 做什么——浏览商品、下单、付钱、查订单、改资料。每一段完整旅程对应一个服务,旅程里用到的数据归这个服务管。

划边界时我们用了一个很土但好用的方法:把产品经理的需求文档摊开,找高频出现的名词。订单、购物车、商品、库存、用户、优惠券——这些名词之间天然有亲和力,订单永远和购物车、库存一起出现,但几乎不会和用户头像一起出现。按名词聚类划出来的边界,和领域驱动设计里讲的限界上下文是一回事,只是不用背那些术语。

具体做法是拿便利贴:每个名词一张,贴在白板上互相连线,强相关的推到一起,一个月下来白板上自然形成三堆。有几张便利贴会反复挪动,比如“地址”,既属于用户资料又被订单引用,最后我们归给用户域,订单下单时只保存一份地址快照——这个决定后来在拆库时省掉了跨库 join。

还有一类名词要特别小心:以动词命名的流程,比如“退款”“维权”。它们横跨多个域,谁都不愿意收。我们的处理是仍然按主数据归属落位:退款归订单域,因为退款的生命周期挂在订单上;维权归商品域,因为纠纷对象是商品。流程可以跨域调用,但数据有唯一主人。

我们最终划成三个服务加一个网关,对应三个业务域:

业务域 归属数据 对外能力 团队归属
用户域 user users、addresses 登录、鉴权、资料 1 人维护
商品域 product products、skus、stocks 详情、库存扣减、上下架 2 人维护
订单域 order orders、coupons、payments 下单、支付、查单 2 人维护
网关 gateway 无独立数据 路由、鉴权前置、限流 兼职维护

注意库存归到了商品域而不是订单域——这是争论最久的一点。理由是库存只跟商品生命周期绑定,订单只是库存的消费者。谁拥有数据,谁负责它的正确性,这条原则后面在数据与消息一篇里会反复用到。

划完之后我们做了一次压力测试式的评审:每人挑三个未来可能上线的需求,假装 review 它们落在哪个域。其中一个“拼团”需求让大家吵了起来——拼团既是营销玩法(商品域)又会产生订单(订单域)。最后的裁决是按主数据归位:拼团产生的核心数据是成团记录与分摊金额,本质是一笔特殊订单,归订单域;商品域只负责在详情页展示拼团标签,通过接口拉取拼团摘要。这种演练比事后补救便宜得多,强烈建议在写第一行拆分代码之前做一遍。

目录结构先于代码拆分

服务边界定下来后,我们没有立刻拆仓库,而是先在单体里按域整理目录。这一步成本极低,但能提前暴露问题。整理后的结构长这样:

shop/
├── cmd/shop/main.go          # 暂时还是单体启动
├── internal/
│   ├── user/                 # 用户域:只依赖自己的表
│   │   ├── handler.go
│   │   ├── repo.go
│   │   └── service.go
│   ├── product/              # 商品域
│   │   ├── handler.go
│   │   ├── repo.go
│   │   └── service.go
│   └── order/                # 订单域
│       ├── handler.go
│       ├── repo.go
│       └── service.go
└── pkg/                      # 真正的公共代码:日志、错误码

强制一条规则:internal 下三个域之间的调用只能走包的公开接口,禁止直接 import 对方的 repo。这一条落地后,后续真正拆服务时,每个域的对外依赖只有两三个方法,迁移成本可控。

踩坑:循环依赖悄悄冒出来

目录整理后的第二周,编译就开始报循环依赖。原因是订单域算价时要查商品的促销价,商品域做报表又要看订单量,两个包互相 import。

第一版解决方案是抽出 promo 到一个公共包,结果发现这才是灾难的开始:优惠券规则既跟商品有关也跟订单有关,公共包迅速膨胀成新的“共享业务层”,又退回单体老路。

真正的解法是把依赖变成单向的:商品域对外提供“查询快照价格”的同步接口,订单域依赖它;商品域需要订单数据时,不直接调订单,而是订阅订单域发出的事件。下单成功后订单域广播一条事件:

// 订单域:下单成功后发布事件,不关心谁来消费
type OrderPaidEvent struct {
    OrderID  string
    UserID  string
    Amount  int64
    Items   []OrderItem
}

商品域订阅这个事件更新销量统计。这样订单知道商品的存在,商品对订单一无所知,依赖图重新变成有向无环。这套事件机制后来演进成 RocketMQ 消息驱动的核心模式,在RocketMQ 削峰一节会详细展开。

顺带说一个发现循环依赖的手段:把三个域的 import 关系导出成一张图,每周自动跑一次。脚本很简单,解析 go list 的输出即可:

# 列出每个包被哪些其他域的包 import,形成依赖矩阵
go list -f "{{.ImportPath}} {{.Imports}}" ./internal/... \
  | grep "internal/order" | wc -l

图里只要出现双向箭头,当周的负责人就必须当天处理掉,不允许过夜。微服务时代的循环依赖不会在编译期报错,会变成线上说不清的相互超时。

判断边界划得好不好,我们总结了一个自检方法:把任意两个服务合回单体,如果编译通过且行为不变,说明边界是干净的;如果必须写胶水代码才能合并,说明边界上还有隐藏耦合。

另一个自检是看接口数量:一个服务对外暴露的方法如果超过二十个,大概率边界划大了,里面还塞着好几个子域;如果只有两三个方法却被频繁调用,可能是划太碎,考虑合并。我们商品域一开始暴露了十一个接口,把库存管理、上下架、营销配置全算进去,后来把营销配置挪到运营后台,收敛到七个接口,每个都对应一条明确的用户旅程。

小结

拆分边界的第一原则是按业务切:围绕用户旅程找名词聚类,让数据归属跟着业务域走,技术分层留在每个服务内部。边界上的依赖必须是单向的,同步走接口,异步走事件。不要一上来就拆仓库,先在单体内按域整理目录并禁止跨域直接访问内部实现,这步能以极低成本提前暴露所有边界问题。

还有一条心法:边界一旦定下来,就要给它半年的稳定期。期间发现划错了的小问题,用接口和事件去绕,而不是频繁重划域。来回重划等于反复推翻所有人的代码布局,对五人团队的伤害比边界本身不准更大。

延伸阅读