B2B2C多商户商城系统架构设计:从单体到微服务的演进
B2B2C多商户商城系统的架构核心在于解决多租户数据隔离、商户独立运营与平台统一管理的矛盾。本文基于多年电商系统开发实践,完整梳理B2B2C商城从单体架构演进到Spring Cloud微服务架构的全过程,涵盖多租户隔离方案、商户服务拆分策略、网关路由设计等关键决策,并附可运行代码示例。
一、问题背景:为什么B2B2C需要独立架构
B2B2C模式与普通B2C最大的区别在于"多商户"——平台上有多个商家入驻,每个商家独立管理自己的商品、订单、营销活动,但共用平台级的用户体系、支付体系和搜索服务。
单体架构在项目早期足够用,一个SpringBoot工程包含所有模块,部署简单、调试方便。但当商户数量超过100家、日订单量破万后,单体架构会暴露三个致命问题:
- 资源竞争:大商户的促销活动占满CPU,小商户的正常交易也被拖慢
- 部署耦合:改一个商户的营销规则,全站重新部署,所有商户中断服务
- 数据膨胀:所有商户数据混在同一个数据库,单表数据量爆炸,查询性能骤降
在做某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-service | API网关、路由、限流 | 流量入口,必须独立 |
| user-service | 用户注册、登录、权限 | 用户体系全平台共用 |
| merchant-service | 商户入驻、资质审核、店铺管理 | 商户独立运营核心 |
| product-service | 商品发布、类目、SKU管理 | 数据量大,需独立扩容 |
| order-service | 下单、支付、发货流程 | 事务最复杂,隔离部署 |
| marketing-service | 优惠券、满减、拼团 | 营销规则频繁变更 |
| search-service | ES商品搜索、聚合 | 独立中间件依赖 |
三、实现细节与关键代码
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+商户的日常运营,这套架构不是理论推演,而是在真实业务场景中不断打磨出来的实践成果。