题目
MyBatis 多租户架构下动态数据源切换与 SQL 改写实践
信息
- 类型:问答
- 难度:⭐⭐⭐
考点
MyBatis 插件开发,动态数据源路由,SQL 解析与改写,多租户架构设计
快速回答
实现多租户架构需要解决两个核心问题:
- 动态数据源路由:根据租户上下文自动切换数据源
- SQL 自动改写:在运行时动态添加租户隔离条件
解决方案:
- 使用
AbstractRoutingDataSource实现动态数据源路由 - 通过 MyBatis 插件拦截 SQL 并重写语句
- 利用 JSqlParser 解析和修改 SQL 语法树
- 结合 ThreadLocal 管理租户上下文
1. 核心问题与架构设计
多租户架构要求实现:
- 数据隔离:每个租户访问独立数据库或共享数据库隔离数据
- 透明访问:业务代码无需显式处理租户ID
- 性能保障:避免全表扫描带来的性能问题
2. 动态数据源路由实现
原理说明:通过继承 AbstractRoutingDataSource 重写 determineCurrentLookupKey 方法,从线程上下文获取租户ID
代码示例:
public class TenantDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getCurrentTenant(); // 从ThreadLocal获取租户ID
}
}
// 配置示例(Spring Boot)
@Bean
@Primary
public DataSource dataSource() {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("tenant1", tenant1DataSource());
targetDataSources.put("tenant2", tenant2DataSource());
TenantDataSource router = new TenantDataSource();
router.setTargetDataSources(targetDataSources);
router.setDefaultTargetDataSource(defaultDataSource());
return router;
}3. SQL 改写插件实现
原理说明:通过 MyBatis Interceptor 拦截 StatementHandler.prepare 方法,使用 JSqlParser 解析 SQL 并注入租户条件
代码示例:
@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 {
StatementHandler handler = (StatementHandler) invocation.getTarget();
MetaObject metaObject = SystemMetaObject.forObject(handler);
// 获取原始SQL
String originalSql = (String) metaObject.getValue("delegate.boundSql.sql");
// 使用JSqlParser解析并修改SQL
Statement stmt = CCJSqlParserUtil.parse(originalSql);
if (stmt instanceof Select) {
Select select = (Select) stmt;
PlainSelect ps = (PlainSelect) select.getSelectBody();
// 添加租户条件: tenant_id = ?
EqualsTo tenantCondition = new EqualsTo();
tenantCondition.setLeftExpression(new Column("tenant_id"));
tenantCondition.setRightExpression(new JdbcParameter());
if (ps.getWhere() == null) {
ps.setWhere(tenantCondition);
} else {
ps.setWhere(new AndExpression(ps.getWhere(), tenantCondition));
}
// 更新SQL
metaObject.setValue("delegate.boundSql.sql", stmt.toString());
}
return invocation.proceed();
}
}4. 租户上下文管理
最佳实践:
public class TenantContext {
private static final ThreadLocal<String> currentTenant = new ThreadLocal<>();
public static void setCurrentTenant(String tenantId) {
currentTenant.set(tenantId);
}
public static String getCurrentTenant() {
return currentTenant.get();
}
public static void clear() {
currentTenant.remove();
}
}
// 在Controller层通过过滤器设置
@WebFilter("/*")
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
HttpServletRequest req = (HttpServletRequest) request;
TenantContext.setCurrentTenant(req.getHeader("X-Tenant-ID"));
chain.doFilter(request, response);
TenantContext.clear(); // 必须清理避免内存泄漏
}
}5. 关键挑战与解决方案
| 挑战 | 解决方案 |
|---|---|
| JOIN 语句改写 | 递归遍历 Join 对象,为每张表添加别名关联 |
| INSERT 语句处理 | 自动插入 tenant_id 字段和当前租户值 |
| 性能开销 | 使用缓存 SQL 解析结果,避免重复解析 |
| 子查询处理 | 深度遍历 SelectBody 对象,确保所有层级都被改写 |
6. 常见错误
- 上下文泄漏:未及时清理 ThreadLocal 导致跨请求数据污染
- SQL 注入风险:直接拼接租户ID而非使用预编译参数
- DDL 语句误改:未排除
CREATE TABLE等管理语句导致语法错误 - 分布式事务:跨租户数据源事务需引入 Seata 等分布式事务方案
7. 扩展知识
- 混合模式:结合独立数据库(大客户)和共享数据库(小客户)
- 二级缓存隔离:在 CacheKey 中注入 tenantId 实现缓存隔离
- 租户元数据管理:动态注册新租户数据源(需结合服务发现)
- 性能优化:对 tenant_id 字段建立分区索引提升查询效率