B2B2C多商户商城系统架构设计:从单体到微服务的演进

B2B2C多商户商城系统的架构核心在于解决多租户数据隔离、商户独立运营与平台统一管理的矛盾。本文基于多年电商系统开发实践,完整梳理B2B2C商城从单体架构演进到Spring Cloud微服务架构的全过程,涵盖多租户隔离方案、商户服务拆分策略、网关路由设计等关键决策,并附可运行代码示例。

一、问题背景:为什么B2B2C需要独立架构

B2B2C模式与普通B2C最大的区别在于"多商户"——平台上有多个商家入驻,每个商家独立管理自己的商品、订单、营销活动,但共用平台级的用户体系、支付体系和搜索服务。

单体架构在项目早期足够用,一个SpringBoot工程包含所有模块,部署简单、调试方便。但当商户数量超过100家、日订单量破万后,单体架构会暴露三个致命问题:

  1. 资源竞争:大商户的促销活动占满CPU,小商户的正常交易也被拖慢
  2. 部署耦合:改一个商户的营销规则,全站重新部署,所有商户中断服务
  3. 数据膨胀:所有商户数据混在同一个数据库,单表数据量爆炸,查询性能骤降

在做某S2B2C平台时就遇到了这些问题。最初的单体架构在客户量达到200+商户时开始扛不住,最终决定拆分为微服务架构。

二、核心方案:多租户隔离 + 服务拆分

2.1 多租户数据隔离方案选型

B2B2C系统的多租户隔离有三种常见方案:

方案实现方式优点缺点适用场景
独立数据库每个商户一个DB隔离性最好运维成本高大客户/私有化部署
共享数据库+字段隔离同一表用tenant_id区分实现简单数据耦合大中小商户为主
共享数据库+Schema隔离每商户独立Schema折中方案迁移复杂商户规模差异大

该平台最终选择了共享数据库+字段隔离作为默认方案,同时支持大商户切换到独立数据库。核心思路是:在MyBatis-Plus的基础上实现多租户拦截器,SQL自动追加tenant_id条件。

/**
 * 多租户SQL拦截器 - 基于MyBatis-Plus的TenantLineHandler
 * 自动为所有查询SQL追加 tenant_id 条件
 */
@Component
public class TenantSQLInterceptor implements TenantLineHandler {

    @Override
    public Expression getTenantId() {
        // 从ThreadLocal获取当前请求的商户ID
        Long tenantId = TenantContext.getCurrentTenantId();
        if (tenantId == null) {
            throw new RuntimeException("租户ID未设置");
        }
        return new LongValue(tenantId);
    }

    @Override
    public String getTenantIdColumn() {
        return "tenant_id";
    }

    @Override
    public boolean ignoreTable(String tableName) {
        // 平台级表不需要租户隔离:用户表、区域表、系统字典表
        return "sys_user".equals(tableName) 
            || "sys_region".equals(tableName)
            || "sys_dict".equals(tableName);
    }
}

2.2 微服务拆分策略

从单体拆到微服务,不能"一刀切"。拆分原则是:先拆边界清晰的、再拆耦合度高的。

最终拆出7个核心服务:

服务名职责拆分理由
gateway-serviceAPI网关、路由、限流流量入口,必须独立
user-service用户注册、登录、权限用户体系全平台共用
merchant-service商户入驻、资质审核、店铺管理商户独立运营核心
product-service商品发布、类目、SKU管理数据量大,需独立扩容
order-service下单、支付、发货流程事务最复杂,隔离部署
marketing-service优惠券、满减、拼团营销规则频繁变更
search-serviceES商品搜索、聚合独立中间件依赖

三、实现细节与关键代码

3.1 网关路由与商户路由

Spring Cloud Gateway是整个微服务架构的流量入口。在B2B2C场景下,网关不仅要路由到不同服务,还要根据请求路径识别商户身份,注入到请求头中传递给下游服务。

