从单体到微服务:我们项目的拆分思路和踩坑记录
我们的项目是一个基于 Spring Boot 2.7 + JDK 8 的电商 SaaS 平台,包含商城、交易、支付、会员、商户、营销等 10+ 业务模块。前期采用模块化单体架构(所有模块打包成一个
ymh-server.jar),随着业务发展决定拆成独立微服务。本文记录整个拆分过程中的思路、方案选择和实际踩过的坑。
一、为什么拆?不拆行不行?
先说结论:能不分就不分。
我们项目前期是典型的模块化单体------Maven 多模块,复杂领域做了 API/BIZ 拆分,但所有 -biz 模块都塞在 ymh-server 里,打成一个 fat JAR,端口 48080,共享同一个 MySQL 和 Redis。
好处很明显:
- 开发快,本地启动一个 JAR 就能调通全链路
- 调试方便,跨模块调用就是方法调用,没有网络开销
- 部署简单,Jenkins 一条脚本搞定
但问题也越来越明显:
- 启动越来越慢,从最初的 30 秒涨到 2 分钟
- 改一个模块要重新构建全部,CI/CD 时间越来越长
- 流量无法独立扩缩容,订单高峰期整个服务一起扩容,浪费资源
- 代码耦合越堆越多,虽然分了模块,但跨模块直接 import 的情况比比皆是
所以决定拆。但不是盲目拆------我们定了几个原则:

- 不追求一步到位,按优先级分批独立
- 数据库初期不强制物理拆分,先按 Schema 隔离
- 跨模块调用从 Maven 直依赖逐步改为 Feign,不是一次性替换
- Gateway 最后再做,前期每个服务独立端口调试,稳定后再统一路由
二、技术选型
注册中心 & 配置中心:Nacos
选 Nacos 的原因很简单:Spring Cloud Alibaba 生态成熟,国内社区活跃,而且它同时承担注册中心和配置中心两个角色,少维护一个组件。
xml
<!-- 版本管理放在 ymh-dependencies BOM 中 -->
<properties>
<spring-cloud.version>2021.0.8</spring-cloud.version>
<spring-cloud-alibaba.version>2021.0.5.0</spring-cloud-alibaba.version>
</properties>
RPC 通信:OpenFeign + LoadBalancer
项目是 JDK 8 + Spring Boot 2.7,Dubbo 接入成本较高,先用 Feign 过渡。后续如果性能成为瓶颈再迁移到 Dubbo,接口层改动很小。
网关:Spring Cloud Gateway
统一入口,承担路由转发、JWT 校验、租户透传、全局限流。前期每个服务独立端口调试,稳定后再接入网关。
消息队列:复用已有的 RocketMQ 抽象层
我们项目本身就有 ymh-spring-boot-starter-mq,支持 RocketMQ/RabbitMQ/Kafka。拆分时直接在此基础上加事务消息支持,订单创建等场景用 MQ 做最终一致性。
三、拆分顺序与过渡策略
3.1 拆分顺序
我们按依赖关系和业务独立性排了优先级,不是按"谁重要",而是按"谁先拆不影响别人"。
sql
Phase 1 → system + infra(基础依赖,其他都靠它)
Phase 2 → pay(支付独立性强,合规要求高)
Phase 3 → trade(订单核心链路,但对其他服务依赖最多)
Phase 4 → member + merchant(业务耦合度相对低)
Phase 5 → mall(商品模块)
Phase 6 → promotion(营销模块,跨服务调用最多)
Phase 7 → qr(二维码交易)
Phase 8 → open + partner + pmp(边缘模块最后拆)
Phase 9 → Gateway 统一入口
Phase 10 → 废弃 ymh-server

