Administrator
发布于 2020-05-11 / 3127 阅读
72

微服务拆分的第一步:边界怎么划分

第一次拆分拆错了,我们重做了一版

去年 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、commoncommon 被其他两组强依赖,平台组成了瓶颈

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%
单次下单的跨服务调用数54
下单 P99410 ms338 ms

服务数量从 8 变 7(合并了商品 SKU 和价格),但耦合度降下来不少。

怎么判断自己是不是造了个分布式单体

三个信号,命中任何一个都要警惕:

  • 改一个服务,必须同时发版另外两个以上。说明边界划错了。
  • 有个服务被超过 3 个服务同步调用,而且调用的是业务查询(不是短信、鉴权这类基础设施)。说明有公共逻辑没下沉。
  • 两个服务互相调用。A 调 B,B 又调 A,这种双向依赖几乎总是数据所有权没划清导致的。

我们第一版三条全中。

下篇预告

这篇先把《微服务拆分的第一步:边界怎么划分》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考