前置知识: Spring Boot、Java

从单体到微服务:动机、时机与代价

20 min中级

以「40 万行电商单体的四大痛」引入:微服务代价清单先泼冷水、四个拆分信号与模块化单体的正名、数据库私有第一戒律与绞杀者路径,附本模块十四篇五阶段学习地图。

知识点地图

  • 知识类别:微服务架构的动机、时机与代价——单体痛点诊断、拆分信号、拆分原则与绞杀者落地路径。
  • 解决什么问题:“要不要拆微服务”的决策本身;以及决定拆之后”怎么拆才不出事故”。
  • 什么时候用到:团队为单体库表耦合、发布互相阻塞、局部扩容困难而讨论架构演进时;评估”模块化单体 vs 微服务”时。
  • 本模块 14 篇的总纲:本篇是决策与地图,020-140 按五阶段展开(注册发现/配置 -> 调用与网关 -> 容错与观测 -> 事务与消息 -> 幂等认证与实战)。

前置知识

  • 知道 Spring Boot 配置管理 里 application.yml 与 profile 大概是什么:本模块里的每个微服务仍然是一个个 Boot 应用,没读过也能跟;

  • 翻过 Spring Cloud 微服务开发 的目录:对「微服务」三个字有模糊印象即可,本篇负责把这个印象变成可判断的工程决策。

  • 会用 Maven 跑过任意 Java 项目:本模块实验在本地跑,需要 JDK 21 与 Docker 就位(第 7 节有环境清单)。

学习目标

读完本文你将能够:

  1. 对着手头的项目说出单体四大痛哪几条已经成立、程度如何;
  2. 复述微服务的五项代价,向团队讲清「拆服务」买到了什么、付出了什么;
  3. 用四个信号判断当前项目该不该拆,说清模块化单体在什么条件下是更好的答案;
  4. 背出拆分三原则,论证「数据库每服务私有」为什么必须是第一戒律;
  5. 说出本模块十四篇的五阶段地图、选型主线,以及它与 Spring Boot 模块的分工。

预计 25 分钟。

1. 你现在要解决什么问题

你在维护一个 40 万行的电商单体:订单、库存、内容管理、营销、报表全住在同一个 jar 里,连着同一个数据库。四个数字勾勒它的日常。构建 15 分钟:改一行日志文案,也要等完整构建加一次五分钟的启动才能验证。发布靠全量回归:没人敢断言一行改动会波及谁,于是回归跑两天、发版攒一月,业务方嫌你慢,你觉得委屈。大促只能整体扩容:订单模块是 CPU 密集型,要 3 倍资源才扛得住,于是 IO 密集型的内容管理也跟着翻了 3 倍——机器钱花在了不需要的地方。故障全站陪葬:上周报表模块一次内存泄漏,整个进程 OOM 重启,下单、浏览、退款一起消失,故障复盘会上每个团队都在问「为什么是我」。这四条痛指向同一个方向:把一个大东西拆成几个小东西。但在动手之前,本文要先做一件反直觉的事——把微服务的账单摊开给你看。

2. 准备现场:给单体拍一张 X 光

先确认你脚下的项目是不是「会痛的单体」。看两样东西:代码的组织方式,和几个可观测的数字。

text
shop-monolith/                     一个应用、一个进程、一个库
  src/main/java/com/example/shop/
    order/        订单
    stock/        库存
    content/      内容管理
    marketing/    营销
    report/       报表
    common/       汇合点:order 引用 stock 的类,report 查所有模块的表
  application.yml                 全部模块共享一份配置
bash
# 构建耗时基线:在单体根目录执行,看末尾 real 一行
time mvn clean package

# 启动耗时:正常启动后掐表,从 main 到 Started XxxApplication
mvn spring-boot:run

# 跨模块耦合抽样:stock 包里有多少文件引用了 order 的类(换成你的包名)
grep -rl "com.example.shop.order" src/main/java/com/example/shop/stock | wc -l

