微服务架构拆分——从单体到微服务的实战路径

3 阅读9分钟

干过几年后端的人大概都经历过这么个阶段:项目刚起步时一个 Maven 工程(或者一个 Git 仓库)搞定一切,部署简单,联调方便,团队几个人在一个 codebase 里写得不亦乐乎。但业务一旦跑起来,代码量蹭蹭往上涨,这个"大一统"工程就开始要命了。

单体撑不住的时候,信号其实很明显

不用等到系统崩了才去拆。单体架构的痛点,平时就会冒头,只是很多时候大家忍忍就过去了。但有几个信号一旦同时出现,基本就该认真考虑拆分了:

部署成了噩梦。 改了一个支付模块的小 bug,结果整个应用重新编译、重新打包、重新部署,十几分钟就过去了。更要命的是,一个模块有问题,整个应用都跟着挂。促销活动高峰期订单服务 OOM 了,库存和用户服务也得跟着陪葬。

团队协作打架。 二十几号人改同一个代码库,Git merge 冲突天天有。谁也不敢大刀阔斧地重构,因为牵一发动全身,改了 A 模块不知道会不会把 B 模块搞挂。技术栈也被锁死了——你想试试 Go 写风控引擎?不行,整个项目是 Java 写的,老老实实用 Java。

发布节奏跟不上。 商品团队想一周发三次,但支付团队还在做合规审查,两周才能上一个版本。单体架构下只能跟着最慢的那个走,快团队被活活拖慢。

这些信号凑齐了三个以上,就别硬扛了。

拆之前先把边界想清楚

拆分最忌讳的是上来就动手。见过不少团队一拍脑袋就开拆,按"前端调哪个接口"来划分,结果拆出来一堆互相调用的环形依赖,比单体还乱。

正确的做法是先做领域建模。说白了就是把业务领域梳理一遍,找到那些内聚度高、耦合度低的业务边界。这里推荐用 DDD(领域驱动设计)的限界上下文来做划分依据。不用搞得多正式,关键是把业务流程走一遍,问自己几个问题:

  • 这个业务动作,哪些数据是必须一起变更的?
  • 哪些业务规则是独立的,改了不影响别的?
  • 团队是怎么分工的?一个团队负责的完整业务链路应该尽量在一个服务里。

举个例子,电商系统里,订单创建涉及扣减库存、生成支付单、通知用户。这些动作要么在一个事务里完成,要么通过消息解耦。但商品管理(上下架、改价格)跟订单的变更逻辑是独立的,完全可以拆成两个服务。

拆出来的服务数量要克制。别一上来就拆二十个微服务,三个人的团队根本管不过来。先拆三到五个核心域,后续按需再拆。

绞杀者模式:别一刀切

确定好边界后,怎么拆就是技术问题了。最推荐的策略是绞杀者模式(Strangler Fig Pattern),这个名字来自绞杀榕——新植物慢慢缠绕包裹老植物,最终取而代之。

具体操作就是在单体前面加一层网关或代理,新功能直接用新服务实现,旧功能逐步迁移。老单体里的某个模块需要重写了,就新建一个微服务,把路由慢慢切过去,确认没问题了再把老代码删掉。这样整个过程是渐进式的,随时可以回滚。

实际操作中,迁移路径大概是这样的:

第一步,先挑一个边界清晰、风险小的模块试水,比如后台管理类的功能。这类功能 QPS 不高,出问题影响面小,适合当试验田。

第二步,在网关层做路由分流,对外的 API 路径不变,但内部转发到新的微服务。

第三步,灰度切流。先切 10% 的流量到新服务,观察日志和监控,没问题再加到 50%、100%。

第四步,确认稳定后,把老单体里对应的代码删掉,清理共享代码。

这个过程可能要花好几个月,急不得。我见过有的团队想一步到位,一个周末就迁移完所有服务,周一上线直接炸了,回滚花了三天。

数据库怎么拆才是真正的硬骨头

服务拆分只是表面功夫,真正的难点在数据库。很多团队服务拆了,数据库还连在一起用,这种叫"分布式单体"——看起来是微服务,实际上数据库层还是紧耦合的,改个表结构还得协调好几个服务。

数据库拆分得跟着服务边界走。订单服务用订单库,商品服务用商品库,互不干涉。但这里有个大坑:跨库的 JOIN 查询做不了了。以前一个 SQL 搞定的复杂查询,现在得在应用层做组装。

怎么办?几种常见做法:

数据冗余。 订单服务里冗余一份商品的名称和价格快照。下单的时候把商品信息一起存下来,后续查订单就不用再去调商品服务。代价是商品改名了,历史订单里的名字不会跟着变。但对于订单这种场景,存快照反而更合理——你两年前下的单,当时的商品价格就应该锁死在订单里。

