前言
Multi-Tenant架构简介
首先我们来看一下百度百科给出的解释:
多租户技术(英语:multi-tenancy technology)或称多重租赁技术,是一种软件架构技术,它是在探讨与实现如何于多用户的环境下共用相同的系统或程序组件,并且仍可确保各用户间数据的隔离性。
简单来说,就是一个app是可以为多个组织、机构、公司服务。多租户技术可以实现多个系统实例共享,又可以对单个用户进行个性化定制,通过在多个租户之间的资源服用,节省硬件成本以及运营成本。让我们用一张图来描述一下:
我们再来看一下传统应用部署与多租户应用部署的区别:
从图1来看租户A 购买了系统A,租户B购买了系统B,模块3 就是app共享实例,然后通过一个实例去管理租户以及租户对应的用户、系统、模块等业务,这是都是有app提供商来提供一个实例,不需要为每个租户单独去部署实例.我们再看看图2,第一个就代表了传统软件服务提供商为每个客户都单独部署了一个实例,一个数据库。这样就要额外的购买服务器去部署,这样不仅仅是增加了软件的成本,也大大的增加了运维难度。第二个和第三个,则代表了SaaS应用的体系,只部署一个软件实例。这样硬件和运维成本就大大降低了。这样我们对Multi-tenant架构就有了初步的了解了吧。
多租户数据库方案
多租户技术是一种软件架构技术,是实现如何在多用户环境下共用相同的系统或程序组件,并且可确保各用户间数据的隔离性。多租户在数据存储上主要有三方案,分别是:
- 独立数据库
顾名思义,一个租户一个数据库,这样每个租户之间的数据隔离最安全的,如果出现故障数据恢复也是比较简单的,但成本较高。我们看图2 这种方案和传统方案的差别在于软件统一部署在软件提供商或运营商那里。这种方案适应于银行、医院、通信等需要非常高的数据隔离级别租户。
- 共享数据库,隔离数据架构
多个或者每一个租户共享同一个Database,但是每一个租户对应一个Schema(如若对database和schema含义不清楚,请查阅资料)。优点:数据隔离级别较高,且数据库部署量小,硬件成本较低。缺点:数据备份和恢复困难
- 共享数据库,共享数据架构
租户共享同一个Database、同一个Schema,但在表中增加TenantID多租户的数据字段。 优点: 三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。 缺点: 隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量; 数据备份和恢复最困难,需要逐表逐条备份和还原。
我们用张图来描述一下:
有关多租户的详解,大家可以去看看Spring Cloud在云计算SaaS中的实战经验分享这篇文章,详细的介绍了多租户的一些概念,以及Spring Cloud 环境下SaaS 的架构思想!
方案选型:共享+隔离+独立数据库的混合方式
项目上采用了共享数据库和隔离数据结构的方式,通过数据库中间件mycat以及mybatis Interceptor来实现这一功能。mycat的基本使用,需要各位自己去拓展,我这里不赘述!
首先我们来在mysql数据库中新增两个数据库,每个数据库都有一张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 |
+----+-----------+
我们再来设计一张全局用户表
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 |
+----+-----------+---------------+
这里我们想一下姓名相同的两个人在不同的公司的话,那么怎么区分呢!
+----+-----------+---------------+
| id | emp | address |
+----+-----------+---------------+
| 1 | lilei | tenant_google |
| 2 | hanmeimei | tenant_google |
| 3 | hanmeimei | tenant_amazon |
+----+-----------+---------------+
不同租户的用户登录后台系统的入口只有一个,当hanmeimei登录后,怎么去选择她的公司呢。这里我们可以将account和公司代码拼接如:
+----+-------------------+---------------+
| id | emp | address |
+----+-------------------+---------------+
| 1 | GOOGLE_lilei | tenant_google |
| 2 | GOOGLE_hanmeimei | tenant_google |
| 3 | AMAZOM_hanmeimei | tenant_amazon |
+----+-----------+-----------------------+
也可以在注册之前查一下global表,看用户名是否已经存在,这里的业务逻辑大家自己去实现,我们还是回到第一张全局用户表。用户登录,向后端获取令牌,走 "/oauth/token"这个路径,我们来一步一步实现用户登录前实现数据库的选择。
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 相当于一个数据库 物理database- 与schema.xml 中schema相对应-->
<user name="root">
<property name="password">123456</property>
<property name="schemas">tenant</property>
</user>
</mycat:server>
server.xml主要是设置登录用户名密码,登录端口之类的信息。
配置schema.xml
<?xml version="1.0"?>
<!DOCTYPE mycat:schema SYSTEM "schema.dtd">
<mycat:schema xmlns:mycat="http://io.mycat/">
<!--与server.xml 相对应-->
<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>
<!--这里才是物理数据库的账户和密码配置-->
<writeHost host="hostS1" url="localhost:3306" user="root"
password="123456" />
</dataHost>
</mycat:schema>
这里我们也可以看到当我们,租户增加的时候,需要手动去增加datanode节点,也是很麻烦的。这里我们先不去管
Mybatis设置拦截器插件
首先我们设置数据源:
# 数据源
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执行前进行拦截调用,我们来看一下Mybatis的拦截器
Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
ParameterHandler (getParameterObject, setParameters)
ResultSetHandler (handleResultSets, handleOutputParameters)
StatementHandler (prepare, parameterize, batch, update, query)
我们通过实现mybatis的Interceptor接口,将sql语句进行拼接即可。为了实现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" 具体是什么我也不清楚,功能实现来源网络
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存入当前线程中,从线程中获取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 信息 再去指定的数据集去查找信息,我们使用的是spring Security框架,我们知道用户登录的信息会封装成UsernamePasswordToken,然后通过UserDetailsService接口获得封装了用户信息的User类,通过DaoAuthenticationProvider去进行对比,然后执行登录成功或者失败后的操作,不太了解的读者可以去看看我另外一篇文章《Spring Security 源码解析》。那么回到我们话题,怎么在UserDetailsService接口调用loadUserByUsername之前,找到用户对应的数据库的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);
//将查询到的数据库地址信息封存到TenantContextHolder类中
TenantContextHolder.setTenant(global.getSchema());
UserInfo result = remoteUserService.info(username);
//这里是将UserInfo对象 构建成UserDetails对象
return getUserDetails(result);
}
// 代码省略............
}
我们再来看看TenantContextHolder是怎么设计的吧
@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这个类设计的
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的多租户架构基本上就搭建完成了。