问题背景
SaaS模式的跨境电商独立站,本质上需要同时服务成百上千个店铺——每个店铺的数据必须实现严格的逻辑隔离。简单说,这就是多租户架构必须攻克的核心难点。

Taocarts早期版本将所有店铺数据混存在同一套表中,仅依靠shop_id字段进行区分。当店铺数量增长到数千家时,问题集中爆发:某次慢查询遗漏了shop_id条件,导致全量数据被扫描,数据库CPU飙升到90%,所有店铺业务同时受到影响。这次事故让团队彻底认识到——多租户隔离不能依赖开发人员的“自觉”,必须通过架构设计来兜底。
三种数据隔离方案对比
在多租户数据隔离领域,业内常用的方案主要有三种经典路线:
方案一:独立数据库。每个租户独享一个数据库实例,隔离级别达到最高,但成本也相应最高。适用于企业级大租户场景。
方案二:独立Schema。在同一数据库实例下,每个租户使用独立的Schema,隔离级别中上,成本也相对适中。是中型租户的主流选择。
方案三:共享表(租户ID区分)。所有租户共用一套表,通过tenant_id字段来区分数据。成本最低,但隔离级别相对较弱。
Taocarts的设计采用了分层混合策略:免费版租户使用共享表方案,降低入门门槛;付费版租户分配独立Schema,获得更好的性能与隔离性;企业级租户则直接使用独立数据库,满足高安全性需求。这套组合拳既有效控制了成本,又守住了数据安全的底线。
租户上下文的传递与穿透
共享表方案面临的最大挑战是:每次数据库查询都必须携带租户ID作为过滤条件,否则极易导致数据泄露。Taocarts的做法是利用ThreadLocal在请求链路中传递租户上下文:
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:
@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();
}
}
这样一来,每个请求进入后,租户ID已经在线程中就位,后续的业务代码只需专注于自身逻辑即可。
MyBatis拦截器自动注入租户ID
如果每个SQL都要手动拼接租户ID条件,代码会变得非常繁琐。Taocarts借助MyBatis拦截器实现了自动化注入:
@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();
}
}
核心逻辑非常直观:拦截器在SQL执行前进行判断,如果已经包含where子句且没有tenant_id,则自动将租户ID条件插入进去。业务层完全不需要关心隔离细节,代码变得清爽许多。
动态数据源切换
对于使用独立Schema的租户,需要动态切换数据库连接。Taocarts借助Spring的AbstractRoutingDataSource实现路由:
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
String tenantId = TenantContext.getTenantId();
return TenantDataSourceRegistry.getDataSourceKey(tenantId);
}
}
运行时根据当前租户ID,动态确定要连接哪个数据源。既保证了数据隔离,又维持了执行效率。
踩坑与经验
在实际落地过程中,有几个容易踩的坑值得单独拿出来分享。
第一,定时任务的租户隔离。定时任务没有请求上下文,执行时必须主动遍历所有租户,为每个租户单独初始化上下文,否则数据会出现串乱。
第二,异步线程的租户传递。使用@Async时,子线程默认无法继承父线程的ThreadLocal,需要自定义TaskDecorator,在任务执行前把租户ID复制到子线程中。
第三,批量操作的租户校验。执行批量更新时,必须确认所有数据都属于同一个租户,否则可能发生跨租户修改数据的风险。这个环节一旦遗漏,后果会非常严重。
总结
多租户架构是SaaS系统的根基所在。Taocarts通过混合数据隔离策略 + 租户上下文的自动穿透,成功将业务代码与隔离细节彻底解耦。这套方案已经在上线环境中稳定支撑了数千个店铺同时运行,租户之间数据零泄露。说到底,好的架构不是约束开发者,而是让开发者能够安心专注于业务逻辑的编写。
