一、 从"单租户"到"多租户"的演进背景
在电商软件开发领域,我们经历过一个典型的技术演进路径:

第一阶段:为每个客户独立部署一套代码 + 一套数据库。交付周期长,维护成本高,代码复用率极低。

第二阶段:将共性功能抽离为"基础平台",不同客户通过配置差异化。但部署方式仍然是"一套代码,一套数据库"。

第三阶段:全面SaaS化——一套代码,一套数据库集群,通过"租户隔离"服务所有客户。

这个演进路径的核心驱动力是边际成本的递减效应:当代码只需要维护一份、基础设施只需要管理一套集群时,服务新增客户的增量成本趋近于零。

本文将聚焦多租户SaaS架构中的三个核心技术课题:租户数据隔离方案、租户上下文传递、资源用量计量与计费。

二、 租户数据隔离:三种方案的选择与权衡
多租户架构最核心的决策是"租户的数据如何隔离"。行业内有三种主流方案,各有优劣。

2.1 方案对比
维度 独立数据库 独立Schema 共享表(tenant_id)
隔离级别 物理隔离(最高) 逻辑隔离(中等) 逻辑共享(最低)
资源成本 最高(每个租户独立DB实例) 中等(共用DB,独立Schema) 最低(单DB单Schema)
数据恢复 简单(独立备份恢复) 中等(导出导入Schema) 复杂(需按tenant_id筛选)
跨租户查询 不支持 不支持 支持(但需谨慎)
单租户扩容 支持(独立升级配置) 不支持(所有租户共享实例) 不支持
适用场景 大客户/高安全要求 付费租户 免费试用/中小租户
2.2 混合策略:按租户等级分层
在实际工程中,通常不会"一刀切",而是根据租户的等级采取不同策略:

text
免费版/试用版租户 → 共享表(tenant_id)
付费版租户 → 独立Schema
企业版租户 → 独立数据库
优势:

免费用户降低成本(共享资源,无需额外计费)

付费用户获得更好的性能和隔离性

企业级用户满足合规要求

这种分层策略在资源成本和用户体验之间取得了较好的平衡。

三、 租户上下文传递:ThreadLocal + 拦截器
在多租户系统中,每个请求都需要携带租户标识,并在整个请求链路中传递。如果每个方法都手动传递租户ID,代码会极其臃肿且容易遗漏。

3.1 租户上下文持有者(ThreadLocal)
java
public class TenantContext {
private static final ThreadLocal currentTenant = new ThreadLocal<>();
private static final ThreadLocal currentTenantLevel = new ThreadLocal<>();

public static void setTenantId(String tenantId) {
    currentTenant.set(tenantId);
}

public static String getTenantId() {
    return currentTenant.get();
}

public static void setTenantLevel(String level) {
    currentTenantLevel.set(level);
}

public static String getTenantLevel() {
    return currentTenantLevel.get();
}

public static void clear() {
    currentTenant.remove();
    currentTenantLevel.remove();
}

}
3.2 拦截器解析租户标识
租户标识可以从多个位置获取,需要设计合理的优先级:

java
@Component
public class TenantInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, 
                         Object handler) {
    String tenantId = null;
    
    // 优先级1:从请求头获取(移动端/API调用)
    tenantId = request.getHeader("X-Tenant-Id");
    
    // 优先级2:从子域名解析(如 tenant-a.yourdomain.com)
    if (StringUtils.isBlank(tenantId)) {
        String host = request.getServerName();
        tenantId = extractTenantFromHost(host);
    }
    
    // 优先级3:从JWT Token中解析
    if (StringUtils.isBlank(tenantId)) {
        String token = request.getHeader("Authorization");
        if (StringUtils.isNotBlank(token)) {
            tenantId = parseTenantFromToken(token);
        }
    }
    
    // 优先级4:从请求参数获取(兜底方案)
    if (StringUtils.isBlank(tenantId)) {
        tenantId = request.getParameter("tenantId");
    }
    
    if (StringUtils.isBlank(tenantId)) {
        throw new BusinessException("无法识别租户信息");
    }
    
    TenantContext.setTenantId(tenantId);
    return true;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, 
                            Object handler, Exception ex) {
    // 请求结束后必须清理,防止内存泄漏
    TenantContext.clear();
}

}
3.3 MyBatis拦截器自动注入租户条件
在共享表方案中,所有查询都需要带上tenant_id条件。手动写SQL时容易遗漏,造成数据泄露风险。

