1.背景
2.多租戶數據隔離架構設計
3.mybatis-plus優雅實現多租戶數據權限隔離
4.總結
1.背景
開發過SaaS系統平臺的小伙伴一定對多租戶這個概念不陌生,簡單來說一個租戶就是一個公司客戶,多個租戶共用同一個SaaS系統,一旦SaaS系統不可用,那么所有的租戶都不可用。你可以這么理解SaaS系統就像一棟大樓,而租戶就是大樓里面租辦公樓層的公司,平時每家公司做著自己的業務,互不干擾,但是一旦大樓的電梯壞了,那么影響到的就是所有的公司。
多租戶問題,其是一種架構設計方式,就是在一臺或者一組服務器上運行的SaaS系統,可以為多個租戶(客戶)提供服務,目的是為了讓多個租戶在互聯網環境下使用同一套程序,且保證租戶間的數據隔離。
從這種架構設計的模式上,不難看出來,多租戶架構的重點就是同一套程序下多個租戶數據的隔離。由于租戶數據是集中存儲的,所以要實現數據的安全性,就是看能否實現對租戶數據的隔離,防止租戶數據不經意或被他人惡意地獲取和篡改。在講多租戶數據隔離實現之前,先來看看什么是SaaS系統。
什么是SaaS系統 ?
SaaS平臺是運營saas軟件的平臺。SaaS提供商為企業搭建信息化所需要的所有網絡基礎設施及軟件、硬件運作平臺,并負責所有前期的實施、后期的維護等一系列服務,租戶(企業)無需購買軟硬件、建設機房、招聘IT人員,即可通過互聯網使用信息系統。SaaS 是一種軟件布局模型,其應用專為網絡交付而設計,便于用戶通過互聯網托管、部署及接入。
簡單來說就是租戶給SaaS平臺付租金就能使用平臺提供的功能服務,當下比較典型就是各種云平臺、云服務廠商。
2.多租戶數據隔離架構設計
目前saas多租戶系統的數據隔離有三種架構設計,即為每個租戶提供獨立的數據庫、獨立的表空間、按字段區分租戶,每種方案都有其各自的適用情況。
一個租戶獨立一個數據庫
一個租戶獨立使用一個數據庫,那就意味著我們的SaaS系統需要連接多個數據庫,這種實現方案其實就和分庫分表架構設計是一樣的,好處就是數據隔離級別高、安全性好,畢竟一個租戶單用一個數據庫,但是物理硬件成本,維護成本也變高了。
獨立的表空間
這種方案的實現方式,就是所有租戶共用一個數據庫系統,但是每個租戶在數據庫系統中擁有一個獨立的表空間。
按租戶id字段隔離租戶
這種方案是多租戶方案中最簡單的數據隔離方法,即在每張表中都添加一個用于區分租戶的字段(如tenant_id或org_id啥的)來標識每條數據屬于哪個租戶,當進行查詢的時候每條語句都要添加該字段作為過濾條件,其特點是所有租戶的數據全都存放在同一個表中,數據的隔離性是最低的,完全是通過字段來區分的,很容易把數據搞串或者誤操作。
三種數據隔離架構設計的對比如下:
隔離方案 | 成本 | 支持租戶數量 | 優點 | 缺點 |
---|---|---|---|---|
獨立數據庫系統 | 高 | 少 | 數據隔離級別高,安全性,可以針對單個租戶開發個性化需求 | 數據庫獨立安裝,物理成本和維護成本都比較高 |
獨立的表空間 | 中 | 較多 | 提供了一定程度的邏輯數據隔離,一個數據庫系統可支持多個租戶 | 數據庫管理比較困難,表繁多,同時數據修復稍復雜 |
按租戶id字段區分 | 低 | 多 | 維護和購置成本最低,每個數據庫能夠支持的租戶數量最多 | 隔離級別最低,安全性也最低 |
大部分公司都是采用第三種:按租戶id字段隔離租戶 架構設計實現多租戶數據隔離的。接下來我們就來看看代碼層面怎么實現多租戶數據隔離的。
3.mybatis-plus優雅實現多租戶數據權限隔離
上面我們說過按租戶id字段隔離租戶 這種方式就是在獲取數據的時候對每一條SQL語句添加租戶id作為過濾條件來隔離租戶數據的。但是這樣意味著每個查詢SQL都必須加上租戶id這個過濾條件,如果漏加就意味著會查詢出不同租戶的數據,這是絕對不允許的,同時每個查詢接口都需要手動設置過濾條件,重復勞動,一點都不夠優雅。這時候就不得不說說mybatis-plus的多租戶插件了,看看它如何優雅實現多租戶隔離的?
再講述之前,我們先思考一下如何優雅實現數據隔離?首先我們要求每一條SQL都加上租戶id這個過濾條件,這意味著我們需要解析原始SQL在合適的地方加上租戶id過濾條件,我們知道mybatis提供擴展點就是攔截器,可以對SQL語句處理前后進行增強邏輯,分頁插件就是這么做的,所以我們這里要增強SQL自然也是這樣,接下來我們就來看看mybatis-plus多租戶插件是怎么實現多租戶數據隔離的。
該攔截器部分源碼如下:
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?ignoreTables?=?new?HashSet<>(); ????public?TenantDatabaseHandler(TenantProperties?properties)?{ ????????//?將配置文件配置的忽略表名同步大小寫,適配不同寫法 ????????properties.getIgnoreTables().forEach(table?->?{ ????????????ignoreTables.add(table.toLowerCase()); ????????????ignoreTables.add(table.toUpperCase()); ????????}); ????} ????/** ?????*?獲取租戶字段名 ?????*? ?????*?默認字段名叫:?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?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()
????
啟動項目,先登錄之后使用token掉接口執行下面代碼邏輯:
?public?PageResult
????????Page?page?=?new?Page<>(query.getPageNo(),?query.getPageSize()); ????????List ?userList?=?userDAO.getUserList(page,?query); ????????List ?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?{ ????@InterceptorIgnore(tenantLine?=?"true") ????List ?getUserList(IPage ?userPage,?@Param("query")?UserQuery?query); }
這樣調用getUserList()不再會加多租戶過濾條件了。
通過上面我們知道了這個多租戶插件其實就是通過解析SQL,然后進行拼接多租戶id過濾條件來實現SQL增強從而做到數據隔離,解析SQL的框架叫:JSqlParser
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"?at?line?2,?column?29.
我在mybatis-plus的官方提了一個issue:https://github.com/baomidou/mybatis-plus/issues/5086,也得到官方維護者的迅速回應說是JSqlParser解析的問題,不是mybatis-plus的問題~~~,給出的建議就是把別名ur改成別的,或者升級到JSqlParser的最新版本。
4.總結
至此,我們對多租戶系統數據隔離實現方案,架構設計,以及如何優雅實現全局操作數據隔離都講完了,同時也對mybati-plus的多租戶插件實現原理和源碼流程套路進行了淺析,也對實際應用案例中進行了舉證并闡述了相關細節點。
當然數據權限不止停留在租戶(公司)層面上面,大多數系統的數據權限會按照業務組織架構角色來控制,數據權限其套路和根據角色判斷菜單權限一回事。
由于數據權限通常與公司業務相關,比較個性化,每家公司業務組織架構不盡相同,所以實際開發項目的數據權限隔離還需要大家按實際需求進行修改,但總的來說我們可以模仿多租戶隔離實現方式,比如說一個業務系統組織架構有公司(org_id),公司下有多個部門(dept_id),部門下有多個團隊分組(team_id),團隊下有多個人員(user_id)。
不同角色只能看到不同數據,部門經理只能看到自己部門的數據,小組長只能看到自己小組的數據,這些實現邏輯套路都可以模仿多租戶插件的方式進行優雅實現,這也是我后面有時間想研究的,后續會再出一篇數據權限的實現方案總結。
編輯:黃飛
?
評論
查看更多