从单体到微服务:我们项目的拆分思路与踩坑记录

作者:袖梨 2026-07-24

从单体到微服务:我们项目的拆分思路和踩坑记录


一、为什么拆?不拆行不行?

先说结论:能不分就不分

我们项目前期是典型的模块化单体——Maven 多模块,复杂领域做了 API/BIZ 拆分,但所有 -biz 模块都塞在 ymh-server 里,打成一个 fat JAR,端口 48080,共享同一个 MySQL 和 Redis。

好处很明显:

  • 开发快,本地启动一个 JAR 就能调通全链路
  • 调试方便,跨模块调用就是方法调用,没有网络开销
  • 部署简单,Jenkins 一条脚本搞定

但问题也越来越明显:

  • 启动越来越慢,从最初的 30 秒涨到 2 分钟
  • 改一个模块要重新构建全部,CI/CD 时间越来越长
  • 流量无法独立扩缩容,订单高峰期整个服务一起扩容,浪费资源
  • 代码耦合越堆越多,虽然分了模块,但跨模块直接 import 的情况比比皆是

所以决定拆。但不是盲目拆——我们定了几个原则:

img_6a62b4911a51130.webp

  1. 不追求一步到位,按优先级分批独立
  2. 数据库初期不强制物理拆分,先按 Schema 隔离
  3. 跨模块调用从 Maven 直依赖逐步改为 Feign,不是一次性替换
  4. Gateway 最后再做,前期每个服务独立端口调试,稳定后再统一路由

二、技术选型

注册中心 & 配置中心:Nacos

选 Nacos 的原因很简单:Spring Cloud Alibaba 生态成熟,国内社区活跃,而且它同时承担注册中心和配置中心两个角色,少维护一个组件。

 复制代码<!-- 版本管理放在 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 拆分顺序

我们按依赖关系和业务独立性排了优先级,不是按"谁重要",而是按"谁先拆不影响别人"。

 复制代码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

img_6a62b4911a51731.webp

3.2 过渡期:单体与新微服务互相调用

拆分过程中会存在一段过渡期ymh-server(单体)还在运行,部分模块已经拆到了独立微服务。此时会出现两种调用方式并存的情况:

  • 单体内部调新微服务 → 走 Feign HTTP 调用
  • 新微服务调单体内部的模块 → 也走 Feign HTTP 调用(不再是方法直调)

关键要求:所有对外暴露的接口必须保证请求/响应 DTO 完全兼容,统一使用 CommonResult 返回体。这样在切换流量时无需前端改动。

3.3 灰度迁移与回滚方案

每个微服务独立后的上线流程:

 复制代码1. 本地调试通过2. 测试环境并行验证(单体 + 微服务同时跑,数据对比)3. 预发环境少量流量切到新服务(网关层按权重路由)4. 全量切换5. 观察 48 小时无异常后,下线单体中的旧模块

回滚预案:一旦出现异常,网关路由切回单体 ymh-server,整个过程不超过 1 分钟。

3.4 API 版本兼容策略

迁移周期内单体和多个新版本微服务并行运行,接口迭代极易不兼容。我们的做法:

  • 新增字段一律向后兼容,不删除已有字段
  • 重大变更新增接口路径(如 /v2/member/get),不直接修改旧接口
  • 所有 Feign 远程接口的 DTO 字段设置默认值,避免反序列化失败

四、Phase 1:system + infra 独立

这是最底层的基础服务,其他所有服务都依赖它,越早独立越好。

4.1 新建服务工程

 复制代码ymh-service-system/
├── pom.xml
├── Dockerfile
└── src/main/resources/
    ├── application.yaml          # 公共配置
    ├── application-local.yaml    # 本地开发
    ├── application-dev.yaml      # 开发环境
    └── application-pre.yaml      # 预发布

4.2 POM 依赖

 复制代码<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 启动类

 复制代码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 相关配置,放到新服务的配置文件中:

 复制代码# application.yaml
spring:
  application:
    name: ymh-service-system
  profiles:
    active: local# 注意:过渡期端口与原来 ymh-server 错开
server:
  port: 48081nacos:
  discovery:
    server-addr: 127.0.0.1:8848
  config:
    server-addr: 127.0.0.1:8848
    file-extension: yaml

4.5 数据库过渡方案(绞杀者模式)

img_6a62b4911a51a32.webp

初期不建议一次性物理拆库。但我们不能简单地用双数据源切换——ymh-server(单体)和新微服务同时读写同一张表,无分布式锁/同步策略,极易产生脏写和数据冲突。

我们采用的是**绞杀者模式(Strangler Fig Pattern)**逐步迁移:

阶段操作风险
阶段 1微服务只读旧库,不写入无风险,验证功能正确性
阶段 2开启双写:单体 + 微服务同时写入新旧库需数据一致性校验
阶段 3微服务读写新库,单体逐步切流量网关层按权重路由
阶段 4下线单体访问旧库,确认数据一致后删除低风险

双数据源过渡期的配置示例:

 复制代码# 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 注解切换数据源:

 复制代码@Service
@DS("system")  // 指定使用 system 数据源
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUserDO>
    implements SysUserService {
}

