侧边栏壁纸
博主头像
colo

欲买桂花同载酒

  • 累计撰写 1823 篇文章
  • 累计收到 0 条评论

MyBatis 多租户架构下动态数据源切换与 SQL 改写实践

2025-12-9 / 0 评论 / 4 阅读

题目

MyBatis 多租户架构下动态数据源切换与 SQL 改写实践

信息

  • 类型:问答
  • 难度:⭐⭐⭐

考点

MyBatis 插件开发,动态数据源路由,SQL 解析与改写,多租户架构设计

快速回答

实现多租户架构需要解决两个核心问题:

  1. 动态数据源路由:根据租户上下文自动切换数据源
  2. 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 字段建立分区索引提升查询效率