典型形态是:包结构按业务分了目录,但包与包之间互相引用;一张订单表在报表模块被直接 SELECT;任何一个模块的改动都会触发全量构建与回归。把观测到的数字填进这张痛感自测表:

观测项健康线红灯线对应的痛
构建耗时(clean package)2 分钟内10 分钟以上痛一:改一行等一场
全量回归耗时半天内两天起步痛二:发布靠全员祈祷
按模块独立扩容可行不可能(同进程)痛三:扩容一刀切
故障半径单个模块全站重启痛四:OOM 陪葬

四条里亮一条红灯,单体开始还债;亮两条,拆分动机已经出现;四条全红,动机充分——但先别动手,第 3 节的代价清单必须读完再做决定。这份 X 光片后面会反复用到:第 4 节的四个信号、第 5 节的三条原则,都要对照它来检查你的项目。

数据侧也要拍一张。把单体库里的表按业务模块标注 owner,数一数被多个模块直接查询的表——第 5.2 节的戒律能不能落地,看这张标注表就知道:跨模块互查的表越多,将来拆分时的数据搬迁成本越高。代码边界好画,数据边界难拆,是拆分成本评估的第一经验。

3. 先泼冷水:微服务的代价清单

微服务不是「把单体拆小」,而是把一类问题换成了另一类问题。动手之前把新账单逐条看清:

单体里免费的事微服务里变成什么
方法调用:stockService.deduct(...)远程调用:网络会失败、会延迟、会分区,每次调用多出三种单体里不存在的失败
一个 @Transactional 包住两笔写跨库事务:数据库事务的边界到不了另一个服务,中间态谁来收拾
一份部署、一份监控、一份日志N 份部署监控日志,外加注册中心、网关这些新基础设施本身的开销
一段完整堆栈定位问题一个请求跨 5 个服务,日志散在 5 台机器,要靠链路追踪拼图
端到端测试拉起一个进程端到端测试要拉起整条服务链,环境缺一角就测不动

逐条展开五项代价。第 1 条先看一个最小样本——同一次「扣减库存」,单体与微服务的失败面完全不同:

text
本地调用:stockService.deduct(req)
  失败方式只有一种:抛异常 → 事务回滚 → 结束,一切可预期

远程调用:POST http://stock-service/api/stock/deduct
  请求根本没到对方        → 超时。它到底执行了没有?不知道
  执行了、响应丢了        → 超时。同上,而且操作可能已经生效
  对面活着但就是慢        → 超时。等还是不等?等多久?
三种失败共享同一个症状,处理策略却完全不同——这就是分布式复杂性的最小样本。
  1. 分布式复杂性。每个远程调用点都要回答上面那段「超时之后怎么办」:敢不敢重试、重试算不算重复执行、要不要给用户报错。「扣库存超时后重试」这个决定的正确答案,取决于接口是幂等的还是有副作用的——每处调用都得想一遍,这是 040/050/110 三篇反复回炉的主题。
  2. 数据一致性难题。具体化一个中间态:订单库写成功、库存库没扣成,用户看到「下单成功」,仓库却照常发货——超卖;反过来扣了库存、订单没落,库存凭空少一件。单体里一个 @Transactional 让这两种中间态根本不可能存在;拆开的瞬间它们都成了可能,090 篇整篇在回答「谁负责收拾」。
  3. 运维成本。拆 5 个服务,真实清单是:5 条 CI 流水线、5 套告警阈值、5 份日志治理、5 个制品仓库权限,外加注册中心、配置中心、网关三样新成员各自的版本升级与故障面。基础设施本身也会半夜报警,而且报的全是「平台性」故障,业务团队没人熟悉。
  4. 排障难度。「用户下单失败」在单体里是一条日志讲完的故事;微服务里要先定位 500 错误发生在 5 个进程中的哪一个,再把 5 段日志按时间线拼起来。没有 traceId 的团队靠 grep 一台台服务器,有 traceId 的团队靠链路系统(080 篇)——没有后者,微服务排障等于蒙眼修车。
  5. 测试复杂。接口测试本来 Mock 一个依赖就够;现在一条链路的环境一致性本身成了工程问题,联调从「起一个进程」变成「起半套平台」,端到端测试的失败先要人工分诊「是代码坏了还是环境坏了」。