3.2 过渡期:单体与新微服务互相调用
拆分过程中会存在一段过渡期 :ymh-server(单体)还在运行,部分模块已经拆到了独立微服务。此时会出现两种调用方式并存的情况:
- 单体内部调新微服务 → 走 Feign HTTP 调用
- 新微服务调单体内部的模块 → 也走 Feign HTTP 调用(不再是方法直调)
关键要求 :所有对外暴露的接口必须保证请求/响应 DTO 完全兼容,统一使用 CommonResult 返回体。这样在切换流量时无需前端改动。
3.3 灰度迁移与回滚方案
每个微服务独立后的上线流程:
markdown
1. 本地调试通过
↓
2. 测试环境并行验证(单体 + 微服务同时跑,数据对比)
↓
3. 预发环境少量流量切到新服务(网关层按权重路由)
↓
4. 全量切换
↓
5. 观察 48 小时无异常后,下线单体中的旧模块
回滚预案 :一旦出现异常,网关路由切回单体 ymh-server,整个过程不超过 1 分钟。
3.4 API 版本兼容策略
迁移周期内单体和多个新版本微服务并行运行,接口迭代极易不兼容。我们的做法:
- 新增字段一律向后兼容,不删除已有字段
- 重大变更新增接口路径(如
/v2/member/get),不直接修改旧接口 - 所有 Feign 远程接口的 DTO 字段设置默认值,避免反序列化失败
四、Phase 1:system + infra 独立
这是最底层的基础服务,其他所有服务都依赖它,越早独立越好。
4.1 新建服务工程
bash
ymh-service-system/
├── pom.xml
├── Dockerfile
└── src/main/resources/
├── application.yaml # 公共配置
├── application-local.yaml # 本地开发
├── application-dev.yaml # 开发环境
└── application-pre.yaml # 预发布
4.2 POM 依赖
xml
<parent>
<groupId>com.yimahui</groupId>
<artifactId>ymh</artifactId>
<version>${revision}</version>
</parent>
<artifactId>ymh-service-system</artifactId>
<packaging>jar</packaging>
<dependencies>
<!-- 原 system + infra 模块 -->
<dependency>
<groupId>com.yimahui</groupId>
<artifactId>ymh-module-system</artifactId>
</dependency>
<dependency>
<groupId>com.yimahui</groupId>
<artifactId>ymh-module-infra</artifactId>
</dependency>
<!-- 微服务基础 -->
<dependency>
<groupId>com.yimahui</groupId>
<artifactId>ymh-spring-boot-starter-cloud</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>
4.3 启动类
java
package com.yimahui.server;
@SpringBootApplication(exclude = {
DruidDataSourceAutoConfiguration.class
})
@MapperScan("com.yimahui.module.*.dal")
public class SystemApplication {
public static void main(String[] args) {
SpringApplication.run(SystemApplication.class, args);
}
}
注意排除的是 DruidDataSourceAutoConfiguration,数据源配置由 infra 模块自己的 autoconfigure 接管。排除原生的 DataSourceAutoConfiguration 会导致多数据源初始化异常。
4.4 配置文件
从原来的 ymh-server/src/main/resources/application.yaml 中提取 system/infra 相关配置,放到新服务的配置文件中:
yaml
# application.yaml
spring:
application:
name: ymh-service-system
profiles:
active: local
# 注意:过渡期端口与原来 ymh-server 错开
server:
port: 48081
nacos:
discovery:
server-addr: 127.0.0.1:8848
config:
server-addr: 127.0.0.1:8848
file-extension: yaml
4.5 数据库过渡方案(绞杀者模式)

