前言
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)營成本蛹批。讓我們用一張圖來描述一下:
?
我們再來看一下傳統(tǒng)應(yīng)用部署與多租戶應(yīng)用部署的區(qū)別:
?
從圖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ù)最困難践叠,需要逐表逐條備份和還原。
我們用張圖來描述一下:
有關(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)基本上就搭建完成了。