这五张账单没有一张是虚的。所以先收下本文最重要的判断:如果没有单体的痛,别吃微服务的药。微服务解决的是「规模分化」——团队规模、部署节奏、流量画像长到不一致的那一天;项目还小就上微服务,等于用五张确定的新账单,去换四张还没收到的旧账单。

五项代价还可以按「什么时候开始收费」分两层读:前两条是代码层的账,写第一行业务代码那天就开始收——每个远程调用点都要想失败处理,每条跨服务数据都要想一致性;后三条是平台层的账,上线运营才收到——运维、排障、测试的成本随服务数量线性甚至超线性增长。估算自家要付哪几笔时,按团队现状对号入座,比笼统说「微服务很贵」有用得多。

4. 什么时候才该拆:四个信号

代价看完了,什么时候值得付?对照四个信号,满足得越多越该拆。

4.1 四个信号

  • 信号一:架构对不上组织。康威定律(1968 年,Melvin Conway)说:系统结构必然复制组织的沟通结构。40 个人分成订单、库存、内容三条业务线,却改同一个仓库、同一批表——每次发布都要三条线对齐排期,谁也快不起来。让服务边界与团队边界对齐,是拆分最持久的理由;反过来,一个人维护 10 个微服务,是在给自己造流水线。
  • 信号二:部署节奏冲突。A 团队想每天发版修问题,B 团队的核心模块一个月才敢动一次。单体里它们互相绑架,只能取最慢的节奏;拆开后各自按各自的风险偏好走。
  • 信号三:扩容画像差异。订单 CPU 密集、内容管理 IO 密集、报表内存密集——画像不同就该有不同的扩容策略与机型选择。第 1 节里「给订单加 3 倍却让全站陪跑 3 倍」的浪费,只有拆开才能止血。
  • 信号四:故障隔离诉求。报表的内存泄漏不该连累下单。拆开进程,故障半径从「全站」缩到「一个服务」。

把四个信号换成可操作的自测问题,逐条打勾:

信号自测问题算「满足」的判据
组织团队按业务线分工了吗至少两条业务线各自有独立的排期与发布诉求
部署节奏发布频率差多远快线被慢线锁死,节奏差距在月度量级
扩容画像各模块资源画像一致吗CPU 密集与 IO 密集混居且扩容频繁发生
故障隔离发生过「一个模块拖垮全站」吗一年内有实际事故且复盘归因于此

半亮半灭的灰色地带是常态,给一条裁决规则:满足两条以内,先做模块化单体并持续观测——信号是会随业务长出来的;满足三条及以上,拆分方案立项,从绞杀者四步里的「入口收口」起步。唯一不变的判断是:没有一个信号时,拆分永远排在待办清单之外。

4.2 模块化单体:被低估的正确答案

四个信号一个都不满足?那么正确答案很可能不是微服务,而是模块化单体(Modular Monolith)——它不是「没拆完的单体」,而是有纪律的单体。长这样:

text
shop-modular/
  shop-order/       order 模块:自己的接口、逻辑、表
  shop-stock/       stock 模块
  shop-content/     content 模块
  shop-app/         组装壳:把各模块装进同一个进程启动

依赖方向:shop-app → 各业务模块 → 共享内核(最小化的 common)
纪律三条:模块之间只通过接口调用;SQL 不碰别的模块的表;表按模块划分 owner

它保住了单体的全部红利(本地事务、本地调用、一份运维、一次启动的调试体验),同时用边界纪律先把「发布爆炸半径」与代码耦合摁住。业界有模块化单体扛住超大流量的公开案例(Stack Overflow 的架构分享、Shopify 的模块化改造都是著名样本);「先做模块化单体,信号亮了再拆服务」是绝大多数团队的最优路径。更值钱的是它保留的拆分期权:当四个信号真的亮起,模块边界就是现成的服务边界,绞杀者从模块下手即可;而从一团乱麻里现找边界,是拆分失败的头号原因。想清楚一件事:模块化动的是代码边界,改错了改回来就行;微服务动的是进程与数据边界,拆错了再合回去,成本高到几乎不可逆。

