《基于myabtis和mycat實現(xiàn)Mutli-Tenant架構(gòu)》

四合院

前言

Multi-Tenant架構(gòu)簡介

首先我們來看一下百度百科給出的解釋:

多租戶技術(shù)(英語:multi-tenancy technology)或稱多重租賃技術(shù)拥娄,是一種軟件架構(gòu)技術(shù)插掂,它是在探討與實現(xiàn)如何于多用戶的環(huán)境下共用相同的系統(tǒng)或程序組件烦周,并且仍可確保各用戶間數(shù)據(jù)的隔離性。

簡單來說,就是一個app是可以為多個組織、機(jī)構(gòu)絮记、公司服務(wù)。多租戶技術(shù)可以實現(xiàn)多個系統(tǒng)實例共享虐先,又可以對單個用戶進(jìn)行個性化定制怨愤,通過在多個租戶之間的資源服用,節(jié)省硬件成本以及運(yùn)營成本蛹批。讓我們用一張圖來描述一下:

多租戶架構(gòu)

?

我們再來看一下傳統(tǒng)應(yīng)用部署與多租戶應(yīng)用部署的區(qū)別:

timg (1).jpeg

?

從圖1來看租戶A 購買了系統(tǒng)A撰洗,租戶B購買了系統(tǒng)B,模塊3 就是app共享實例般眉,然后通過一個實例去管理租戶以及租戶對應(yīng)的用戶了赵、系統(tǒng)、模塊等業(yè)務(wù)甸赃,這是都是有app提供商來提供一個實例柿汛,不需要為每個租戶單獨去部署實例.我們再看看圖2,第一個就代表了傳統(tǒng)軟件服務(wù)提供商為每個客戶都單獨部署了一個實例埠对,一個數(shù)據(jù)庫络断。這樣就要額外的購買服務(wù)器去部署,這樣不僅僅是增加了軟件的成本项玛,也大大的增加了運(yùn)維難度貌笨。第二個和第三個,則代表了SaaS應(yīng)用的體系襟沮,只部署一個軟件實例锥惋。這樣硬件和運(yùn)維成本就大大降低了。這樣我們對Multi-tenant架構(gòu)就有了初步的了解了吧开伏。

多租戶數(shù)據(jù)庫方案

多租戶技術(shù)是一種軟件架構(gòu)技術(shù)膀跌,是實現(xiàn)如何在多用戶環(huán)境下共用相同的系統(tǒng)或程序組件,并且可確保各用戶間數(shù)據(jù)的隔離性固灵。多租戶在數(shù)據(jù)存儲上主要有三方案捅伤,分別是:

  • 獨立數(shù)據(jù)庫

顧名思義,一個租戶一個數(shù)據(jù)庫巫玻,這樣每個租戶之間的數(shù)據(jù)隔離最安全的丛忆,如果出現(xiàn)故障數(shù)據(jù)恢復(fù)也是比較簡單的祠汇,但成本較高。我們看圖2 這種方案和傳統(tǒng)方案的差別在于軟件統(tǒng)一部署在軟件提供商或運(yùn)營商那里熄诡。這種方案適應(yīng)于銀行可很、醫(yī)院、通信等需要非常高的數(shù)據(jù)隔離級別租戶粮彤。

  • 共享數(shù)據(jù)庫根穷,隔離數(shù)據(jù)架構(gòu)

多個或者每一個租戶共享同一個Database,但是每一個租戶對應(yīng)一個Schema(如若對database和schema含義不清楚导坟,請查閱資料)。優(yōu)點:數(shù)據(jù)隔離級別較高圈澈,且數(shù)據(jù)庫部署量小惫周,硬件成本較低。缺點:數(shù)據(jù)備份和恢復(fù)困難

  • 共享數(shù)據(jù)庫康栈,共享數(shù)據(jù)架構(gòu)

租戶共享同一個Database递递、同一個Schema,但在表中增加TenantID多租戶的數(shù)據(jù)字段啥么。 優(yōu)點: 三種方案比較登舞,第三種方案的維護(hù)和購置成本最低,允許每個數(shù)據(jù)庫支持的租戶數(shù)量最多悬荣。 缺點: 隔離級別最低菠秒,安全性最低,需要在設(shè)計開發(fā)時加大對安全的開發(fā)量氯迂; 數(shù)據(jù)備份和恢復(fù)最困難践叠,需要逐表逐條備份和還原。