初期不建议一次性物理拆库。但我们不能简单地用双数据源切换 ------ymh-server(单体)和新微服务同时读写同一张表,无分布式锁/同步策略,极易产生脏写和数据冲突。
我们采用的是**绞杀者模式(Strangler Fig Pattern)**逐步迁移:
| 阶段 | 操作 | 风险 |
|---|---|---|
| 阶段 1 | 微服务只读旧库,不写入 | 无风险,验证功能正确性 |
| 阶段 2 | 开启双写:单体 + 微服务同时写入新旧库 | 需数据一致性校验 |
| 阶段 3 | 微服务读写新库,单体逐步切流量 | 网关层按权重路由 |
| 阶段 4 | 下线单体访问旧库,确认数据一致后删除 | 低风险 |
双数据源过渡期的配置示例:
yaml
# application-dev.yaml
spring:
datasource:
dynamic:
datasource:
# 旧的共享库(过渡期保留)
master:
url: jdbc:mysql://old-host:3306/yimahui_db
# 新的 system 独立库
system:
url: jdbc:mysql://new-host:3306/yimahui_system
primary: system
通过 MyBatis-Plus 的 @DS 注解切换数据源:
scala
@Service
@DS("system") // 指定使用 system 数据源
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUserDO>
implements SysUserService {
}
五、Phase 2:pay 独立
支付模块独立性强,支付宝/微信回调是外部主动请求,不走内部 Feign,拆分风险相对较低。
5.1 关键改造点
pay-biz 当前直接依赖 qr-api、trade-api、system。拆分后需要把这些直调改为 Feign 远程调用。
改造前(同进程直调):
typescript
// pay-biz 中的原有代码
@Service
public class PayOrderServiceImpl {
@Autowired
private QrTradeApiService qrTradeApiService; // 直接 import
public void notifyPaySuccess(Long orderId) {
// 直接调用
qrTradeApiService.updateTradeStatus(orderId, "PAID");
}
}
改造后(Feign 远程调用):
less
// 1. 定义远程接口
@FeignClient(
name = "ymh-service-qr",
path = "/qr",
fallbackFactory = QrRemoteServiceFallbackFactory.class
)
public interface QrRemoteService {
@PutMapping("/trade/update-status")
CommonResult<Boolean> updateTradeStatus(
@RequestParam("orderId") Long orderId,
@RequestParam("status") String status
);
}
// 2. 降级处理
@Component
public class QrRemoteServiceFallbackFactory implements FallbackFactory<QrRemoteService> {
private static final Logger log = LoggerFactory.getLogger(QrRemoteServiceFallbackFactory.class);
@Override
public QrRemoteService create(Throwable cause) {
log.error("QR 服务调用失败", cause);
return new QrRemoteService() {
@Override
public CommonResult<Boolean> updateTradeStatus(Long orderId, String status) {
// ⚠️ 写类接口不建议直接降级返回成功,需持久化失败记录
// 由后台定时任务扫描补偿
failRecordService.recordFailedCall("updateTradeStatus", orderId, status, cause);
return CommonResult.error("QR 服务不可用,已记录待补偿");
}
};
}
}
// 3. 注入使用
@Service
public class PayOrderServiceImpl {
@Autowired
private QrRemoteService qrRemoteService; // 改为 Feign 客户端
public void notifyPaySuccess(Long orderId) {
qrRemoteService.updateTradeStatus(orderId, "PAID");
}
}
降级策略区分:
- 查询接口:可缓存兜底,返回旧数据比直接报错好
- 写类接口(状态变更、扣减库存等):不建议直接降级,优先持久化失败记录,后台定时任务补偿
5.2 支付宝/微信回调路径
这些是外部主动请求,需要在网关层明确放行:
yaml
spring:
cloud:
gateway:
routes:
- id: pay-callback
uri: lb://ymh-service-pay
predicates:
- Path=/union-api/api/**,/app-api/pay/wx/**,/app-api/pay/alipay/**
# StripPrefix=0 语义等价于「不剥离任何路径」,显式注释说明
filters:
- StripPrefix=0
注意这些回调路径跳过 JWT 校验,因为支付宝/微信不会带你的 Token。改用签名验签机制。
六、Phase 3:trade 独立(最复杂的一关)
订单模块是拆分中最复杂的,因为它对其它服务的依赖最多,且涉及分布式事务。
6.1 跨服务调用清单
trade-biz 当前直接依赖的服务:
| 被调用方 | 改造方式 |
|---|---|
| member-api | Feign 远程调用 |
| pay-api | Feign 远程调用 |
| pay-biz | Feign 远程调用支付接口 |
| mall-api | Feign 远程调用商品查询 |
| promotion-api | Feign 远程调用优惠计算 |
6.2 订单创建链路改造
这是最需要仔细设计的部分。
改造前(同一事务内完成):
scss
@Transactional
public TradeOrderCreateRespDTO createOrder(TradeOrderCreateReqDTO req) {
// 1. 扣减库存(mall 模块)
productService.deductStock(req.getSkuId(), req.getCount());
// 2. 计算优惠(promotion 模块)
DiscountInfo discount = promotionService.calculateDiscount(req.getUserId(), req.getCouponId());
// 3. 创建订单
TradeOrderDO order = convertToOrder(req, discount);
orderMapper.insert(order);
// 4. 创建支付单(pay 模块)
payService.createPayOrder(order.getId(), order.getPayAmount());
// 5. 增加会员消费额(member 模块)
memberService.addConsumeAmount(req.getUserId(), order.getPayAmount());
return convertToResp(order);
}
所有操作在一个事务里,要么全成功要么全回滚。拆分后不能再这么写。
改造后(本地事务 + MQ 最终一致性):
scss
// 1. 订单服务只负责创建订单
@Transactional
public TradeOrderCreateRespDTO createOrder(TradeOrderCreateReqDTO req) {
// 1. 查询商品信息(Feign 调用,不参与事务)
ProductSkuRespDTO sku = mallRemoteService.getSku(req.getSkuId());
// 2. 查询优惠券信息
CouponInfoDTO coupon = promotionRemoteService.getCoupon(req.getCouponId());
// 3. 创建订单(本地事务)
TradeOrderDO order = buildOrder(req, sku, coupon);
orderMapper.insert(order);
// 4. 发送 MQ 事务消息(异步通知其他服务)
rocketMQTemplate.sendMessageInTransaction(
"transaction-order-topic",
MessageBuilder.withPayload(new OrderCreatedEvent(order.getId()))
.setHeader("skuId", req.getSkuId())
.setHeader("userId", req.getUserId())
.build(),
order
);
return convertToResp(order);
}
// 2. MQ 消费者处理后续逻辑
@RocketMQMessageListener(
topic = "transaction-order-topic",
consumerGroup = "trade-consumer-group"
)
public class OrderCreatedConsumer implements RocketMQListener<OrderCreatedEvent> {
@Autowired
private FailRecordService failRecordService;
@Override
public void onMessage(OrderCreatedEvent event) {
Long orderId = event.getOrderId();
// ⚠️ 所有操作必须幂等!RocketMQ 允许消息重复投递
// 扣减库存(mall 服务)
try {
mallRemoteService.deductStock(event.getSkuId(), event.getCount());
} catch (Exception e) {
log.error("扣库存失败,orderId={}", orderId, e);
failRecordService.recordFail("deductStock", orderId, e);
throw e; // 触发重试
}
// 核销优惠券(promotion 服务)
try {
promotionRemoteService.verifyCoupon(event.getCouponId(), orderId);
} catch (Exception e) {
log.error("核销优惠券失败,orderId={}", orderId, e);
failRecordService.recordFail("verifyCoupon", orderId, e);
throw e;
}
// 增加会员消费额(member 服务)
memberRemoteService.addConsumeAmount(event.getUserId(), event.getAmount());
// 创建支付单(pay 服务)
payRemoteService.createPayOrder(orderId, event.getAmount());
}
}
幂等设计 :所有 MQ 消费者必须基于业务主键实现幂等。例如扣库存接口内部先查一次"是否已扣过此订单",核销优惠券用 orderId 作为去重 key。单纯依赖重试 + 死信无法防止重复消费引发资损。
6.3 事务消息的回退处理
RocketMQ 事务消息只能保证「本地事务成功 → 消息一定投递」,不能保证下游消费成功。所以我们用了三级策略:
第一级:自动重试
yaml
# application.yaml 中的 RocketMQ 配置
rocketmq:
consumer:
retry-times: 3 # 自动重试 3 次
delay-level: 1,3,5 # 第 1 次 1s 后重试,第 2 次 3s,第 3 次 5s
第二级:死信队列 + 定时补偿
yaml
rocketmq:
dead-letter:
enabled: true # 超过重试次数进入死信队列
table: mq_dead_letter # 死信表名
死信队列里的消息每天凌晨跑一次补偿任务,重试失败的标记为 NEED_MANUAL_REVIEW,运维后台可以看到并手动触发重放。
第三级:人工补偿
运维后台提供"手动重放"按钮,选中死信消息后重新发送到消费组。
七、踩过的坑
坑 1:Feign 超时时间太短
默认 1 秒,我们一个订单创建链路里有 4-5 个 Feign 调用,任何一个慢了就直接超时。
yaml
# 每个服务的 application.yaml
spring:
cloud:
openfeign:
client:
config:
default:
connect-timeout: 3000 # 连接超时 3s
read-timeout: 10000 # 读取超时 10s
ymh-service-trade:
read-timeout: 15000 # 订单服务单独设置更长超时
坑 2:LoadBalancer 默认重试导致非幂等操作重复执行
Spring Cloud 2021.x 移除了 Ribbon,使用原生 LoadBalancer。但 LoadBalancer 默认对 POST 请求开启重试!
如果 Feign 超时触发重试,创建订单、扣库存这类非幂等接口会重复执行,直接资损。
必须关闭:
yaml
spring:
cloud:
loadbalancer:
retry:
enabled: false # 关闭全局重试;业务需要重试手动实现
坑 3:循环依赖
拆分初期,member 调 merchant,merchant 又调 member,Feign 直接报错:
csharp
BeanCurrentlyInCreationException:
bean 'memberRemoteService' is not eligible for getting wrapped by proxy
解决方案:
- 梳理依赖图,消除循环引用
- 对于确实无法避免的场景,用
@Lazy延迟加载 - 最根本的方式是重新设计领域边界,把共用的功能下沉到一个更底层的公共模块
坑 4:跨服务 JOIN 变多次查询
改造前一个 SQL 查订单+商品+会员信息,改造后需要调 3 个 Feign 接口拼起来。
我们的做法:
| 场景 | 方案 |
|---|---|
| 读多写少 | 调用方本地缓存 + Redis 二级缓存 |
| 高频查询 | CDC 同步关联表数据到订单表(DDD 聚合根思想) |
| 列表/报表 | Canal 同步数据到 ES / 数仓,查询不走业务微服务 |
| 批量查询 | 上游服务提供批量查询接口,减少 N+1 调用 |
kotlin
@Service
public class OrderDetailService {
@Autowired
private TradeRemoteService tradeRemoteService;
@Autowired
private MallRemoteService mallRemoteService;
// ⚠️ 必须设置过期时间,否则缓存不更新且可能内存溢出
@Cacheable(value = "orderDetail", key = "#orderId", unless = "#result == null", expire = 300)
public OrderDetailDTO getOrderDetail(Long orderId) {
TradeOrderDTO order = tradeRemoteService.getOrder(orderId);
ProductSkuDTO sku = mallRemoteService.getSku(order.getSkuId());
return buildDetail(order, sku);
}
}
坑 5:租户上下文在异步场景丢失
我们是多租户 SaaS,每个请求都有 tenant-id。拆成微服务后,这个 Header 需要在 Feign 调用链中透传。
但 Feign 拦截器只在工作线程生效 ,@Async、RocketMQ 消费者、线程池内部调用 Feign 时,ThreadLocal 取不到租户 ID,下游服务租户隔离失效,发生跨租户数据泄露。
java
@Configuration
public class FeignConfig implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// ⚠️ 仅 Web 同步请求生效;@Async、MQ 消费者等异步线程无法获取 ThreadLocal 租户上下文
Long tenantId = TenantContextHolder.getTenantId();
if (tenantId != null) {
template.header("X-Tenant-Id", tenantId.toString());
}
String traceId = TraceContext.getTraceId();
if (traceId != null) {
template.header("X-Request-Id", traceId);
}
}
}
配套约束:
- MQ 消费 / 异步任务发起远程调用时,必须手动把
X-Tenant-Id作为参数传入,不要依赖拦截器自动透传 - 网关、所有微服务增加校验:不存在
X-Tenant-Id拒绝业务请求
坑 6:Swagger/Knife4j 文档分散
每个服务独立后,API 文档也分散了。
方案 :在 Gateway 层集成 Knife4j 的聚合功能,或者用 Swagger UI 的 urls 属性做多文档聚合。我们选了后者,在每个服务的 application.yaml 里暴露 Swagger 的 JSON 端点,Gateway 层统一汇聚。
八、拆分后的端口规划
注意:Gateway 和原来单体
ymh-server都曾用过 48080 端口,过渡期必须错开。
| 服务 | 端口 | 说明 |
|---|---|---|
| Gateway | 48080 | 统一入口(Phase 9 才启用) |
| ymh-server(单体) | 48099 | 过渡期临时端口 |
| system | 48081 | 用户、权限、字典、文件 |
| pay | 48083 | 支付渠道 |
| trade | 48084 | 订单、售后 |
| member | 48085 | 会员、账户 |
| merchant | 48086 | 商户、品牌、门店 |
| mall | 48087 | 商品、SKU |
| promotion | 48088 | 优惠券、营销活动 |
| qr | 48089 | 二维码交易 |
| open | 48090 | 开放平台 |
| partner | 48091 | 合作伙伴 |
| pmp | 48092 | 精准营销 |
九、总结
这次拆分我们用了大约 3 周时间完成了前三个阶段(system/pay/trade),剩余模块按同样模式推进。整体感受:
- API/BIZ 拆分做得好,迁移成本低 。我们的
-api子模块天然就是 Feign 的接口定义,只需要把实现从-biz搬到独立服务即可 - 不要追求一步到位。数据库可以先共用,Gateway 可以最后做,Feign 调用可以增量替换
- MQ 事务消息 + 消费者幂等是订单拆分的核心。缺一不可
- 渐进式拆分最大成本不在代码改造,而在于过渡期两套架构并行维护的复杂度,需要预留充足测试回归人力
拆分不是目的,解决实际问题才是。如果你的团队规模还不大、QPS 不高,模块化单体完全够用。但当启动时间超过 1 分钟、CI 流水线跑 20 分钟的时候,就该认真考虑拆分了。