一、背景介绍
多租户技术或称多重租赁技术,简称SaaS,是一种软件架构技术,是实现如何在多用户环境下(此处的多用户一般是面向企业用户)共用相同的系统或程序组件,并且可确保各用户间数据的隔离性。
简单讲:在一台服务器上运行单个应用实例,它为多个租户(客户)提供服务。从定义中我们可以理解:多租户是一种架构,目的是为了让多用户环境下使用同一套程序,且保证用户间数据隔离。那么重点就很浅显易懂了,多租户的重点就是同一套程序下实现多用户数据的隔离。
二、基础介绍
近日有些朋友向我讨教关于多租户设计方案,正好公司做的也是多租户系统,功能实现不是我开发的,之前也没有细致的了解实现过程。借此机会,结合公司现有的多租户方案及其网上浏览的租户方案设计,自己实现了一套技术方案,可以解决共享数据库或共享数据表。
三、数据隔离技术方案
1.独立数据库
即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高。
优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。
缺点:增多了数据库的安装数量,随之带来维护成本和购置成本的增加。
2.共享数据库,独立 Schema
多个或所有租户共享Database,但是每个租户一个Schema(也可叫做一个user)。底层库比如是:DB2、ORACLE等,一个数据库下可以有多个SCHEMA。
优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可支持更多的租户数量。
缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵涉到其他租户的数据;
3.共享数据库,共享 Schema,共享数据表
即租户共享同一个Database、同一个Schema,但在表中增加TenantID多租户的数据字段。这是共享程度最高、隔离级别最低的模式。
简单来讲,即每插入一条数据时都需要有一个客户的标识。这样才能在同一张表中区分出不同客户的数据,这也是我们系统目前用到的(tenant_id)
优点:三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。
缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量; 数据备份和恢复最困难,需要逐表逐条备份和还原。
四、优点介绍
本次租户业务实现看似是一个方案,其实是结合了独立数据库、共享数据库,共享 Schema,共享数据表类实现的两个方案,具体根据您的需求来设计。
由于多租户中我们不止是每个租户都存在一个客户标识,可能每个表中都存在创建人、更新人、删除标识,这次我也是集成了方案解决。
根据自己的需求,灵活变更业务逻辑,业务代码高可用、注释完善、易上手。
网上的教程大部分都是基于mybatis-plus的TenantLineInnerInterceptor 实现所有的租户通过tenant_id来处理多租户之间打数据隔离,这个局限性太低了,而我实现的可灵活自定义实现。
五、业务实现
注意:以下实现主要列出核心编码实现,完整代码放在文章最下方。如有不足之处,希望各位IT界大佬多多指教,谢谢!
1.导入maven jar包
1 <dependencies> 2 <dependency> 3 <groupId>org.springframework.boot</groupId> 4 <artifactId>spring-boot-starter-jdbc</artifactId> 5 </dependency> 6 <dependency> 7 <groupId>org.springframework.boot</groupId> 8 <artifactId>spring-boot-starter-web</artifactId> 9 </dependency> 10 11 <dependency> 12 <groupId>mysql</groupId> 13 <artifactId>mysql-connector-java</artifactId> 14 <scope>runtime</scope> 15 </dependency> 16 <dependency> 17 <groupId>org.springframework.boot</groupId> 18 <artifactId>spring-boot-starter-test</artifactId> 19 <scope>test</scope> 20 </dependency> 21 <dependency> 22 <groupId>org.apache.commons</groupId> 23 <artifactId>commons-lang3</artifactId> 24 </dependency> 25 <!--阿里数据库连接池 --> 26 <dependency> 27 <groupId>com.alibaba</groupId> 28 <artifactId>druid-spring-boot-starter</artifactId> 29 <version>1.1.21</version> 30 </dependency> 31 <dependency> 32 <groupId>com.baomidou</groupId> 33 <artifactId>mybatis-plus-boot-starter</artifactId> 34 <version>3.4.1</version> 35 </dependency> 36 <dependency> 37 <groupId>org.projectlombok</groupId> 38 <artifactId>lombok</artifactId> 39 <optional>true</optional> 40 </dependency> 41 <dependency> 42 <groupId>com.baomidou</groupId> 43 <artifactId>mybatis-plus-extension</artifactId> 44 <version>3.4.1</version> 45 </dependency> 46 </dependencies>
2.数据库表,可以建两个库进行模拟
1 CREATE TABLE `tenant` ( 2 `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id', 3 `name` varchar(255) DEFAULT NULL COMMENT '租户名称', 4 `tenant_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '租户id', 5 `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', 6 `create_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建人', 7 `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间', 8 `update_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '修改人', 9 `is_delete` tinyint(1) DEFAULT '0' COMMENT '1删除 0未删除 默认0', 10 PRIMARY KEY (`id`) 11 ) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='企业表';
3.我们实现Mybtis中Interceptor,来拦截我们的接口,处理sql。
注意:handleReplace 方法中的 tenantProperties.getTenantTable()就是获取库的名称,这个需要根据你业务,用户登录后存储租户标识,此处根据不同的用户获取不同的标识,切换数据库,我在本案例中只是指定了某个库区执行sql。
1 /** 2 * @author: fuzongle 3 * @description: 拦截器 4 **/ 5 6 @Slf4j 7 @Intercepts({ 8 @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), 9 @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}) 10 }) 11 public class SqlLogInterceptor implements Interceptor { 12 13 @Autowired 14 private TenantProperties tenantProperties; 15 16 public static class CustomSqlSource implements SqlSource { 17 18 private BoundSql boundSql; 19 20 protected CustomSqlSource(BoundSql boundSql){ 21 this.boundSql = boundSql; 22 } 23 24 @Override 25 public BoundSql getBoundSql(Object o) { 26 return boundSql; 27 } 28 } 29 30 @Override 31 public Object intercept(Invocation invocation) throws Throwable { 32 MappedStatement ms = (MappedStatement)invocation.getArgs()[0]; 33 Object parameterObject = null; 34 InterceptorIgnore annotation = null; 35 Class<?> clazz = Class.forName(ms.getId().substring(0, ms.getId().lastIndexOf("."))); 36 Method[] methods = clazz.getDeclaredMethods(); 37 for (Method method : methods) { 38 annotation = method.getAnnotation(InterceptorIgnore.class); 39 } 40 if(invocation.getArgs().length > 1){ 41 parameterObject = invocation.getArgs()[1]; 42 } 43 44 BoundSql boundSql = ms.getBoundSql(parameterObject); 45 String sql = boundSql.getSql(); 46 47 if (tenantProperties.getEnable() || annotation == null) { 48 sql = handleReplace(boundSql.getSql()); 49 } 50 BoundSql newBoundSql = new BoundSql( 51 ms.getConfiguration(), 52 sql, //sql替换 唯一发生改变的地方 53 boundSql.getParameterMappings(), 54 boundSql.getParameterObject() 55 ); 56 57 MappedStatement.Builder build = new MappedStatement.Builder( 58 ms.getConfiguration(), 59 ms.getId(), 60 new CustomSqlSource(newBoundSql), 61 ms.getSqlCommandType() 62 ); 63 build.resource(ms.getResource()); 64 build.fetchSize(ms.getFetchSize()); 65 build.statementType(ms.getStatementType()); 66 build.keyGenerator(ms.getKeyGenerator()); 67 build.timeout(ms.getTimeout()); 68 build.parameterMap(ms.getParameterMap()); 69 build.resultMaps(ms.getResultMaps()); 70 build.cache(ms.getCache()); 71 72 MappedStatement newStmt = build.build(); 73 //替换原来的MappedStatement 74 invocation.getArgs()[0] = newStmt; 75 76 return invocation.proceed(); 77 } 78 // 核心业务处理 79 private String handleReplace(String sql) throws JSQLParserException { 80 Statement stmt = CCJSqlParserUtil.parse(sql); 81 //需要sql校验。 82 String schemeName = String.format("`%s`", tenantProperties.getTenantTable()); 83 if(stmt instanceof Insert){ 84 Insert insert = (Insert)stmt; 85 return SqlParser.doInsert(insert, schemeName); 86 }else if(stmt instanceof Update){ 87 Update update = (Update) stmt; 88 return SqlParser.doUpdate(update, schemeName); 89 }else if(stmt instanceof Delete){ 90 Delete delete = (Delete) stmt; 91 return SqlParser.doDelete(delete, schemeName); 92 }else if(stmt instanceof Select){ 93 Select select = (Select)stmt; 94 return SqlParser.doSelect(select, schemeName); 95 } 96 throw new RuntimeException("非法sql!"); 97 } 98 99 100 101 102 103 }
4.新增语句处理
1 public static String doInsert(Insert insert, String schemaName) { 2 String tableName = insert.getTable().getName(); 3 //校验系统非追加表 4 if (tenantProperties.getIgnoreTables().contains(tableName)){ 5 return insert.toString(); 6 } 7 //获取表对应实体路劲 8 String entityPath = EntityTableCache.getInstance().getCacheData(tableName).toString(); 9 //判断实体是否有createdBy属性,追加create_by 10 if (EntityUtils.isHaveAttr(entityPath, TenantGlobalColumnHandler.COLUMN_CREATED_BY_ENTITY)) { 11 handleColumnsAndExpressions(insert, TenantGlobalColumnHandler.COLUMN_CREATED_BY); 12 } 13 //判断实体是否有updateBy属性,追加update_by 14 if (EntityUtils.isHaveAttr(entityPath,TenantGlobalColumnHandler.COLUMN_UPDATED_BY_ENTITY)) { 15 handleColumnsAndExpressions(insert,TenantGlobalColumnHandler.COLUMN_UPDATED_BY); 16 } 17 //追加tenant_id 18 insert.getColumns().add(new Column(TenantGlobalColumnHandler.COLUMN_TENANT_ID)); 19 ((ExpressionList) insert.getItemsList()).getExpressions().add(TenantGlobalColumnHandler.getTenantId()); 20 //是否设置库名 21 Table table = insert.getTable(); 22 table.setSchemaName(schemaName); 23 insert.setTable(table); 24 return insert.toString(); 25 }
5.删除语句处理
1 public static String doDelete(Delete delete, String schemaName) { 2 String tableName = delete.getTable().getName(); 3 //校验系统非追加表 4 if (tenantProperties.getIgnoreTables().contains(tableName)){ 5 return delete.toString(); 6 } 7 //构建where条件 8 BinaryExpression binaryExpression = andExpression(delete.getTable(), delete.getWhere()); 9 //追加where条件 10 delete.setWhere(binaryExpression); 11 //设置库名 12 Table t = delete.getTable(); 13 t.setSchemaName(schemaName); 14 delete.setTable(t); 15 return delete.toString(); 16 }
6.修改语句处理
1 public static String doUpdate(Update update, String schemaName) throws JSQLParserException{ 2 String tableName = update.getTable().getName(); 3 //校验系统非追加表 4 if (tenantProperties.getIgnoreTables().contains(tableName)){ 5 return update.toString(); 6 } 7 //构建where条件 8 BinaryExpression binaryExpression = andExpression(update.getTable(), update.getWhere()); 9 //追加where条件 10 update.setWhere(binaryExpression); 11 //获取表对应实体路劲 12 String entityPath = EntityTableCache.getInstance().getCacheData(tableName).toString(); 13 //判断实体是否有updateBy属性,追加update_by 14 if (EntityUtils.isHaveAttr(entityPath,TenantGlobalColumnHandler.COLUMN_UPDATED_BY_ENTITY)) { 15 handleColumnsAndExpressions(update,TenantGlobalColumnHandler.COLUMN_UPDATED_BY); 16 } 17 18 //追加库名 19 StringBuilder buffer = new StringBuilder(); 20 Table tb = update.getTable(); 21 tb.setSchemaName(schemaName); 22 update.setTable(tb); 23 // 处理from 24 FromItem fromItem = update.getFromItem(); 25 if (fromItem != null) { 26 Table tf = (Table) fromItem; 27 tf.setSchemaName(schemaName); 28 } 29 // 处理join 30 List<Join> joins = update.getJoins(); 31 if (joins != null && joins.size() > 0) { 32 for (Object object : joins) { 33 Join t = (Join) object; 34 Table rightItem = (Table) t.getRightItem(); 35 rightItem.setSchemaName(schemaName); 36 System.out.println(); 37 } 38 } 39 ExpressionDeParser expressionDeParser = new ExpressionDeParser(); 40 UpdateDeParser p = new UpdateDeParser(expressionDeParser, null, buffer); 41 expressionDeParser.setBuffer(buffer); 42 p.deParse(update); 43 44 return update.toString(); 45 }
7.查询语句处理,如果不满足您的业务可参考Mybatis Puls中的TenantSqlParser自定义追加条件。
1 public static String doSelect(Select select, String schemaName){ 2 processPlainSelect((PlainSelect) select.getSelectBody()); 3 StringBuilder buffer = new StringBuilder(); 4 ExpressionDeParser expressionDeParser = new ExpressionDeParser(); 5 SQLParserSelect parser = new SQLParserSelect(expressionDeParser, buffer); 6 parser.setSchemaName(schemaName); 7 expressionDeParser.setSelectVisitor(parser); 8 expressionDeParser.setBuffer(buffer); 9 select.getSelectBody().accept(parser); 10 11 return buffer.toString(); 12 }
六、执行过程
1.新增
2.删除
3.修改
4.查询
七、源码地址:https://gitee.com/fuzongle