多租戶(Multi-Tenant)是SaaS中的一個重要概念,它是一種軟件架構技術,在多個租戶的環境下,共享同一套系統實例,并且租戶之間的數據具有隔離性,也就是說一個租戶不能去訪問其他租戶的數據。基于不同的隔離級別,通常具有下面三種實現方案:
每個租戶使用獨立DataBase,隔離級別高,性能好,但成本大
租戶之間共享DataBase,使用獨立的Schema
租戶之間共享Schema,在表上添加租戶字段,共享數據程度最高,隔離級別最低。
數據庫設計
Mybatis-plus在第3層隔離級別上,提供了基于分頁插件的多租戶的解決方案,我們對此來進行介紹。在正式開始前,首先做好準備工作創建兩張表,在基礎字段后都添加租戶字段tenant_id:
CREATE?TABLE?`user`?( ??`id`?bigint(20)?NOT?NULL, ??`name`?varchar(20)?DEFAULT?NULL, ??`phone`?varchar(11)?DEFAULT?NULL, ??`address`?varchar(64)?DEFAULT?NULL, ??`tenant_id`?bigint(20)?DEFAULT?NULL, ??PRIMARY?KEY?(`id`) ) CREATE?TABLE?`dept`?( ??`id`?bigint(20)?NOT?NULL, ??`dept_name`?varchar(64)?DEFAULT?NULL, ??`comment`?varchar(128)?DEFAULT?NULL, ??`tenant_id`?bigint(20)?DEFAULT?NULL, ??PRIMARY?KEY?(`id`) )
引入依賴
在項目中導入需要的依賴:
???? com.baomidou ????mybatis-plus-boot-starter ????3.3.2 com.github.jsqlparser jsqlparser 3.1
實現
Mybatis-plus 配置類:
@EnableTransactionManagement(proxyTargetClass?=?true) @Configuration public?class?MybatisPlusConfig?{ ????@Bean ????public?PaginationInterceptor?paginationInterceptor()?{ ????????PaginationInterceptor?paginationInterceptor?=?new?PaginationInterceptor(); ????????List?sqlParserList=new?ArrayList<>(); ????????TenantSqlParser?tenantSqlParser=new?TenantSqlParser(); ????????tenantSqlParser.setTenantHandler(new?TenantHandler()?{ ????????????@Override ????????????public?Expression?getTenantId(boolean?select)?{ ????????????????String?tenantId?=?"3"; ????????????????return?new?StringValue(tenantId); ????????????} ????????????@Override ????????????public?String?getTenantIdColumn()?{ ????????????????return?"tenant_id"; ????????????} ????????????@Override ????????????public?boolean?doTableFilter(String?tableName)?{ ????????????????return?false; ????????????} ????????}); ????????sqlParserList.add(tenantSqlParser); ????????paginationInterceptor.setSqlParserList(sqlParserList); ????????return?paginationInterceptor; ????} }
這里主要實現的功能:
創建SQL解析器集合
創建租戶SQL解析器
設置租戶處理器,具體處理租戶邏輯
這里暫時把租戶的id固定寫成3,來進行測試。測試執行全表語句:
public?List?getUserList()?{ ????????return?userMapper.selectList(new?LambdaQueryWrapper ().isNotNull(User::getId)); ????????}
使用插件解析執行的SQL語句,可以看到自動在查詢條件后加上了租戶過濾條件:
那么在實際的項目中,怎么將租戶信息傳給租戶處理器呢,根據情況我們可以從緩存或者請求頭中獲取,以從Request請求頭獲取為例:
@Override public?Expression?getTenantId(boolean?select)?{ ????????ServletRequestAttributes?attributes=(ServletRequestAttributes)?RequestContextHolder.getRequestAttributes(); ????????HttpServletRequest?request?=?attributes.getRequest(); ????????String?tenantId?=?request.getHeader("tenantId"); ????????return?new?StringValue(tenantId); ????????}
前端在發起http請求時,在Header中加入tenantId字段,后端在處理器中獲取后,設置為當前這次請求的租戶過濾條件。
如果是基于請求頭攜帶租戶信息的情況,那么在使用中可能會遇到一個坑,如果當使用多線程的時候,新開啟的異步線程并不會自動攜帶當前線程的Request請求。
@Override public?List?getUserListByFuture()?{ ????????Callable?getUser=()->?userMapper.selectList(new?LambdaQueryWrapper ().isNotNull(User::getId)); ????????FutureTask >?future=new?FutureTask<>(getUser); ????????new?Thread(future).start(); ????????try?{ ????????return?future.get(); ????????}?catch?(Exception?e)?{ ????????e.printStackTrace(); ????????} ????????return?null; ????????}
執行上面的方法,可以看出是獲取不到當前的Request請求的,因此無法獲得租戶id,會導致后續報錯空指針異常:
修改的話也非常簡單,開啟RequestAttributes的子線程共享,修改上面的代碼:
@Override public?List?getUserListByFuture()?{ ????????ServletRequestAttributes?sra?=?(ServletRequestAttributes)?RequestContextHolder.getRequestAttributes(); ????????Callable?getUser=()->?{ ????????RequestContextHolder.setRequestAttributes(sra,?true); ????????return?userMapper.selectList(new?LambdaQueryWrapper ().isNotNull(User::getId)); ????????}; ????????FutureTask >?future=new?FutureTask<>(getUser); ????????new?Thread(future).start(); ????????try?{ ????????return?future.get(); ????????}?catch?(Exception?e)?{ ????????e.printStackTrace(); ????????} ????????return?null; ????????}
這樣修改后,在異步線程中也能正常的獲取租戶信息了。
那么,有的小伙伴可能要問了,在業務中并不是所有的查詢都需要過濾租戶條件啊,針對這種情況,有兩種方式來進行處理。
1、如果整張表的所有SQL操作都不需要針對租戶進行操作,那么就對表進行過濾,修改doTableFilter方法,添加表的名稱:
@Override public?boolean?doTableFilter(String?tableName)?{ ????????List?IGNORE_TENANT_TABLES=?Arrays.asList("dept"); ????????return?IGNORE_TENANT_TABLES.stream().anyMatch(e->e.equalsIgnoreCase(tableName)); ????????}
這樣,在dept表的所有查詢都不進行過濾:
2、如果有一些特定的SQL語句不想被執行租戶過濾,可以通過@SqlParser注解的形式開啟,注意注解只能加在Mapper接口的方法上:
@SqlParser(filter?=?true) @Select("select?*?from?user?where?name?=#{name}") User?selectUserByName(@Param(value="name")?String?name);
或在分頁攔截器中指定需要過濾的方法:
@Bean public?PaginationInterceptor?paginationInterceptor()?{ ????????PaginationInterceptor?paginationInterceptor?=?new?PaginationInterceptor(); ????????paginationInterceptor.setSqlParserFilter(metaObject->{ ????????MappedStatement?ms?=?SqlParserHelper.getMappedStatement(metaObject); ????????//?對應Mapper、dao中的方法 ????????if("com.cn.tenant.dao.UserMapper.selectUserByPhone".equals(ms.getId())){ ????????return?true; ????????} ????????return?false; ????????}); ????????... ????????}
?
上面這兩種方式實現的功能相同,但是如果需要過濾的SQL語句很多,那么第二種方式配置起來會比較麻煩,因此建議通過注解的方式進行過濾。
除此之外,還有一個比較容易踩的坑就是在復制Bean時,不要復制租戶id字段,否則會導致SQL語句報錯:
public?void?createSnapshot(Long?userId){ ????????User?user?=?userMapper.selectOne(new?LambdaQueryWrapper().eq(User::getId,?userId)); ????????UserSnapshot?userSnapshot=new?UserSnapshot(); ????????BeanUtil.copyProperties(user,userSnapshot); ????????userSnapshotMapper.insert(userSnapshot); ????????}
查看報錯可以看出,本身Bean的租戶字段不為空的情況下,SQL又自動添加一次租戶查詢條件,因此導致了報錯:
我們可以修改復制Bean語句,手動忽略租戶id字段,這里使用的是hutool的BeanUtil工具類,可以添加忽略字段。
BeanUtil.copyProperties(user,userSnapshot,"tenantId");
在忽略了租戶id的拷貝后,查詢可以正常執行。
最后,再來看一下對聯表查詢的支持,首先看一下包含子查詢的SQL:
@Select("select?*?from?user?where?id?in?(select?id?from?user_snapshot)") List?selectSnapshot();
查看執行結果,可以看見,在子查詢的內部也自動添加的租戶查詢條件:
再來看一下使用Join進行聯表查詢:
@Select("select?u.*?from?user?u?left?join?user_snapshot?us?on?u.id=us.id") List?selectSnapshot();
同樣,會在左右兩張表上都添加租戶的過濾條件:
再看一下不使用Join的普通聯表查詢:
@Select("select?u.*?from?user?u?,user_snapshot?us,dept?d?where?u.id=us.id?and?d.id?is?not?null") List?selectSnapshot();
?
?
查看執行結果,可以看見在這種情況下,只在FROM關鍵字后面的第一張表上添加了租戶的過濾條件,因此如果使用這種查詢方式,需要額外注意,用戶需要手動在SQL語句中添加租戶過濾。
編輯:黃飛
?
評論
查看更多