拆了 3 年,我们把 14 个微服务合并成 3 个进程:P99 从 380ms 回到 92ms

🔑 关键词:微服务拆分,单体架构,微服务合并,Kubernetes成本,架构复盘

📖 摘要:一个 9 人团队把单体拆成 14 个微服务,跑了 3 年后又合并回 3 个进程。记录了 P99、云账单、CI 时长、MTTR 的真实数字,以及合并过程中 AbstractRoutingDataSource、ArchUnit、流量双写的具体做法和踩到的坑。

拆了 3 年,我们把 14 个微服务合并回了 3 个进程

图片

凌晨 2 点 07 分,我盯着投影仪上的 SkyWalking 拓扑图,耳朵里嗡嗡响,像有只蚊子在耳道深处打转。一个用户说下单没反应,我们查了 6 个 Kibana 索引、翻了 4 个服务的日志、开了 2 个钉钉群,最后发现是促销服务里一个 Redis 连接池配成了 3。

3。你信吗?一个每天被调用四百万次的服务,连接池大小是 3。

那是 2021 年 11 月,我们的微服务拆分走到第 3 年。胃里泛酸,那天我喝了 5 杯美式。

先说清楚,这不是一篇「微服务就是错」的爽文。我们当年拆是有理由的,只是拆多了。

先把当年的账摊开

2019 年 11 月,团队 9 个人(后端 6、前端 2、我算架构外加打杂)。我们干了一件事:把一个跑了 5 年的单体 Spring Boot 应用拆成 14 个服务。

技术栈:Java 8、Spring Boot 2.1.6、Spring Cloud Gateway 做网关、Nacos 1.1.4 做注册和配置、OpenFeign 调服务、SkyWalking 6.6 做链路追踪、MySQL 5.7 每服务一个库,跑在阿里云 ACK 上,12 台 4C8G 的节点。

图片

一次下单请求经过的路径:网关 → 用户服务 → 商品服务 → 库存服务 → 促销服务 → 订单服务 → 支付回调。7 跳。

我们内网一次 RPC 的平均耗时是 6 到 9 毫秒(含序列化、注册中心寻址、连接复用)。7 跳就是 45ms 往上的纯网络开销,而且是平均数,长尾更难看。当时单体版本的 P99 是 92ms,拆完之后 P99 是 380ms。我们花了三个月让这个数字「看起来」合理——加缓存、加批量接口、把促销的两次调用合成一次——最低压到 290ms,再往下压不动了。

其它几个数字,我记在当时的复盘文档里,一直没删:

  • CI 流水线:从 6 分 40 秒变成 22 分 15 秒(14 个镜像要构建、打标签、推仓库、部署)
  • 云账单:月均 1.8 万 → 6.4 万(ACK 节点 + SLB + RDS 实例数 + 日志服务)
  • 发布:从一天能发 4 次,变成一周能发 2 次(有依赖顺序,订单服务不先发,其他都别动)
  • 事故定位时间 MTTR:中位数从 25 分钟涨到 1 小时 40 分

最后一条最要命。因为出事的时候,你永远不知道是哪个服务。有一次故障,我们排查了 3 小时,结论是「商品服务的线程池打满,导致库存服务的熔断触发,订单服务超时重试把数据库连接耗尽」。三个服务都对,三个服务也都错。谁的锅?谁的锅都不是,最后是我在复盘会上说「这次算架构问题」收场。

真正的转折点,是一个很蠢的问题

2022 年 9 月,一次周会上,我们那个刚来三个月的实习生问了一句:

「我们一天的订单量是多少来着?」

图片

我说,峰值 QPS 1400,日均请求 300 万左右。

他「哦」了一声,然后说:「那我上一家公司 8 万 QPS,也才 6 个服务。」

会议室安静了大概五秒钟。那五秒里我能听见空调出风口的动静。

(现在回头看,他这句话其实不严谨。8 万 QPS 和我们的业务复杂度不一样,订单、库存、促销这三个的耦合度是天然高的,硬拆开就是在跟业务逻辑打架。但他那句话像根针,戳破了一个我们自己吹了三年的气球。)

我们拆微服务的真实驱动力是什么?我现在可以很坦白地说:

  1. 2019 年「中台」和「服务化」是政治正确的词,不拆显得团队没技术追求
  2. 我当时的 KPI 里有一条「完成核心系统服务化改造」
  3. 我们招人的时候,JD 里写微服务架构,比写单体好招人

