SaaS多租户数据隔离实战:MyBatis拦截器实现行级安全方案
📖 前言
在当今云计算时代,SaaS(Software as a Service) 模式已经成为企业服务的主流形态。作为SaaS系统的核心架构要素,多租户技术能够让单个应用实例为多个客户(租户)提供服务,同时确保各租户数据的安全隔离。
本文将深入探讨如何使用 MyBatis拦截器实现高效、安全的行级数据隔离方案,为正在构建或优化SaaS系统的开发者提供完整的技术解决方案。
🏗️ 多租户数据隔离方案对比
在SaaS系统中,数据隔离主要有三种实现方案:
隔离级别 | 实现方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
数据库级 | 每个租户独立数据库 | 隔离性最高、安全性最好 | 成本高、扩展性差 | 金融、政府等高安全要求 |
Schema级 | 共享数据库,独立Schema | 良好隔离性、中等成本 | 扩展性受限 | 中大型企业客户 |
行级 | 共享数据库和Schema | 成本最低、扩展性最佳 | 依赖应用层安全 | 标准化SaaS产品 |
行级数据隔离 因其成本效益和可扩展性成为大多数SaaS企业的首选方案。其核心原理是在所有业务表中添加tenant_id字段,通过SQL拦截自动添加租户过滤条件。
🔧 MyBatis拦截器核心实现
环境准备
首先在pom.xml中添加必要依赖:
<dependencies><!-- MyBatis --><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.3.1</version></dependency><!-- JSqlParser for SQL解析 --><dependency><groupId>com.github.jsqlparser</groupId><artifactId>jsqlparser</artifactId><version>4.5</version></dependency>
</dependencies>
1. 租户上下文管理
@Component
public class TenantContext {private static final ThreadLocal<Long> CURRENT_TENANT = new ThreadLocal<>();public static void setCurrentTenant(Long tenantId) {CURRENT_TENANT.set(tenantId);}public static Long getCurrentTenant() {return CURRENT_TENANT.get();}public static void clear() {CURRENT_TENANT.remove();}
}
2. Web层租户拦截器
@Component
public class TenantInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {// 从请求头获取租户IDString tenantIdHeader = request.getHeader("X-Tenant-ID");if (StringUtils.isNotBlank(tenantIdHeader)) {TenantContext.setCurrentTenant(Long.valueOf(tenantIdHeader));} else {throw new IllegalArgumentException("Tenant ID is required");}return true;}@Overridepublic void afterCompletion(HttpServletRequest request,HttpServletResponse response, Object handler, Exception ex) {TenantContext.clear();}
}
3. MyBatis多租户拦截器(核心代码)
@Component
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class TenantInterceptor implements Interceptor {private static final Logger logger = LoggerFactory.getLogger(TenantInterceptor.class);@Overridepublic Object intercept(Invocation invocation) throws Throwable {logger.info("🚀 MyBatis拦截器开始执行...");// 获取StatementHandlerStatementHandler statementHandler = (StatementHandler) invocation.getTarget();MetaObject metaObject = SystemMetaObject.forObject(statementHandler);// 获取MappedStatementMappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");logger.info("📝 拦截的Mapper方法: {}", mappedStatement.getId());// 获取当前租户IDLong tenantId = TenantContext.getCurrentTenant();if (tenantId == null) {logger.warn("⚠️ 未找到租户ID,跳过拦截");return invocation.proceed();}// 获取并修改SQLBoundSql boundSql = statementHandler.getBoundSql();String originalSql = boundSql.getSql();String modifiedSql = addTenantCondition(originalSql, tenantId);// 设置修改后的SQLmetaObject.setValue("delegate.boundSql.sql", modifiedSql);return invocation.proceed();}/*** 核心方法:为SQL添加租户条件*/private String addTenantCondition(String sql, Long tenantId) {try {Statement statement = CCJSqlParserUtil.parse(sql);if (statement instanceof Select) {return processSelect((Select) statement, tenantId);} else if (statement instanceof Update) {return processUpdate((Update) statement, tenantId);} else if (statement instanceof Delete) {return processDelete((Delete) statement, tenantId);}} catch (JSQLParserException e) {logger.warn("SQL解析失败,使用正则降级处理");return addTenantConditionWithRegex(sql, tenantId);}return sql;}private String processSelect(Select select, Long tenantId) {PlainSelect plainSelect = (PlainSelect) select.getSelectBody();Expression where = plainSelect.getWhere();// 创建租户过滤条件Expression tenantCondition = new EqualsTo().withLeftExpression(new Column("tenant_id")).withRightExpression(new LongValue(tenantId));if (where == null) {plainSelect.setWhere(tenantCondition);} else {plainSelect.setWhere(new AndExpression(where, tenantCondition));}return select.toString();}// 类似的processUpdate和processDelete方法...@Overridepublic Object plugin(Object target) {return Plugin.wrap(target, this);}
}
🎯 注解驱动增强方案
为了提供更灵活的控制,我们可以实现注解驱动的多租户方案:
1. 注解定义
// 跳过租户过滤注解
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface SkipTenantFilter {String value() default "";
}// 强制租户过滤注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ForceTenantFilter {String tenantIdColumn() default "tenant_id";
}
2. 注解驱动拦截器
@Component
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class AnnotationDrivenTenantInterceptor implements Interceptor {@Overridepublic Object intercept(Invocation invocation) throws Throwable {StatementHandler handler = (StatementHandler) invocation.getTarget();MetaObject metaObject = SystemMetaObject.forObject(handler);MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");// 解析方法注解Method method = getMethodFromMappedStatement(mappedStatement);if (method == null) return invocation.proceed();// 注解决策逻辑if (method.isAnnotationPresent(SkipTenantFilter.class)) {return invocation.proceed(); // 跳过处理}Long tenantId = TenantContext.getCurrentTenant();if (tenantId != null) {BoundSql boundSql = handler.getBoundSql();String modifiedSql = addTenantCondition(boundSql.getSql(), tenantId);metaObject.setValue("delegate.boundSql.sql", modifiedSql);}return invocation.proceed();}
}
3. Mapper使用示例
@Mapper
public interface UserMapper {// 默认启用租户过滤@Select("SELECT * FROM users WHERE status = #{status}")List<User> findByStatus(@Param("status") String status);// 跳过租户过滤(管理员使用)@SkipTenantFilter@Select("SELECT * FROM users")List<User> findAllForAdmin();// 强制指定租户字段@ForceTenantFilter(tenantIdColumn = "company_id")@Select("SELECT * FROM users")List<User> findByCompany();
}
📊 执行流程图解
🧪 完整测试用例
1. 实体类配置
@Data
public class BaseEntity {private Long tenantId;private Long id;private Date createTime;private Date updateTime;
}@Entity
@Table(name = "users")
public class User extends BaseEntity {private String username;private String email;private String status;
}
2. 数据库索引策略
-- 为tenant_id创建索引
CREATE INDEX idx_users_tenant_id ON users(tenant_id);-- 复合索引将tenant_id放在首位
CREATE INDEX idx_users_tenant_status ON users(tenant_id, status);
CREATE INDEX idx_orders_tenant_created ON orders(tenant_id, created_at);
3.测试用例
@SpringBootTest
class TenantInterceptorTest {@Autowiredprivate UserMapper userMapper;@Testvoid testTenantFilter() {// 设置租户上下文TenantContext.setCurrentTenant(123L);List<User> users = userMapper.findByStatus("ACTIVE");// 验证SQL中自动添加了tenant_id条件assertThat(users).allMatch(user -> user.getTenantId().equals(123L));}@Testvoid testSkipTenantFilter() {TenantContext.setCurrentTenant(123L);// 使用@SkipTenantFilter注解的方法应该返回所有数据List<User> allUsers = userMapper.findAllForAdmin();assertThat(allUsers).isNotEmpty();}
}
💡 最佳实践总结
- 自动化的数据隔离:拦截器透明处理,业务代码无需关心租户隔离
- 灵活的配置策略:通过注解精细控制过滤行为
- 双重安全防护:应用层拦截 + 数据库行级安全
- 性能优先:合理的索引策略和缓存方案
- 完善的监控:日志记录和性能监控
🎯 适用场景
- 标准化SaaS产品:面向中小企业的通用SaaS服务
- 多租户管理系统:需要为不同客户隔离数据的后台系统
- 云服务平台:提供多租户能力的PaaS平台
🔚 结语
通过MyBatis拦截器实现多租户数据隔离,我们构建了一个安全、高效、易维护的SaaS架构。这种方案不仅保证了数据的安全性,还提供了优秀的开发体验和系统性能。
主要优势:
- ✅ 开发效率高:业务代码无需关心租户隔离
- ✅ 维护成本低:集中化的拦截器管理
- ✅ 系统性能好:基于索引的高效查询
- ✅ 安全系数高:多重安全防护机制