数据同步。 用消息队列做异步同步。商品服务发布价格变更事件,订单服务订阅后更新冗余数据。这种方案增加了系统的复杂度,但保证了最终一致性。

接口调用。 实时性要求高的场景,直接通过接口调商品服务。但要注意别在循环里调接口,N+1 问题会让你的服务慢到怀疑人生。

拆数据库还有一个特别容易踩的坑:分布式事务。单体时代一个数据库事务就能搞定的事情,拆开后跨服务了。常见的做法是放弃强一致性,用 Saga 模式或者本地消息表来实现最终一致性。TCC 也能做,但补偿逻辑写起来很痛苦,能不用就不用。

服务间通信别想当然

服务拆好了,它们之间怎么通信又是一个话题。主要就两类:同步和异步。

同步通信最常见的是 HTTP REST 和 gRPC。REST 简单直接,调试方便,浏览器直接能调。gRPC 性能好,Protobuf 序列化效率高,适合服务内部调用。但同步调用有个根本问题:调用链长了之后,任何一个节点慢了,整条链路跟着慢。而且耦合度高,下游挂了你这边也得报错。

异步通信用消息队列(Kafka、RocketMQ、RabbitMQ)。下单之后发一条消息,库存服务、通知服务各自消费,互不阻塞。这种模式解耦做得好,但也带来新问题——消息可能重复消费、可能丢消息、消息顺序没法保证。消费者必须做幂等处理,不然同一个消息处理两遍,库存扣两次就炸了。

我的经验是,核心交易链路尽量用同步调用,保证实时反馈;非核心的辅助操作用异步,比如发通知、记日志、更新统计。别什么都异步,调试的时候你会想哭的。

拆完之后的运维,才是真正花钱的地方

拆之前觉得拆了就好了,拆完发现运维成本翻了好几倍。这不是吓唬人,是真的。

监控全链路追踪成了必需品。 以前一个单体应用,日志翻一翻就能定位问题。现在一个请求经过五六个服务,没链路追踪根本不知道卡在哪。得搭 SkyWalking 或者 Jaeger,每个服务都要接入。

部署和配置管理复杂度暴涨。 以前一个应用一个部署流程,现在十几个服务,每个有独立的 CI/CD 流水线,独立的配置。没有 K8s 和配置中心(Nacos、Apollo)根本玩不转。

服务发现和容错。 服务实例动态上下线,得有注册中心。服务间调用失败要有熔断、降级机制。Sentinel 或者 Resilience4j 这类组件得用起来,不然一个下游服务超时,上游的线程池全被占满,雪崩效应一来全完。

一个真实的迁移案例

之前带过一个电商团队的拆分,分享下踩过的坑。

一个 30 万行的单体 Java 应用,8 个人维护,拆分前一次部署要 20 分钟,一年平均有四五次因为某个模块内存泄漏导致全站不可用。

花了半年时间分三步走:第一步拆出商品服务和搜索服务,第二步拆出订单和支付,第三步拆出风控和通知。数据库用了三个月做迁移,商品数据先同步到新库,双写跑了两周确认没问题才切流。

踩的最大的坑是跨服务的分布式事务。订单创建的时候要同时扣库存、创建支付单、记风控日志。一开始想用 Seata 做 TCC,写了两周补偿逻辑,测试发现各种边界 case 补不回来。后来改成方案是:订单服务先创建订单(初始状态为"待处理"),然后发消息让库存服务扣减,扣减成功后再发消息让支付服务创建支付单。整个过程用本地消息表保证消息可靠性,订单状态通过状态机驱动。不算优雅,但能跑,而且出了问题能恢复。

拆完之后效果是实打实的:部署时间从 20 分钟降到 3 分钟(因为只部署改了的服务),某个服务出问题不再拖垮全站,团队可以独立迭代了。但运维成本也实打实涨了——多了 etcd 集群、Kafka、SkyWalking、Sentinel Dashboard,运维同学的工作量翻了一倍。

最后说几句实在的

微服务拆分不是银弹,也不是目的,是手段。如果单体已经够用了,业务量没那么大,团队就三五个人,真没必要拆。微服务带来的运维复杂度不是开玩笑的,服务治理、链路追踪、分布式事务、配置管理,每一项都要投入人力。

拆之前算笔账:拆分需要的人力、后续运维的人力、基础设施的成本,和你获得的好处(独立部署、技术栈自由、故障隔离)相比,值不值。如果值,就按上面的路子走,稳扎稳打,别贪快。如果不值,单体也挺好,别为了微服务而微服务。