我們用張圖來描述一下:


timg.jpeg

有關(guān)多租戶的詳解嚼蚀,大家可以去看看Spring Cloud在云計算SaaS中的實戰(zhàn)經(jīng)驗分享這篇文章禁灼,詳細(xì)的介紹了多租戶的一些概念,以及Spring Cloud 環(huán)境下SaaS 的架構(gòu)思想轿曙!


方案選型:共享+隔離+獨立數(shù)據(jù)庫的混合方式

項目上采用了共享數(shù)據(jù)庫和隔離數(shù)據(jù)結(jié)構(gòu)的方式弄捕,通過數(shù)據(jù)庫中間件mycat以及mybatis Interceptor來實現(xiàn)這一功能。mycat的基本使用导帝,需要各位自己去拓展守谓,我這里不贅述!

首先我們來在mysql數(shù)據(jù)庫中新增兩個數(shù)據(jù)庫舟扎,每個數(shù)據(jù)庫都有一張emp職員表分飞。

租戶google:

mysql> use tenant_google;
mysql> create table emp( id int primary key auto_increment , name varchar(20) not null  );
mysql> insert into emp values(1,'lilei');
mysql> select * from emp;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | lilei     |
+----+-----------+

租戶amazon:

mysql> use tenant_amazon;
mysql> create table emp( id int primary key auto_increment , name varchar(20) not null  );
mysql> select * from emp;
+----+-----------+
| id | name      |
+----+-----------+
|  1 | hanmeimei |
+----+-----------+

我們再來設(shè)計一張全局用戶表

mysql> create database tenant_global;
Query OK, 1 row affected (0.01 sec)
mysql> use tenant_global;
mysql> create table global_emp( id int primary key auto_increment ,emp varchar(20) not null, address varchar(50) not null);
?
mysql> select * from global_emp;
+----+-----------+---------------+
| id | emp       | address       |
+----+-----------+---------------+
|  1 | lilei     | tenant_google |
|  2 | hanmeimei | tenant_amazon |
+----+-----------+---------------+

這里我們想一下姓名相同的兩個人在不同的公司的話,那么怎么區(qū)分呢睹限!

 +----+-----------+---------------+
| id | emp       | address       |
+----+-----------+---------------+
|  1 | lilei     | tenant_google |
|  2 | hanmeimei | tenant_google |
|  3 | hanmeimei | tenant_amazon |
+----+-----------+---------------+

不同租戶的用戶登錄后臺系統(tǒng)的入口只有一個譬猫,當(dāng)hanmeimei登錄后讯檐,怎么去選擇她的公司呢。這里我們可以將account和公司代碼拼接如:

+----+-------------------+---------------+
| id | emp               | address       |
+----+-------------------+---------------+
|  1 | GOOGLE_lilei      | tenant_google |
|  2 | GOOGLE_hanmeimei  | tenant_google |
|  3 | AMAZOM_hanmeimei  | tenant_amazon |
+----+-----------+-----------------------+

也可以在注冊之前查一下global表染服,看用戶名是否已經(jīng)存在别洪,這里的業(yè)務(wù)邏輯大家自己去實現(xiàn),我們還是回到第一張全局用戶表柳刮。用戶登錄挖垛,向后端獲取令牌,走 "/oauth/token"這個路徑秉颗,我們來一步一步實現(xiàn)用戶登錄前實現(xiàn)數(shù)據(jù)庫的選擇痢毒。

mycat配置

配置sever.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mycat:server SYSTEM "server.dtd">
<mycat:server xmlns:mycat="http://io.mycat/">
 <system>
 <property name="useSqlStat">0</property> 
 <property name="useGlobleTableCheck">0</property>
 <property name="sequnceHandlerType">2</property>
 <property name="handleDistributedTransactions">0</property>
 <property name="useOffHeapForMerge">1</property>
 <property name="memoryPageSize">1m</property>
 <property name="spillsFileBufferSize">1k</property>
 <property name="useStreamOutput">0</property>
 <property name="systemReserveMemorySize">384m</property>
 <property name="useZKSwitch">true</property>
 </system>
 <!-- 這里的schema 相當(dāng)于一個數(shù)據(jù)庫 物理database- 與schema.xml 中schema相對應(yīng)-->
 <user name="root">
 <property name="password">123456</property>
 <property name="schemas">tenant</property>
 </user>
?
</mycat:server>

server.xml主要是設(shè)置登錄用戶名密碼,登錄端口之類的信息蚕甥。

配置schema.xml

<?xml version="1.0"?>
<!DOCTYPE mycat:schema SYSTEM "schema.dtd">
<mycat:schema xmlns:mycat="http://io.mycat/">
 <!--與server.xml 相對應(yīng)-->
 <schema name="tenant" checkSQLschema="false" sqlMaxLimit="100">
 <table name="emp" dataNode="tenant_google,tenant_amazon" />
 </schema>
 <dataNode name="tenant_google" dataHost="localhost1" database="tenant_google" />
 <dataNode name="tenant_amazon" dataHost="localhost1" database="tenant_amazon" />
 <dataHost name="localhost1" maxCon="1000" minCon="10" balance="0"
 writeType="0" dbType="mysql" dbDriver="native" switchType="1"  slaveThreshold="100">
 <heartbeat>select user()</heartbeat>
 <!--這里才是物理數(shù)據(jù)庫的賬戶和密碼配置-->
 <writeHost host="hostS1" url="localhost:3306" user="root"
 password="123456" />
 </dataHost>

</mycat:schema>

這里我們也可以看到當(dāng)我們哪替,租戶增加的時候,需要手動去增加datanode節(jié)點菇怀,也是很麻煩的凭舶。這里我們先不去管

Mybatis設(shè)置攔截器插件

首先我們設(shè)置數(shù)據(jù)源:

# 數(shù)據(jù)源
spring:
 datasource:
 type: com.zaxxer.hikari.HikariDataSource
 driver-class-name: com.mysql.jdbc.Driver
 username: root
 password: 123456
 url: jdbc:mysql://127.0.0.1:8806/tenant?characterEncoding =utf8&useSSL=false

mybatis的攔截器允許我們在sql執(zhí)行前進(jìn)行攔截調(diào)用,我們來看一下Mybatis的攔截器

Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)  
ParameterHandler (getParameterObject, setParameters) 
ResultSetHandler (handleResultSets, handleOutputParameters) 
StatementHandler (prepare, parameterize, batch, update, query)

我們通過實現(xiàn)mybatis的Interceptor接口爱沟,將sql語句進(jìn)行拼接即可帅霜。為了實現(xiàn)SQL的改造增加注解,我們可以攔截StatementHandler的prepare方法呼伸,在此之前完成SQL的重新編寫身冀。

import com.sanshengshui.multitenant.utils.SessionUtil;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
?
import java.sql.Connection;
import java.util.Properties;
// 1
@Intercepts(value = {
 @Signature(type = StatementHandler.class,
 method = "prepare",
 args = {Connection.class,Integer.class})})
public class TenantInterceptor implements Interceptor {
 private static final String preState="/*!mycat:datanode=";
 private static final String afterState="*/";
?
 @Override
 public Object intercept(Invocation invocation) throws Throwable {
 StatementHandler statementHandler=(StatementHandler)invocation.getTarget();
 MetaObject metaStatementHandler=SystemMetaObject.forObject(statementHandler);
 Object object=null;
 //分離代理對象鏈,"h" 具體是什么我也不清楚,功能實現(xiàn)來源網(wǎng)絡(luò)
 while(metaStatementHandler.hasGetter("h")){
 object=metaStatementHandler.getValue("h");
 metaStatementHandler=SystemMetaObject.forObject(object);
 }
 statementHandler=(StatementHandler)object;
 // 2
 String sql=(String)metaStatementHandler.getValue("delegate.boundSql.sql");
 /**
 * 通過用戶去全局用戶表里查找node蜂大,將node存入當(dāng)前線程中闽铐,從線程中獲取node
 */
 String node =TenantContextHolder.getSchemal();
 // String node=(String) SessionUtil.getSession().getAttribute("corp");
 if(node!=null) {
 sql = preState + node + afterState + sql;
 }
?
 System.out.println("sql is "+sql);
 metaStatementHandler.setValue("delegate.boundSql.sql",sql);
 Object result = invocation.proceed();
 System.out.println("Invocation.proceed()");
 return result;
 }
?
 @Override
 public Object plugin(Object target) {
?
 return Plugin.wrap(target, this);
 }
?
 @Override
 public void setProperties(Properties properties) {}

}

1-2代碼皆為固定格式,用戶登錄之前通過去全局用戶表間去查找node 信息 再去指定的數(shù)據(jù)集去查找信息,我們使用的是spring Security框架奶浦,我們知道用戶登錄的信息會封裝成UsernamePasswordToken兄墅,然后通過UserDetailsService接口獲得封裝了用戶信息的User類,通過DaoAuthenticationProvider去進(jìn)行對比澳叉,然后執(zhí)行登錄成功或者失敗后的操作隙咸,不太了解的讀者可以去看看我另外一篇文章《Spring Security 源碼解析》。那么回到我們話題成洗,怎么在UserDetailsService接口調(diào)用loadUserByUsername之前五督,找到用戶對應(yīng)的數(shù)據(jù)庫的schema地址呢。我們來看看:

@Service
@AllArgsConstructor
public class SmartUserDetailsServiceImpl implements SmartUserDetailsService {
 private final RemoteUserService remoteUserService;
 private final RemoteTenantService remoteTenantService;
?
 /**
 * 用戶密碼登錄
 *
 * @param username 用戶名
 * @return
 * @throws UsernameNotFoundException
 */
 @Override
 public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
 GlobalUser  global =remoteTenantService.info(username);
 //將查詢到的數(shù)據(jù)庫地址信息封存到TenantContextHolder類中
 TenantContextHolder.setTenant(global.getSchema());
 UserInfo result = remoteUserService.info(username);
 //這里是將UserInfo對象 構(gòu)建成UserDetails對象
 return getUserDetails(result);
 }
 // 代碼省略............
}

我們再來看看TenantContextHolder是怎么設(shè)計的吧

@Service
@Lazy(false)
public class TenantContextHolder implements ApplicationContextAware, DisposableBean {
?
 private static ThreadLocal<String> tenantThreadLocal= new ThreadLocal<>();
 private static ApplicationContext applicationContext =null;
?
 @Override
 public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
 TenantContextHolder.applicationContext =applicationContext;
?
 }
 public static final void setTenant(String schema){
 tenantThreadLocal.set(schema);
 }
?
 public static final String getTenant(){
 String schema = tenantThreadLocal.get();
 if(schema == null){
 schema = "";
 }
 return schema;
 }
 @Override
 public void destroy() throws Exception {
 TenantContextHolder.clearHolder();
 }
?
 public static void clearHolder() {
 if (log.isDebugEnabled()) {
 log.debug("清除TenantContextHolder中的ApplicationContext:" + applicationContext);
 }
 applicationContext = null;
 }
}

是不是看起來很眼熟瓶殃?對的充包,這個類是參照SecurityContextHolder這個類設(shè)計的

package org.springframework.security.core.context;
?
import java.lang.reflect.Constructor;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
?
public class SecurityContextHolder {
 public static final String MODE_THREADLOCAL = "MODE_THREADLOCAL";
 public static final String MODE_INHERITABLETHREADLOCAL = "MODE_INHERITABLETHREADLOCAL";
 public static final String MODE_GLOBAL = "MODE_GLOBAL";
 public static final String SYSTEM_PROPERTY = "spring.security.strategy";
 private static String strategyName = System.getProperty("spring.security.strategy");
 private static SecurityContextHolderStrategy strategy;
 private static int initializeCount = 0;
?
 public SecurityContextHolder() {
 }
?
 public static void clearContext() {
 strategy.clearContext();
 }
?
 public static SecurityContext getContext() {
 return strategy.getContext();
 }
?
 public static int getInitializeCount() {
 return initializeCount;
 }
?
 private static void initialize() {
 if (!StringUtils.hasText(strategyName)) {
 strategyName = "MODE_THREADLOCAL";
 }
?
 if (strategyName.equals("MODE_THREADLOCAL")) {
 strategy = new ThreadLocalSecurityContextHolderStrategy();
 } else if (strategyName.equals("MODE_INHERITABLETHREADLOCAL")) {
 strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
 } else if (strategyName.equals("MODE_GLOBAL")) {
 strategy = new GlobalSecurityContextHolderStrategy();
 } else {
 try {
 Class<?> clazz = Class.forName(strategyName);
 Constructor<?> customStrategy = clazz.getConstructor();
 strategy = (SecurityContextHolderStrategy)customStrategy.newInstance();
 } catch (Exception var2) {
 ReflectionUtils.handleReflectionException(var2);
 }
 }
?
 ++initializeCount;
 }
?
 public static void setContext(SecurityContext context) {
 strategy.setContext(context);
 }
?
 public static void setStrategyName(String strategyName) {
 strategyName = strategyName;
 initialize();
 }
?
 public static SecurityContextHolderStrategy getContextHolderStrategy() {
 return strategy;
 }
?
 public static SecurityContext createEmptyContext() {
 return strategy.createEmptyContext();
 }
?
 public String toString() {
 return "SecurityContextHolder[strategy='" + strategyName + "'; initializeCount=" + initializeCount + "]";
 }
?
 static {
 initialize();
 }
}