第三条是真的,我试过。同一份薪资范围,写「微服务」收到的简历大概是写「单体」的两倍多。

图片

合并,但不是简单回滚

2022 年 10 月到 2023 年 3 月,我们花了大概 5 个月做合并。不是 git revert 那种回滚,是把 14 个进程收敛成 3 个。

具体怎么做,我把踩过的坑写一下,因为这部分网上资料真的少。

第一步,先合边界最模糊的两个。 我们选的是促销和商品。这两个服务互相调用的方法有 11 个,其中 7 个是同步查询。合并它们风险最低,收益最直接。合完后 P99 降了大概 40ms。

第二步,数据源不要合。 这是我认为最关键的一条。14 个服务各自一个 MySQL 库,千万别急着花一个月把它们合成一个大库——那是另一个地狱。我们是让合并后的进程连多个数据源,用 Spring 的 AbstractRoutingDataSource 加一个基于注解的路由(@DS("order") 这种写法),一个连接池对一个库。跨库事务的问题,我们直接放弃分布式事务,改成「允许最终不一致 + 对账任务兜底」。对账任务每天凌晨 3 点跑,跑完发钉钉,有差异就 @ 我。

第三步,用 ArchUnit 把模块边界钉死。 合并之后最大的风险是代码互相乱引,半年后又变成一个糊。我们加了 ArchUnit 的单元测试:

@ArchTest
static final ArchRule order_should_not_access_promotion_internal =
    noClasses().that().resideInAPackage("..order..")
        .should().accessClassesThat().resideInAPackage("..promotion.internal..");

图片

这种规则我们写了 23 条,跑在 CI 里,破了就挂。

第四步,流量切换用双写。 新老两套并行跑了 11 天,用 Nginx 的 split_clients 按 5% → 20% → 50% → 100% 放量。中间有一次放到 20% 的时候 P99 抖到 700ms,查出来是新进程的 GC 参数沿用了小服务的配置,堆只给了 2G,调到 8G 就稳了。

结果,和几个不太好看的实话

2023 年 4 月,合并稳定一个月后的数据:

  • P99:380ms → 92ms(回到了拆之前的水平,没有更好)
  • 服务器:12 台 4C8G → 5 台 8C16G
  • 云账单:6.4 万 → 2.1 万
  • CI:22 分钟 → 7 分钟
  • 发布:一周 2 次 → 一天 3 次
  • 后端人数:6 人 → 4 人

最后那条我犹豫了很久要不要写。事实是,拆微服务那三年,有两个同事是被「服务太多、每天在开会同步接口」这件事耗走的。不是裁的,是自己走的。合并之后,我们不用再开那个周三下午的「接口对齐会」了。

但我不打算把这篇写成单体万岁。

合并之后我们出过一次很严重的事故:2023 年 7 月,定时任务模块有个内存泄漏(一个本地缓存没设上限),把整个进程 OOM 了,订单接口跟着一起挂,47 分钟。如果还是微服务,这只影响 job,不影响下单。这就是单体的代价,实打实的,我认。

图片

所以我们现在也没完全回到单体:文件处理单独一个进程(它吃内存太狠),定时任务单独一个进程(它可以随便重启),其余全在一个 api 里。三个。

我现在的偏见

我现在的判断标准特别粗暴:如果你团队的后端不超过 15 个人,如果你的核心链路 QPS 没到 8000,如果你的拆分理由是「感觉应该拆」——那就别拆。

微服务解决的是组织问题,不是性能问题(性能问题它通常是加重的)。康威定律那句话说烂了,但它反过来也成立:你拆出来的服务数,会变成你需要的团队数,而不是反过来。

我见过太多 8 个人的团队养着 20 个服务,每个人守着自己那 2 个服务,写着自己那 200 行代码,接口文档一年没更新。那不叫架构,那叫把一个大泥球切成了 20 个小泥球,切的时候还多撒了一地泥。

最后说个细节。合并完成那天晚上,我在终端敲 kubectl get pods,出来 3 个 Pod。我盯着看了挺久。不是高兴,是有点说不出的荒谬——我们绕了三年,付了大概 180 万的云成本,就为了回到起点,然后用一堆工程手段把「回到起点」包装成一次架构升级,写进了年终总结。

但我不后悔。有些路你不亲自走一遍,你心里永远会觉得「是不是拆了就好了」。现在我知道了。不太便宜,但是知道了。

🏷️ 标签: