一、问题背景
SaaS 模式的跨境电商独立站需要为多个店铺提供服务,每个店铺的数据必须严格隔离,同时又要共用一套代码和基础设施。如何设计一套安全、高效、可扩展的多租户架构?本文以 Taocarts 跨境电商独立站系统为例,详细讲解数据隔离方案选型、租户上下文传递以及动态数据源切换的实现。
二、数据隔离方案对比
多租户数据隔离通常有三种经典方案:
独立数据库:每个租户拥有独立的数据库实例,隔离级别最高,但成本也最高,适合大租户。
独立 Schema:同一数据库实例,每个租户拥有独立的 Schema,隔离级别较高,成本适中。
共享表(租户ID区分) :所有租户共用同一套表,通过 tenant_id 字段区分,成本最低,但隔离级别相对较弱。
Taocarts 采用混合策略:免费版租户使用共享表方案降低入门门槛;付费版租户分配独立 Schema 提供更好的性能和隔离;企业级租户使用独立数据库满足高安全要求。
三、租户上下文传递(ThreadLocal)
在共享表方案中,每次数据库查询都需要带上租户 ID 条件。首先在请求入口处解析租户标识,并在整个请求链路中传递。
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 都手动拼接 tenant_id 条件,使用 MyBatis 拦截器自动注入。
java
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class})})
public class TenantInterceptor 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();
// 如果 SQL 中不包含 tenant_id 条件,则自动添加
if (sql.toLowerCase().contains("where") && !sql.contains("tenant_id")) {
String newSql = sql.replaceFirst("(?i)where", "where tenant_id = '" + tenantId + "' and ");
// 通过反射修改 BoundSql
Field field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, newSql);
}
return invocation.proceed();
}
}
五、动态数据源切换
对于使用独立 Schema 的租户,需要动态切换数据库连接。使用 Spring 的 AbstractRoutingDataSource 实现:
java
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
String tenantId = TenantContext.getTenantId();
return TenantDataSourceRegistry.getDataSourceKey(tenantId);
}
}
六、总结
Taocarts 通过混合数据隔离策略,在成本、性能和隔离性之间取得了平衡。租户上下文的自动穿透让业务代码无需关心租户隔离细节,大幅提升了开发效率。这套方案已在生产环境稳定支撑数千个店铺同时运行。