# application.yml - Spring Cloud Gateway路由配置
spring:
  cloud:
    gateway:
      routes:
        - id: merchant-product
          uri: lb://product-service
          predicates:
            - Path=/api/merchant/{merchantId}/product/**
          filters:
            - name: TenantResolve
              args:
                merchantIdParam: merchantId
        - id: merchant-order
          uri: lb://order-service
          predicates:
            - Path=/api/merchant/{merchantId}/order/**
          filters:
            - name: TenantResolve
              args:
                merchantIdParam: merchantId
        - id: platform-admin
          uri: lb://merchant-service
          predicates:
            - Path=/api/admin/**

对应的网关过滤器实现:

/**
 * 自定义网关过滤器:从路径中提取商户ID,注入请求头
 * 下游服务通过RequestInterceptor读取header设置TenantContext
 */
@Component
public class TenantResolveGatewayFilterFactory 
        extends AbstractGatewayFilterFactory<TenantResolveGatewayFilterFactory.Config> {

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            String path = request.getURI().getPath();
            
            // 从路径 /api/merchant/10086/product/list 提取商户ID
            String[] segments = path.split("/");
            String merchantId = null;
            for (int i = 0; i < segments.length; i++) {
                if ("merchant".equals(segments[i]) && i + 1 < segments.length) {
                    merchantId = segments[i + 1];
                    break;
                }
            }
            
            if (merchantId != null) {
                ServerHttpRequest mutated = request.mutate()
                    .header("X-Tenant-Id", merchantId)
                    .build();
                return chain.filter(exchange.mutate().request(mutated).build());
            }
            return chain.filter(exchange);
        };
    }
    
    public static class Config {
        private String merchantIdParam;
        // getter/setter...
    }
}

3.2 Feign调用传递租户上下文

微服务之间通过Feign调用时,TenantContext(ThreadLocal)不会自动传递。需要自定义Feign拦截器把租户ID通过HTTP Header传给下游服务。

/**
 * Feign请求拦截器:传递租户ID到下游服务
 * 解决微服务间调用时ThreadLocal上下文丢失问题
 */
@Component
public class TenantFeignInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate template) {
        Long tenantId = TenantContext.getCurrentTenantId();
        if (tenantId != null) {
            template.header("X-Tenant-Id", String.valueOf(tenantId));
        }
        
        // 同时传递链路追踪ID
        String traceId = MDC.get("traceId");
        if (traceId != null) {
            template.header("X-Trace-Id", traceId);
        }
    }
}

/**
 * 租户上下文持有者 - 基于ThreadLocal
 * 在Web过滤器中从Header读取并设置
 */
public class TenantContext {
    private static final ThreadLocal<Long> TENANT_HOLDER = new ThreadLocal<>();

    public static void setCurrentTenantId(Long tenantId) {
        TENANT_HOLDER.set(tenantId);
    }

    public static Long getCurrentTenantId() {
        return TENANT_HOLDER.get();
    }

    public static void clear() {
        TENANT_HOLDER.remove();
    }
}

3.3 订单服务分库分表

订单是B2B2C系统中数据量增长最快的表。当单表超过1000万行后,即使有索引,查询也会明显变慢。该平台采用ShardingSphere按商户ID做分库、按月份做分表。

/**
 * ShardingSphere分片配置 - 订单表
 * 分库策略:按 tenant_id 取模分到4个库
 * 分表策略:按 create_time 按月分表
 */
@Configuration
public class OrderShardingConfig {
    
    @Bean
    public ShardingRule shardingRule() {
        return ShardingRule.builder()
            .tableRules(Collections.singletonList(
                TableRule.builder("t_order")
                    .databaseShardingStrategy(
                        DatabaseShardingStrategy.builder()
                            .shardingColumn("tenant_id")
                            .algorithm(new ModShardingAlgorithm(4))
                            .build())
                    .tableShardingStrategy(
                        TableShardingStrategy.builder()
                            .shardingColumn("create_time")
                            .algorithm(new MonthlyShardingAlgorithm())
                            .build())
                    .build()))
            .build();
    }
}

3.4 商户独立限流

