引言#
多租戶主要用來做數據隔離的,它通常被用於 SAAS 系統裡面,把軟體作為服務提供給企業使用。每個企業就是一個租戶,每個租戶只能看到本租戶內的數據。所以需要對每個租戶做數據隔離。
數據隔離#
多租戶的數據隔離方案,可以分成三種:
DATASOURCE 模式:獨立數據庫
SCHEMA 模式:共享數據庫,獨立 Schema
COLUMN 模式:共享數據庫,共享 Schema,共享數據表
DATASOURCE 模式#
一個租戶一個數據庫,這種方案的用戶數據隔離級別最高,安全性最好,但成本也高。
DATASOURCE 模式
優點:為不同的租戶提供獨立的數據庫,有助於簡化數據模型的擴展設計,滿足不同租戶的獨特需求;如果出現故障,恢復數據比較簡單。
缺點:增大了數據庫的安裝數量,隨之帶來維護成本和購置成本的增加。
SCHEMA 模式#
多個或所有租戶共享數據庫,但一個租戶一個表。
SCHEMA 模式
優點:為安全性要求較高的租戶提供了一定程度的邏輯數據隔離,並不是完全隔離;每個數據庫可以支持更多的租戶數量。
缺點:如果出現故障,數據恢復比較困難,因為恢復數據庫將牽扯到其他租戶的數據;如果需要跨租戶統計數據,存在一定困難。
COLUMN 模式#
共享數據庫,共享數據架構。租戶共享同一個數據庫、同一個表,但在表中通過 tenant_id 字段區分租戶的數據。這是共享程度最高、隔離級別最低的模式。
COLUMN 模式
優點:維護和購置成本最低,允許每個數據庫支持的租戶數量最多。
缺點:隔離級別最低,安全性最低,需要在設計開發時加大對安全的開發量;數據備份和恢復最困難,需要逐表逐條備份和還原。
總結#
在大多數情況下,DATASOURCE 和 COLUMN 模式最常被使用。
當預算充足或租戶對安全性,隔離性要求較高時選擇DATASOURCE 模式
普通情況下選擇COLUMN 模式就足夠了,開發、運維簡單,以最少的伺服器為最多的租戶提供服務。
原理#
下面以最常用的COLUMN 共享數據庫的模式來解釋多租戶的實現原理。
相信你已經注意到了,在這個模式下和普通的單租戶應用的唯一区別僅在數據庫表中新增了一個 tenant_id 字段。
沒錯,此字段為唯一租戶標識,程序需要使用此字段來判斷每個租戶下有哪些數據。
你會想,那我直接在寫 SQL 時把租戶字段拼接上去不就行了?
當然可以這麼做,但是,很明顯不夠優雅。
mybatis plus 多租戶插件#
那怎麼寫出優雅的代碼實現多租戶呢?
mybatis 作為國內開發者最常用也是最熟悉的 ORM 框架,給我們提供了原生的多租戶插件。
官方多租戶插件
示例
public interface TenantLineHandler {
/**
* 獲取租戶 ID 值表達式,只支持單個 ID 值
* <p>
*
* @return 租戶 ID 值表達式
*/
Expression getTenantId();
/**
* 獲取租戶字段名
* <p>
* 默認字段名叫: tenant_id
*
* @return 租戶字段名
*/
default String getTenantIdColumn() {
// 如果該字段你不是固定的,請使用 SqlInjectionUtils.check 檢查安全性
return "tenant_id";
}
/**
* 根據表名判斷是否忽略拼接多租戶條件
* <p>
* 默認都要進行解析並拼接多租戶條件
*
* @param tableName 表名
* @return 是否忽略, true:表示忽略,false:需要解析並拼接多租戶條件
*/
default boolean ignoreTable(String tableName) {
return false;
}
}
說明:
多租戶!= 權限過濾,不要亂用,租戶之間是完全隔離的!!!
啟用多租戶後所有執行的 method 的 sql 都會進行處理。
自寫的 sql 請按規範書寫 (sql 涉及到多個表的每個表都要給別名,特別是 inner join 的要寫標準的 inner join)
當我們使用此插件時,拼接 SQL 的操作就由 mybatis-plus 框架幫我們做了,它的實現方式是基於分頁插件 (攔截器) 進行實現的。
進階#
上面只是個簡單的示例,接下來我將以 ruoyi-vue-plus
腳手架來擴展多租戶的功能。
yml 配置#
在 yml 配置文件新增如下配置
# 多租戶配置
tenant:
# 是否開啟多租戶模式
enable: true
# 租戶字段名
column: tenant_id
# 需要判斷多租戶的表名
includes:
- sr_card
- sr_room
- sr_seat
- sr_room_seat
- sr_swiper
- sys_oss
編寫組件對應配置文件中的值
@Data
@Component
@ConfigurationProperties(prefix = "tenant")
public class TenantProperties {
/**
* 是否開啟多租戶模式
*/
private Boolean enable;
/**
* 租戶字段名
*/
private String column;
/**
* 需要判斷多租戶的表名
*/
private List<String> includes;
}
mybatis-plus 配置#
然後需要在 mybatis-plus 的配置文件中註冊多租戶插件,並且編寫插件邏輯
@Resource
private TenantProperties tenantProperties;
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
if (tenantProperties.getEnable()) {
// 多租戶插件
interceptor.addInnerInterceptor(tenantLineInnerInterceptor());
}
// 數據權限處理
interceptor.addInnerInterceptor(dataPermissionInterceptor());
// 分頁插件
interceptor.addInnerInterceptor(paginationInnerInterceptor());
// 樂觀鎖插件
interceptor.addInnerInterceptor(optimisticLockerInnerInterceptor());
return interceptor;
}
首先,引入配置文件,然後註冊多租戶插件,需要注意的是,多租戶插件需要在分頁插件的前面。
然後編寫對應的邏輯
public TenantLineInnerInterceptor tenantLineInnerInterceptor() {
return new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
try {
LoginUser loginUser = LoginUserShareMap.getLoginUser();
if (loginUser == null) {
System.err.println("當前未登錄用戶!");
//多租戶模式,租戶id為null
return new LongValue(0L);
} else {
//多租戶模式,租戶id從當前用戶獲取
return new LongValue(loginUser.getUserId());
}
} catch (Exception e) {
throw new RuntimeException("獲取當前登錄用戶信息失敗!", e);
}
}
@Override
public String getTenantIdColumn() {
// 對應數據庫租戶ID的列名
System.err.println("當前租戶列名:" + tenantProperties.getColumn());
return tenantProperties.getColumn();
}
// 這是 default 方法,默認返回 false 表示所有表都需要拼多租戶條件
@Override
public boolean ignoreTable(String tableName) {
LoginUser loginUser = LoginUserShareMap.getLoginUser();
// 判斷是否登錄,如果登錄則過濾
if (loginUser != null) {
// 判斷是否平台超級管理員,如果是平台超級管理員則擁有所有數據權限
if (!LoginHelper.isAdmin()) {
// 需要過濾租戶的表
List<String> includes = tenantProperties.getIncludes();
return !includes.contains(tableName);
}
}
return true;
}
});
}
這裡面有些需要注意的點
- 默認情況下
ruoyi-vue-plus
在項目初始化時會讀取數據庫表中的配置信息,而且又因為框架使用的是 sa-token 做的登錄驗證。在項目初始化執行此方法時,如果你使用 sa-token 獲取登錄用戶,則會報 無法獲取 web 上下文 的錯誤 - 可以使用一個組件來保存登錄狀態,通常情況下我們會使用
ThreadLocal
,但注意,此框架使用了線程池,使用ThreadLocal
可能會有預期外的錯誤。比如在高併發或高負載的情況下,用戶登錄的線程是 A, 但判斷租戶的線程卻是 B,這 2 個線程的ThreadLocal
是不一樣的。
特定 SQL 過濾#
多租戶插件雖然提供了基於 表 的過濾,但沒有提供更細粒度的基於 SQL 的過濾。
不過它提供了一個註解來實現
注解 | mybatis-plus 版本 |
---|---|
@InterceptorIgnore(tenantLine = "true") | Mybatis-Plus3.4+ |
@SqlParser(filter=true) | Mybatis-Plus3.4- |
需要注意的是如果你的 MP 版本在 3.1.1 及以下使用 @SqlParser(filter=true)
還需要配置
# 開啟 SQL 解析緩存註解生效,如果你的MP版本在3.1.1及以上則不需要配置
mybatis-plus:
global-config:
sql-parser-cache: true
在 mapper 中使用即可
public interface TenantMapper extends BaseMapper<Tenant> {
/**
* 自定Wrapper, @SqlParser(filter = true)註解代表不進行SQL解析也就沒有租戶的附加條件。
*
* @return
*/
@SqlParser(filter = true)
@Select("SELECT count(5) FROM t_tenant ")
public Integer myCount();
}