前言

做过电商系统的人都知道,业务从小做到大的过程中,最怕的不是流量问题,而是架构问题。单体架构在业务初期够用,但当你的平台从自营B2C扩展到多商家入驻的B2B2C,再演进到供应链赋能的S2B2C模式时,单体架构就会变成一场灾难——每次发布像拆炸弹,一个小改动可能牵连整个系统。

本文围绕一个S2B2C电商系统的真实落地案例,分享如何用Spring Cloud从单体架构演进到微服务架构的实战经验。不是理论教程,是踩坑之后的真实验证。以小羊云商为参考案例,覆盖了多租户隔离、供应链分销等S2B2C特有场景的架构决策。

一、为什么要从单体走向微服务

1.1 单体架构的瓶颈

该平台最初是一个标准的B2C商城单体应用,所有功能打包在一个WAR包里:商品管理、订单管理、支付、会员、营销、物流……前两年没太大问题,但业务扩展到B2B2C多商家入驻后,问题集中爆发:

  • 发布耦合:商品模块改一个接口,整个系统要重新部署,所有功能都被影响
  • 技术栈受限:搜索模块想用Elasticsearch,推荐模块想用Python做AI算法,但在单体里很难混用技术栈
  • 扩展困难:大促时只有订单和支付模块需要扩容,但单体架构只能整体扩容,资源浪费严重
  • 团队协作冲突:多个团队改同一个代码库,代码合并冲突频繁,开发效率直线下降

1.2 为什么选Spring Cloud

选型时我们对比了Dubbo和Spring Cloud两条路线:

维度DubboSpring Cloud
通信协议RPC(TCP)HTTP/REST
生态完整度偏服务治理全套微服务解决方案
社区活跃度阿里维护Spring社区 + Netflix生态
与Spring Boot集成需额外适配原生集成
学习曲线相对陡峭对Spring开发者友好

最终选Spring Cloud的核心原因:我们整个技术栈就是Spring Boot体系,Spring Cloud的集成成本最低,且Netflix Eureka + Feign + Zuul/Spring Cloud Gateway + Config Server这套组合已经非常成熟。

二、微服务拆分策略

2.1 按业务域拆分

这是最重要的决策。拆分粒度太粗等于没拆,太细则服务数量爆炸,运维成本飙升。我们的拆分原则是按业务域(Domain)拆,不按技术层拆。

最终拆分出以下核心服务:

服务名职责技术选型
mall-gatewayAPI网关,路由、限流、鉴权Spring Cloud Gateway
mall-auth认证授权中心,OAuth2 + JWTSpring Security
mall-product商品中心,SPU/SKU管理、类目Spring Boot + MyBatis-Plus
mall-trade交易中心,订单、购物车、结算Spring Boot + Seata分布式事务
mall-user用户中心,会员、商家、权限Spring Boot + Redis缓存
mall-pay支付中心,聚合支付Spring Boot + 对接微信/支付宝
mall-promotion营销中心,优惠券、满减、拼团Spring Boot + 规则引擎
mall-search搜索中心,商品搜索Spring Boot + Elasticsearch
mall-config配置中心Spring Cloud Config + Nacos
mall-monitor监控中心Spring Boot Admin + Prometheus

2.2 S2B2C场景的特殊考量

S2B2C模式比普通B2B2C多了"供应链赋能"的概念,拆分时需要额外考虑:

  • 多租户隔离:不同商家(B端)的数据必须隔离,我们在mall-product和mall-trade中引入了租户ID维度,所有查询自动带租户过滤
  • 供应链服务:新增了mall-supply-chain服务,管理S端到B端的供货关系、分销层级、佣金结算
  • 分销裂变:mall-promotion中独立了分销模块,支持多级分销链路计算

三、核心组件实战

3.1 服务注册与发现:Nacos替代Eureka

早期我们用的Eureka,后来切换到Nacos,原因很简单——Nacos同时支持注册中心和配置中心,不需要单独部署Spring Cloud Config Server。

# application.yml
spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848
        namespace: mall-prod
      config:
        server-addr: 127.0.0.1:8848
        file-extension: yaml
        group: MALL_GROUP

3.2 服务间通信:OpenFeign + Sentinel

服务间调用我们用OpenFeign声明式接口,配合Sentinel做熔断降级:

@FeignClient(name = "mall-product", fallback = ProductFallback.class)
public interface ProductFeignClient {

    @GetMapping("/api/product/spu/{id}")
    Result<ProductVO> getSpuById(@PathVariable("id") Long id);

    @GetMapping("/api/product/list")
    Result<List<ProductVO>> listProducts(@RequestParam("tenantId") Long tenantId,
                                          @RequestParam("page") int page,
                                          @RequestParam("size") int size);
}

