《基于myabtis和mycat实现Mutli-Tenant架构》

四合院

前言

Multi-Tenant架构简介

首先我们来看一下百度百科给出的解释:

多租户技术(英语:multi-tenancy technology)或称多重租赁技术,是一种软件架构技术,它是在探讨与实现如何于多用户的环境下共用相同的系统或程序组件,并且仍可确保各用户间数据的隔离性。

简单来说,就是一个app是可以为多个组织、机构、公司服务。多租户技术可以实现多个系统实例共享,又可以对单个用户进行个性化定制,通过在多个租户之间的资源服用,节省硬件成本以及运营成本。让我们用一张图来描述一下:

多租户架构

我们再来看一下传统应用部署与多租户应用部署的区别:

timg (1).jpeg

从图1来看租户A 购买了系统A,租户B购买了系统B,模块3 就是app共享实例,然后通过一个实例去管理租户以及租户对应的用户、系统、模块等业务,这是都是有app提供商来提供一个实例,不需要为每个租户单独去部署实例.我们再看看图2,第一个就代表了传统软件服务提供商为每个客户都单独部署了一个实例,一个数据库。这样就要额外的购买服务器去部署,这样不仅仅是增加了软件的成本,也大大的增加了运维难度。第二个和第三个,则代表了SaaS应用的体系,只部署一个软件实例。这样硬件和运维成本就大大降低了。这样我们对Multi-tenant架构就有了初步的了解了吧。

多租户数据库方案

多租户技术是一种软件架构技术,是实现如何在多用户环境下共用相同的系统或程序组件,并且可确保各用户间数据的隔离性。多租户在数据存储上主要有三方案,分别是:

  • 独立数据库

顾名思义,一个租户一个数据库,这样每个租户之间的数据隔离最安全的,如果出现故障数据恢复也是比较简单的,但成本较高。我们看图2 这种方案和传统方案的差别在于软件统一部署在软件提供商或运营商那里。这种方案适应于银行、医院、通信等需要非常高的数据隔离级别租户。

  • 共享数据库,隔离数据架构

多个或者每一个租户共享同一个Database,但是每一个租户对应一个Schema(如若对database和schema含义不清楚,请查阅资料)。优点:数据隔离级别较高,且数据库部署量小,硬件成本较低。缺点:数据备份和恢复困难

  • 共享数据库,共享数据架构

租户共享同一个Database、同一个Schema,但在表中增加TenantID多租户的数据字段。 优点: 三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。 缺点: 隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量; 数据备份和恢复最困难,需要逐表逐条备份和还原。

我们用张图来描述一下:


timg.jpeg

有关多租户的详解,大家可以去看看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的多租户架构基本上就搭建完成了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,633评论 18 139
  • 废话不多说,自己进入今天的主题 1、面向对象的特征有哪些方面? 答:面向对象的特征主要有以下几个方面: - 抽象:...
    传奇内服号阅读 2,343评论 1 31
  • 老榆木新中式家具的简约质朴带给您一股清流 老榆木,俗称老榆木落梁,是从古老的房子上拆下来的...
    古韵祥阅读 148评论 0 0
  • 一位在北方一线城市上学的朋友给我发微信说,他今天从早上七点出门到实习的地方,跟个陀螺似的旋转了一整天,晚上九点过才...
    小啵先生阅读 495评论 1 3