SaaS 系统架构,Spring Boot 动态数据源实现!
这段时候在准备从零开始做一套SaaS
系统,之前的经验都是开发单数据库系统并没有接触过SaaS系统,所以接到这个任务的时候也有也些头疼,不过办法部比困难多,难得的机会。
在网上找了很多关于SaaS
的资料,看完后使我受益匪浅,写文章之前也一直在关注SaaS
系统的开发,通过几天的探索也有一些方向。
多租户系统首先要解决的问题就是如何组织租户的数据问题
,通常情况有三种解决方案:
按数据的隔离级别依次为:
- 一个租户一个数据库实例(数据库级)
- 一个租户一个
Schema
(Schema) - 每个租户都存储在一个数据库 (行级)
以上三种数据组织方案网上都有一些介绍,就不多啰嗦了。理解三种隔离模式后,起初觉得还是蛮简单的真正开始实施的时候困难不少。
租户标识接口
定义一个TenantInfo
来标识租户信息,关于获取当前租户的方式,后面会再提到。
public interface TenantInfo {
/**
* 获取租户id
* @return
*/
Long getId();
/**
* 租户数据模式
* @return
*/
Integer getSchema();
/**
* 租户数据库信息
* @return
*/
TenantDatabase getDatabase();
/**
* 获取当前租户信息
* @return
*/
static Optional<TenantInfo> current(){
return Optional.ofNullable(
TenantInfoHolder.get()
);
}
}
DataSource 路由
以前开发的系统基本都是一个DataSource
,但是切换为多租户后我暂时分了两种数据源:
- 租户数据源(TenantDataSource)
- 系统数据源(SystemDataSource)
起初我的设想是使用Schema级
但是由于是使用的Mysql
中的Schema
和Database
是差不多的概念,所以后来的实现是基于数据库级
的。使用数据库级
的因为是系统是基于企业级用户的,数据都比较重要,企业客户很看重数据安全性方面的问题。
下面来一步步的解决动态数据源的问题。
DataSource 枚举
public enum DataSourceType {
/**
* 系统数据源
*/
SYSTEM,
/**
* 多租户数据源
*/
TENANT,
}
DataSource 注解
定义DataSourceType
枚举后,然后定义一个DataSource
注解,名称可以随意,一时没想到好名称,大家看的时候不要跟javax.sql.DataSource
类混淆了:
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface DataSource {
/**
* 数据源key
* @return
*/
com.csbaic.datasource.core.DataSourceType value() default com.csbaic.datasource.core.DataSourceType.SYSTEM;
}
处理 SpringBoot 自动装配的 DataSource
如果你熟悉SpringBoot
,应该知道有一个DataSourceAutoConfiguration
配置会自动创建一个javax.sql.DataSource
,由于在多租户环境下随时都有可能要切换数据源,所以需要将自动装配的javax.sql.DataSource
替换掉:
@Slf4j
public class DataSourceBeanPostProcessor implements BeanPostProcessor {
@Autowired
private ObjectProvider<RoutingDataSourceProperties> dataSourceProperties;
@Autowired
private ObjectProvider<TenantDataSourceFactory> factory;
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if(bean instanceof DataSource){
log.debug("process DataSource: {}", bean.getClass().getName());
return new RoutingDataSource((DataSource) bean, factory, dataSourceProperties);
}
return bean;
}
}
基于BeanPostProcessor
的处理,将自动装配的数据源替换成RoutingDataSource
,关于RoutingDataSource
后面会再提到。这样可将自动装配的数据源直接作为系统数据源
其他需要使用数据源的地方不用特殊处理,也不需要在每个服务中排除DataSourceAutoConfiguration
的自动装配。
使用 ThreadLocal 保存数据源类型
数据源的切换是根据前面提到的数据源类型枚举DataSourceType
来的,当需要切换不到的数据源时将对应的数据源类型设置进ThreadLocal
中:
public class DataSourceHolder {
private static final ThreadLocal<Stack<DataSourceType>> datasources = new ThreadLocal<>();
/**
* 获取当前线程数据源
* @return
*/
public static DataSourceType get(){
Stack<DataSourceType> stack = datasources.get();
return stack != null ? stack.peek() : null;
}
/**
* 设置当前线程数据源
* @param type
*/
public static void push(DataSourceType type){
Stack<DataSourceType> stack = datasources.get();
if(stack == null){
stack = new Stack<>();
datasources.set(stack);
}
stack.push(type);
}
/**
* 移除数据源配置
*/
public static void remove(){
Stack<DataSourceType> stack = datasources.get();
if(stack == null){
return;
}
stack.pop();
if(stack.isEmpty()){
datasources.remove();
}
}
}
在DataSourceHolder.datasources
是使用的Stack
而不是直接持有DataSource
这样会稍微灵活一点,试想一下从方法A
中调用方法B
,A,B方法中各自要操作不同的数据源,当方法B
执行完成后,回到方法A
中,如果是在ThreadLocal
直接持有DataSource
的话,A方法
继续操作就会对数据源产生不确定性。
AOP 切换数据源
要是在每个类方法都需要手机切换数据源,那也太不方便了,得益于AOP编程可以在调用需要切换数据源的方法的时候做一些手脚:
@Slf4j
@Aspect
public class DataSourceAspect {
@Pointcut(value = "(@within(com.csbaic.datasource.annotation.DataSource) || @annotation(com.csbaic.datasource.annotation.DataSource)) && within(com.csbaic..*)")
public void dataPointCut(){
}
@Before("dataPointCut()")
public void before(JoinPoint joinPoint){
Class<?> aClass = joinPoint.getTarget().getClass();
// 获取类级别注解
DataSource classAnnotation = aClass.getAnnotation(DataSource.class);
if (classAnnotation != null){
com.csbaic.datasource.core.DataSourceType dataSource = classAnnotation.value();
log.info("this is datasource: "+ dataSource);
DataSourceHolder.push(dataSource);
}else {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
DataSource methodAnnotation = method.getAnnotation(DataSource.class);
if (methodAnnotation != null){
com.csbaic.datasource.core.DataSourceType dataSource = methodAnnotation.value();
log.info("this is dataSource: "+ dataSource);
DataSourceHolder.push(dataSource);
}
}
}
@After("dataPointCut()")
public void after(JoinPoint joinPoint){
log.info("执行完毕!");
DataSourceHolder.remove();
}
}
DataSourceAspect
很简单在有com.csbaic.datasource.annotation.DataSource
注解的方法或者类中切换、还原使用DataSourceHolder
类切换数据源。
动态获取、构造数据源
前面说了那么多都是在为获取、构建数据源做准备工作,一但数据源切换成功,业务服务获取数据时就会使用javax.sql.DataSource
获取数据库连接,这里就要说到RoutingDataSource
了:
@Slf4j
public class RoutingDataSource extends AbstractDataSource {
/**
* 已保存的DataSource
*/
private final DataSource systemDataSource;
/**
* 租户数据源工厂
*/
private final ObjectProvider<TenantDataSourceFactory> factory;
/**
* 解析数据源
* @return
*/
protected DataSource resolveDataSource(){
DataSourceType type = DataSourceHolder.get();
RoutingDataSourceProperties pros = properties.getIfAvailable();
TenantDataSourceFactory tenantDataSourceFactory = factory.getIfAvailable();
if(tenantDataSourceFactory == null){
throw new DataSourceLookupFailureException("租户数据源不正确");
}
if(pros == null){
throw new DataSourceLookupFailureException("数据源属性不正确");
}
if(type == null){
log.warn("没有显示的设置数据源,使用默认数据源:{}", pros.getDefaultType());
type = pros.getDefaultType();
}
log.warn("数据源类型:{}", type);
if(type == DataSourceType.SYSTEM){
return systemDataSource;
}else if(type == DataSourceType.TENANT){
return tenantDataSourceFactory.create();
}
throw new DataSourceLookupFailureException("解析数据源失败");
}
}
在resolveDataSource
方法中,首先获取数据源类型:
DataSourceType type = DataSourceHolder.get();
然后根据数据源类型获取数据源:
if(type == DataSourceType.SYSTEM){
return systemDataSource;
}else if(type == DataSourceType.TENANT){
return tenantDataSourceFactory.create();
}
系统类型的数据源较简单直接返回,在租户类型的数据时就要作额外的操作,如果是数据库级
的隔离模式就需要为每个租户创建数据源,这里封装了一个TenantDataSourceFactory
来构建租户数据源:
public interface TenantDataSourceFactory {
/**
* 构建一个数据源
* @return
*/
DataSource create();
/**
* 构建一个数据源
* @return
*/
DataSource create(TenantInfo info);
}
实现方面大致就是从系统数据源中获取租户的数据源配置信息,然后构造一个javax.sql.DataSource
。
注意:租户数据源一定要缓存起来,每次都构建太浪费。。。
小结
经过上面的一系统配置后,相信切换数据已经可以实现了。业务代码不关心使用的数据源,后续切换成隔离模式也比较方便。但是呢,总觉得只支持一种隔离模式又不太好,隔离模式更高的模式也可以作为收费项的麻。。。
使用 Mybatis Plus 实现行级隔离模式
上前提到动态数据源都是基于数据库级的,一个租户一个数据库消耗还是很大的,难达到SaaS的规模效应,一但租户增多数据库管理、运维都是成本。
比如有些试用用户不一定用购买只是想试用,直接开个数据库也麻烦,况且前期开发也麻烦的很,数据备份、还原、字段修改都要花时间和人力的,所以能不能同时支持多种数据隔离模式呢?答案是肯定的,利益于Mybatis Plus
可的多租户 SQL 解析器
以轻松实现,详细文档可参考:
多租户 SQL 解析器:https://mp.baomidou.com/guide/tenant.html
只需要配置TenantSqlParser
和TenantHandler
就可以实现行级的数据隔离模式:
public class RowTenantHandler implements TenantHandler {
@Override
public Expression getTenantId(boolean where) {
TenantInfo tenantInfo = TenantInfo.current().orElse(null);
if(tenantInfo == null){
throw new IllegalStateException("No tenant");
}
return new LongValue(tenantInfo.getId());
}
@Override
public String getTenantIdColumn() {
return TenantConts.TENANT_COLUMN_NAME;
}
@Override
public boolean doTableFilter(String tableName) {
TenantInfo tenantInfo = TenantInfo.current().orElse(null);
//忽略系统表或者没有解析到租户id,直接过滤
return tenantInfo == null || tableName.startsWith(SystemInfo.SYS_TABLE_PREFIX);
}
}
回想一下上面使用的TenantDataSourceFactory
接口,对于行级的隔离模式,构造不同的数据源就可以了。
如何解析当前租户信息?
多租户环境下,对于每一个http请求可能是对系统数据或者租户数据的操作,如何区分租户也是个问题。
以下列举几种解析租户的方式:
- 系统为每个用户生成一个二级域名如:
tenant-{id}.csbaic.com
业务系统使用Host
、Origin
、X-Forwarded-Host
等请求头按指定的模式解析租户 - 前端携带租户id参数如:
www.csbaic.com?tenantId=xxx
- 根据请求uri路径获取如:
www.csbaic.com/api/{tenantId}
- 解析前端传递的token,获取租户信息
- 租户自定义域名解析,有些功能租户可以绑定自己的域名
解析方式现在大概只知道这些,如果有好的方案欢迎大家补充。为了以为扩展方便定义一个TenantResolver
接口:
/**
* 解析租户
*/
public interface TenantResolver {
/**
* 从请求中解析租户信息
* @param request 当前请求
* @return
*/
Long resolve(HttpServletRequest request);
}
然后可以将所有的解析方式都聚合起来统一处理:
/**
*
* @param domainMapper
* @return
*/
@Bean
public TenantResolver tenantConsoleTenantResolver(TenantDomainMapper domainMapper, ITokenService tokenService){
return new CompositeTenantResolver(
new SysDomainTenantResolver(),
new RequestHeaderTenantResolver(),
new RequestQueryTenantResolver(),
new TokenTenantResolver(tokenService),
new CustomDomainTenantResolver(domainMapper)
);
}
最后再定义一个Filter
来调用解析器,解析租户:
public class UaaTenantServiceFilter implements Filter {
private final TenantInfoService tenantInfoService;
public UaaTenantServiceFilter(TenantInfoService tenantInfoService) {
this.tenantInfoService = tenantInfoService;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
//从request解析租户信息
try{
TenantInfo tenantInfo = tenantInfoService.getTenantInfo((HttpServletRequest) request);
TenantInfoHolder.set(tenantInfo);
chain.doFilter(request,response);
}finally {
TenantInfoHolder.remove();
}
}
}
TenantInfoService
是获取租户信息的接口,内部还是通过TenantResolver
来解析租户Id,然后通过id从系统数据库获取当前租户的信息。
总结
解决完动态数据源、租户信息获取两个问题后,只是一小步,后续还有很多问题需要处理如:系统权限和租户权限、统一登陆和鉴权、数据统计等等。。。,相信这些问题都会解决的,后续再来分享。
推荐阅读
- 十分钟入门RocketMQ
- Spring Boot 构建多租户 SaaS 平台核心技术指南
- Redis 缓存和MySQL数据一致性方案详解
- Nginx 限流配置
- 深入探秘 Netty、Kafka中的零拷贝技术!
学习资料分享
12 套 微服务、Spring Boot、Spring Cloud 核心技术资料,这是部分资料目录:
- Spring Security 认证与授权
- Spring Boot 项目实战(中小型互联网公司后台服务架构与运维架构)
- Spring Boot 项目实战(企业权限管理项目))
- Spring Cloud 微服务架构项目实战(分布式事务解决方案)
- ...
公众号后台回复arch028
获取资料::
SaaS 系统架构,Spring Boot 动态数据源实现!的更多相关文章
- (43). Spring Boot动态数据源(多数据源自动切换)【从零开始学Spring Boot】
在上一篇我们介绍了多数据源,但是我们会发现在实际中我们很少直接获取数据源对象进行操作,我们常用的是jdbcTemplate或者是jpa进行操作数据库.那么这一节我们将要介绍怎么进行多数据源动态切换.添 ...
- 43. Spring Boot动态数据源(多数据源自动切换)【从零开始学Spring Boot】
[视频&交流平台] àSpringBoot视频 http://study.163.com/course/introduction.htm?courseId=1004329008&utm ...
- Spring Boot 动态数据源(多数据源自己主动切换)
本文实现案例场景: 某系统除了须要从自己的主要数据库上读取和管理数据外.另一部分业务涉及到其它多个数据库,要求能够在不论什么方法上能够灵活指定详细要操作的数据库. 为了在开发中以最简单的方法使用,本文 ...
- Spring Boot 动态数据源(Spring 注解数据源)
本文实现案例场景:某系统除了需要从自己的主要数据库上读取和管理数据外,还有一部分业务涉及到其他多个数据库,要求可以在任何方法上可以灵活指定具体要操作的数据库. 为了在开发中以最简单的方法使用,本文基于 ...
- Spring Boot 动态数据源(多数据源自动切换)
本文实现案例场景: 某系统除了需要从自己的主要数据库上读取和管理数据外,还有一部分业务涉及到其他多个数据库,要求可以在任何方法上可以灵活指定具体要操作的数据库. 为了在开发中以最简单的方法使用,本文基 ...
- spring boot动态数据源方案
动态数据源 1.背景 动态数据源在实际的业务场景下需求很多,而且想要沟通多数据库确实需要封装这种工具,针对于bi工具可能涉及到从不同的业务库或者数据仓库中获取数据,动态数据源就更加有意义. 2.依赖 ...
- 22. Spring Boot 动态数据源(多数据源自动切换)
转自:https://blog.csdn.net/catoop/article/details/50575038
- saas系统架构经验总结
2B Saas系统最近几年都很火.很多创业公司都在尝试创建企业级别的应用 cRM, HR,销售, Desk Saas系统.很多Saas创业公司也拿了大额风投.毕竟Saas相对传统软件的优势非常明显. ...
- SaaS 系统架构设计经验总结
2B SaaS系统最近几年都很火.很多创业公司都在尝试创建企业级别的应用 cRM, HR,销售, Desk SaaS系统.很多SaaS创业公司也拿了大额风投.毕竟SaaS相对传统软件的优势非常明显. ...
随机推荐
- zabbix 邮箱告警
脚本内容 #!/bin/env python #coding:utf- import smtplib from email.mime.text import MIMEText from sys imp ...
- 私有云nextcloud、seafile、syncthing的比较
可选 nextcloud.seafile.syncthing 1. seafile https://www.jianshu.com/p/43f570118e63 https://www.jianshu ...
- 【漏洞复现】Fastjson <=1.2.47远程命令执行
0x01 漏洞概述 漏洞描述 Fastjson是一款开源JSON解析库,它可以解析JSON格式的字符串,支持将Java Bean序列化为JSON字符串,也可以从JSON字符串反序列化到JavaBe ...
- DS-4-单链表的各种插入与删除的实现
typedef struct LNode { int data; struct LNode *next; }LNode, *LinkList; 带头结点的按位序插入: //在第i个位置插入元素e bo ...
- mysql索引小总结
MySql 1.索引 mysql索引默认使用的是B+Tree(B-树的变种版).也可以使用HASH表. 二叉树: 二叉树又称二叉搜索树,二叉排序树,特点如下: 左子树上所有结点值均小于根结点 右子树上 ...
- Zookeeper——基本使用以及应用场景(手写实现分布式锁和rpc框架)
文章目录 Zookeeper的基本使用 Zookeeper单机部署 Zookeeper集群搭建 JavaAPI的使用 Zookeeper的应用场景 分布式锁的实现 独享锁 可重入锁 实现RPC框架 基 ...
- mac App 破解之路六 studio 3t
不想无限使用,直接破解到正版: 输入邮箱 名字之后 还有licence信息之后,处理函数是: this.text.getText() 很明显是你输入的licence. 然后交给父类okPress处 ...
- .Net Core踩坑记:读取txt中文乱码
迁移.net framework的项目,有块读取txt中文转码的问题,普通的不能再普通的代码,想都没想直接copy过去,也没测,结果今天就被坑了.Core是3.1版本,这是原来的代码: string ...
- 138 张图带你 MySQL 入门
SQL 基础使用 MySQL 是一种关系型数据库,说到关系,那么就离不开表与表之间的关系,而最能体现这种关系的其实就是我们接下来需要介绍的主角 SQL,SQL 的全称是 Structure Query ...
- NodeJs将异步方法改为同步以上传文件为例
[本文版权归微信公众号"代码艺术"(ID:onblog)所有,若是转载请务必保留本段原创声明,违者必究.若是文章有不足之处,欢迎关注微信公众号私信与我进行交流!] 下面这个例子既写 ...