独立数据库 独立服务实例
优点:独立部署,数据服务隔离度高,业务定制扩展性高,如果出现故障,恢复数据比较简单
缺点:运维成本高
独立数据库 共享服务实例
优点:数据隔离度高,服务共享减少维护成本,扩展性中等
缺点:数据库独立部署成本高,服务实例共享对高可用性要求高
共享数据库 共享服务实例
优点: 维护和购置成本最低,允许每个数据库支持的租户数量最多。
缺点: 隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量
数据备份和恢复最困难,需要逐表逐条备份和还原;
如果希望以最少的服务器为最多的租户提供服务,并且租户接受以牺牲隔离级别换取降低成本,这种方案最适合
独立数据库(数据库代理方案如mycat) 共享服务实例
优点:数据库隔离度高,开发者只需要关注代理服务, 扩展性中等
缺点:数据库独立部署成本高,使用数据库代理增加维护成本,对数据库代理中间件高可用要求高,对服务实例高可用性要求高
独立数据库 共享服务实例 技术实现
- 建立租户中心服务及数据库
- 租户管理
- 租户对应的数据库连接信息
- 用户管理
- 关联租户
- 租户及用户表结构
DROP TABLE IF EXISTS `tenant`;
CREATE TABLE `tenant` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '租户名称',
`tenant_database` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '数据库名称',
`tenant_database_url` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '数据库连接',
`tenant_database_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '数据库登录用户',
`tenant_database_password` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '数据库密码',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '租户信息' ROW_FORMAT = Dynamic;
DROP TABLE IF EXISTS `user_info`;
CREATE TABLE `user_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '用户名',
`password` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '密码',
`tenant_id` bigint(20) NOT NULL COMMENT '租户id',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '用户表' ROW_FORMAT = Dynamic;
- 流程如下
- 用户请请求租户中心 登录, 返回token 并将token和用户信息绑定至redis
- 用户携带token请求业务服务,aop拦截,环绕通知中根据token从redis中取出用户及租户信息,判断切换数据源。
- 切换数据源相关技术
- 使用AbstractRoutingDataSource 抽象路由数据源 切换 数据源
- 重写AbstractRoutingDataSource类,并实例化此类。注入DataSourceTransactionManager及SqlSessionFactory
- 使用一个ThreadLocal来管理当前线程路由键,aop设置路由键
独立数据库(数据库代理方案如mycat) 共享服务实例
- 租户表设计同上,租户表除库名以外的连接信息可以删除
- 流程如下
- 用户请请求租户中心 登录, 返回token 并将token和用户信息绑定至redis
- 用户携带token请求业务服务,在sql发送至mycat前拦截sql并增强sql,使用mycat注释指定相应的schema
// /*!mycat:schema=test_01*/ sql ;
sql = "/*!mycat:schema="+userInfo.getTenantDatabase()+"*/" + sql;
- mycat配置
- schema.xml
<?xml version="1.0"?>
<!DOCTYPE mycat:schema SYSTEM "schema.dtd">
<mycat:schema xmlns:mycat="http://io.mycat/">
<!--schema start-->
<!--A业务库-->
<schema name="lzq" checkSQLschema="false" sqlMaxLimit="100">
<table name="business" dataNode="tenant_lzq"/>
</schema>
<!--B业务库-->
<schema name="wy" checkSQLschema="false" sqlMaxLimit="100">
<table name="business" dataNode="tenant_wy"/>
</schema>
<!--租户中心业务库-->
<schema name="tenant" checkSQLschema="false" sqlMaxLimit="100">
<table name="user_info" dataNode="tenant_centre"/>
<table name="tenant" dataNode="tenant_centre"/>
</schema>
<!--schema end-->
<!--dataNode start-->
<dataNode name="tenant_lzq" dataHost="tenant_lzq_host" database="tenant_lzq" />
<dataNode name="tenant_wy" dataHost="tenant_wy_host" database="tenant_wy" />
<dataNode name="tenant_centre" dataHost="tenant_centre_host" database="tenant_centre" />
<!--dataNode end-->
<!--dataHost start-->
<dataHost name="tenant_lzq_host" maxCon="1000" minCon="10" balance="1"
writeType="0" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100">
<heartbeat>select user()</heartbeat>
<writeHost host="hostM1" url="101.37.152.195:3306" user="root" password="lzq199528">
<readHost host="hostS2" url="101.37.152.195:3306" user="root" password="lzq199528" />
</writeHost>
</dataHost>
<dataHost name="tenant_wy_host" maxCon="1000" minCon="10" balance="1"
writeType="0" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100">
<heartbeat>select user()</heartbeat>
<writeHost host="hostM1" url="101.37.152.195:3306" user="root" password="lzq199528">
<readHost host="hostS2" url="101.37.152.195:3306" user="root" password="lzq199528" />
</writeHost>
</dataHost>
<dataHost name="tenant_centre_host" maxCon="1000" minCon="10" balance="1"
writeType="0" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100">
<heartbeat>select user()</heartbeat>
<writeHost host="hostM1" url="101.37.152.195:3306" user="root" password="lzq199528">
<readHost host="hostS2" url="101.37.152.195:3306" user="root" password="lzq199528" />
</writeHost>
</dataHost>
<!--dataHost end-->
</mycat:schema>
- server.xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- - - Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License. - You
may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0
- - Unless required by applicable law or agreed to in writing, software -
distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the
License for the specific language governing permissions and - limitations
under the License. -->
<!DOCTYPE mycat:server SYSTEM "server.dtd">
<mycat:server xmlns:mycat="http://io.mycat/">
<system>
<property name="useSqlStat">0</property> <!-- 1为开启实时统计、0为关闭 -->
<property name="useGlobleTableCheck">0</property> <!-- 1为开启全加班一致性检测、0为关闭 -->
<property name="sequnceHandlerType">0</property>
<property name="processorBufferPoolType">0</property>
<!--默认是65535 64K 用于sql解析时最大文本长度 -->
<!--<property name="maxStringLiteralLength">65535</property>-->
<!--<property name="sequnceHandlerType">0</property>-->
<!--<property name="backSocketNoDelay">1</property>-->
<!--<property name="frontSocketNoDelay">1</property>-->
<!--<property name="processorExecutor">16</property>-->
<!--
<property name="serverPort">8066</property> <property name="managerPort">9066</property>
<property name="idleTimeout">300000</property> <property name="bindIp">0.0.0.0</property>
<property name="frontWriteQueueSize">4096</property> <property name="processors">32</property> -->
<!--分布式事务开关,0为不过滤分布式事务,1为过滤分布式事务(如果分布式事务内只涉及全局表,则不过滤),2为不过滤分布式事务,但是记录分布式事务日志-->
<property name="handleDistributedTransactions">0</property>
<!--
off heap for merge/order/group/limit 1开启 0关闭
-->
<property name="useOffHeapForMerge">1</property>
<!--
单位为m
-->
<property name="memoryPageSize">1m</property>
<!--
单位为k
-->
<property name="spillsFileBufferSize">1k</property>
<property name="useStreamOutput">0</property>
<!--
单位为m
-->
<property name="systemReserveMemorySize">384m</property>
<!--是否采用zookeeper协调切换 -->
<property name="useZKSwitch">true</property>
</system>
<!--mycat用户信息 账号root 密码 lzq199528 -->
<user name="root">
<property name="password">lzq199528</property>
<!--对应schemas.xml文件的schemas标签的name-->
<property name="schemas">lzq,wy,tenant</property>
</user>
</mycat:server>
- 租户表中的 数据库名 存储 mycat schema名
- 写一个sql拦截器,在sql发送给mycat前 增加指定schema 注解
- sql拦截器代码
package mycat.multi.tenancy.conf;
import lombok.extern.slf4j.Slf4j;
import mycat.multi.tenancy.domain.UserInfo;
import mycat.multi.tenancy.service.impl.UserServiceImpl;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.DefaultReflectorFactory;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.util.Properties;
/**
* sql拦截器,通过mybatis提供的Interceptor接口实现
* @author liuzhiqiang
*/
@Slf4j
@Component
@Intercepts({@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})})
public class MySqlInterceptor implements Interceptor {
@Autowired
private HttpServletRequest request;
/**
* 拦截sql
*
* @param invocation
*/
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
// 通过MetaObject优雅访问对象的属性,这里是访问statementHandler的属性;:MetaObject是Mybatis提供的一个用于方便、
// 优雅访问对象属性的对象,通过它可以简化代码、不需要try/catch各种reflect异常,同时它支持对JavaBean、Collection、Map三种类型对象的操作。
MetaObject metaObject = MetaObject.forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY, SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY,
new DefaultReflectorFactory());
// 先拦截到RoutingStatementHandler,里面有个StatementHandler类型的delegate变量,其实现类是BaseStatementHandler,然后就到BaseStatementHandler的成员变量mappedStatement
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
// id为执行的mapper方法的全路径名,如com.cq.UserMapper.insertUser, 便于后续使用反射
String id = mappedStatement.getId();
// sql语句类型 select、delete、insert、update
String sqlCommandType = mappedStatement.getSqlCommandType().toString();
BoundSql boundSql = statementHandler.getBoundSql();
// 获取到原始sql语句
String sql = boundSql.getSql().toLowerCase();
log.info("SQL:{}", sql);
// 增强sql
// 通过反射,拦截方法上带有自定义@InterceptAnnotation注解的方法,并增强sql
String mSql = sqlAnnotationEnhance(id, sqlCommandType, sql);
// 直接增强sql
// mSql = sql + " limit 2";
//通过反射修改sql语句
Field field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, mSql);
log.info("增强后的SQL:{}", mSql);
return invocation.proceed();
}
/**
* 通过反射,拦截方法上带有自定义@InterceptAnnotation注解的方法,并增强sql
* @param id 方法全路径
* @param sqlCommandType sql类型
* @param sql 所执行的sql语句
*/
private String sqlAnnotationEnhance(String id, String sqlCommandType, String sql) throws ClassNotFoundException {
// 通过类全路径获取Class对象
Class<?> classType = Class.forName(id.substring(0, id.lastIndexOf(".")));
// 获取当前所拦截的方法名称
String mName = id.substring(id.lastIndexOf(".") + 1);
String token = request.getHeader("Authorization");
if (!StringUtils.isEmpty(token)) {
// 获取user信息 也可以登陆时存到redis中,从redis中取
UserInfo userInfo = UserServiceImpl.map.get(token);
if (userInfo != null) {
sql = "/*!mycat:schema="+userInfo.getTenantDatabase()+"*/" + sql;
}
}
log.info("sql====" + sql);
return sql;
}
@Override
public Object plugin(Object target) {
if (target instanceof StatementHandler) {
return Plugin.wrap(target, this);
} else {
return target;
}
}
@Override
public void setProperties(Properties properties) {
}
}
demo示例代码
mycat官方多租户解决方案
SAAS多租户案例
SAAS多租户的案例是Mycat粉丝的创新性应用案例之一,思路巧妙并且实现方式简单。
SAAS应用中,不同租户的数据是需要进行相互隔离的,比较常用的一种方式是不同的租户采用不同的Database存放业务数据,常规的做法是应用程序中根据租户ID连接到相应的Database,通常是需要启动多个应用实例,每个租户一个,
但这种模式消耗的资源比较多,而且不容易管理,还需要开发额外的功能,以对应租户和部署的应用实例。
在Mycat出现以后,有人利用Mycat的SQL拦截功能,巧妙的实现了SAAS多租户特
性,传统应用仅做少量的改动,就直接进化为多租户的SAAS应用,下面的内容是Mycat用户提供的具体细节:
单租户就是传统的给每个租户独立部署一套web + db 。由于租户越来越多,整个web部分的机器和运维成本都非常高,因此需要改进到所有租户共享一套web的模式(db部分暂不改变)。
基于此需求,我们对单租户的程序做了简单的改造实现web多租户共享。具体改造如下:
1.web部分修改:
a.在用户登录时,在线程变量(ThreadLocal)中记录租户的id
b.修改 jdbc的实现:在提交sql时,从ThreadLocal中获取租户id, 添加sql 注释,把租户的schema 放到注释中。例如:/*!mycat : schema = test_01 */ sql ;
2.在db前面建立proxy层,代理所有web过来的数据库请求。proxy层是用mycat实现的,web提交的sql过来时在注释中指定schema, proxy层根据指定的schema 转发sql请求。
此方案有几个关键点:
- ThreadLocal变量的巧妙使用,与Hibernate的事务管理器一样的机制,线程的一个ThreadLocal变量中保留当前线程涉及到的数据库连接、事务状态等信息
,当Service的某个事务托管的业务方法被调用时,Hibernate自动完成数据库连接的建立或重用过程,当此方法结束时,自动回收数据库连接以及提交事务
。在这里,操作数据库的线程中以ThreadLocal变量方式放入当前用户的Id以及对应的数据库Schema(Database),则此线程随后的整个调用方法堆栈中的任何一个点都能获取到用户对应的Schema,包括在JDBC的驱动程序中。
- Mycat的SQL拦截机制,Mycat提供了强大的SQL注解机制,可以用来影响SQL的路由,用户可以灵活扩展。在此方案中,:/*!mycat : schema = test_01 */ 这个注解就表明此SQL将在test_01这个Schema(Database)中执行
- 改造MySQL JDBC 驱动,MySQL JDBC驱动是开源的项目,在这里实现对SQL的拦截改造,比在程序里实现,要更加安全和可靠