B2B2C多商户商城系统架构设计:从单体到微服务的演进
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+商户的日常运营,这套架构不是理论推演,而是在真实业务场景中不断打磨出来的实践成果。
更多推荐

所有评论(0)