第一次拆分拆错了,我们重做了一版
去年 10 月到今年 2 月,我把单体拆成了 7 个服务。上线稳定运行两个月之后,我自觉地把其中两个又合并了、一个重新划了边界。
原因说出来很打脸:我们造了个 common-service。它承担了字典、地区、短信模板、附件上传、金额计算这些"大家都要用"的能力,被另外 6 个服务调用。三个月之后它变成了这样:
common-service 的调用关系(2020-02 统计)
order-service → common-service 每天 470 万次
user-service → common-service 每天 210 万次
promo-service → common-service 每天 180 万次
inventory-service → common-service 每天 96 万次
report-service → common-service 每天 41 万次
admin-service → common-service 每天 12 万次
它成了新的单点。改一次 common-service 的代码,要通知 6 个团队回归测试,发版要选在所有人都有空的窗口。这跟两年前那个单体没有任何本质区别,只是把本地方法调用换成了 HTTP 调用,还搭进去了网络开销和序列化成本。
这就是分布式单体:服务拆开了,耦合还在,甚至因为跨网络变得更难改。
错在哪:按"能不能复用"拆,而不是按业务能力拆
我们当时划分服务的标准很朴素:被多处调用的东西就独立成一个服务。这个标准来自面向对象里的"抽取公共方法",直觉上很合理,但在服务拆分上是错的。
错在两个地方。
一、复用是结果,不是拆分的依据
微服务拆分的第一性依据是业务能力(business capability),也就是"这块业务能不能独立地对外提供价值"。用户管理、商品目录、订单履约、库存、促销、支付,这些是业务能力。而"字典查询""金额计算"不是业务能力,它们是能力内部的实现细节。
判断方法我总结成一句话:能不能用一句业务语言说清楚这个服务是干什么的,而且这句话里不包含"公共""通用""基础"这类词。
- "订单服务负责订单的创建、支付、履约、售后" —— 成立。
- "公共服务提供各种通用能力" —— 不成立,它什么都是,等于什么都不是。
二、被复用的是代码,不是服务
如果多个服务都需要同一段逻辑,正确的处理方式取决于这段逻辑的性质:
| 逻辑的性质 | 处理方式 | 例子 |
|---|---|---|
| 纯计算,无状态,不访问数据库 | 做成二方库(jar),不要做成服务 | 金额计算、雪花 ID 生成、加解密、日期工具 |
| 有状态,但要操作的数据归某个服务所有 | 下沉到那个服务里,别人通过 API 调用 | 优惠券核销(数据归促销服务) |
| 有状态,数据归属不清晰 | 先划清数据归属,再决定放哪 | 会员积分(最后归到了用户服务) |
| 真正独立的基础设施能力 | 做成服务 | 短信网关、文件存储、消息推送 |
我们那个 common-service 里,一半的东西属于第一类,本该做成 jar 包直接依赖;另一半属于第二、三类,本该就近下沉;只有短信和文件存储真的该独立成服务。
重做的四条判断标准
第二次划分时我列了四条标准,每划一个服务都过一遍。
一、数据所有权:谁写这张表,逻辑就归谁
这条最硬,也最好用。做法是把库里每张表标上归属方(owner),然后看哪些表的 owner 是同一个。
表 owner 备注
t_order order-service
t_order_item order-service
t_order_address order-service
t_stock inventory-service
t_stock_flow inventory-service
t_coupon promo-service
t_coupon_use promo-service 曾经被 order-service 写过,已改
t_point user-service
t_point_flow user-service
t_dict — 归属不清,最后拆成了各服务的枚举
t_sms_template sms-service
t_coupon_use 这张表是最典型的例子。它记录"哪张券在哪个订单里被用了",一看就跟订单有关,所以最初我们让订单服务直接写它。结果就是:促销服务想查券的使用情况要调订单服务,订单服务想知道券的规则要调促销服务,双向依赖。
改法:这张表的 owner 是促销服务,订单服务只能通过 promo-service 的 API 来核销券。因为"这张券能不能用、用了之后状态怎么变"是促销领域的规则,订单不该知道。改完之后依赖方向变成了单向的:订单 → 促销。
判断 owner 时我会问:修改这张表的业务规则属于哪个领域?不是"谁在读它",也不是"它的名字里有没有某个服务的关键词"。
二、变更频率:总是一起改的,应该在一起
我拉了半年的 Git 提交记录做统计。如果两个模块经常在同一次需求里被一起修改,它们大概率属于同一个限界上下文。
$ git log --since=2019-07-01 --name-only --pretty=format: | \
grep -E "^src/main/java" | \
sed -E 's#src/main/java/com/xxx/([a-z]+)/.*#\1#' | \
sort | uniq -c | sort -rn
412 order
231 promo
188 inventory
156 user
97 payment
...
更有效的做法是看同一次 commit 里出现了哪几个模块:
$ git log --since=2019-07-01 --pretty=format:"%H" | while read c; do
git show --name-only --pretty=format: "$c" | \
sed -E 's#.*/([a-z]+)/[A-Z].*#\1#' | sort -u | tr '\n' ',' ; echo
done | grep -c "order,promo"
67 次提交同时改了 order 和 promo
67 次里有多少是真的因为业务耦合?我抽了 20 个 commit 看,其中 14 个是"下单要用优惠券"这个场景。这说明订单和促销确实有强耦合——但耦合的方向是订单依赖促销,反过来不成立。所以它们该是两个服务,只是订单要调促销的 API。
真正应该合并的是那种"改 A 必然改 B,改 B 必然改 A,而且分不清方向"的情况。我们最后合并的就是这么一对(商品 SKU 和商品价格,之前拆成了两个服务,实际上改价必须改 SKU、改 SKU 几乎都要改价)。
三、团队结构:康威定律
系统的架构会趋同于组织的沟通结构。我们当时是三个小组,每组 3 到 4 人:
| 小组 | 负责的服务 | 问题 |
|---|---|---|
| 交易组 | order、payment、inventory | 合理,都是一个业务流 |
| 用户增长组 | user、promo | 合理 |
| 平台组 | admin、report、common | common 被其他两组强依赖,平台组成了瓶颈 |
common-service 归平台组维护,但交易组和增长组天天要改它。跨组协调的成本比技术成本高得多。拆掉 common 之后,平台组只维护真正的基础设施(短信、文件、消息),其他逻辑各自下沉。
四、事务边界:跨服务的一致性代价有多大
如果两个操作必须在同一个事务里完成,把它们拆到两个服务里,就要引入分布式事务。AT 模式一次全局事务的开销是 138 ms(见我 Seata 那篇),这是个实打实的成本。
所以我的判断是:可以用最终一致解决的,就拆;必须强一致的,先别拆,或者想办法改造成最终一致。
我们的会员积分就是这么处理的。原来下单加积分在同一个事务里,拆开之后改成了:下单成功后发 MQ 消息,积分服务消费消息加积分,失败重试。这个改造让一致性从"强"降到了"最终",但业务上完全可接受——用户晚 2 秒看到积分,没人在意。
共享代码到底怎么做
这是拆完 common-service 之后最实际的问题。我们定了三条:
一、纯计算逻辑做二方库
<dependency>
<groupId>com.xxx.shop</groupId>
<artifactId>shop-common-util</artifactId>
<version>1.4.2</version>
</dependency>
里面放:金额工具类(BigDecimal 运算)、雪花 ID、加解密、日期处理、统一的 Result 包装、异常定义。
版本管理要严格。我们吃过一次亏:shop-common-util 从 1.3.0 升到 1.4.0 改了一个方法签名,订单服务升了、用户服务没升,上线后用户服务报 NoSuchMethodError。现在的规矩是这个库必须向后兼容,破坏性变更要大版本号。
二、DTO 不共享,各自定义
我见过很多项目把所有的 DTO 放在一个 common 包里,所有服务引用。这会造成改一个字段牵一发动全身。
我们的做法:服务之间只通过接口契约交互,各自定义自己的入参出参类。比如订单服务调库存服务:
// inventory-api 模块,inventory-service 提供,order-service 依赖
public interface InventoryApi {
Result<Boolean> deduct(DeductRequest req);
}
@Data
public class DeductRequest implements Serializable {
private Long skuId;
private Integer qty;
private String bizNo; // 幂等键
}
订单服务里自己有 OrderDTO,调库存时手动转换。多写几行代码,换来了两侧的独立演进。
三、基础设施能力才做成服务
短信、文件存储、推送这类东西,有独立的外部依赖、独立的配额管理、独立的供应商切换需求,值得独立成服务:
sms-service 接 3 家短信供应商,做路由和失败切换
file-service 接 OSS,做鉴权和防盗链
push-service 极光推送 + 自建长连接
这三个服务上线之后,切换短信供应商从"改 6 个服务的代码"变成了"在 sms-service 的配置里换个权重"。
重做前后的对比
| 指标 | 第一版(8 个服务) | 第二版(7 个服务) |
|---|---|---|
| 最大扇出(被依赖最多的服务被调用方数) | common-service 被 6 个依赖 | promo-service 被 3 个依赖 |
| 改一处需要回归的服务数(中位数) | 2.4 个 | 1.2 个 |
| 跨组协调才能发版的变更占比 | 41% | 13% |
| 单次下单的跨服务调用数 | 5 | 4 |
| 下单 P99 | 410 ms | 338 ms |
服务数量从 8 变 7(合并了商品 SKU 和价格),但耦合度降下来不少。
怎么判断自己是不是造了个分布式单体
三个信号,命中任何一个都要警惕:
- 改一个服务,必须同时发版另外两个以上。说明边界划错了。
- 有个服务被超过 3 个服务同步调用,而且调用的是业务查询(不是短信、鉴权这类基础设施)。说明有公共逻辑没下沉。
- 两个服务互相调用。A 调 B,B 又调 A,这种双向依赖几乎总是数据所有权没划清导致的。
我们第一版三条全中。
下篇预告
这篇先把《微服务拆分的第一步:边界怎么划分》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。