微服务拆分粒度怎么定?我们把37个服务合并回6个之后的复盘
去年三月的一个周一早上,下单确认页开始转圈。用户点一下要等 8 秒,然后报超时。
我打开 SkyWalking 看链路,/api/order/confirm 这一个请求下面挂了 9 个 span,全是串行调用。有意思的是每个服务单看都挺健康:order 45ms,user 30ms,member 60ms,inventory 80ms,9 个加起来撑死 600ms,怎么会 8 秒。最后定位到罪魁是 coupon-service,它里面有个统计 SQL 忘了走索引,扫了 200 多万行,单次 1.2 秒。平时这服务有 4 个实例,负载均衡能扛。但那天上午十点做活动,QPS 是平峰的 6 倍,inventory-service 的 Hystrix 线程池先被打满,请求开始排队,上游 Feign 又触发了 Ribbon 的下一实例重试,一个请求变两个,雪崩就这么滚起来了。下面写的数字都是我们自己环境里的,不一定套得到你身上,看个意思就行。
这套东西当初是怎么拆出来的
2020 年我们把这个系统从 1 个单体拆成了 37 个服务。拆的理由其实挺正当:团队从 4 个人涨到 22 个人,一个 Spring Boot 工程,git pull 每天冲突三四次,发版得等所有人手上的分支合完,一个功能上线平均排 5 天。拆,是对的。
问题是拆完之后账不太好看。机器从 12 台 8C16G 涨到 43 台,这个我有心理准备。没准备的是数据库连接:HikariCP 默认 maximumPoolSize 是 10,37 个服务平均每个 3 个实例,就是 111 个连接池乘 10,1110 个潜在连接,而 MySQL 的 max_connections 默认只有 151。我们连夜把 RDS 参数调到 3000,运维同事在群里发了个句号。
还有几个数字我到现在都记得:CI 从 1 条 11 分钟的流水线变成 37 条,升级一个公共工具包要改 30 个仓库的 pom.xml;ELK 日志量从每天 20GB 涨到 180GB,因为大家把所有服务都打了 INFO,跨服务排查还得手动拼 traceId;2022 年统计过一次,37 个服务里有 14 个,过去 3 个月的 commit 数不到 5。也就是说四成的服务基本是死的,但它们的机器在跑,安全补丁要打,依赖要升,半夜出告警还得有人爬起来看。
我们拆错的三个具体原因
第一个,按数据库表拆。有张 order_item 表被 order-service 和 after-sale-service 同时写,当时觉得没事,反正各写各的字段。结果就是任何一个涉及改商品数量的需求,都要改两个服务、两个库、两个发布窗口,还得考虑中间态。这其实就是分布式单体,只是穿了微服务的衣服。
第二个,按人拆。谁当初写的模块谁拿一个服务,听上去很合理。但人是会走的,走了之后那服务就成了祖传代码,新人不愿意碰。我们有个会员服务,原作者离职两年了,代码里还留着注释写着这里先这样下版本重构。
第三个,把能拆当成该拆。技术上拆一个服务太容易了,加个 pom,复制一份配置文件,一小时的事。成本不在拆的时候,在后面每一次跨服务的变更上。你拆出去的那天很爽,三个月后改一个字段要协调三方的时候就不爽了。
我们后来用的判断方法:变更耦合度
这个东西是我们自己拍的,没有学术出处,但你可以在自己仓库里跑一遍,比看十篇架构文章管用。核心思路一句话:两个模块如果总是在同一个 commit 里被改,它们就不该分开。
第一步,把过去 6 个月的提交历史导出来,只保留文件路径:
git log --since="6 months ago" \
--name-only \
--pretty=format:"__COMMIT__%H" \
-- src/main/java/com/xxx/order \
src/main/java/com/xxx/payment \
src/main/java/com/xxx/coupon \
> /tmp/commits.txt
第二步,写个几十行脚本,统计每一对模块同时出现在同一个 commit 里的次数,除以至少出现一次的次数,得到耦合度。
第三步,看结果。耦合度大于 30% 的,合并,这俩压根是一个东西;10% 到 30% 的,先别动,观察,可能是某个功能还没沉淀完;小于 10% 的,有拆的价值,再去评估团队和事务边界。我们跑完之后发现,order 和 order-item 相关的 6 个服务互相之间耦合度都在 45% 以上,coupon 和 marketing 是 61%,而 user 和 auth 只有 8%,它俩留着拆是对的。
除了变更耦合度,我们还看了两个指标。一个是事务半径:一个业务操作如果要保证原子性的写操作跨了 2 个及以上的库,你就得引入 Saga、TCC 或者本地消息表,成本不是线性涨的,是跳着涨的,我们这边跨库事务超过 2 个的业务,出错率大概翻了 3 倍。另一个是维护人数:一个服务如果长期没有一个全职的人负责,它就不该独立存在。这个是从康威定律反推的,不一定严谨,但统计下来挺准。
合并的过程,以及踩的坑
从 2022 年底开始动手,到 2023 年 5 月合完,37 个服务变成 6 个。过程比想象中顺利,但有几个坑我得说一下。
顺序上,先合库,再合代码,反过来会很痛苦。我们先把表合回一个 schema,这一步花了三周,主要是处理字段类型不一致,同样叫金额的字段,有的用 DECIMAL(10,2),有的用 BIGINT 存分。合库前写了对账脚本跑了两周,确认没有对不上的数据才切的。代码那边,合并成一个 Gradle 多模块工程,每个老服务变成一个 module,为了不让人乱引用,加了 ArchUnit 规则,不允许跨模块直接调 Mapper,只能调对方暴露的接口,CI 里会跑,违反就红。对外接口一个都没变,网关那层按请求头里的 x-shadow 标记分流,老应用和新应用并行跑了一个月。
踩的坑有三个,都挺典型。一是 Spring Boot 2.4 之后 bootstrap.yml 默认不加载了,得额外加 spring-cloud-starter-bootstrap 依赖,我们合并时正好卡在这个版本上,Nacos 配置读不到,排查了半天。二是事务代理失效,两个服务合并到一个工程后,原来跨进程的调用变成同一个类里的 this 调用,@Transactional 直接不生效,这个坑很老,但合并的时候特别容易中招,因为你根本想不到去检查。三是定时任务重复执行,原来 A 服务一个 job,B 服务一个 job,合并成一个应用多实例部署之后,同一时刻有 6 个实例在跑同一个任务,后来上了 ShedLock 才压住。
我现在的观点
合完之后最爽的不是省了 25 台机器,是改一个需求不用开 5 个 PR、不用等 4 个服务的发布窗口。
所以我现在对微服务的看法比较直接:它不是一个架构目标,它是组织规模超过某个阈值之后的妥协方案。这个阈值是多少,我不敢给普适答案,按我们自己的情况,大概是三个条件同时满足才划算:研发团队 50 人以上、有至少 3 个能独立发布的业务域、有一个专职的平台或 SRE 团队。三个缺一个,模块化单体大概率更省钱。这个数字是我拍脑袋的,你要是团队 30 人也拆得很舒服,那当我没说。
至于网上那些微服务十大优势,我现在看会多想一层:它列出来的每一条优势,对应的成本是多少,谁付。这个问题答不上来的话,那多半不该拆。