一、背景介紹
多租戶技術或稱多重租賃技術,簡稱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.查詢
七、源碼地址: