优点:独立部署,数据服务隔离度高,业务定制扩展性高,如果出现故障,恢复数据比较简单
缺点:运维成本高
优点:数据隔离度高,服务共享减少维护成本,扩展性中等
缺点:数据库独立部署成本高,服务实例共享对高可用性要求高
优点: 维护和购置成本最低,允许每个数据库支持的租户数量最多。
缺点: 隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量
数据备份和恢复最困难,需要逐表逐条备份和还原;
如果希望以最少的服务器为最多的租户提供服务,并且租户接受以牺牲隔离级别换取降低成本,这种方案最适合
优点:数据库隔离度高,开发者只需要关注代理服务, 扩展性中等
缺点:数据库独立部署成本高,使用数据库代理增加维护成本,对数据库代理中间件高可用要求高,对服务实例高可用性要求高
建立租户中心服务及数据库
租户管理
用户管理
租户及用户表结构
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;
切换数据源相关技术
租户表设计同上,租户表除库名以外的连接信息可以删除
// /*!mycat:schema=test_01*/ sql ;
sql = "/*!mycat:schema="+userInfo.getTenantDatabase()+"*/" + sql;
mycat配置
<mycat:schema xmlns:mycat="http://io.mycat/">
<schema name="lzq" checkSQLschema="false" sqlMaxLimit="100">
<table name="business" dataNode="tenant_lzq"/>
schema>
<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>
<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" />
<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>
mycat:schema>
0
0
0
0
0
1
1m
1k
0
384m
true
lzq199528
lzq,wy,tenant
租户表中的 数据库名 存储 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) {
}
}
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的拦截改造,比在程序里实现,要更加安全和可靠