java
@Intercepts({
@Signature(type = StatementHandler.class, method = “prepare”,
args = {Connection.class, Integer.class})
})
public class TenantSqlInterceptor implements Interceptor {

private static final Set<String> SYSTEM_TABLES = new HashSet<>(
    Arrays.asList("tenant", "sys_config", "sys_user", "sys_log")
);

@Override
public Object intercept(Invocation invocation) throws Throwable {
    String tenantId = TenantContext.getTenantId();
    if (StringUtils.isBlank(tenantId)) {
        return invocation.proceed();
    }
    
    StatementHandler handler = (StatementHandler) invocation.getTarget();
    BoundSql boundSql = handler.getBoundSql();
    String sql = boundSql.getSql();
    String lowerSql = sql.toLowerCase();
    
    // 跳过系统表(租户表本身不需要租户条件)
    // 跳过已有tenant_id条件的SQL(防止重复注入)
    if (!needInject(sql, lowerSql)) {
        return invocation.proceed();
    }
    
    // 判断SQL类型,注入不同的条件
    String newSql = injectTenantCondition(sql, lowerSql, tenantId);
    
    // 通过反射修改BoundSql的sql字段
    Field field = boundSql.getClass().getDeclaredField("sql");
    field.setAccessible(true);
    field.set(boundSql, newSql);
    
    return invocation.proceed();
}

private boolean needInject(String sql, String lowerSql) {
    // 跳过系统表
    for (String table : SYSTEM_TABLES) {
        if (lowerSql.contains(" " + table + " ")) {
            return false;
        }
    }
    // 已有tenant_id条件则跳过
    if (lowerSql.contains("tenant_id") || lowerSql.contains("`tenant_id`")) {
        return false;
    }
    return true;
}

private String injectTenantCondition(String sql, String lowerSql, String tenantId) {
    if (lowerSql.contains("insert")) {
        // INSERT语句:在列名和值中增加tenant_id
        return injectInsert(sql, tenantId);
    } else if (lowerSql.contains("where")) {
        // UPDATE/DELETE/SELECT:在WHERE后追加tenant_id条件
        return injectWhere(sql, lowerSql, tenantId);
    } else {
        // 无WHERE子句:在表名后添加WHERE
        return injectTableWithWhere(sql, lowerSql, tenantId);
    }
}

}
工程中常见的隐患:如果某个查询需要跨租户(如系统管理员查询全部数据),需要提供绕过拦截器的机制,例如使用@TenantIgnore注解。

四、 独立Schema的动态数据源切换
对于采用"独立Schema"方案的高阶租户,需要在运行时动态切换数据库连接。

4.1 动态数据源路由
java
public class DynamicDataSource extends AbstractRoutingDataSource {

@Override
protected Object determineCurrentLookupKey() {
    String tenantId = TenantContext.getTenantId();
    if (StringUtils.isBlank(tenantId)) {
        return "default";
    }
    // 判断该租户是否使用独立Schema
    String level = TenantContext.getTenantLevel();
    if ("PRO".equals(level) || "ENTERPRISE".equals(level)) {
        return tenantId;
    }
    return "default";
}

}
4.2 租户入驻时的Schema初始化
java
@Service
public class TenantProvisionService {

@Transactional
public void provisionTenant(Tenant tenant) {
    String tenantId = tenant.getId();
    String schemaName = "tenant_" + tenantId;
    
    if ("ENTERPRISE".equals(tenant.getPlan())) {
        // 1. 创建独立数据库(实际通过数据库管理命令执行)
        createDatabase(schemaName);
        
        // 2. 执行建表脚本
        runMigrationScript(schemaName);
        
        // 3. 注册数据源到动态路由
        registerDataSource(tenantId, schemaName);
    } else if ("PRO".equals(tenant.getPlan())) {
        // 独立Schema:在同一数据库实例中创建Schema
        createSchema(schemaName);
        runMigrationScript(schemaName);
        registerDataSource(tenantId, schemaName);
    } else {
        // 免费版:使用默认数据源,通过tenant_id区分
        // 不需要做额外操作,MyBatis拦截器会自动处理
    }
}

}
五、 用量计量与计费系统设计
SaaS商业模式的财务基础是"按量计费"或"套餐计费"。计费系统的核心职责是:准确记录每个租户的资源用量,并在达到阈值时触发相应的业务动作。

5.1 计量指标设计
不同SaaS产品的计量维度不同,电商SaaS常见的计量指标包括:

计量指标 计量单位 典型阈值
商品SKU数量 个 100 / 5000 / 不限
月订单量 笔 50 / 5000 / 不限
存储空间 GB 1GB / 10GB / 不限
API调用次数 次/月 1000 / 50000 / 不限
并发用户数 人 5 / 50 / 不限
子账号数 个 1 / 10 / 50
5.2 计量数据表设计
sql
– 租户套餐配置表
CREATE TABLE tenant_plan (
id bigint NOT NULL AUTO_INCREMENT,
plan_code varchar(20) NOT NULL COMMENT ‘FREE/PRO/ENTERPRISE’,
plan_name varchar(50) NOT NULL,
max_products int DEFAULT ‘0’ COMMENT ‘0表示不限’,
max_orders_month int DEFAULT ‘0’,
max_storage_gb int DEFAULT ‘0’,
max_api_calls_month int DEFAULT ‘0’,
max_concurrent_users int DEFAULT ‘0’,
price_year decimal(10,2) DEFAULT ‘0.00’,
price_month decimal(10,2) DEFAULT ‘0.00’,
PRIMARY KEY (id)
);

