news 2026/2/4 18:48:32

MyBatis基础入门《十四》多租户架构实战:基于 MyBatis 实现 SaaS 系统的动态数据隔离

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis基础入门《十四》多租户架构实战:基于 MyBatis 实现 SaaS 系统的动态数据隔离

前情回顾
在 《MyBatis基础入门《十三》Lombok + MapStruct 极简开发》 中,我们构建了高可维护、类型安全的现代化 DAO 层。
但当你的系统需要服务成百上千家企业客户(租户)时,新的挑战浮现:

  • 所有租户共用一套应用,但数据必须严格隔离
  • 不同租户可能使用不同版本的数据库结构
  • 运维需支持按租户统计资源消耗、备份恢复
  • 开发不能为每个租户写一套 SQL!

如何在不修改业务代码的前提下,让 MyBatis 自动识别当前租户并路由到正确数据?

答案:通过MyBatis 插件(Interceptor)+ 租户上下文 + 动态 SQL 重写实现透明化多租户支持!
本文将带你从理论到落地,掌握 SaaS 架构下的数据隔离核心能力。


一、什么是多租户(Multi-Tenancy)?

多租户是一种软件架构模式,单个实例服务多个客户(租户),每个租户的数据逻辑或物理隔离。

1.1 三种主流多租户方案对比

方案描述优点缺点适用场景
独立数据库(Database per Tenant)每个租户拥有独立数据库实例隔离性最强,备份/扩容灵活成本高,运维复杂金融、政府等强合规场景
共享数据库,独立 Schema同一 DB,每个租户一个 Schema隔离较好,资源利用率高需管理大量 Schema中大型 SaaS,如 ERP、CRM
共享数据库,共享表(字段隔离)所有租户共用表,通过tenant_id区分成本最低,开发最简单隔离弱,易数据泄露初创公司、轻量级 SaaS

本文重点

  • 方案二(Schema 隔离):通过动态替换 Schema 名实现;
  • 方案三(字段隔离):通过自动注入WHERE tenant_id = ?实现;
  • 统一抽象:无论哪种方案,业务代码无需感知租户逻辑

二、核心设计原则

  1. 透明性:Service/Controller 层完全 unaware 租户存在;
  2. 安全性:杜绝跨租户数据访问(即使 SQL 写错);
  3. 性能:拦截器开销可控,避免全表扫描;
  4. 可扩展:支持未来切换隔离策略(如从字段隔离升级到 Schema 隔离)。

三、基础准备:租户上下文(Tenant Context)

所有租户信息必须在请求链路中传递。我们使用ThreadLocal存储当前租户 ID。

// context/TenantContext.java package com.charles.multitenant.context; public class TenantContext { private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>(); public static void setTenantId(String tenantId) { CURRENT_TENANT.set(tenantId); } public static String getCurrentTenantId() { return CURRENT_TENANT.get(); } public static void clear() { CURRENT_TENANT.remove(); } }

🔔 注意:在 Web 应用中,需在Filter 或 Interceptor中解析租户标识(如子域名tenant1.app.com、HeaderX-Tenant-ID),并设置到上下文。

3.1 租户解析拦截器(Spring Boot)

// interceptor/TenantInterceptor.java @Component public class TenantInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 从 Header 获取租户 ID(也可从 JWT、子域名等解析) String tenantId = request.getHeader("X-Tenant-ID"); if (tenantId == null || tenantId.isBlank()) { throw new IllegalArgumentException("Missing X-Tenant-ID header"); } TenantContext.setTenantId(tenantId); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TenantContext.clear(); // 防止 ThreadLocal 泄漏 } } // WebConfig.java @Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new TenantInterceptor()); } }

✅ 每个请求自动绑定租户,后续 MyBatis 拦截器可直接读取。


四、方案一:字段隔离(共享表 + tenant_id)

这是最常用、成本最低的方案。所有表增加tenant_id VARCHAR(64) NOT NULL字段。

