淺析 SaaS 多租戶系統數據隔離實現方案
1. 背景
開發過 SaaS 系統平臺的小夥伴一定對多租戶這個概念不陌生,簡單來說一個租戶就是一個公司客戶,多個租戶共用同一個 SaaS 系統,一旦 SaaS 系統不可用,那麼所有的租戶都不可用。你可以這麼理解 SaaS 系統就像一棟大樓,而租戶就是大樓裏面租辦公樓層的公司,平時每家公司做着自己的業務,互不干擾,但是一旦大樓的電梯壞了,那麼影響到的就是所有的公司。
多租戶問題,其是一種架構設計方式,就是在一臺或者一組服務器上運行的 SaaS 系統,可以爲多個租戶(客戶)提供服務,目的是爲了讓多個租戶在互聯網環境下使用同一套程序,且保證租戶間的數據隔離。從這種架構設計的模式上,不難看出來,多租戶架構的重點就是同一套程序下多個租戶數據的隔離。由於租戶數據是集中存儲的,所以要實現數據的安全性,就是看能否實現對租戶數據的隔離,防止租戶數據不經意或被他人惡意地獲取和篡改。在講多租戶數據隔離實現之前,先來看看什麼是 SaaS 系統。
什麼是 SaaS 系統?
SaaS 平臺是運營 saas 軟件的平臺。SaaS 提供商爲企業搭建信息化所需要的所有網絡基礎設施及軟件、硬件運作平臺,並負責所有前期的實施、後期的維護等一系列服務,租戶 (企業) 無需購買軟硬件、建設機房、招聘 IT 人員,即可通過互聯網使用信息系統。SaaS 是一種軟件佈局模型,其應用專爲網絡交付而設計,便於用戶通過互聯網託管、部署及接入。
簡單來說就是租戶給 SaaS 平臺付租金就能使用平臺提供的功能服務,當下比較典型就是各種雲平臺、雲服務廠商。
項目推薦:基於 SpringBoot2.x、SpringCloud 和 SpringCloudAlibaba 企業級系統架構底層框架封裝,解決業務開發時常見的非功能性需求,防止重複造輪子,方便業務快速開發和企業技術棧框架統一管理。引入組件化的思想實現高內聚低耦合並且高度可配置化,做到可插拔。嚴格控制包依賴和統一版本管理,做到最少化依賴。注重代碼規範和註釋,非常適合個人學習和企業使用
Github 地址:github.com/plasticene/…
Gitee 地址:gitee.com/plasticene3…
微信公衆號:Shepherd 進階筆記
2. 多租戶數據隔離架構設計
目前 saas 多租戶系統的數據隔離有三種架構設計,即爲每個租戶提供獨立的數據庫、獨立的表空間、按字段區分租戶,每種方案都有其各自的適用情況。
一個租戶獨立一個數據庫
一個租戶獨立使用一個數據庫,那就意味着我們的 SaaS 系統需要連接多個數據庫,這種實現方案其實就和分庫分表架構設計是一樣的,好處就是數據隔離級別高、安全性好,畢竟一個租戶單用一個數據庫,但是物理硬件成本,維護成本也變高了。
獨立的表空間
這種方案的實現方式,就是所有租戶共用一個數據庫系統,但是每個租戶在數據庫系統中擁有一個獨立的表空間。
按租戶 id 字段隔離租戶
這種方案是多租戶方案中最簡單的數據隔離方法,即在每張表中都添加一個用於區分租戶的字段(如 tenant_id 或 org_id 啥的)來標識每條數據屬於哪個租戶,當進行查詢的時候每條語句都要添加該字段作爲過濾條件,其特點是所有租戶的數據全都存放在同一個表中,數據的隔離性是最低的,完全是通過字段來區分的,很容易把數據搞串或者誤操作。
三種數據隔離架構設計的對比如下:
3.mybatis-plus 優雅實現多租戶數據權限隔離
上面我們說過按租戶 id 字段隔離租戶這種方式就是在獲取數據的時候對每一條 SQL 語句添加租戶 id 作爲過濾條件來隔離租戶數據的。但是這樣意味着每個查詢 SQL 都必須加上租戶 id 這個過濾條件,如果漏加就意味着會查詢出不同租戶的數據,這是絕對不允許的,同時每個查詢接口都需要手動設置過濾條件,重複勞動,一點都不夠優雅。這時候就不得不說說 mybatis-plus 的多租戶插件了,看看它如何優雅實現多租戶隔離的?再講述之前,我們先思考一下如何優雅實現數據隔離?首先我們要求每一條 SQL 都加上租戶 id 這個過濾條件,這意味着我們需要解析原始 SQL 在合適的地方加上租戶 id 過濾條件,我們知道 mybatis 提供擴展點就是攔截器,可以對 SQL 語句處理前後進行增強邏輯,分頁插件就是這麼做的,所以我們這裏要增強 SQL 自然也是這樣,接下來我們就來看看 mybatis-plus 多租戶插件是怎麼實現多租戶數據隔離的,插件官網介紹地址:
www.baomidou.com/pages/aef2f…,該攔截器部分源碼如下:
public class TenantLineInnerInterceptor extends JsqlParserSupport implements InnerInterceptor {
// 多租戶處理器
private TenantLineHandler tenantLineHandler;
// 改SQL,添加多租戶id條件
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
if (!InterceptorIgnoreHelper.willIgnoreTenantLine(ms.getId())) {
MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql);
mpBs.sql(this.parserSingle(mpBs.sql(), (Object)null));
}
}
public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);
MappedStatement ms = mpSh.mappedStatement();
SqlCommandType sct = ms.getSqlCommandType();
if (sct == SqlCommandType.INSERT || sct == SqlCommandType.UPDATE || sct == SqlCommandType.DELETE) {
if (InterceptorIgnoreHelper.willIgnoreTenantLine(ms.getId())) {
return;
}
MPBoundSql mpBs = mpSh.mPBoundSql();
mpBs.sql(this.parserMulti(mpBs.sql(), (Object)null));
}
}
// 礙於篇幅問題,下面省略的代碼就是繼承抽象類JsqlParserSupport解析SQL然後添加多租戶id條件的,可以自行查看源碼
......
}
接着我們來看看處理器 TenantLineHandler,這是一個接口,需要我們提供自定義實現,指定多租戶相關配置:
public class TenantDatabaseHandler implements TenantLineHandler {
private final Set<String> ignoreTables = new HashSet<>();
public TenantDatabaseHandler(TenantProperties properties) {
// 將配置文件配置的忽略表名同步大小寫,適配不同寫法
properties.getIgnoreTables().forEach(table -> {
ignoreTables.add(table.toLowerCase());
ignoreTables.add(table.toUpperCase());
});
}
/**
* 獲取租戶字段名
* <p>
* 默認字段名叫: tenant_id,我這裏使用org_id
*
* @return 租戶字段名
*/
@Override
public String getTenantIdColumn() {
return "org_id";
}
@Override
public Expression getTenantId() {
// 這裏通過登錄信息上下文返回租戶id給多租戶攔截器增強SQL使用
return new LongValue(RequestUserHolder.getCurrentUser().getOrgId());
}
@Override
public boolean ignoreTable(String tableName) {
// 忽略多租戶的表
return CollUtil.contains(ignoreTables, tableName);
}
}
配置屬性如下:
@ConfigurationProperties(prefix = "ptc.tenant")
@Data
public class TenantProperties {
/**
* 全局控制是否開啓多租戶功能
*/
private Boolean enable = Boolean.TRUE;
/**
* 需要忽略多租戶的表
*
* 即默認所有表都開啓多租戶的功能,所以記得添加對應的 tenant_id 字段喲
*/
private Set<String> ignoreTables = Collections.emptySet();
}
接下來注入攔截器插件即可:
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties properties) {
MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
// 必須保證多租戶插件在分頁插件之前,這個是 MyBatis-plus 的規定
if (properties.getEnable()) {
mybatisPlusInterceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantDatabaseHandler(properties)));
}
// 分頁插件
mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return mybatisPlusInterceptor;
}
使用示例如下:這裏提供了一個常見的案例:用戶和角色關聯查詢的 SQL:getUserList()
<select id="getUserList" resultType="com.plasticene.textile.entity.User">
select u.* from user u
left join user_role r on u.id = r.user_id
<where>
<if test="query.status != null">
and u.status = #{query.status}
</if>
<if test="query.roleId != null">
and r.role_id = #{query.roleId}
</if>
<if test="query.keyword != null">
and ((u.name like concat('%',#{query.keyword},'%')) or (u.mobile like concat(#{query.keyword},'%')))
</if>
<if test="query.startEntryTime != null">
and u.entry_time >= #{query.startEntryTime}
</if>
<if test="query.endEntryTime != null">
<![CDATA[ and u.entry_time <= #{query.endEntryTime}]]>
</if>
</where>
group by u.id
order by u.id desc
</select>
啓動項目,先登錄之後使用 token 掉接口執行下面代碼邏輯:
public PageResult<UserDTO> getList(UserQuery query) {
Page<UserDTO> page = new Page<>(query.getPageNo(), query.getPageSize());
List<User> userList = userDAO.getUserList(page, query);
List<UserDTO> userDTOS = toUserDTOList(userList);
return new PageResult<>(userDTOS, page.getTotal(), page.getPages());
}
查看控制檯發現:
[1658720355293990912] [DEBUG] [2023-05-17 14:25:25.504] [http-nio-16688-exec-1@23652] com.plasticene.textile.dao.UserDAO.getUserList debug : ==> Preparing: SELECT u.* FROM user u LEFT JOIN user_role r ON u.id = r.user_id AND r.org_id = 3 WHERE u.org_id = 3 GROUP BY u.id ORDER BY u.id DESC LIMIT ?
[1658720355293990912] [DEBUG] [2023-05-17 14:25:25.505] [http-nio-16688-exec-1@23652] com.plasticene.textile.dao.UserDAO.getUserList debug : ==> Parameters: 20(Long)
user 表 u 加上 u.org_id=3 這個多租戶過濾條件,user_role 也同樣加上了,說明多租戶插件起作用了。
當然如果想忽略掉表 user,我們只需要在配置文件如下配置即可:
ptc:
tenant:
ignore-tables: user
這樣 user 表 u 就不會再加上 u.org_id=3 這個多租戶過濾條件,但是這裏有一個細節需要注意,由於 user 在 MySQL 中是關鍵字,所以我有時候爲了規範書寫 SQL,會按照如下編寫:
select u.* from `user` u
left join user_role r on u.id = r.user_id
這時候你會發現上面配置的忽略表 user 不起作用,還是會加上 u.org_id=3 這個多租戶過濾條件,跟源碼才發現我們上面自定義的多租戶處理器 TenantLineHandler 只對表名進行了大小寫適配,然而這裏 SQL 解析出來的表名是: user ,所以匹配不到配置不起作用。
當然我們有可能需要針對單一 SQL 語句不加多租戶過濾條件,可以使用 @InterceptorIgnore 註解:
public interface UserDAO extends BaseMapperX<User> {
@InterceptorIgnore(tenantLine = "true")
List<User> getUserList(IPage<UserDTO> userPage, @Param("query") UserQuery query);
}
這樣調用 getUserList() 不再會加多租戶過濾條件了。
通過上面我們知道了這個多租戶插件其實就是通過解析 SQL,然後進行拼接多租戶 id 過濾條件來實現 SQL 增強從而做到數據隔離,解析 SQL 的框架叫:JSqlParser,官方文檔:github.com/JSQLParser/…,之前我總結過一篇關於 Druid 解析動態 SQL。Druid 也可以解析 SQL,我們都知道 SQL 語句會生成語法樹,兩者對 SQL 解析的孰強孰弱 (特別是複雜 SQL) 不得而知,可以自行驗證對比,我這裏給出一個 JSqlParser 解析出錯的情況,把上面的 SQL 語句 user_role r 改爲 user_role ur
select u.* from user u
left join user_role ur on u.id = ur.user_id
按照上面一樣調用執行 getUserList(), 會報解析錯誤:
Caused by: com.baomidou.mybatisplus.core.exceptions.MybatisPlusException: Failed to process, Error SQL: select u.* from user u
left join user_role ur on u.id = ur.user_id
group by u.id
order by u.id desc
at com.baomidou.mybatisplus.core.toolkit.ExceptionUtils.mpe(ExceptionUtils.java:39)
at com.baomidou.mybatisplus.extension.parser.JsqlParserSupport.parserSingle(JsqlParserSupport.java:52)
at com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor.beforeQuery(TenantLineInnerInterceptor.java:65)
at com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor.intercept(MybatisPlusInterceptor.java:78)
at org.apache.ibatis.plugin.Plugin.invoke(Plugin.java:62)
at com.sun.proxy.$Proxy178.query(Unknown Source)
at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:151)
... 101 common frames omitted
Caused by: net.sf.jsqlparser.parser.ParseException: Encountered unexpected token: "ur" <K_ISOLATION>
at line 2, column 29.
我在 mybatis-plus 的官方提了一個 issue:github.com/baomidou/my…,也得到官方維護者的迅速回應說是 JSqlParser 解析的問題,不是 mybatis-plus 的問題~~~,給出的建議就是把別名 ur 改成別的,或者升級到 JSqlParser 的最新版本。
4. 總結
至此,我們對多租戶系統數據隔離實現方案,架構設計,以及如何優雅實現全局操作數據隔離都講完了,同時也對 mybati-plus 的多租戶插件實現原理和源碼流程套路進行了淺析,也對實際應用案例中進行了舉證並闡述了相關細節點。當然數據權限不止停留在租戶 (公司) 層面上面,大多數系統的數據權限會按照業務組織架構角色來控制,數據權限其套路和根據角色判斷菜單權限一回事。由於數據權限通常與公司業務相關,比較個性化,每家公司業務組織架構不盡相同,所以實際開發項目的數據權限隔離還需要大家按實際需求進行修改,但總的來說我們可以模仿多租戶隔離實現方式,比如說一個業務系統組織架構有公司(org_id),公司下有多個部門(dept_id),部門下有多個團隊分組(team_id),團隊下有多個人員(user_id)。不同角色只能看到不同數據,部門經理只能看到自己部門的數據,小組長只能看到自己小組的數據,這些實現邏輯套路都可以模仿多租戶插件的方式進行優雅實現,這也是我後面有時間想研究的,後續會再出一篇數據權限的實現方案總結。
來源:https://juejin.cn/post/7234763992333189175
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/i10FJtMgo81htaJcBTBGtw