– 租户用量记录表
CREATE TABLE tenant_usage (
id bigint NOT NULL AUTO_INCREMENT,
tenant_id varchar(32) NOT NULL,
metric_code varchar(30) NOT NULL COMMENT ‘products/orders/storage/api_calls’,
period_type varchar(10) NOT NULL COMMENT ‘month/day’,
period_key varchar(20) NOT NULL COMMENT ‘2024-01’,
current_value int DEFAULT ‘0’,
limit_value int DEFAULT ‘0’,
last_reset_time datetime DEFAULT NULL,
update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_tenant_metric_period (tenant_id, metric_code, period_key)
);
5.3 用量检查拦截器
在每个核心业务操作前,检查租户的用量是否超限:

java
@Component
public class UsageCheckAspect {

@Around("@annotation(usageCheck)")
public Object checkUsage(ProceedingJoinPoint joinPoint, UsageCheck usageCheck) throws Throwable {
    String tenantId = TenantContext.getTenantId();
    String metric = usageCheck.metric();
    int increment = usageCheck.increment();
    
    // 获取租户套餐配置
    TenantPlan plan = planService.getTenantPlan(tenantId);
    int limit = plan.getLimit(metric);
    
    // 不限量则直接放行
    if (limit == 0) {
        return joinPoint.proceed();
    }
    
    // 获取当前用量
    int current = usageService.getCurrentUsage(tenantId, metric);
    
    if (current + increment > limit) {
        throw new BusinessException(
            String.format("【%s】已达到套餐上限(%d/%d),请升级套餐", 
                          metric, current, limit)
        );
    }
    
    // 先执行业务,再异步更新用量(最终一致性)
    Object result = joinPoint.proceed();
    
    // 异步更新用量(非阻塞)
    CompletableFuture.runAsync(() -> {
        usageService.incrementUsage(tenantId, metric, increment);
    });
    
    return result;
}

}

// 使用注解
@UsageCheck(metric = “products”, increment = 1)
public void createProduct(ProductDTO dto) {
// 业务逻辑
}
5.4 用量统计的"最终一致性"设计
在高并发场景下,同步扣减用量会导致性能瓶颈。我们采用异步增量 + 定时对账的方案:

text
[业务操作] → [发送MQ消息] → [消费者异步更新用量]

[每日定时对账]

[发现差异则触发补偿任务]
这种设计的优势在于:用量统计不影响主业务链路的性能,同时通过定时对账保证了数据的最终准确性。

六、 到期管理与续费流程
6.1 到期自动停用调度器
java
@Component
public class TenantExpireScheduler {

@Scheduled(cron = "0 0 3 * * ?")
public void processExpiredTenants() {
    // 1. 查询未来7天即将到期的租户
    List<Tenant> expiringSoon = tenantService.findExpiringInDays(7);
    for (Tenant tenant : expiringSoon) {
        sendExpireWarning(tenant, 7);
    }
    
    // 2. 查询未来3天即将到期的租户
    List<Tenant> expiringIn3Days = tenantService.findExpiringInDays(3);
    for (Tenant tenant : expiringIn3Days) {
        sendExpireWarning(tenant, 3);
    }
    
    // 3. 查询已过期且超过宽限期的租户
    LocalDateTime threshold = LocalDateTime.now().minusDays(3);
    List<Tenant> expired = tenantService.findExpiredBefore(threshold);
    for (Tenant tenant : expired) {
        disableTenant(tenant);
        sendDisabledNotification(tenant);
    }
}

private void disableTenant(Tenant tenant) {
    tenant.setStatus(TenantStatus.DISABLED);
    tenantService.update(tenant);
    
    // 如果是独立Schema租户,可以将其数据源从路由中移除(但保留数据)
    if ("PRO".equals(tenant.getIsolateLevel()) || "ENTERPRISE".equals(tenant.getIsolateLevel())) {
        DataSourceRegistry.removeDataSource(tenant.getId());
    }
}

}
6.2 续费后的恢复流程
java
@Transactional
public void renewTenant(String tenantId, String planCode, Integer months) {
Tenant tenant = tenantService.getById(tenantId);

// 更新套餐和到期时间
tenant.setPlanCode(planCode);
tenant.setStatus(TenantStatus.ACTIVE);

LocalDateTime newExpireTime = tenant.getExpireTime();
if (newExpireTime.isBefore(LocalDateTime.now())) {
    // 已过期:从当前时间起算
    newExpireTime = LocalDateTime.now();
}
tenant.setExpireTime(newExpireTime.plusMonths(months));
tenantService.update(tenant);

// 如果之前被停用,恢复数据源
if ("PRO".equals(tenant.getIsolateLevel()) || "ENTERPRISE".equals(tenant.getIsolateLevel())) {
    DataSourceRegistry.registerDataSource(tenant.getId(), tenant.getSchemaName());
}

}
七、 多商户分账系统的设计
当SaaS平台支持"多商户入驻"时,需要处理订单金额在"平台"和"商家"之间的分账。

