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

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

我们的项目是一个基于 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 的情况比比皆是

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

  1. 不追求一步到位,按优先级分批独立
  2. 数据库初期不强制物理拆分,先按 Schema 隔离
  3. 跨模块调用从 Maven 直依赖逐步改为 Feign,不是一次性替换
  4. 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-apitrade-apisystem。拆分后需要把这些直调改为 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

解决方案

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

坑 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),剩余模块按同样模式推进。整体感受:

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

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

相关推荐
TDengine (老段)2 小时前
TDengine 免费版说明
java·大数据·数据库·物联网·时序数据库·tdengine
码上有光2 小时前
异常和智能指针
java·大数据·c++·servlet·异常·智能指针
学计算机的计算基3 小时前
操作系统内存管理全解:虚拟内存、页表、COW、malloc、OOM一篇搞定
java·笔记·算法
tachibana23 小时前
hot100 前 K 个高频元素(347)
java·数据结构·算法·leetcode
万亿少女的梦1683 小时前
基于Spring Boot、Java与MySQL的网络订餐系统设计与实现
java·spring boot·mysql·系统设计·网络订餐
咩咩啃树皮11 小时前
第40篇:Vue3组件化开发精讲——组件拆分、复用、父子通信、工程化架构
java·前端·架构
鱟鲥鳚12 小时前
Spring Boot 集成 LangChain4j:从模型调用到 Tool Calling(Demo版)
java·spring boot
大模型码小白13 小时前
【Python零基础教程】继承、多态与魔法函数:面向对象编程三大核心特性详解
java·大数据·开发语言·人工智能·python·ai编程
腾渊信息科技公司14 小时前
Spring Boot对接MES实战:视觉检测数据自动同步方案
java·人工智能·spring boot·后端·计算机视觉·ai·软件需求