5. 决定拆之后:三条拆分原则

5.1 按业务能力拆,不按技术层拆

错误示范:user-entity、user-service、user-dao 各成一个服务——技术分层拆出来的是「每一层都要远程调用所有层」的分布式单体,痛没少一分,代价全付了。正确姿势:订单服务 = 订单的接口、逻辑、数据打包成一个自治单元。DDD 里对应的概念叫限界上下文:同一个词「商品」,在订单语境里是「用户买的东西」,在库存语境里是「可扣减的实物」——含义分岔的地方,就是边界所在。方法论细节归 DDD 专著,本模块记住「按业务能力、不按技术层」即可。

粒度警告:反方向同样有坑。拆得太细(nanoservices,一个开发者维护十几个服务)会让第 3 节的运维与排障代价指数上升,收益却不再增长。起步粒度建议「一个服务对应一条业务线内的一个限界上下文」,宁可先粗后细——运行中发现该分,顺着接口边界再拆;拆过头再合并,几乎没人做得到。三种拆分问法的结果对照:

问法典型结果
按「谁拥有这份数据」拆边界即数据边界,戒律自然落地
按「哪个技术层」拆分布式单体,代价全付、收益为零
按「先拆个框架再说」拆边界持续漂移,返工到怀疑人生

5.2 数据库每服务私有:第一戒律

规则只有两句:一个服务一个库;禁止跨库 join,禁止别的服务直接连你的库。为什么必须是戒律而不只是建议——因为一旦允许跨库访问,两个服务的表结构就锁死在一起:我改一张表要通知所有连它的人,独立部署、独立演进、独立扩容全部作废,你只拆了进程,没拆耦合,还背上了全部分布式代价。这是最常见的「假微服务」。落地时的三条细则:

  • 每个服务用独立的数据库实例,退一步也要独立的 schema,并明确 owner;
  • 跨服务拿数据的两条正路:同步走接口查询,异步走事件在本地同步一份只读视图(090 与 100 篇的领地);
  • 报表、搜索这类「天生跨域」的读需求,不例外——它们消费的是事件同步出来的副本,不是直连各家的业务库。

一个自查动作可以直接放进 code review 清单:随便挑两个服务,问「A 服务能不能不经过 B 的任何接口,直接 SELECT B 的表」——答案是能,戒律已破,拆分是假的。

5.3 接口先行

先冻结服务间契约(API 路径、请求响应结构、错误语义),双方再并行开发。契约不是拆完再补的文档,而是拆分方案本身——第 5.1 节画的边界,最终都落在契约上。一份最小契约长这样:

text
订单服务与库存服务的契约(节选):
  方法与路径   POST /api/stock/deduct
  请求        { "skuId": 1, "count": 2 }
  响应        { "success": true, "remaining": 87 }
  错误语义     400 参数非法;409 库存不足;5xx 提供方故障(调用方自行决定重试与否)
  演进规则     只加字段不改含义;字段下线先标废弃,新旧双版本并行一个发布周期

错误语义尤其值得先写清楚:409 与 5xx 对调用方是完全不同的指令(一个转告用户,一个触发容错),契约阶段不谈妥,联调阶段必然扯皮。契约要演进时保留版本(新旧并存过渡),不做「直接改字段含义」式的破坏。

6. 怎么落地:绞杀者模式