7.1 分账模型
text
订单总金额 ¥100
├── 平台抽成 5% → ¥5.00
├── 支付通道费 0.6% → ¥0.60
└── 商家净收入 → ¥94.40
7.2 分账表设计
sql
CREATE TABLE split_account (
id bigint NOT NULL AUTO_INCREMENT,
order_no varchar(64) NOT NULL,
platform_tenant_id varchar(32) NOT NULL COMMENT ‘平台租户ID’,
merchant_id varchar(32) NOT NULL COMMENT ‘商家ID’,
total_amount decimal(10,2) NOT NULL,
platform_fee_rate decimal(5,4) NOT NULL COMMENT ‘抽成比例’,
platform_fee decimal(10,2) NOT NULL,
channel_fee decimal(10,2) NOT NULL,
merchant_amount decimal(10,2) NOT NULL,
status varchar(20) DEFAULT ‘PENDING’ COMMENT ‘PENDING/SETTLED/FAILED’,
settle_time datetime DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_merchant_id (merchant_id),
KEY idx_order_no (order_no)
);
7.3 分账计算与执行
java
@Service
public class SplitAccountService {

public SplitAccount calculate(Order order, Merchant merchant) {
    BigDecimal total = order.getAmount();
    BigDecimal platformFeeRate = merchant.getContractFeeRate(); // 如0.05
    BigDecimal platformFee = total.multiply(platformFeeRate);
    
    // 支付通道费(由商户承担)
    BigDecimal channelFeeRate = new BigDecimal("0.006");
    BigDecimal channelFee = total.multiply(channelFeeRate);
    
    BigDecimal merchantAmount = total.subtract(platformFee).subtract(channelFee);
    
    return SplitAccount.builder()
        .orderNo(order.getOrderNo())
        .merchantId(merchant.getId())
        .totalAmount(total)
        .platformFee(platformFee)
        .channelFee(channelFee)
        .merchantAmount(merchantAmount)
        .status("PENDING")
        .build();
}

}
八、 踩坑实录
坑1:租户数据泄露
现象:某次查询返回了其他租户的数据。

根因:某条SQL的tenant_id条件被遗漏。在人工编写SQL的场景下,这种问题非常容易发生。

解决方案:

MyBatis拦截器自动注入(防止人为遗漏)

在数据库层对租户表增加tenant_id默认值约束(非空)

单元测试必须覆盖租户隔离场景

坑2:大租户影响小租户性能
现象:大客户的促销活动消耗了大量数据库资源,导致其他租户响应变慢。

解决方案:

按租户设置独立的限流阈值(在网关层实现)

独立Schema租户天然隔离资源

共享表方案中,对tenant_id建立分区索引

坑3:用量统计数据与业务数据不一致
现象:租户实际订单数1000条,但用量统计显示800条。

根因:消息丢失导致异步更新遗漏。

解决方案:

每日定时对账任务

对账发现差异后自动触发补偿

关键操作使用同步更新(如订单数)而非异步

九、 总结
多租户SaaS架构的核心技术决策可以归纳为以下几点:

决策点 推荐方案 考量因素
数据隔离 按租户等级分层(免费→共享表,付费→Schema,企业→独立DB) 成本与隔离性的平衡
租户传递 ThreadLocal + 拦截器 + MyBatis插件 对业务代码的侵入性最小化
用量计量 异步更新 + 定时对账 性能与一致性的权衡
计费决策 预检查 + 到期停用 + 宽限期 用户体验与商业利益的平衡
多商户分账 订单级分账 + 定时结算 实时计算 + 批量处理
三个核心设计原则:

隔离性优先于便利性:租户数据隔离是SaaS的底线,不能因为"方便"而牺牲隔离性。

最终一致性优于强一致性:用量统计、计费数据可以允许短暂的不一致,但必须最终正确。

配置驱动优于硬编码:套餐参数、限流阈值、分账比例都应配置化,便于运营调整。

文末思考:

多租户架构是SaaS系统的基础设施,设计决策会影响未来数年系统的可扩展性和运维成本。在分层隔离策略、租户上下文传递方案的选择上,建议从业务规模和团队能力出发,选择适度的复杂度,避免过度设计。

Logo

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

更多推荐