微服务重构:单体拆分为微服务的七步法,数据库拆分策略
提出问题
一个 200 万行代码的单体应用,日均处理上亿请求,现在团队决定拆分为微服务。最危险的想法是"先拆代码,再拆数据库"——结果往往是代码拆了,业务还在共享一个库,变成了"分布式单体":既没有微服务的独立部署能力,又多了网络开销。更常见的是拆分到一半发现服务边界画错了,数据库拆错了,进退两难。
单体拆微服务,技术不是最难的问题,最难的是业务边界识别和数据库拆分策略。面试官问这个问题,考的不是你能不能背出"七步法"的步骤名称,而是你有没有真正拆过、踩过坑、知道什么情况下该停。
分析问题
识别限界上下文:DDD 是工具,不是仪式
第一步也是最关键的一步:识别限界上下文(Bounded Context)。基于 DDD 领域建模,将业务拆分为多个有清晰边界的领域——订单、商品、支付、用户等,每个限界上下文对应一个微服务。
实战经验:不要追求"完美"的领域建模。领域专家画出来的上下文边界和实际代码里的依赖关系往往不一致。更务实的做法是从代码依赖图出发:用工具(如 JDepend、ArchUnit)扫描单体代码,画出包之间的依赖关系,找出高内聚低耦合的模块。每个模块的界限就是限界上下文。DDD 术语只在需要和业务方对齐时用,不要为了 DDD 而 DDD。
边界判断标准:如果一个模块的代码变更不需要牵动其他模块的测试,这个模块就是好的候选服务。
代码剥离:从无依赖的领域开始
从无依赖的领域开始剥离(如用户服务、商品服务),将代码从单体中复制到新服务,单体中保留代理(API 代理)转发请求。
// 单体中的代理类:把请求转发到新拆出的微服务
@Component
public class UserServiceProxy {
private final RestClient restClient;
public UserServiceProxy(RestClient.Builder builder) {
this.restClient = builder.baseUrl("http://user-service").build();
}
public UserDTO findById(Long userId) {
// 使用 FeignClient 或 RestClient 调用新服务
return restClient.get()
.uri("/users/{id}", userId)
.retrieve()
.body(UserDTO.class);
}
}关键点:代理类必须和原接口完全一致,调用方无感知。单体中的 UserService 代码可以逐步删除,但先保留一段时间做回滚备选。
数据库拆分:最危险也最关键的步骤
数据库拆分是单体到微服务中最危险的一步,也是面试官最深挖的地方。
策略:
- 先拆逻辑,再拆数据:先通过代码层隔离(每个服务只访问自己的表),观察一段时间确认无跨服务查询后,再把表物理迁移到独立库。
- 分库分表:按业务将数据库表分到不同服务,每个服务拥有自己的数据库。坑:跨服务 Join 查询无法直接使用,需要在服务层做聚合查询。
// 分库后无法做跨服务 Join,需要用服务层聚合
// 坏方案:在订单服务中直接查商品库
// jdbcTemplate.query("SELECT * FROM orders o JOIN products p ON o.product_id = p.id", ...)
// 好方案:订单服务只查自己的库,商品信息通过 RPC 获取
public class OrderDetailService {
private final OrderRepository orderRepo;
private final ProductServiceClient productClient;
public OrderDetailDTO getOrderDetail(Long orderId) {
Order order = orderRepo.findById(orderId);
// 跨服务调用获取商品信息
ProductDTO product = productClient.getProduct(order.getProductId());
return new OrderDetailDTO(order, product);
}
}- 事件回溯(Event Backfill):拆分数据库时,旧数据需要从单体库迁移到新服务库。策略:单体库先保留只读,新服务库写新数据,通过 CDC(Change Data Capture,如 Canal/Debezium)监听单体库的变更事件,同步到新服务库。旧数据通过批处理脚本一次性迁移,清洗和映射后写入新库。
// 使用 Debezium 监听 MySQL binlog 做数据同步
@Component
public class UserDataSyncListener {
private final UserRepository userRepo;
@EventListener
public void onBinlogEvent(ChangeDataCaptureEvent event) {
// 从 CDC 事件中解析出变更的数据
UserDTO user = event.parse(UserDTO.class);
// 写入新服务的数据库
userRepo.save(user);
}
}绞杀者模式:逐步替换,而非一次性重写
Martin Fowler 提出的 Strangler Fig Pattern 是单体拆微服务的黄金法则:不一次性重写,而是逐步用新服务替换单体的功能,单体逐步缩小直到被完全替代。
核心原则:
- 先拆读多写少的服务(如商品查询服务),再拆写多读少的服务(如订单服务)
- 先拆无依赖的服务(如用户服务、通知服务),再拆有复杂依赖的服务(如订单服务依赖商品和支付)
- 每次拆分后单体中保留代理,确保线上流量可以在单体和新服务之间灰度切换
# Spring Cloud Gateway 路由配置,支持灰度切换
spring:
cloud:
gateway:
routes:
- id: user-service-route
uri: lb://user-service # 新服务
predicates:
- Header=X-Canary, v2
filters:
- StripPrefix=1
- id: user-service-fallback
uri: lb://monolith-app # 单体兜底
predicates:
- Path=/api/users/**拆分失败的三大原因
- 拆分粒度太细:200 行代码一个服务,导致分布式通信成本 > 业务逻辑成本。一个功能改完要跨 10 个服务协调发布。
- 未做数据库拆分先拆代码:代码拆了但业务还在共享一个库,导致"分布式单体"——既没有微服务的独立部署,又增加了网络开销。
- 未做数据一致性设计:分布式事务没处理好,数据不一致。订单扣库存场景,拆分后库存扣减变成跨服务调用,如果没做 Saga 或本地消息表,可能出现"扣款成功但库存没扣"的情况。
总结
关键避坑清单
- 拆分前先用代码依赖图确定边界,别靠"感觉"画服务
- 先拆读多写少的服务,从无依赖的领域开始
- 数据库拆分必须和代码拆分同步,不能先拆代码再拆库
- 每个拆分步骤必须可回滚——单体代理保留,新服务有问题立刻切回
- 跨服务 Join 用服务层聚合代替,必要时引入 CQRS + ES
- 旧数据迁移用 CDC(Debezium/Canal)做增量同步,批处理脚本做全量迁移
面试话术示例
"单体拆微服务,我踩过最大的坑是数据库拆分顺序。我们当时先拆了代码,但 6 个服务共享一个 MySQL 实例,数据库连接池不够用,慢查询互相影响。正确做法是:代码拆分和数据库拆分同步推进,绞杀者模式逐步替换。每拆一个服务,就把它对应的表物理迁移到独立库,用 CDC 保证旧数据同步。整个过程需要 6-12 个月,不能急。"
参考:Martin Fowler. Strangler Fig Application;Sam Newman. Building Microservices;Debezium 官方文档. Change Data Capture;Alibaba Canal 文档.