4.1 表结构示例

CREATE TABLE orders ( id BIGINT PRIMARY KEY, tenant_id VARCHAR(64) NOT NULL, -- 租户标识 order_no VARCHAR(50), amount DECIMAL(10,2), create_time DATETIME, INDEX idx_tenant (tenant_id) );

4.2 MyBatis 拦截器:自动注入 tenant_id 条件

// interceptor/TenantFieldInterceptor.java @Intercepts({ @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}) }) @Component public class TenantFieldInterceptor implements Interceptor { private static final List<String> SKIP_TABLES = Arrays.asList("sys_tenant", "sys_user"); @Override public Object intercept(Invocation invocation) throws Throwable { String tenantId = TenantContext.getCurrentTenantId(); if (tenantId == null) { return invocation.proceed(); // 无租户上下文,跳过 } Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0]; Object parameter = args[1]; // 1. 处理 SELECT:自动添加 WHERE tenant_id = ? if (ms.getSqlCommandType() == SqlCommandType.SELECT) { BoundSql boundSql = ms.getBoundSql(parameter); String originalSql = boundSql.getSql().trim(); // 跳过系统表 if (shouldSkip(originalSql)) return invocation.proceed(); // 构造新 SQL:原 SQL + AND tenant_id = ? String newSql = appendTenantCondition(originalSql, "tenant_id", tenantId); // 创建新的 BoundSql 和 MappedStatement BoundSql newBoundSql = new BoundSql( ms.getConfiguration(), newSql, boundSql.getParameterMappings(), parameter ); // 复用原 ResultMap 等配置 MappedStatement newMs = copyFromMappedStatement(ms, new BoundSqlSqlSource(newBoundSql)); args[0] = newMs; } // 2. 处理 INSERT:自动设置 tenant_id 字段 else if (ms.getSqlCommandType() == SqlCommandType.INSERT && parameter != null) { if (parameter instanceof BaseEntity) { ((BaseEntity) parameter).setTenantId(tenantId); } // 若使用 Map 传参,需额外处理(见后文) } return invocation.proceed(); } private String appendTenantCondition(String sql, String tenantColumn, String tenantId) { // 简单实现:假设 SQL 以 SELECT 开头,末尾无分号 // 更健壮做法:使用 SQL 解析器(如 JSqlParser) if (sql.toLowerCase().contains(" where ")) { return sql + " AND " + tenantColumn + " = '" + tenantId + "'"; } else { int fromIndex = sql.toLowerCase().indexOf(" from "); if (fromIndex == -1) return sql; return sql.substring(0, fromIndex) + " FROM " + sql.substring(fromIndex + 6) + " WHERE " + tenantColumn + " = '" + tenantId + "'"; } } private boolean shouldSkip(String sql) { for (String table : SKIP_TABLES) { if (sql.toLowerCase().contains(table.toLowerCase())) { return true; } } return false; } // 工具方法:复制 MappedStatement(略,见附录) private MappedStatement copyFromMappedStatement(MappedStatement ms, SqlSource newSqlSource) { MappedStatement.Builder builder = new MappedStatement.Builder( ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType() ); builder.resource(ms.getResource()); builder.fetchSize(ms.getFetchSize()); builder.statementType(ms.getStatementType()); builder.keyGenerator(ms.getKeyGenerator()); if (ms.getKeyProperties() != null && ms.getKeyProperties().length != 0) { builder.keyProperty(String.join(",", ms.getKeyProperties())); } builder.timeout(ms.getTimeout()); builder.parameterMap(ms.getParameterMap()); builder.resultMaps(ms.getResultMaps()); builder.resultSetType(ms.getResultSetType()); builder.cache(ms.getCache()); builder.flushCacheRequired(ms.isFlushCacheRequired()); builder.useCache(ms.isUseCache()); return builder.build(); } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } }

⚠️重要缺陷:上述appendTenantCondition使用字符串拼接,存在 SQL 注入风险且不支持复杂查询