40 万行代码不可能一次迁完,也不该试。绞杀者模式(Strangler Fig,Martin Fowler 命名)一句话:像绞杀榕一样,新功能长在新服务上,老功能一块块从单体里绞出来,入口处用网关按路由把流量逐步切到新服务,直到单体只剩躯壳。落地四步,每一步独立可回滚:

  1. 入口收口:所有流量先进网关(060 篇),单体降级为网关后面的普通服务——这是后续一切切换的前提;
  2. 新功能新服务:接到需求先问「这块落在哪个未来的服务上」,有答案就起对应的新服务;老代码只修缺陷、不加功能,不让要绞掉的枝条再长粗;
  3. 逐块搬迁:把一个模块从单体里剥成新服务,网关把它的路由从单体切到新服务,灰度观察稳定一周,再迁下一块;
  4. 数据清账:模块搬走后,把它的表从单体的库迁出成独立库,顺手补上第 5.2 节的戒律——只搬代码不搬数据,等于留了根反向依赖。

全程单体功能不中断:绞杀榕缠上老树,树还活着,直到新枝完全接管。每切一块,都在网关保留一键回切的路由开关——灰度观察期发现新服务不稳,流量立刻回单体,绞杀永远不赌命。与它相对的是「大爆炸重写」——停旧线、另起炉灶、一次切换,业界翻车最多的就是这条路。过渡期纪律:允许新服务调用单体,避免单体反向依赖新服务——被依赖得越多的旧代码越死不掉。

7. 本模块学习地图与选型声明

本模块十四篇分五个阶段,本篇是地图:

演进与基础设施

010-030:动机、注册发现、配置中心

通信与入口

040-060:OpenFeign、负载均衡、网关

稳定性与一致性

070-100:Sentinel、链路、Seata、消息

治理与安全

110-130:幂等、统一认证、监控告警

实战收官

140:电商微服务

演进与基础设施

010-030:动机、注册发现、配置中心

通信与入口

040-060:OpenFeign、负载均衡、网关

稳定性与一致性

070-100:Sentinel、链路、Seata、消息

治理与安全

110-130:幂等、统一认证、监控告警

实战收官

140:电商微服务

mermaid diagram
  • 演进与基础设施(010 至 030):回答「拆与不拆、服务怎么找到彼此、配置怎么改」——本篇之外,还有 服务注册与发现 与配置中心;
  • 通信与入口(040 至 060):OpenFeign 声明式调用、负载均衡与重试、API 网关;
  • 稳定性与一致性(070 至 100):Sentinel 熔断限流、分布式链路追踪、Seata 分布式事务、事件驱动消息;
  • 治理与安全、实战(110 至 140):分布式锁与幂等、统一认证、监控告警,最后一篇电商实战把前面全部串起来。

阅读建议:按序推进,020 与 030 搭起来的 Nacos 与双服务现场,是 040 之后所有实验的地基;着急看容错与分布式事务的读者,至少先把 020 的现场跑起来再跳读,否则后面每篇的实验都无从下手。

选型声明:本模块主线走 Spring Cloud Alibaba 系——Nacos(注册与配置,020/030)、Sentinel(流控熔断,070)、Seata(分布式事务,090),这是国内企业现役主流。官方系组件对照着记:Eureka/Consul 对应 Nacos 的注册半边、Spring Cloud Config 对应 Nacos Config、Resilience4j 对应 Sentinel。主线基于 Spring Cloud 2025.0.x + Spring Boot 3.5.x + Java 21;Spring Cloud Alibaba 与 Spring Cloud 的版本对应关系,以官方 wiki 版本说明为准,本模块不锁死小版本。LoadBalancer 与 OpenFeign 两条用官方系组件。Hystrix、Zuul、Ribbon、Sleuth 等退役组件,会在对应篇目的「旧路标」一句话指路,不作教学主体。

text
主线基线:Spring Cloud 2025.0.x + Spring Boot 3.5.x + Java 21
升级瞭望:Spring Cloud 2025.1 起可配 Spring Boot 4.0,随官方发布节奏另行迁移

与兄弟模块的分工:Spring Boot 模块 那 19 篇练的是单服务内功——一个 Boot 应用如何站起来、对外提供 REST、管好数据与事务;本模块 14 篇练的是多服务协作——N 个 Boot 应用如何互相找到、互相调用、共同扛住故障。所有单服务内的功夫,本模块一律当作已知。

