干这行这么多年,经常会有人问我:架构到底难不难?我的回答一直是——架构的本质就是拆和合。把一个复杂的大系统拆成多个小模块,再把小模块合理地组合起来,这事儿就想通了。"架构很简单:系统拆分与组合"这句话,听起来像一句废话,但真正落地的时候,大多数人的系统恰恰是拆也拆不动、合也合不拢。拆得乱了,服务之间互相调用像一团乱麻;合得紧了,又退回成一个大单体,性能说崩就崩。这篇文章我就结合自己这些年做系统设计的实际经验,把"拆分"和"组合"这两个动作掰开揉碎讲清楚,把原则、方法、实操步骤和那些文档里从来不写的坑,一次性讲透。
不管是刚接触微服务的新手,还是已经在做系统重构、承担架构设计工作的开发者,这篇文章都值得你花十分钟认真看一遍。我会用大量实际踩过的例子来说明,为什么同样做拆分,有些团队越拆越顺,有些团队越拆越死。最后还会把分布式事务、链路追踪、版本兼容这几个高频问题单独拎出来说,这些是拆分之后最难啃的硬骨头。
1. 先从"为什么拆"说起
很多人一说拆分就两眼放光,觉得把系统拆成微服务就代表先进,拆成模块就代表代码质量高。但实际上,如果没有想清楚拆分的根本动机,你只是把一个单体的问题扩散成了更多个组件的问题。就像一间屋子堆满了杂物,你把它隔成好几个小房间,杂物还在,只是从看得见的乱变成看不见的乱。
1.1 拆的不是模块,而是"变更边界"
我在不少公司的项目里见过一种典型的"伪拆分":按照代码层来拆,controller 一个工程,service 一个工程,dao 一个工程。从目录结构看确实拆了,实际上每次需求一来,要同时改三个工程,发布的时候还要小心翼翼地按顺序部署。这种拆法,本质上是在拆"代码文件",不是在拆"业务边界"。
真正值得拆的,是变更的边界。你要问自己一个问题:当某个业务需求发生变化时,影响的范围能不能被控制在一个小范围内?如果答案是否定的,说明边界划错了。举个例子,订单系统和库存系统通常在同一个单体里发展壮大,但当订单的促销逻辑频繁变化、库存的扣减策略频繁调整时,这两个大块已经开始互相拖累——订单改一行代码要重新验证库存的完整回归,库存的并发优化又会牵连订单的锁定逻辑。这时候把订单和库存拆成两个独立的模块甚至独立的服务,才真正有意义。
判断边界划得到底对不对,我再提供一个更实操的标准:看需求的改动是否会同时触碰多个模块。如果一个月内,几乎所有需求都要跨模块联调,那你的拆分就是失败的。拆分不是为了美观,是为了让变更发生时,你只需要在一小片区域里动刀。换句话说,拆分的收益是用"变更爆炸半径"来衡量的。
1.2 拆分该有的收益
不谈收益的架构设计都是耍流氓。拆分一定是有代价的——网络传输、数据一致性、服务治理复杂度都会上升。所以只有当收益明显大于代价时,才值得拆。我梳理了一下,拆分真正有价值的收益不外乎下面几类:
第一是性能的纵向扩展能力。单体能承载的并发总是有上限的,你可以通过堆硬件来解决一部分问题,但总有某个模块会成为瓶颈。比如日志处理模块吃 CPU,业务查询模块吃内存,放在一起的时候,扩容只能捆绑进行。拆开以后,CPU 密集的服务用高主频实例,内存密集的服务配大内存实例,资源利用率能提高不少。
第二是团队的并行效率。这个几乎是所有公司拆分的直接原因。几十上百人维护一个仓库,光是代码合并就能消耗大量精力。拆开之后,团队 A 改订单模块,团队 B 改商品模块,工作的冲突大幅度减少,发布的节奏也可以独立安排。这一点的价值往往被低估,但它其实是拆分带来的最明显、最直接的效率提升。
第三是技术异构的可能。虽然我不建议一上来就搞多语言,但拆分确实带来了独立选型的自由。搜索模块用 Elasticsearch 生态,实时计算模块用 Flink,而普通业务模块继续用 Java,这种"专家工具干专家活"的组合,在不拆分的结构里是做不到的。
第四是故障的隔离。单体系统里,一个模块的内存泄漏可以把整个进程拖垮;拆分之后,只要隔离措施做到位,某个服务的异常通常不会让整个系统宕机。不过这里要泼一盆冷水:服务之间的依赖关系如果处理不当,故障隔离只能是理论上的美好愿望。后续在组合那一章里我会专门讲这个问题。
2. 拆解的边界怎么划
这一章是全文的核心,也是最容易翻车的地方。很多人拆分的失败案例,问题不在执行力,而在最初划边界时拍脑袋。边界划对了,后面的拆分工作就是水到渠成;边界划错了,怎么改怎么别扭。
2.1 用业务能力和数据归属来找边界
要划好边界,第一件事是跳出技术视角。你盯着代码看,看到的是类和函数;你盯着数据看,看到的是表和字段。但边界的本质是业务上的高内聚。一个直白的做法是:梳理业务流程,找出其中的关键业务能力,比如下单、支付、发货、售后,然后问一个问题——这个能力从输入到输出,哪些数据是它独占的?独占的数据就是它的地盘,地盘划清楚了,模块边界自然就浮出来了。
数据归属是一个绕不过去的核心问题。我在设计系统边界时的一条经验是:先定数据归属,再定服务边界。因为服务之间的耦合,归根结底是数据的耦合。如果订单模块的数据和支付模块的数据混在一个数据库里,那你不管代码怎么拆分,底层依然是一个隐形的大单体,因为数据库是唯一的真相来源。两个服务同时读写同一张表,谁都觉得自己有权限,结果就是谁也不让谁,最后只能加一张莫名其妙的中间表来"协调"。
有一个比较实用的划分思路:把系统中的数据分为"核心数据"和"衍生数据"。核心数据由归属方独占管理,比如订单的原始记录、用户的账户余额,这类数据只能通过所属服务的接口来操作。衍生数据可以冗余到其他服务里,比如订单服务可以冗余一份商品快照,避免每次查询都要远程调用商品服务。这个"快照"的思路特别重要,它能让服务之间的交互从同步调用变成本地消费,大幅减少耦合。
2.2 尽量避开"跨模块事务"
边界划分时最致命的一个设计缺陷,是让一次业务操作横跨多个模块,并且要求强一致性。很多系统在拆分之后变得无比脆弱,就是因为为了做一个操作,两个服务必须在一个分布式事务里协调彼此,一旦网络抖动,整个流程就卡在那里。
从设计的角度说,如果发现一个业务操作要横跨三个模块,而且必须强一致,那通常意味着边界没划对。要么把相关的操作合并到一个模块里,要么接受"最终一致性"的设计思路。比如下单成功后要扣库存,这件事可以是同步协调,也可以是异步通知、对账补偿。前者实现简单但脆弱,后者实现复杂但韧性好。
引入 DDD(领域驱动设计)里"聚合"的概念,对划清边界非常有帮助。一个聚合就是一组高内聚的对象,对外只有一个入口,内部状态的一致性由聚合自己保证。聚合和聚合之间不共享数据库表,只通过接口交互。我在多个项目里验证过,只要聚合的粒度设计合理,事务的复杂度会显著下降。反过来,如果聚合划分得太细,比如一个订单被拆成订单基本信息、订单明细、订单状态流转好几个聚合,那么一个下单操作又变成了跨聚合的大事务。
2.3 拆分粒度的"两级模型"
关于拆分粒度,我有一套自己的经验模型,叫"两级模型":第一级是模块级拆分,第二级是服务级拆分。不是所有系统都一步到位拆成微服务,那是过度设计。
模块级拆分指的是在一个进程里,用明确的代码边界把不同业务模块隔离出来。模块之间通过接口调用,但底层共享同一个运行时。这种拆分的成本较低,数据的强一致性需求也不用担心,适合中小规模系统。
服务级拆分则是把模块升级为独立的进程,通过远程调用来协作。服务级拆分会引入网络延迟、序列化开销、故障传播等一系列问题,只有在模块频繁成为性能瓶颈或者团队规模大到无法协作时,才真正需要。
我的建议是:先做到模块级拆分,保证代码层面的边界清晰,然后在运行时压力测试中找出真正的热点服务,再逐步把它们拆成独立服务。从模块到服务,应该是一个缓慢演进的过程,而不是一个重构大项目。
3. 拆完之后,怎么"组合"才是关键
拆分只是第一步,文章标题里同样重要的是"组合"。组合的方式选择,直接决定了系统在运行时的表现。你拆出来的这些服务,最终还是要通过某种方式协作起来,共同完成业务功能。这一章我会详细讲组合的几种套路,以及它们各自的适用场景。
3.1 组合的核心问题:同步还是异步
组合的第一个问题,是服务之间采用同步调用还是异步消息。这几乎是每个架构师在设计协作方案时最先要做的决定。
同步调用的好处是直观、容易理解、调试方便。A 服务调用 B 服务,拿到结果再继续往下走,整个过程就像写一段顺序代码。但同步调用的代价也很明显:整条链路的耗时取决于最慢的那个服务,而且任何一个服务抖动,都会导致整条链路超时。如果你有十个服务连成一条同步链路,等于复制了十个单点故障。
异步消息的模式(比如通过消息队列)则正好相反。A 服务发出一个事件,B 服务监听到之后自己处理,两边不需要同时在线。好处是解耦彻底,系统的吞吐能力更强;坏处是处理链路变长,出现问题后排查困难,而且需要额外处理消息的重复投递和顺序问题。
我的建议是:核心的主链路用同步调用,非核心的支线流程用异步消息。拿电商系统举例,用户下单的核心链路是:验价、锁定库存、生成订单、返回支付链接。这几个步骤之间存在强依赖关系,用同步调用更合理,因为用户的心理预期就是"我点了下单,必须马上得到反馈"。但是下单成功之后要做的那些事——发短信通知、更新用户积分、生成物流预报、同步到数据分析平台——这些事即使延迟几秒钟也不影响用户体验,就应该放到消息队列里异步处理。
3.2 服务编排与协同模式
系统大了以后,服务之间的调用关系会变得复杂。怎么管理这种复杂关系,业界主要有两条路线:编排(Orchestration)和 choreography(中文有时候叫编舞,实际上是一种多方协作模式)。虽然听起来相似,但实现思路完全不同。
编排模式,通常由一个中心化的编排器来负责整个业务流程。订单服务收到请求后,编排器根据预先定义好的流程,依次调用库存服务、价格服务、支付服务。这种模式的好处是流程清晰,所有状态都在编排器里管理,出了问题容易定位。缺点是编排器可能成为一个新的瓶颈,而且当流程特别长时,编排器本身的复杂度会非常可观。
对了,提醒一下,现在很多人会用 BPMN、工作流引擎来做编排(比如 Flowable、Camunda),对于流程变动频繁的业务来说,这确实是好工具。但如果你只是想把服务串起来跑一次,还不需要完成的审批流、任务分派、超时提醒,那我建议直接写代码编排就足够了,引入一个工作流引擎反而会带来沉重的部署和学习成本。
choreography 模式则更像一个去中心化的协作。每个服务只管自己要做的事情,做完以后把结果以事件的形式发出去,至于谁会关心这个事件,由对方来决定。比如"订单已创建"这个事件发出后,通知服务、风控服务、数据服务各自决定要不要响应。这种模式的好处是服务之间没有硬依赖,扩展性好;坏处是业务逻辑的流转变得很难看清——你可能要看五六个服务的代码才能还原一条完整的业务链路。
在做组合方案的实际选择上,我给出的组合拳建议是:主流程交给编排,支流程走事件,耗时的重活放队列,实时性要求高的全链路走同步短链路,并在关键节点做好耗时监控和降级预案。这样组合下来的系统,既保证了核心业务的实时反馈,又避免了同步调用导致的耦合过深问题。
3.3 网关与入口层的组合
既然服务已经拆成了不同的模块或进程,聚合的入口就变得很重要。你不能让用户请求直接打到每一个后端服务上,那样客户端得记住每个服务的地址,还要处理各种协议转换。这就要引入聚合层或 API 网关。
网关在组合中的职责可以总结为四个:路由、过滤、聚合、转换。路由很好理解,根据请求的路径把流量转发到对应的服务;过滤主要做认证、鉴权、限流这些横切关注点;聚合则是网关把多个底层服务的响应拼装成一个完整的页面数据返回给客户端;转换是做的协议适配,比如把后端 REST 接口转换成 GraphQL 或者 gRPC。
这里我想强调一下聚合层的取舍。使用 BFF(Backend For Frontend)模式,为不同端(Web 端、移动端、小程序端)分别提供定制化的聚合接口,在互联网公司已经非常普遍。但聚合层也会带来一个问题:如果有几十个微服务,聚合层和它们之间的调用关系会变得异常复杂。我建议在聚合层中只做"轻聚合",也就是只做响应数据的组装和字段裁剪,不把复杂的业务逻辑塞进聚合层。一旦发现聚合层里堆积了大量 if-else 的业务分支,就该停下来反思一下了。
4. 实操中的三个大坑
拆分和组合的过程,真正会劝退人的不是架构设计理论,而是实操中遇到的分布式环境下的各种反直觉的坑。这一部分我单独拎出来讲,因为几乎每一个踩过这些坑的人,都极其痛苦。
4.1 数据一致性的处理
拆分之后遇到的第一个大坑,就是原本由数据库事务保障的一致性,现在拆成了跨服务操作,没有办法用传统事务来管理。分布式事务这个词听起来很高大上,实际上它只是把一个全局问题转换成了多个局部问题和一堆补偿逻辑。
针对分布式事务,我的态度一直是分场景讨论:如果强一致性无法实现,那就退而求其次,用最终一致性的方案。
比如更新库存和创建订单这两件事,你可以在创建订单的同时,发送一个"扣减库存"的命令消息到消息队列。库存服务消费这个消息后执行扣减,成功就继续,失败就回滚订单。但消息队列本身又带来了新的问题:消息可能丢失,也可能重复投递。
解决消息丢失的办法是:把消息的发送和业务数据的变更放在同一个本地事务里,这就是"本地消息表"方案。先写业务数据,再写一条消息记录到本地表,两者在同一个数据库事务中提交。然后由一个后台任务把消息表里状态为"待发送"的记录发到消息队列,发送成功后更新状态。这个方法虽然笨,但非常可靠,至今在很多没有引入重型中间件的系统里依然是最实用的兜底方案。
解决重复投递的办法也简单:消费方做好幂等。在业务表里加一个唯一的业务流水号,处理消息前先检查流水号是否已存在,存在就说明这条消息曾经处理过,直接丢弃。给每一个跨服务操作设计业务流水号,是一个成本极低但收益巨大的好习惯。
4.2 链路追踪与问题排查
服务拆分之后,代码已经不能在一台服务器上完整地跑完一个业务流程了。调试一个请求,你可能要从入口服务一路排查到第三个、第四个子服务,如果中间还有一个异步消息消费的环节,排查的难度会成倍上升。没有链路追踪,出了问题就只能靠日志一个个翻,效率低得让人崩溃。
我的建议是,在服务拆分的第一天就要把链路追踪系统搭起来。不用多复杂的方案,用 OpenTelemetry 作为埋点规范,配合一套可观测的平台就行。核心的任务是:为每一个请求生成一个全局唯一的 traceId,在服务之间传递,日志系统里统一打上 traceId,查询的时候按 traceId 拉出整条链路的所有日志和调用关系。
实际踩坑的一个细节:异步消费的场景很容易把 traceId 弄丢。消息从生产方发到消费方,如果不手动把 traceId 塞进消息头,消费方就会开一条全新的链路,两边的日志就对不上了。我在设计消息体的时候,会约定一个通用的 header 来传递 traceId,消费方消费前先从消息头里取 traceId,没有就生成新的。这样一条业务请求从用户点击到最终数据处理完成,整条链路都是连续的。
4.3 版本兼容与接口演化
服务拆分后的另一个现实问题是接口的演进。你不能保证所有下游服务都跟着你同时升级。如果你重构了订单服务的接口,把某个参数从必填改成选填,或者改了一个返回值的含义,下游的支付服务、营销服务可能都还依赖着旧的行为。
处理这个问题,我习惯的做法是"先加后减"。新版本上线时,旧接口不急着下掉,而是让同一个接口同时支持新旧两套参数。用某种方式(比如版本号、header 标识)区隔开,逐步把流量切换到新版本上。等确认所有下游消费方都升级完成,再下线旧版本。这个过程可能要持续几周甚至几个月。
还有一点容易被忽略:接口契约的语义要明确。很多接口问题的根源不是参数对不上,而是返回值的语义双方理解不一致。比如接口返回 null,下游理解为"没有数据",但上游其实是想表达"服务暂不可用,请稍后重试"。这个差异在单体内是不存在的,但在分布式环境下会引发很诡异的不定时 bug。后来我们会明确约定:业务上查不到数据返回 null 或空值,服务异常则直接抛错误码,绝不混在一起。
5. 组合模式选型参考
聊完了实操中的坑,我把服务组合时常见的几种模式做一个横向对比,方便大家结合实际场景做选型。这几种模式覆盖了绝大部分的跨服务协作场景。
| 模式 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 同步调用(REST/gRPC) | 服务间点对点直接获取结果 | 简单直观、实时性强 | 链路中出现单点故障会传导,容易导致超时堆积 | 用户实时能感知的核心主链路 |
| 异步消息(MQ) | 生产方发事件,消费方自行处理 | 解耦彻底、削峰填谷、吞吐量高 | 链路不可见,需要额外处理幂等和顺序 | 通知、积分、数据同步等非实时场景 |
| 服务编排(工作流/BPMN) | 中心化流程控制器依次调度服务 | 业务状态清晰、便于人工介入 | 编排器可能成为瓶颈,流程复杂时维护成本高 | 涉及人工审批、多分支状态的复杂流程 |
| 事件驱动(Kafka/EventBridge) | 服务自发对外发事件,互不感知 | 扩展性极佳,新服务可以按需订阅 | 业务链路难以全局观测,需要事件设计功底 | 业务边界清晰、领域事件明确的领域驱动设计 |
| API 聚合/BFF | 网关或聚合层统一编排返回 | 客户端交互简单、可按端定制 | 聚合层容易膨胀成为新的大泥球 | 多端接入、能力复用要求高的入口场景 |
表中的模式并非互斥。真实的生产系统里,往往是几条模式混用的状态。同步调用负责核心主干,异步消息处理旁支,事件驱动支撑跨域协作,API 聚合统一收口。关键不是选用哪种模式,而是在设计时明确每个场景的组合策略,并在代码和文档中保持一致。
6. 关于架构这件事的最后一点体会
架构设计没有银弹,也没有哪种拆分方案是永远正确的。系统的拆分与组合,本质上是在性能和复杂度的两端寻找平衡。一个刚起步的产品,强行拆分只会拖慢迭代速度;一个活跃用户过亿的系统,拒绝拆分又会让技术团队寸步难行。
我个人的经验是,不要迷信某一种架构风格,甚至不要迷信"微服务"这个标签。同样的业务,在不同阶段可以有不同的拆分粒度。你能做的,是保持对"变更边界"的敏锐嗅觉——当发现每一次变更都快不起来的时候,就是在提醒你该重新审视边界了;当发现拆出来的服务反而让一次简单操作变得无比耗时的时候,就是提醒你该合并了。架构是一套持续演化的策略,而不是一次性的终局图纸。
最后给那些正在考虑做系统拆分的团队一个建议:先别急着写代码,也别急着搭微服务基础设施,先从画一张系统现状图开始。把现有的业务模块、数据归属、依赖关系都画出来,找出那些耦合最重的点和重复最明显的领域,用拆分与组合的思路重新组织一遍边界,再动手。你会发现,"架构很简单"这句话,只有在真正理解了拆分的依据和组合的代价之后,才会变成你的底气。