Sentinel的降级策略配置:

@Component
public class ProductFallback implements ProductFeignClient {
    @Override
    public Result<ProductVO> getSpuById(Long id) {
        // 返回兜底数据,而非直接报错
        return Result.fail("商品服务暂时不可用,请稍后重试");
    }

    @Override
    public Result<List<ProductVO>> listProducts(Long tenantId, int page, int size) {
        return Result.ok(Collections.emptyList());
    }
}

3.3 API网关:Spring Cloud Gateway

网关层做了几件关键事情:

spring:
  cloud:
    gateway:
      routes:
        - id: mall-product-route
          uri: lb://mall-product
          predicates:
            - Path=/api/product/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 100
                redis-rate-limiter.burstCapacity: 200
            - name: Retry
              args:
                retries: 3
                statuses: BAD_GATEWAY, GATEWAY_TIMEOUT

鉴权过滤器:网关层统一校验JWT Token,下游服务无需重复鉴权:

@Component
public class AuthFilter implements GlobalFilter, Ordered {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (StringUtils.isBlank(token)) {
            return unauthorized(exchange, "缺少认证信息");
        }
        // 校验Token有效性
        String userId = redisTemplate.opsForValue().get("token:" + token);
        if (userId == null) {
            return unauthorized(exchange, "Token已失效");
        }
        // 透传用户信息到下游服务
        ServerHttpRequest request = exchange.getRequest().mutate()
                .header("X-User-Id", userId)
                .build();
        return chain.filter(exchange.mutate().request(request).build());
    }

    @Override
    public int getOrder() {
        return -100;
    }
}

3.4 分布式事务:Seata

电商系统最头疼的就是分布式事务。订单创建涉及mall-trade(创建订单)、mall-product(扣减库存)、mall-promotion(核销优惠券)、mall-user(扣减积分)四个服务。

我们用Seata的AT模式处理:

@GlobalTransactional(timeoutMills = 60000, name = "createOrder")
public OrderVO createOrder(OrderDTO orderDTO) {
    // 1. 创建订单(本地事务)
    Order order = orderService.save(orderDTO);

    // 2. 远程扣减库存
    Result result = productFeignClient.deductStock(orderDTO.getSkuId(), orderDTO.getQuantity());
    if (!result.isSuccess()) {
        throw new BusinessException("库存扣减失败");
    }

    // 3. 远程核销优惠券
    Result couponResult = promotionFeignClient.useCoupon(orderDTO.getCouponId());
    if (!couponResult.isSuccess()) {
        throw new BusinessException("优惠券核销失败");
    }

    // 4. 远程扣减积分
    userFeignClient.deductPoints(orderDTO.getUserId(), orderDTO.getPointsUsed());

    return convertToVO(order);
}

Seata通过undo_log自动回滚,比TCC模式开发量小很多。但注意:AT模式有全局锁,高并发场景下性能不如TCC。我们在大促期间对核心链路改用TCC模式。

四、踩过的坑

4.1 Feign调用丢失Request头

问题:网关鉴权后透传了X-User-Id头,但Feign调用下游服务时头信息丢失了。

解决:加Feign拦截器:

@Configuration
public class FeignConfig implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        ServletRequestAttributes attributes =
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attributes != null) {
            HttpServletRequest request = attributes.getRequest();
            template.header("X-User-Id", request.getHeader("X-User-Id"));
            template.header("Authorization", request.getHeader("Authorization"));
        }
    }
}

4.2 Seata与MyBatis-Plus的冲突

MyBatis-Plus的乐观锁插件和Seata的AT模式会冲突——两个都在SQL里加version条件。解决方案是Seata模式下关闭MyBatis-Plus的乐观锁,由Seata的全局锁保证并发安全。

4.3 Nacos配置刷新不生效

配置类必须加@RefreshScope注解,且不能用@Value直接注入到构造函数中。正确做法:

@RestController
@RefreshScope
public class ProductController {

    @Value("${mall.product.default-page-size:20}")
    private int defaultPageSize;
}

五、架构演进总结

阶段架构时间核心问题
V1.0单体应用2019-2020发布耦合、扩展困难
V2.0Spring Cloud微服务(Eureka)2020-2022服务间通信、配置管理
V3.0Spring Cloud Alibaba(Nacos+Sentinel+Seata)2022至今高可用、分布式事务

从单体到微服务,不是技术问题,是业务驱动的问题。如果你的业务还没到那个体量,不要为了微服务而微服务——单体够用就先用单体,等到痛点出现再拆也不迟。

该平台目前的微服务架构支撑了多商家入驻、供应链分销、多租户隔离等S2B2C核心场景,日常QPS在5000左右,大促峰值可达3万+。这套架构不是一步到位设计的,是业务一步步推着演进出来的。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