使用本模块前准备三样环境,后面每一篇的实验都建立在它们之上:JDK 21 与 Maven、Docker(注册中心等中间件一律容器起步)、以及 020 篇会搭起的两个最小服务(order-service 与 stock-service)——它们会从注册发现一路陪跑到最后的实战篇。每篇的实验都可以独立复跑,改坏了就删容器重建,别怕弄脏现场。

动手实践

任务一:给你的单体拍一张 X 光。 对第 2 节的 X 光命令(统计代码行数、模块依赖、发布频率与耦合信号),在真实项目(或任意开源单体)上跑一遍,按第 4.1 节的四个信号逐项打分。提示:四个信号不必全满足才讨论拆分,但”连一个都不满足”的项目,拆分讨论应该被记录为”不拆”及理由——决策留痕与拆分本身同样有价值。

任务二:写一页拆分预案。 选定一个要拆的边界(如”营销优惠计算”),用第 5 节三条原则写出:业务能力边界一句话、数据归属(哪些表搬走)、对外接口草案(先写契约定什么,不写实现),并回答”违反数据库私有戒律的第一步会是什么”。提示:预案的全部价值在”数据归属”一段——接口都是好谈的,表搬不搬得走才见真章。

任务三:绞杀者模式沙盘。 针对任务二的边界,画出绞杀路径的三个阶段(网关接管该路径 -> 新服务承接写路径 -> 单体删除旧实现),标注每个阶段的回退手段。提示:回退手段要具体到配置级别(“网关路由切回单体路径”),写不出回退手段的阶段就是还没准备好进入的阶段。

先自己操作,再对照参考要点:

任务三参考要点
text
阶段 0(网关接管):
  网关为 /api/promo/** 增加独立路由,上游仍指向单体
  回退:删除该路由条目(请求走默认单体路由),零风险
阶段 1(读路径先行):
  新 promo-service 上线,网关把 GET 类请求切到新服务,写路径仍在单体
  回退:网关路由改回单体;风险点是两套读实现短暂并存,需对账脚本
阶段 2(写路径迁移):
  双写或消息表过渡(参照 090 篇本地消息表),验证 N 天后单体停写
  回退:恢复单体写路径并回放过渡期消息;这一步必须先在预发演练回退
阶段 3(删除旧实现):
  单体删除优惠计算代码与表,归档数据
  回退:只能从 git 恢复——所以本阶段的准入条件是阶段 2 稳定运行满一个完整业务周期

要点:a) 每个阶段的回退手段都是”配置或数据操作”而非”重新部署”,这就是绞杀者相对大爆炸重写的安全边际;b) 阶段 3 的准入”满一个完整业务周期”是为了覆盖月结、对账这类低频路径——它们是拆分事故的惯常藏身处。

自检

不看上文,凭记忆回答。答不出的,回到对应小节重读。

  1. 单体四大痛分别是哪四条?对着第 2 节的 X 光命令,说出你当前项目哪条痛已经实测成立。
  2. 微服务制造的五项新代价里,哪三项是「拆分当天」就到账的,哪两项可以靠后续基础设施(080 篇、090 篇)缓释?缓释不等于消除,差别是什么?
  3. 用康威定律解释:为什么「一个人维护 10 个微服务」几乎必然是错误架构?
  4. 「数据库每服务私有」这条戒律,禁止的是哪两种行为?违反它之后,5.3 节的「接口先行」契约还守得住吗?为什么?
  5. 什么条件下模块化单体优于微服务?说出它与微服务在「代码边界、进程边界、数据边界」上的三点差别。
  6. 绞杀者模式为什么优于「大爆炸重写」?过渡期「新服务可以依赖单体、单体不要依赖新服务」的纪律,护住的是什么?
  7. 「半亮半灭」的裁决规则是什么?满足两条以内与三条以上分别怎么走?
  8. 契约里的错误语义为什么要在拆分前谈妥?409 与 5xx 对调用方分别是两种什么指令?