一、 从"单租户"到"多租户"的演进背景
在电商软件开发领域,我们经历过一个典型的技术演进路径:
第一阶段:为每个客户独立部署一套代码 + 一套数据库。交付周期长,维护成本高,代码复用率极低。
第二阶段:将共性功能抽离为"基础平台",不同客户通过配置差异化。但部署方式仍然是"一套代码,一套数据库"。
第三阶段:全面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系统的基础设施,设计决策会影响未来数年系统的可扩展性和运维成本。在分层隔离策略、租户上下文传递方案的选择上,建议从业务规模和团队能力出发,选择适度的复杂度,避免过度设计。