培训网站导航应该选用哪种口罩
2025/12/28 0:49:41 网站建设 项目流程
培训网站导航,应该选用哪种口罩,范县网站建设公司,软件app开发公司前情回顾#xff1a; 在 《MyBatis基础入门《十三》Lombok MapStruct 极简开发》 中#xff0c;我们构建了高可维护、类型安全的现代化 DAO 层。 但当你的系统需要服务 成百上千家企业客户#xff08;租户#xff09; 时#xff0c;新的挑战浮现#xff1a;所有租户共用…前情回顾在 《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 ?实现统一抽象无论哪种方案业务代码无需感知租户逻辑二、核心设计原则透明性Service/Controller 层完全 unaware 租户存在安全性杜绝跨租户数据访问即使 SQL 写错性能拦截器开销可控避免全表扫描可扩展支持未来切换隔离策略如从字段隔离升级到 Schema 隔离。三、基础准备租户上下文Tenant Context所有租户信息必须在请求链路中传递。我们使用ThreadLocal存储当前租户 ID。// context/TenantContext.java package com.charles.multitenant.context; public class TenantContext { private static final ThreadLocalString 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 ListString 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 groupIdcom.github.jsqlparser/groupId artifactIdjsqlparser/artifactId version4.7/version /dependency改进appendTenantConditionprivate 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); } }✅ 安全、准确、支持任意复杂 SELECT4.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) MapString, Object paramMap (MapString, Object) parameter; paramMap.put(tenantId, tenantId); // XML 中需有 #{tenantId} }对应 XMLinsert idinsertOrder INSERT INTO orders (tenant_id, order_no, amount) VALUES (#{tenantId}, #{orderNo}, #{amount}) /insert 更优雅方式自定义注解TenantField标记实体类自动注入。五、方案二Schema 隔离动态替换表名前缀每个租户拥有独立 Schema如tenant_abc.orders、tenant_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(); ListString 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(); // 从配置或数据库加载所有租户数据源 MapObject, 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 ListTenantStrategy 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 SecurityRLSPostgreSQL/Oracle 支持 RLSMySQL 可通过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 ListOrder orders orderMapper.selectAll(); // Then assertThat(orders).hasSize(1); assertThat(orders.get(0).getOrderNo()).isEqualTo(ORD-001); }10.2 集成测试多租户数据隔离验证使用 Testcontainers 启动 MySQL创建多个 Schema验证数据互不可见。十一、总结多租户方案选型指南维度字段隔离Schema 隔离独立数据库成本★☆☆☆☆最低★★☆☆☆★★★★★最高隔离性★★☆☆☆★★★★☆★★★★★运维复杂度★☆☆☆☆★★★☆☆★★★★★扩展性租户数 10万 可能瓶颈租户数 1万 较合适租户数 100MyBatis 改造难度中需 SQL 拦截高需 AST 解析低仅数据源路由✅推荐路径初创期字段隔离快速上线成长期Schema 隔离平衡成本与隔离企业级独立数据库金融、医疗等强监管行业。本文系统讲解了 MyBatis 在 SaaS 多租户架构下的三种实现方案涵盖代码、安全、性能、测试全链路。下一篇我们将探索MyBatis 与分布式事务Seata集成解决微服务下的数据一致性难题 如果你觉得有帮助欢迎点赞、收藏、转发 你的系统采用哪种多租户方案欢迎评论区交流

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询