不同商户的流量差异巨大。大商户做秒杀时QPS可能上万,小商户日常只有几十QPS。如果用全局限流,小商户请求会被大商户挤掉。解决方案是基于Redis+Lua的按租户独立限流。

/**
 * 基于Redis Lua的多租户限流器
 * 每个商户独立计数,互不影响
 */
@Service
public class TenantRateLimiter {

    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String LUA_SCRIPT = 
        "local key = KEYS[1]\n" +
        "local limit = tonumber(ARGV[1])\n" +
        "local window = tonumber(ARGV[2])\n" +
        "local current = tonumber(redis.call('GET', key) or '0')\n" +
        "if current >= limit then\n" +
        "    return 0\n" +
        "end\n" +
        "redis.call('INCR', key)\n" +
        "redis.call('EXPIRE', key, window)\n" +
        "return 1";

    public boolean isAllowed(Long tenantId, int limit, int windowSeconds) {
        String key = "rate_limit:tenant:" + tenantId;
        DefaultRedisScript<Long> script = new DefaultRedisScript<>(LUA_SCRIPT, Long.class);
        Long result = redisTemplate.execute(script, Collections.singletonList(key),
                String.valueOf(limit), String.valueOf(windowSeconds));
        return result != null && result == 1;
    }
}

四、踩坑记录

坑1:跨服务事务一致性

订单服务调库存服务扣库存、调营销服务核销优惠券——如果其中一个失败,其余需要回滚。最初用了2PC(Seata AT模式),但在大促时全局锁导致性能下降70%。

最终改用本地消息表 + 最终一致性方案:订单服务先写订单和本地消息表(同一事务),然后异步发MQ通知库存和营销服务。消费者通过幂等性保证重复消费安全。

/**
 * 本地消息表方案:订单与消息写入同一事务
 * 异步线程发送MQ,消费方保证幂等
 */
@Service
public class OrderTransactionService {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private LocalMessageMapper messageMapper;
    @Autowired
    private RocketMQTemplate mqTemplate;

    @Transactional(rollbackFor = Exception.class)
    public void createOrder(Order order) {
        // 1. 写订单(与消息表在同一事务中)
        orderMapper.insert(order);
        
        // 2. 写本地消息表(同一DB同一事务)
        LocalMessage message = new LocalMessage();
        message.setBusinessId(order.getId());
        message.setTopic("order-created");
        message.setPayload(JSON.toJSONString(order));
        message.setStatus(0); // 待发送
        messageMapper.insert(message);
    }
    
    // 定时任务扫描未发送的消息,投递到MQ
    @Scheduled(fixedDelay = 5000)
    public void sendPendingMessages() {
        List<LocalMessage> pending = messageMapper.selectByStatus(0);
        for (LocalMessage msg : pending) {
            mqTemplate.convertAndSend(msg.getTopic(), msg.getPayload());
            messageMapper.updateStatus(msg.getId(), 1); // 已发送
        }
    }
}

坑2:分布式锁的坑

商户操作库存时用Redis分布式锁防止并发超卖,最初用了简单的SETNX。结果在主从切换时锁丢失,导致超卖。后来改用Redisson的RedLock算法,配合看门狗续期机制解决。

坑3:Feign调用循环依赖

商品服务调订单服务统计销量,订单服务调商品服务查商品详情——形成循环依赖。Spring Cloud启动报错。解决方法是抽出一个轻量的product-query-service专门提供只读查询接口,打破环依赖。

五、总结

B2B2C多商户商城系统从单体演进到微服务,核心解决了三个问题:多租户数据隔离(字段隔离+大商户独立DB)、服务独立部署(按业务边界拆分7个核心服务)、流量公平性(按租户限流)。关键决策包括网关层注入租户上下文、Feign拦截器传递租户ID、ShardingSphere分库分表、本地消息表替代分布式事务。以上方案已在某S2B2C平台的生产环境中验证,支撑了200+商户的日常运营,这套架构不是理论推演,而是在真实业务场景中不断打磨出来的实践成果。

更多推荐