好了基于spring Cloud + mybatis+mycat的多租戶架構(gòu)基本上就搭建完成了。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市基矮,隨后出現(xiàn)的幾起案子淆储,更是在濱河造成了極大的恐慌,老刑警劉巖家浇,帶你破解...
    沈念sama閱讀 211,123評論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件本砰,死亡現(xiàn)場離奇詭異,居然都是意外死亡钢悲,警方通過查閱死者的電腦和手機(jī)点额,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,031評論 2 384
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來莺琳,“玉大人还棱,你說我怎么就攤上這事〔训龋” “怎么了诱贿?”我有些...
    開封第一講書人閱讀 156,723評論 0 345
  • 文/不壞的土叔 我叫張陵,是天一觀的道長咕缎。 經(jīng)常有香客問我,道長料扰,這世上最難降的妖魔是什么凭豪? 我笑而不...
    開封第一講書人閱讀 56,357評論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮晒杈,結(jié)果婚禮上嫂伞,老公的妹妹穿的比我還像新娘。我一直安慰自己拯钻,他們只是感情好帖努,可當(dāng)我...
    茶點故事閱讀 65,412評論 5 384
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著粪般,像睡著了一般拼余。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上亩歹,一...
    開封第一講書人閱讀 49,760評論 1 289
  • 那天匙监,我揣著相機(jī)與錄音,去河邊找鬼小作。 笑死亭姥,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的顾稀。 我是一名探鬼主播达罗,決...
    沈念sama閱讀 38,904評論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼静秆!你這毒婦竟也來了粮揉?” 一聲冷哼從身側(cè)響起巡李,我...
    開封第一講書人閱讀 37,672評論 0 266
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎滔蝉,沒想到半個月后击儡,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,118評論 1 303
  • 正文 獨居荒郊野嶺守林人離奇死亡蝠引,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 36,456評論 2 325
  • 正文 我和宋清朗相戀三年阳谍,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片螃概。...
    茶點故事閱讀 38,599評論 1 340
  • 序言:一個原本活蹦亂跳的男人離奇死亡矫夯,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出吊洼,到底是詐尸還是另有隱情训貌,我是刑警寧澤,帶...
    沈念sama閱讀 34,264評論 4 328
  • 正文 年R本政府宣布冒窍,位于F島的核電站递沪,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏综液。R本人自食惡果不足惜款慨,卻給世界環(huán)境...
    茶點故事閱讀 39,857評論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望谬莹。 院中可真熱鬧檩奠,春花似錦、人聲如沸附帽。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,731評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽蕉扮。三九已至整胃,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間慢显,已是汗流浹背爪模。 一陣腳步聲響...
    開封第一講書人閱讀 31,956評論 1 264
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留荚藻,地道東北人屋灌。 一個月前我還...
    沈念sama閱讀 46,286評論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像应狱,于是被迫代替她去往敵國和親共郭。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 43,465評論 2 348

推薦閱讀更多精彩內(nèi)容

  • Spring Cloud為開發(fā)人員提供了快速構(gòu)建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務(wù)發(fā)現(xiàn)除嘹,斷路器写半,智...
    卡卡羅2017閱讀 134,628評論 18 139
  • 廢話不多說,自己進(jìn)入今天的主題 1尉咕、面向?qū)ο蟮奶卣饔心男┓矫妫?答:面向?qū)ο蟮奶卣髦饕幸韵聨讉€方面: - 抽象:...
    傳奇內(nèi)服號閱讀 2,341評論 1 31
  • 老榆木新中式家具的簡約質(zhì)樸帶給您一股清流 老榆木叠蝇,俗稱老榆木落梁,是從古老的房子上拆下來的...
    古韻祥閱讀 148評論 0 0
  • 一位在北方一線城市上學(xué)的朋友給我發(fā)微信說年缎,他今天從早上七點出門到實習(xí)的地方悔捶,跟個陀螺似的旋轉(zhuǎn)了一整天,晚上九點過才...
    小啵先生閱讀 491評論 1 3