4.3 健壮方案:使用 JSqlParser 解析 SQL

引入依赖:

<dependency> <groupId>com.github.jsqlparser</groupId> <artifactId>jsqlparser</artifactId> <version>4.7</version> </dependency>

改进appendTenantCondition

private String appendTenantCondition(String sql, String tenantColumn, String tenantId) { try { Statement stmt = CCJSqlParserUtil.parse(sql); if (stmt instanceof Select) { Select select = (Select) stmt; SelectBody body = select.getSelectBody(); if (body instanceof PlainSelect) { PlainSelect plainSelect = (PlainSelect) body; Expression where = plainSelect.getWhere(); EqualsTo tenantExpr = new EqualsTo(); tenantExpr.setLeftExpression(new Column(tenantColumn)); tenantExpr.setRightExpression(new StringValue(tenantId)); if (where == null) { plainSelect.setWhere(tenantExpr); } else { plainSelect.setWhere(new AndExpression(where, tenantExpr)); } } return stmt.toString(); } return sql; } catch (JSQLParserException e) { throw new RuntimeException("Failed to parse SQL: " + sql, e); } }

✅ 安全、准确、支持任意复杂 SELECT!


4.4 处理 INSERT 参数为 Map 的情况

若 Mapper 使用@Param或 XML 传 Map:

// Mapper void insertOrder(@Param("orderNo") String orderNo, @Param("amount") BigDecimal amount);

需在拦截器中动态注入tenant_id

// 在 intercept 方法中补充 if (ms.getSqlCommandType() == SqlCommandType.INSERT && parameter instanceof Map) { @SuppressWarnings("unchecked") Map<String, Object> paramMap = (Map<String, Object>) parameter; paramMap.put("tenantId", tenantId); // XML 中需有 #{tenantId} }

对应 XML:

<insert id="insertOrder"> INSERT INTO orders (tenant_id, order_no, amount) VALUES (#{tenantId}, #{orderNo}, #{amount}) </insert>

💡 更优雅方式:自定义注解@TenantField标记实体类,自动注入。


五、方案二:Schema 隔离(动态替换表名前缀)

每个租户拥有独立 Schema,如tenant_abc.orderstenant_xyz.orders

5.1 数据库准备

-- 租户 abc CREATE SCHEMA tenant_abc; CREATE TABLE tenant_abc.orders (...); -- 租户 xyz CREATE SCHEMA tenant_xyz; CREATE TABLE tenant_xyz.orders (...);

🔔 应用启动时需确保所有租户 Schema 已存在(可通过 Flyway/Liquibase 初始化)。


5.2 MyBatis 拦截器:动态替换 Schema

// interceptor/TenantSchemaInterceptor.java @Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) @Component public class TenantSchemaInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { String tenantId = TenantContext.getCurrentTenantId(); if (tenantId == null) { return invocation.proceed(); } StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); BoundSql boundSql = statementHandler.getBoundSql(); String originalSql = boundSql.getSql(); // 替换所有表名为 tenant_{id}.table_name String newSql = replaceSchema(originalSql, "tenant_" + tenantId); // 反射修改 BoundSql 的 sql 字段(因无 setter) Field sqlField = BoundSql.class.getDeclaredField("sql"); sqlField.setAccessible(true); sqlField.set(boundSql, newSql); return invocation.proceed(); } private String replaceSchema(String sql, String schema) { // 简单正则:匹配 "FROM table" 或 "JOIN table" // 更健壮:使用 JSqlParser return sql.replaceAll("(?i)(from|join)\\s+([a-zA-Z_][a-zA-Z0-9_]*)", "$1 " + schema + ".$2"); } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } }

⚠️ 正则方案脆弱!推荐使用JSqlParser 重写表名

private String replaceSchema(String sql, String schema) { try { Statement stmt = CCJSqlParserUtil.parse(sql); TablesNamesFinder finder = new TablesNamesFinder(); List<String> tables = finder.getTableList(stmt); // 遍历所有表,替换为 schema.table // (此处简化,实际需递归遍历 AST 节点) // 更佳做法:实现 DeParser 修改表名 return ...; } catch (JSQLParserException e) { throw new RuntimeException(e); } }

✅ 生产环境务必使用 AST 级别解析,避免误替换(如字符串常量中的 "from")。


六、方案三:独立数据库(动态数据源)

每个租户使用独立数据库实例(IP/Port/DBName 不同)。

6.1 动态数据源路由

// datasource/TenantRoutingDataSource.java public class TenantRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return TenantContext.getCurrentTenantId(); } }

6.2 配置多数据源

@Configuration public class DataSourceConfig { @Bean @Primary public DataSource tenantRoutingDataSource() { TenantRoutingDataSource routingDs = new TenantRoutingDataSource(); // 从配置或数据库加载所有租户数据源 Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("abc", createDataSource("jdbc:mysql://db-abc:3306/app")); targetDataSources.put("xyz", createDataSource("jdbc:mysql://db-xyz:3306/app")); routingDs.setTargetDataSources(targetDataSources); routingDs.setDefaultTargetDataSource(createDefaultDataSource()); // 默认 return routingDs; } private DataSource createDataSource(String url) { HikariConfig config = new HikariConfig(); config.setJdbcUrl(url); config.setUsername("user"); config.setPassword("pass"); return new HikariDataSource(config); } }

✅ 适用于租户数量较少、隔离要求极高的场景;
❌ 租户数 > 100 时,连接池资源爆炸,不推荐。


七、统一抽象:多租户策略接口

为支持运行时切换策略,定义统一接口:

// strategy/TenantStrategy.java public interface TenantStrategy { String processTableName(String originalTable); void injectTenantCondition(BoundSql boundSql, String tenantId); boolean isApplicable(); } // 实现类:FieldTenantStrategy, SchemaTenantStrategy, DatabaseTenantStrategy

拦截器中根据配置选择策略:

@Autowired private List<TenantStrategy> strategies; private TenantStrategy getCurrentStrategy() { return strategies.stream() .filter(TenantStrategy::isApplicable) .findFirst() .orElseThrow(() -> new IllegalStateException("No tenant strategy found")); }

✅ 未来可轻松从字段隔离升级到 Schema 隔离!


八、安全加固:防止租户越权

即使有拦截器,仍需双重保障:

8.1 Service 层显式校验(关键操作)

public OrderVO getOrder(Long orderId) { String currentTenant = TenantContext.getCurrentTenantId(); Order order = orderMapper.selectById(orderId); // 额外校验:防止拦截器失效导致越权 if (!currentTenant.equals(order.getTenantId())) { throw new SecurityException("Access denied"); } return converter.toVO(order); }

8.2 数据库层面:Row Level Security(RLS)

PostgreSQL/Oracle 支持 RLS,MySQL 可通过View + Trigger模拟:

-- 创建视图,自动过滤当前租户(需会话变量) CREATE VIEW orders_view AS SELECT * FROM orders WHERE tenant_id = @current_tenant_id;

🔒 安全原则:“防御纵深”—— 应用层 + 数据库层双重防护!


九、性能与监控

9.1 拦截器性能影响

  • JSqlParser 解析:约0.1~0.5ms/SQL
  • 字符串替换:<0.01ms,但不安全;
  • 建议:对高频查询缓存解析结果(如 SQL 模板 + 租户ID 组合缓存)。

9.2 监控指标

  • 每个租户的 QPS、慢 SQL;
  • 拦截器处理耗时分布;
  • 异常租户访问尝试(安全审计)。

十、测试策略

10.1 单元测试:模拟多租户上下文

@Test void shouldOnlyReturnCurrentTenantOrders() { // Given TenantContext.setTenantId("abc"); orderMapper.insert(new Order("ORD-001", "abc")); orderMapper.insert(new Order("ORD-002", "xyz")); // 其他租户 // When List<Order> orders = orderMapper.selectAll(); // Then assertThat(orders).hasSize(1); assertThat(orders.get(0).getOrderNo()).isEqualTo("ORD-001"); }

10.2 集成测试:多租户数据隔离验证

使用 Testcontainers 启动 MySQL,创建多个 Schema,验证数据互不可见。


十一、总结:多租户方案选型指南

维度字段隔离Schema 隔离独立数据库
成本★☆☆☆☆(最低)★★☆☆☆★★★★★(最高)
隔离性★★☆☆☆★★★★☆★★★★★
运维复杂度★☆☆☆☆★★★☆☆★★★★★
扩展性租户数 > 10万 可能瓶颈租户数 < 1万 较合适租户数 < 100
MyBatis 改造难度中(需 SQL 拦截)高(需 AST 解析)低(仅数据源路由)

推荐路径

  • 初创期:字段隔离(快速上线);
  • 成长期:Schema 隔离(平衡成本与隔离);
  • 企业级:独立数据库(金融、医疗等强监管行业)。

本文系统讲解了 MyBatis 在 SaaS 多租户架构下的三种实现方案,涵盖代码、安全、性能、测试全链路。
下一篇我们将探索MyBatis 与分布式事务(Seata)集成,解决微服务下的数据一致性难题!

👍 如果你觉得有帮助,欢迎点赞、收藏、转发!
💬 你的系统采用哪种多租户方案?欢迎评论区交流!

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/2/2 23:06:25

LobeChat现代化架构详解:基于Next.js的高性能聊天应用框架

LobeChat现代化架构详解&#xff1a;基于Next.js的高性能聊天应用框架 在AI助手迅速渗透日常生活的今天&#xff0c;用户早已不满足于“能对话”的机器人——他们期待的是反应迅速、功能丰富、安全可控且体验流畅的智能交互系统。然而&#xff0c;尽管大语言模型&#xff08;LL…

作者头像 李华
网站建设 2026/2/2 23:06:26

这个Pytest函数,轻松实现动态参数化√

无论什么自动化&#xff0c;部分测试用例均会运用到参数化&#xff0c;参数化可以帮助我们覆盖更多的测试用例&#xff0c;减少重复代码逻辑&#xff0c;然而自动化中也有多种实现参数化的方法&#xff0c;比如UnitTest的DDT模式&#xff0c;Pytest的fixture&#xff0c;以及Py…

作者头像 李华
网站建设 2026/2/2 23:06:26

竞赛毕业设计作品定做---【芳心科技】F. STM32 智驱便携电脉冲针刺仪

实物效果图&#xff1a;实现功能&#xff1a;1. 采用 STM32 单片机作为控制核心。 2. 采用 MOSFET 开关管控制电极片的频率。 3. 通过电开关改变电极片的振幅。 4. 通过三极管改变电极片的电流。 5. 采用 LCD 显示屏进行显示。 6. 按键设置频率、振幅和电流数值。原理图&#x…

作者头像 李华
网站建设 2026/2/4 0:41:39

【Java毕设源码分享】基于springboot+vue的疫情防控自动售货机系统的设计与实现(程序+文档+代码讲解+一条龙定制)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java、小程序技术领域和毕业项目实战 ✌️技术范围&#xff1a;&am…

作者头像 李华
网站建设 2026/2/4 10:46:43

梁文锋的“左右互搏”:宕机的DeepSeek与闷声发财的幻方

深夜23点&#xff0c;北京国贸写字楼的灯光只剩零星几点。程序员小林盯着屏幕上刺眼的“服务器繁忙”提示&#xff0c;第三次尝试调用DeepSeek API失败。就在他为瘫痪的程序焦头烂额时&#xff0c;千里之外的杭州&#xff0c;幻方量化的交易系统正自动完成一笔高频交易&#xf…

作者头像 李华