元数据卡
- 前置知识:第11章(分层架构)
- 预计时间:40 分钟
- 核心难度:进阶
- 完成标志:理解单体到微服务的迁移策略和 Strangler Fig 模式
你的进度
你设计的第一台机器从一开始的 3000 个零件,两年后被东拼西凑到 15 万个零件。换一个操作手柄上的标识牌,结果传动模块的测试挂了——因为整台机器共用了同一个“把手”规格定义。
工坊主说:“该拆了。”
你把机器拆成几台独立的机器——每台有自己的动力源、有自己的维护通道。但一个新的问题来了:它们之间怎么传话? 你的任务
不是所有系统都应该先拆成微服务。大多数系统最好的架构就是模块化单体——一个部署单元,但内部模块职责清晰。当单体确实长大到违背了它的假设(单点故障、独立部署、独立扩展)时,才需要拆。这章教你拆的时机和策略。
本章分层
- 必读:单体架构的优缺点、模块化单体、Strangler Fig
- 选读:数据库拆分策略
- 进阶:分库的数据一致性初探
本章不会要求你掌握
- 完整的微服务治理
- 分布式事务的深入实现
破局 · 溯源
你的项目遇到了"单体的边界诅咒":
- 每次部署,所有模块一起停服——改一个页面也要部署整个系统
- 编译 8 分钟,CI/CD 流水线 15 分钟——一天最多 deploy 5 次
- 积分模块高负载,整个系统受影响(共享线程池)
- 测试因为模块间的耦合,改 5 行代码可能影响 10 个模块的测试
你开始想"拆成微服务"。但你知道的另一面是——微服务有它自己的诅咒:分布式复杂性、网络延迟、数据一致性、运维成本。
第一层:模块化单体——不要急于拆
很多人认为的"模块化"只是包结构分层:
com.tournament
├── player
├── match
├── tournament
└── scoring但 Java 的 package 没有强制边界——任何模块都可以访问任何其他模块的类。没有真正的隔离。
模块化单体的要点:在同一个部署单元内,用语言特性或构建工具强制模块边界。 Java 9+ 的模块系统(JPMS)可以做到:
// module-info.java (Java 9+)
module com.tournament.match {
exports com.tournament.match.api; // 只暴露 API
requires com.tournament.player.api; // 依赖另一个模块的 API
// 不暴露 impl 包——外部无法访问内部实现
}如果不能用 JPMS,用 Gradle 的子模块:
tournament/
├── player-api/ # 只包含接口
├── player-impl/ # 实现,依赖 player-api
├── match-api/
├── match-impl/
└── application/ # 组装所有模块模块化单体的优势:不增加运维复杂度,但提供清晰的模块边界。如果你的单体能做到模块化,80% 的组织不需要微服务。
第二层:什么时候该拆
模块化单体不够的情况:
- 独立扩展需求不同——积分模块是 CPU 密集型,玩家注册是 I/O 密集型。它们需要不同的扩缩策略。
- 独立部署频率不同——积分规则每季度改一次,匹配算法每周改一次。
- 团队规模增长——5 个团队在一个仓库里开发,构建排队、代码冲突、发布同步的成本超过微服务收益。
- 技术异构——匹配算法用 Python 更好写,其他模块用 Java。
你至少满足 2-3 项才值得拆。只满足第 3 项的话,先试模块化单体。
第三层:Strangler Fig 模式——拆的过程
拆微服务的最大陷阱:想一口气拆完。真实世界不是这样的——你用一个周末把所有模块微服务化,然后代码不部署、服务跑不起来、回滚发现还有 10 个模块拆到一半。
Strangler Fig 模式——名字来源于绞杀榕,它缠绕在宿主树上慢慢取代宿主。
具体操作:
Phase 1: 识别边界
在单体内部先画出"限界上下文"——积分上下文、比赛上下文、玩家上下文
Phase 2: 建立新服务
新服务部署在单体旁边。用 Strangler Fig Facade 做流量分发:
用户请求 → Strangler Facade
├── 玩家注册 → 新 Player Service
├── 积分查询 → 还在单体
└── 比赛报名 → Strangler Facade 决定去新服务或老单体
Phase 3: 增量迁移
每个 Sprint 将一个功能点从单体移到新服务。保留 Strangler Facade。
Phase 4: 单体被完全取代后
下线单体。只保留新服务。实现 Strangler Facade 的示例(用 Spring Cloud Gateway 或简单 Nginx 配置):
# Nginx Strangler Facade
upstream monolith {
server monolith-server:8080;
}
upstream player-service {
server player-service:8081;
}
upstream match-service {
server match-service:8082;
}
server {
location /api/players {
proxy_pass http://player-service;
}
location /api/matches {
# 比赛查询已迁移到新服务
proxy_pass http://match-service;
}
location / {
# 其他还在单体
proxy_pass http://monolith;
}
}第四层:拆分策略——找一个好的切面
第一个要拆的模块应该是最独立、边界最清晰的。选错了第一个模块会增加整个团队对微服务的怀疑。
选择标准:
| 标准 | 优选 | 避免 |
|---|---|---|
| 耦合程度 | 对单体的依赖少 | 引用单体核心模块 |
| 数据依赖 | 有自己的表 | 和 5 个表有外键 |
| 调用频率 | 中等 | 极高(延迟敏感) |
| 团队 | 有专门团队维护 | 团队还没到位 |
在擂台系统中,积分模块通常是第一个拆分的好选择——它有独立的计算逻辑、独立的数据库表(scores、rankings)、调用频率可控、不参与实时交互。
第五层:数据库拆分——最痛的一刀
微服务最难的环节不是代码,是数据。单体里所有表在一个数据库里,外键约束保证了数据完整性。拆开后:
- 每个服务拥有自己的数据库(database-per-service 模式)
- 服务之间不允许直接访问对方的数据库——必须通过 API
- 外键约束没有了——数据一致性由应用层保证
// 拆分前的单体:一个数据库
SELECT p.name, s.score FROM players p
JOIN scores s ON p.id = s.player_id
// 拆分后:两个数据库,通过 API 查询
// Player Service API 返回玩家信息
// Score Service API 返回积分信息
// 在应用层拼接数据这个拼接看起来简单——但涉及到一致性时(第15章专门讲)。
数据拆分的步骤:
1. 识别数据的"所有者"——每张表只属于一个服务
2. 建立数据复制(CDC),确保迁移过程中新旧系统都能读写
3. 关闭跨服务的数据库访问——替换为 API 调用
4. 迁移旧的共享数据库,数据逐步割接你的实际做法:
# 第一步:在 Score Service 自己的数据库里建表
# 第二步:用 Change Data Capture 同步 Score 相关表
# 第三步:代码改从 Score Service API 获取数据
# 第四步:单体数据库里删掉 Score 相关的表第六层:分割积分的实战案例
你决定从积分模块开始第一次拆分。积分模块依赖数据:scores(玩家积分)、rankings(排行榜)。下面是你需要做的具体工作。
步骤 1:创建新服务的数据库
在积分服务自己的 PostgreSQL 实例中建表:
-- ch13/migration/score-service/schema.sql
CREATE TABLE scores (
player_id VARCHAR(50) PRIMARY KEY,
score INT NOT NULL DEFAULT 0,
tier VARCHAR(20) NOT NULL DEFAULT 'bronze',
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE rankings (
id SERIAL PRIMARY KEY,
player_id VARCHAR(50) NOT NULL,
score INT NOT NULL,
rank INT NOT NULL,
calculated_at TIMESTAMP DEFAULT NOW()
);步骤 2:数据同步——CDC
在迁移过程中,积分数据存在于单体数据库和新服务的数据库。你需要一个数据同步机制。最简单的方式——用 Debezium(建在单体数据库上的 CDC 工具)将积分相关表的变更实时同步到新服务:
单体 PostgreSQL → WAL → Debezium → Kafka Topic → 积分服务(应用变更)CDC 确保过渡期的数据一致性:单体里积分改了,新服务也跟着改。
步骤 3:API 边界
设计积分服务的 API:
// ch13/migration/score-service/api/ScoreController.java
@RestController
@RequestMapping("/api/scores")
public class ScoreController {
private final ScoreService service;
@GetMapping("/{playerId}")
public ScoreDTO getScore(@PathVariable String playerId) { ... }
@PostMapping("/{playerId}/add")
public void addScore(@PathVariable String playerId,
@RequestBody AddScoreRequest req) { ... }
@GetMapping("/rankings")
public List<RankingDTO> getRankings(
@RequestParam(defaultValue = "10") int limit) { ... }
}步骤 4:Strangler Fig 改造——单体端
在单体里的积分调用处,加一层 Route Guard——先试试调用新服务,失败就回落单体:
// ch13/migration/monolith/ScoreServiceProxy.java
// Strangler Fig Facade:渐进切换
@Component
public class ScoreServiceProxy {
private final ScoreClient newServiceClient; // 新微服务
private final LegacyScoreService legacyService; // 单体内的旧实现
public int getPlayerScore(String playerId) {
try {
// 先尝试新服务
return newServiceClient.getScore(playerId);
} catch (Exception e) {
// 新服务不可用,回退单体
log.warn("Score service unavailable, falling back to legacy");
return legacyService.getScore(playerId);
}
}
}当新服务稳定运行后,删掉代理的 fallback 逻辑,直接调用新服务。然后再删掉单体里的 LegacyScoreService。
步骤 5:清理
当 100% 的积分流量都已切到新服务:
- 从单体删除
scores和rankings表 - 删除单体内的
LegacyScoreService和相关代码 - 更新 CI/CD 流水线——积分服务的构建部署不再需要单体一起
- 更新监控——积分服务有自己的监控 Dashboard
"一个模块的迁移完成" 的标志:你可以在不影响单体的情况下独立部署积分服务,独立调整其副本数,独立为其增加监控。
第七层:反模式——分布式单体
有些团队宣称把代码从一个大工程拆成几个小工程,部署到不同服务器,就是微服务了。但它们的模块之间仍然共享数据库、互相直接调用内部方法。这叫做分布式单体——拥有微服务的所有运维复杂度(网络调用、部署、监控),但保留单体的所有耦合问题(共享数据、紧耦合)。
分布式单体的特征:
- 服务共享同一个数据库(多个服务操作同一张表)
- 服务之间用同步 HTTP 调用并且要求强一致性
- 一个服务改了数据模型,其他服务必须立即同步修改
解决方案:如果拆到一半发现做不到数据库隔离,说明这个模块不适合作为独立服务——退回模块化单体阶段。 分布式单体是所有迁移策略里最差的一种——没有单体时的局部性,也没有微服务的独立性。
判断是否陷入了分布式单体的自检清单:
- 独立性:你的服务能和单体独立部署吗?(yes/no)
- 数据自治:你的服务有自己的数据库吗?你能改表结构而不通知其他团队吗?(yes/no)
- 构建独立:你的服务 CI/CD 流水线不依赖其他服务的构建产物吗?(yes/no)
- 故障隔离:你的服务故障不会导致整个系统不可用吗?(yes/no)
如果有一个 no——你还在分布式单体的边缘。先把基础设施完善,再继续拆分。
第八层:迁移节奏——渐进式而非大爆炸
微服务迁移最大的风险是"大爆炸式迁移"——一个周末把整个系统重写为微服务。这几乎从没成功过。实际有效的节奏:
Sprint 1: 拆分第一个模块(积分服务)—— 只迁移读操作
Sprint 2: 积分服务 —— 迁移写操作,建立双写
Sprint 3: 积分服务 —— 切 5% 流量,观察稳定性
Sprint 4: 积分服务 —— 切 50% 流量,监控无异常
Sprint 5: 积分服务 —— 切 100% 流量,下线旧代码
Sprint 6-8: 第二个模块(通知服务)每个模块的迁移周期大约 4-6 个 Sprint。不要并行迁移多个模块——出错时你不知道是哪个服务的问题。
在 Strangler Fig 迁移过程中,你会遇到一个经典场景:单体里的一个方法 getPlayerWithScore(playerId) 需要联表查询玩家表和积分表。积分服务拆分后,两个表在不同数据库中——你无法在 SQL 里 join 了。
解决方案:应用层聚合——从玩家服务拿到玩家数据,再从积分服务 API 拿到积分数据,在应用层组合。
// 应用层聚合:替代 SQL join
public PlayerWithScore getPlayerWithScore(String playerId) {
Player player = playerService.findById(playerId);
int score = scoreService.getScore(playerId);
return new PlayerWithScore(player, score);
}这个模式有两个陷阱:性能(两次网络调用)和一致性(玩家数据和积分数据可能在时间上不一致)。性能问题可以通过批量 API(getScores(List<String>))缓解,一致性问题则是微服务架构的固有成本。
所以,微服务迁移的本质是用分布式的复杂度换取独立部署和独立扩展的能力。这是一个有成本的投资——在开始之前,确保收益大于成本。
常见陷阱 单体里一个端到端测试覆盖了整个流程。拆开后,跨服务流程需要集成测试——而且每个服务的部署相互独立,集成测试变得困难。投资契约测试(Contract Testing),每个服务发布自己的 API 契约文件(Spring Cloud Contract / Pact)。
陷阱二:数据库拆分第二阶段的分布式事务。 "写完积分服务还要更新玩家服务的成就状态,我在两级提交里卡了一天。"——分布式事务是分布式系统最难的问题之一。提前想好:能不能接受最终一致性?能不能用补偿事务(Saga)?
陷阱三:网络调用代替了方法调用但没处理失败。 单体里 playerService.find(id) 不可能超时。微服务里 http://player-service/players/{id} 可能超时、可能 503、可能超时后实际上成功了但客户端认为失败。每个跨服务调用都必须默认失败。
陷阱四:拆了,但没有自动化基础设施。 微服务需要:服务发现、配置中心、负载均衡、监控、分布式追踪。没有这些,微服务的运维成本是单体的 5 倍。在拆第一个服务之前,先把基础设施搭好。
迁移计划的实际模板
当你决定启动微服务迁移时,以下是一份可用的决策模板:
## 模块迁移计划:{模块名}
### 判断条件
- [ ] 模块是否可以被其他团队独立维护?
- [ ] 模块的变更频率是否显著不同于其他模块?
- [ ] 模块是否需要独立的扩缩容策略?
- [ ] 是否有专门的团队负责此模块?
### 迁移策略
- 第一阶段:模块化单体(在单体内划清边界)
- 第二阶段:建立新服务 + CDC 数据同步
- 第三阶段:Strangler Fig 渐进切换
- 第四阶段:清理旧代码
### 回滚方案
- 如果新服务异常,切换回单体(保留双写)
### 验收标准
- 新服务可独立部署
- 新服务有自己的数据库
- 新服务使用自己的 CI/CD 流水线
- 旧代码已清理对于每个模块,在正式开始之前打勾判断条件——至少满足前两项才值得拆。
通关挑战
- 热身:在你当前的系统(哪怕是个人项目)中画出现有的模块/包依赖图。标出哪些是核心模块,哪些边界清晰。
- 挑战:找出一个"适合第一个拆分"的模块,设计它的 API 边界,写一份拆分计划(2 页纸)。
- 观察:找一个大厂或开源项目的微服务迁移回忆录(Netflix、Uber、Amazon 都有)。注意它们的第一个拆分是什么——通常不是最核心的业务模块。
旅人笔记
拆微服务不是目的——隔离变化速度是目的。先从模块化单体开始,再以 Strangler Fig 模式逐步蚕食。不急于拆,但拆了就不回头。
下一站预告
你把系统拆开了。服务之间通过网络调用——但网络是不可靠的。服务发现、API Gateway、断路器、重试——这些是微服务能活下去的基础。下一章:微服务基础模式。