SaaS多租户电商平台架构实战:从租户隔离到计费系统的完整实现

一、 从"单租户"到"多租户"的演进背景

在电商软件开发领域,我们经历过一个典型的技术演进路径:

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

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

第三阶段:全面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系统的基础设施,设计决策会影响未来数年系统的可扩展性和运维成本。在分层隔离策略、租户上下文传递方案的选择上,建议从业务规模和团队能力出发,选择适度的复杂度,避免过度设计。

相关推荐
AI语宙漫游指南1 小时前
【Agent架构】Loop Engineering 深度拆解:从内循环外循环到退出条件定义
架构·agent
gis开发之家3 小时前
vue3直击架构痛点 依赖倒置与接口隔离——打造坚如磐石的插件与高阶组件(HOC)模式
javascript·架构·typescript·前端框架·vue3
码云骑士4 小时前
65-AI-Agent基础原理-AutoGPT-四大架构全景解读
人工智能·python·架构
勤劳打代码5 小时前
Flutter 架构指南 —— windows 平台安装拆解
windows·架构
沪上企服通6 小时前
上海地区代账服务供给侧的五种架构形态:从 pipeline SLA 到领域深度的选型笔记
笔记·架构
某林2127 小时前
大模型边缘部署到底层硬件闭环
python·架构·机器人·硬件架构·ros2
带娃的IT创业者7 小时前
开源新纪元:深度解析小米 MiMo Code 的技术架构与实战应用
架构·开源·大模型·小米·代码生成·技术架构·mimo code
橙臣程7 小时前
集群、分布式与微服务介绍
分布式·微服务·架构
Niuguangshuo7 小时前
TTS 架构的六代演进(六):第 6 代 · 端侧轻量化
架构