一、问题背景
SaaS模式的跨境电商独立站需要为成百上千个店铺提供服务,每个店铺的数据必须严格隔离,这是多租户架构要解决的核心问题。
在Taocarts跨境电商独立站系统的早期版本中,所有店铺数据混在同一套表里,通过shop_id字段区分。随着店铺数量增长到数千家,问题开始暴露:某次慢查询因为没有带shop_id条件,把全量数据扫了一遍,数据库CPU飙升到90%,影响了所有店铺的正常访问。那次事故让我深刻意识到,多租户的隔离不能只靠“约定”,必须靠“架构”。
二、三种数据隔离方案对比
多租户数据隔离通常有三种经典方案:
方案一:独立数据库。每个租户拥有独立的数据库实例,隔离级别最高,但成本也最高。适合企业级大租户。
方案二:独立Schema。同一数据库实例,每个租户拥有独立的Schema,隔离级别较高,成本适中。适合中型租户。
方案三:共享表(租户ID区分) 。所有租户共用同一套表,通过tenant_id字段区分,成本最低,但隔离级别相对较弱。
在Taocarts的设计中,采用了分层混合策略:免费版租户使用共享表方案降低入门门槛;付费版租户分配独立Schema,提供更好的性能和隔离;企业级租户使用独立数据库,满足高安全要求。
三、租户上下文的传递与穿透
共享表方案的核心问题是:每次数据库查询都必须带上租户ID作为过滤条件,否则就会发生数据泄露。Taocarts使用ThreadLocal在请求链路中传递租户上下文:
java
public class TenantContext {
private static final ThreadLocal currentTenant = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
currentTenant.set(tenantId);
}
public static String getTenantId() {
return currentTenant.get();
}
public static void clear() {
currentTenant.remove();
}
}
通过拦截器在请求入口处从子域名或请求头解析租户ID:
java
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 从子域名解析租户ID,例如: shop123.taocarts.com
String host = request.getServerName();
String tenantId = extractTenantFromHost(host);
TenantContext.setTenantId(tenantId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
TenantContext.clear();
}
}
四、MyBatis拦截器自动注入租户ID
为了避免每个SQL都手动拼接租户ID条件,Taocarts使用MyBatis拦截器自动注入:
java
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class})})
public class TenantSqlInterceptor implements Interceptor {
@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();
if (sql.toLowerCase().contains("where") && !sql.contains("tenant_id")) {
String newSql = sql.replaceFirst("(?i)where", "where tenant_id = '" + tenantId + "' and ");
Field field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, newSql);
}
return invocation.proceed();
}
}
五、动态数据源切换
对于使用独立Schema的租户,需要动态切换数据库连接。Taocarts使用Spring的AbstractRoutingDataSource实现路由:
java
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
String tenantId = TenantContext.getTenantId();
return TenantDataSourceRegistry.getDataSourceKey(tenantId);
}
}
六、踩坑与经验
在实践中,有几个容易被忽略的问题:
第一,定时任务的租户隔离。定时任务没有请求上下文,需要在执行时主动遍历所有租户,为每个租户单独初始化上下文。
第二,异步线程的租户传递。使用@Async时,子线程默认无法继承父线程的ThreadLocal。需要自定义TaskDecorator,在任务执行前复制租户ID。
第三,批量操作的租户校验。批量更新时必须确保所有数据都属于同一个租户,否则可能跨租户修改数据。
七、总结
多租户架构是SaaS系统的基石。Taocarts通过混合数据隔离策略和租户上下文的自动穿透,让业务代码完全不需要关心租户隔离细节。这套方案已在生产环境稳定支撑数千个店铺同时运行,租户间数据零泄露。