五、Phase 2:pay 独立

支付模块独立性强,支付宝/微信回调是外部主动请求,不走内部 Feign,拆分风险相对较低。

5.1 关键改造点

pay-biz 当前直接依赖 qr-apitrade-apisystem。拆分后需要把这些直调改为 Feign 远程调用。

改造前(同进程直调):

 复制代码// pay-biz 中的原有代码
@Service
public class PayOrderServiceImpl {    @Autowired
    private QrTradeApiService qrTradeApiService;  // 直接 import    public void notifyPaySuccess(Long orderId) {
        // 直接调用
        qrTradeApiService.updateTradeStatus(orderId, "PAID");
    }
}

改造后(Feign 远程调用):

 复制代码// 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 支付宝/微信回调路径

这些是外部主动请求,需要在网关层明确放行:

 复制代码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-apiFeign 远程调用
pay-apiFeign 远程调用
pay-bizFeign 远程调用支付接口
mall-apiFeign 远程调用商品查询
promotion-apiFeign 远程调用优惠计算

6.2 订单创建链路改造

img_6a62b4911a51c33.webp 这是最需要仔细设计的部分。

改造前(同一事务内完成):

 复制代码@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 最终一致性):

 复制代码// 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 事务消息只能保证「本地事务成功 → 消息一定投递」,不能保证下游消费成功。所以我们用了三级策略:

第一级:自动重试

 复制代码# application.yaml 中的 RocketMQ 配置
rocketmq:
  consumer:
    retry-times: 3           # 自动重试 3 次
    delay-level: 1,3,5       # 第 1 次 1s 后重试,第 2 次 3s,第 3 次 5s

第二级:死信队列 + 定时补偿

 复制代码rocketmq:
  dead-letter:
    enabled: true             # 超过重试次数进入死信队列
    table: mq_dead_letter     # 死信表名

死信队列里的消息每天凌晨跑一次补偿任务,重试失败的标记为 NEED_MANUAL_REVIEW,运维后台可以看到并手动触发重放。

第三级:人工补偿

运维后台提供"手动重放"按钮,选中死信消息后重新发送到消费组。


七、踩过的坑

坑 1:Feign 超时时间太短

默认 1 秒,我们一个订单创建链路里有 4-5 个 Feign 调用,任何一个慢了就直接超时。

 复制代码# 每个服务的 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 超时触发重试,创建订单、扣库存这类非幂等接口会重复执行,直接资损。

必须关闭

 复制代码spring:
  cloud:
    loadbalancer:
      retry:
        enabled: false  # 关闭全局重试;业务需要重试手动实现

坑 3:循环依赖

拆分初期,member 调 merchant,merchant 又调 member,Feign 直接报错:

 复制代码BeanCurrentlyInCreationException:
  bean 'memberRemoteService' is not eligible for getting wrapped by proxy

解决方案

  1. 梳理依赖图,消除循环引用
  2. 对于确实无法避免的场景,用 @Lazy 延迟加载
  3. 最根本的方式是重新设计领域边界,把共用的功能下沉到一个更底层的公共模块

坑 4:跨服务 JOIN 变多次查询

改造前一个 SQL 查订单+商品+会员信息,改造后需要调 3 个 Feign 接口拼起来。

我们的做法

场景方案
读多写少调用方本地缓存 + Redis 二级缓存
高频查询CDC 同步关联表数据到订单表(DDD 聚合根思想)
列表/报表Canal 同步数据到 ES / 数仓,查询不走业务微服务
批量查询上游服务提供批量查询接口,减少 N+1 调用
 复制代码@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,下游服务租户隔离失效,发生跨租户数据泄露。

 复制代码@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 层统一汇聚。


八、拆分后的端口规划

服务端口说明
Gateway48080统一入口(Phase 9 才启用)
ymh-server(单体)48099过渡期临时端口
system48081用户、权限、字典、文件
pay48083支付渠道
trade48084订单、售后
member48085会员、账户
merchant48086商户、品牌、门店
mall48087商品、SKU
promotion48088优惠券、营销活动
qr48089二维码交易
open48090开放平台
partner48091合作伙伴
pmp48092精准营销

九、总结

这次拆分我们用了大约 3 周时间完成了前三个阶段(system/pay/trade),剩余模块按同样模式推进。整体感受:

  1. API/BIZ 拆分做得好,迁移成本低。我们的 -api 子模块天然就是 Feign 的接口定义,只需要把实现从 -biz 搬到独立服务即可
  2. 不要追求一步到位。数据库可以先共用,Gateway 可以最后做,Feign 调用可以增量替换
  3. MQ 事务消息 + 消费者幂等是订单拆分的核心。缺一不可
  4. 渐进式拆分最大成本不在代码改造,而在于过渡期两套架构并行维护的复杂度,需要预留充足测试回归人力

拆分不是目的,解决实际问题才是。如果你的团队规模还不大、QPS 不高,模块化单体完全够用。但当启动时间超过 1 分钟、CI 流水线跑 20 分钟的时候,就该认真考虑拆分了。

相关文章

精彩推荐