这段时候在准备从零开始做一套SaaS系统,之前的经验都是开发单数据库系统并没有接触过SaaS系统,所以接到这个任务的时候也有也些头疼,不过办法部比困难多,难得的机会。

在网上找了很多关于SaaS的资料,看完后使我受益匪浅,写文章之前也一直在关注SaaS系统的开发,通过几天的探索也有一些方向。

多租户系统首先要解决的问题就是如何组织租户的数据问题,通常情况有三种解决方案:

按数据的隔离级别依次为:

  1. 一个租户一个数据库实例(数据库级)
  2. 一个租户一个Schema (Schema)
  3. 每个租户都存储在一个数据库 (行级)

以上三种数据组织方案网上都有一些介绍,就不多啰嗦了。理解三种隔离模式后,起初觉得还是蛮简单的真正开始实施的时候困难不少。

租户标识接口

定义一个TenantInfo来标识租户信息,关于获取当前租户的方式,后面会再提到。

  1. public interface TenantInfo {
  2. /**
  3. * 获取租户id
  4. * @return
  5. */
  6. Long getId();
  7. /**
  8. * 租户数据模式
  9. * @return
  10. */
  11. Integer getSchema();
  12. /**
  13. * 租户数据库信息
  14. * @return
  15. */
  16. TenantDatabase getDatabase();
  17. /**
  18. * 获取当前租户信息
  19. * @return
  20. */
  21. static Optional<TenantInfo> current(){
  22. return Optional.ofNullable(
  23. TenantInfoHolder.get()
  24. );
  25. }
  26. }

DataSource 路由

以前开发的系统基本都是一个DataSource,但是切换为多租户后我暂时分了两种数据源:

  • 租户数据源(TenantDataSource)
  • 系统数据源(SystemDataSource)

起初我的设想是使用Schema级但是由于是使用的Mysql中的SchemaDatabase是差不多的概念,所以后来的实现是基于数据库级的。使用数据库级的因为是系统是基于企业级用户的,数据都比较重要,企业客户很看重数据安全性方面的问题。

下面来一步步的解决动态数据源的问题。

DataSource 枚举


  1. public enum DataSourceType {
  2. /**
  3. * 系统数据源
  4. */
  5. SYSTEM,
  6. /**
  7. * 多租户数据源
  8. */
  9. TENANT,
  10. }

DataSource 注解

定义DataSourceType枚举后,然后定义一个DataSource注解,名称可以随意,一时没想到好名称,大家看的时候不要跟javax.sql.DataSource类混淆了:

  1. @Retention(RetentionPolicy.RUNTIME)
  2. @Documented
  3. @Target({ElementType.TYPE, ElementType.METHOD})
  4. public @interface DataSource {
  5. /**
  6. * 数据源key
  7. * @return
  8. */
  9. com.csbaic.datasource.core.DataSourceType value() default com.csbaic.datasource.core.DataSourceType.SYSTEM;
  10. }

处理 SpringBoot 自动装配的 DataSource

如果你熟悉SpringBoot,应该知道有一个DataSourceAutoConfiguration配置会自动创建一个javax.sql.DataSource,由于在多租户环境下随时都有可能要切换数据源,所以需要将自动装配的javax.sql.DataSource替换掉:

  1. @Slf4j
  2. public class DataSourceBeanPostProcessor implements BeanPostProcessor {
  3. @Autowired
  4. private ObjectProvider<RoutingDataSourceProperties> dataSourceProperties;
  5. @Autowired
  6. private ObjectProvider<TenantDataSourceFactory> factory;
  7. @Override
  8. public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
  9. if(bean instanceof DataSource){
  10. log.debug("process DataSource: {}", bean.getClass().getName());
  11. return new RoutingDataSource((DataSource) bean, factory, dataSourceProperties);
  12. }
  13. return bean;
  14. }
  15. }

基于BeanPostProcessor的处理,将自动装配的数据源替换成RoutingDataSource,关于RoutingDataSource后面会再提到。这样可将自动装配的数据源直接作为系统数据源其他需要使用数据源的地方不用特殊处理,也不需要在每个服务中排除DataSourceAutoConfiguration的自动装配。

使用 ThreadLocal 保存数据源类型

数据源的切换是根据前面提到的数据源类型枚举DataSourceType来的,当需要切换不到的数据源时将对应的数据源类型设置进ThreadLocal中:


  1. public class DataSourceHolder {
  2. private static final ThreadLocal<Stack<DataSourceType>> datasources = new ThreadLocal<>();
  3. /**
  4. * 获取当前线程数据源
  5. * @return
  6. */
  7. public static DataSourceType get(){
  8. Stack<DataSourceType> stack = datasources.get();
  9. return stack != null ? stack.peek() : null;
  10. }
  11. /**
  12. * 设置当前线程数据源
  13. * @param type
  14. */
  15. public static void push(DataSourceType type){
  16. Stack<DataSourceType> stack = datasources.get();
  17. if(stack == null){
  18. stack = new Stack<>();
  19. datasources.set(stack);
  20. }
  21. stack.push(type);
  22. }
  23. /**
  24. * 移除数据源配置
  25. */
  26. public static void remove(){
  27. Stack<DataSourceType> stack = datasources.get();
  28. if(stack == null){
  29. return;
  30. }
  31. stack.pop();
  32. if(stack.isEmpty()){
  33. datasources.remove();
  34. }
  35. }
  36. }

DataSourceHolder.datasources是使用的Stack而不是直接持有DataSource这样会稍微灵活一点,试想一下从方法A中调用方法B,A,B方法中各自要操作不同的数据源,当方法B执行完成后,回到方法A中,如果是在ThreadLocal直接持有DataSource的话,A方法继续操作就会对数据源产生不确定性。

AOP 切换数据源

要是在每个类方法都需要手机切换数据源,那也太不方便了,得益于AOP编程可以在调用需要切换数据源的方法的时候做一些手脚:


  1. @Slf4j
  2. @Aspect
  3. public class DataSourceAspect {
  4. @Pointcut(value = "(@within(com.csbaic.datasource.annotation.DataSource) || @annotation(com.csbaic.datasource.annotation.DataSource)) && within(com.csbaic..*)")
  5. public void dataPointCut(){
  6. }
  7. @Before("dataPointCut()")
  8. public void before(JoinPoint joinPoint){
  9. Class<?> aClass = joinPoint.getTarget().getClass();
  10. // 获取类级别注解
  11. DataSource classAnnotation = aClass.getAnnotation(DataSource.class);
  12. if (classAnnotation != null){
  13. com.csbaic.datasource.core.DataSourceType dataSource = classAnnotation.value();
  14. log.info("this is datasource: "+ dataSource);
  15. DataSourceHolder.push(dataSource);
  16. }else {
  17. MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
  18. Method method = methodSignature.getMethod();
  19. DataSource methodAnnotation = method.getAnnotation(DataSource.class);
  20. if (methodAnnotation != null){
  21. com.csbaic.datasource.core.DataSourceType dataSource = methodAnnotation.value();
  22. log.info("this is dataSource: "+ dataSource);
  23. DataSourceHolder.push(dataSource);
  24. }
  25. }
  26. }
  27. @After("dataPointCut()")
  28. public void after(JoinPoint joinPoint){
  29. log.info("执行完毕!");
  30. DataSourceHolder.remove();
  31. }
  32. }

DataSourceAspect很简单在有com.csbaic.datasource.annotation.DataSource注解的方法或者类中切换、还原使用DataSourceHolder类切换数据源。

动态获取、构造数据源

前面说了那么多都是在为获取、构建数据源做准备工作,一但数据源切换成功,业务服务获取数据时就会使用javax.sql.DataSource获取数据库连接,这里就要说到RoutingDataSource了:


  1. @Slf4j
  2. public class RoutingDataSource extends AbstractDataSource {
  3. /**
  4. * 已保存的DataSource
  5. */
  6. private final DataSource systemDataSource;
  7. /**
  8. * 租户数据源工厂
  9. */
  10. private final ObjectProvider<TenantDataSourceFactory> factory;
  11. /**
  12. * 解析数据源
  13. * @return
  14. */
  15. protected DataSource resolveDataSource(){
  16. DataSourceType type = DataSourceHolder.get();
  17. RoutingDataSourceProperties pros = properties.getIfAvailable();
  18. TenantDataSourceFactory tenantDataSourceFactory = factory.getIfAvailable();
  19. if(tenantDataSourceFactory == null){
  20. throw new DataSourceLookupFailureException("租户数据源不正确");
  21. }
  22. if(pros == null){
  23. throw new DataSourceLookupFailureException("数据源属性不正确");
  24. }
  25. if(type == null){
  26. log.warn("没有显示的设置数据源,使用默认数据源:{}", pros.getDefaultType());
  27. type = pros.getDefaultType();
  28. }
  29. log.warn("数据源类型:{}", type);
  30. if(type == DataSourceType.SYSTEM){
  31. return systemDataSource;
  32. }else if(type == DataSourceType.TENANT){
  33. return tenantDataSourceFactory.create();
  34. }
  35. throw new DataSourceLookupFailureException("解析数据源失败");
  36. }
  37. }

resolveDataSource方法中,首先获取数据源类型:

  1. DataSourceType type = DataSourceHolder.get();

然后根据数据源类型获取数据源:


  1. if(type == DataSourceType.SYSTEM){
  2. return systemDataSource;
  3. }else if(type == DataSourceType.TENANT){
  4. return tenantDataSourceFactory.create();
  5. }

系统类型的数据源较简单直接返回,在租户类型的数据时就要作额外的操作,如果是数据库级的隔离模式就需要为每个租户创建数据源,这里封装了一个TenantDataSourceFactory来构建租户数据源:

  1. public interface TenantDataSourceFactory {
  2. /**
  3. * 构建一个数据源
  4. * @return
  5. */
  6. DataSource create();
  7. /**
  8. * 构建一个数据源
  9. * @return
  10. */
  11. DataSource create(TenantInfo info);
  12. }

实现方面大致就是从系统数据源中获取租户的数据源配置信息,然后构造一个javax.sql.DataSource

注意:租户数据源一定要缓存起来,每次都构建太浪费。。。

小结

经过上面的一系统配置后,相信切换数据已经可以实现了。业务代码不关心使用的数据源,后续切换成隔离模式也比较方便。但是呢,总觉得只支持一种隔离模式又不太好,隔离模式更高的模式也可以作为收费项的麻。。。

使用 Mybatis Plus 实现行级隔离模式

上前提到动态数据源都是基于数据库级的,一个租户一个数据库消耗还是很大的,难达到SaaS的规模效应,一但租户增多数据库管理、运维都是成本。

比如有些试用用户不一定用购买只是想试用,直接开个数据库也麻烦,况且前期开发也麻烦的很,数据备份、还原、字段修改都要花时间和人力的,所以能不能同时支持多种数据隔离模式呢?答案是肯定的,利益于Mybatis Plus可的多租户 SQL 解析器以轻松实现,详细文档可参考:

  1. 多租户 SQL 解析器:https://mp.baomidou.com/guide/tenant.html

只需要配置TenantSqlParserTenantHandler就可以实现行级的数据隔离模式:

  1. public class RowTenantHandler implements TenantHandler {
  2. @Override
  3. public Expression getTenantId(boolean where) {
  4. TenantInfo tenantInfo = TenantInfo.current().orElse(null);
  5. if(tenantInfo == null){
  6. throw new IllegalStateException("No tenant");
  7. }
  8. return new LongValue(tenantInfo.getId());
  9. }
  10. @Override
  11. public String getTenantIdColumn() {
  12. return TenantConts.TENANT_COLUMN_NAME;
  13. }
  14. @Override
  15. public boolean doTableFilter(String tableName) {
  16. TenantInfo tenantInfo = TenantInfo.current().orElse(null);
  17. //忽略系统表或者没有解析到租户id,直接过滤
  18. return tenantInfo == null || tableName.startsWith(SystemInfo.SYS_TABLE_PREFIX);
  19. }
  20. }

回想一下上面使用的TenantDataSourceFactory接口,对于行级的隔离模式,构造不同的数据源就可以了。

如何解析当前租户信息?

多租户环境下,对于每一个http请求可能是对系统数据或者租户数据的操作,如何区分租户也是个问题。

以下列举几种解析租户的方式:

  • 系统为每个用户生成一个二级域名如:tenant-{id}.csbaic.com业务系统使用HostOriginX-Forwarded-Host等请求头按指定的模式解析租户
  • 前端携带租户id参数如:www.csbaic.com?tenantId=xxx
  • 根据请求uri路径获取如:www.csbaic.com/api/{tenantId}
  • 解析前端传递的token,获取租户信息
  • 租户自定义域名解析,有些功能租户可以绑定自己的域名

解析方式现在大概只知道这些,如果有好的方案欢迎大家补充。为了以为扩展方便定义一个TenantResolver接口:


  1. /**
  2. * 解析租户
  3. */
  4. public interface TenantResolver {
  5. /**
  6. * 从请求中解析租户信息
  7. * @param request 当前请求
  8. * @return
  9. */
  10. Long resolve(HttpServletRequest request);
  11. }

然后可以将所有的解析方式都聚合起来统一处理:


  1. /**
  2. *
  3. * @param domainMapper
  4. * @return
  5. */
  6. @Bean
  7. public TenantResolver tenantConsoleTenantResolver(TenantDomainMapper domainMapper, ITokenService tokenService){
  8. return new CompositeTenantResolver(
  9. new SysDomainTenantResolver(),
  10. new RequestHeaderTenantResolver(),
  11. new RequestQueryTenantResolver(),
  12. new TokenTenantResolver(tokenService),
  13. new CustomDomainTenantResolver(domainMapper)
  14. );
  15. }

最后再定义一个Filter来调用解析器,解析租户:

  1. public class UaaTenantServiceFilter implements Filter {
  2. private final TenantInfoService tenantInfoService;
  3. public UaaTenantServiceFilter(TenantInfoService tenantInfoService) {
  4. this.tenantInfoService = tenantInfoService;
  5. }
  6. @Override
  7. public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
  8. //从request解析租户信息
  9. try{
  10. TenantInfo tenantInfo = tenantInfoService.getTenantInfo((HttpServletRequest) request);
  11. TenantInfoHolder.set(tenantInfo);
  12. chain.doFilter(request,response);
  13. }finally {
  14. TenantInfoHolder.remove();
  15. }
  16. }
  17. }

TenantInfoService是获取租户信息的接口,内部还是通过TenantResolver来解析租户Id,然后通过id从系统数据库获取当前租户的信息。

总结

解决完动态数据源、租户信息获取两个问题后,只是一小步,后续还有很多问题需要处理如:系统权限和租户权限、统一登陆和鉴权、数据统计等等。。。,相信这些问题都会解决的,后续再来分享。

推荐阅读

学习资料分享

12 套 微服务、Spring Boot、Spring Cloud 核心技术资料,这是部分资料目录:

  • Spring Security 认证与授权
  • Spring Boot 项目实战(中小型互联网公司后台服务架构与运维架构)
  • Spring Boot 项目实战(企业权限管理项目))
  • Spring Cloud 微服务架构项目实战(分布式事务解决方案)
  • ...

公众号后台回复arch028获取资料::

SaaS 系统架构,Spring Boot 动态数据源实现!的更多相关文章

  1. (43). Spring Boot动态数据源(多数据源自动切换)【从零开始学Spring Boot】

    在上一篇我们介绍了多数据源,但是我们会发现在实际中我们很少直接获取数据源对象进行操作,我们常用的是jdbcTemplate或者是jpa进行操作数据库.那么这一节我们将要介绍怎么进行多数据源动态切换.添 ...

  2. 43. Spring Boot动态数据源(多数据源自动切换)【从零开始学Spring Boot】

    [视频&交流平台] àSpringBoot视频 http://study.163.com/course/introduction.htm?courseId=1004329008&utm ...

  3. Spring Boot 动态数据源(多数据源自己主动切换)

    本文实现案例场景: 某系统除了须要从自己的主要数据库上读取和管理数据外.另一部分业务涉及到其它多个数据库,要求能够在不论什么方法上能够灵活指定详细要操作的数据库. 为了在开发中以最简单的方法使用,本文 ...

  4. Spring Boot 动态数据源(Spring 注解数据源)

    本文实现案例场景:某系统除了需要从自己的主要数据库上读取和管理数据外,还有一部分业务涉及到其他多个数据库,要求可以在任何方法上可以灵活指定具体要操作的数据库. 为了在开发中以最简单的方法使用,本文基于 ...

  5. Spring Boot 动态数据源(多数据源自动切换)

    本文实现案例场景: 某系统除了需要从自己的主要数据库上读取和管理数据外,还有一部分业务涉及到其他多个数据库,要求可以在任何方法上可以灵活指定具体要操作的数据库. 为了在开发中以最简单的方法使用,本文基 ...

  6. spring boot动态数据源方案

    动态数据源 1.背景 动态数据源在实际的业务场景下需求很多,而且想要沟通多数据库确实需要封装这种工具,针对于bi工具可能涉及到从不同的业务库或者数据仓库中获取数据,动态数据源就更加有意义. 2.依赖 ...

  7. 22. Spring Boot 动态数据源(多数据源自动切换)

    转自:https://blog.csdn.net/catoop/article/details/50575038

  8. saas系统架构经验总结

    2B Saas系统最近几年都很火.很多创业公司都在尝试创建企业级别的应用 cRM, HR,销售, Desk Saas系统.很多Saas创业公司也拿了大额风投.毕竟Saas相对传统软件的优势非常明显. ...

  9. SaaS 系统架构设计经验总结

    2B SaaS系统最近几年都很火.很多创业公司都在尝试创建企业级别的应用 cRM, HR,销售, Desk SaaS系统.很多SaaS创业公司也拿了大额风投.毕竟SaaS相对传统软件的优势非常明显. ...

随机推荐

  1. 因为 MongoDB 没入门,我丢了一份实习工作

    有时候不得不感慨一下,系统升级真的是好处多多,不仅让我有机会重构了之前的烂代码,也满足了我积极好学的虚荣心.你看,Redis 入门了.Elasticsearch 入门了,这次又要入门 MongoDB, ...

  2. WPF 如何流畅地滚动ScrollViewer 简单实现下

    看了看原生UWP的ScrollViewer,滑动很流畅(例如 开始菜单),但是WPF自带的ScrollViewer滚动十分生硬.. 突发奇想,今天来实现一个流畅滚动的ScrollViewer. 一.目 ...

  3. cb44a_c++_STL_算法_删除_(2)remove_copy_remove_copy_if

    cb44a_c++_STL_算法_删除_(2)remove_copy_remove_copy_if remove_copy()//在复制过程中删除一些数据remove_copy_if() 删除性算法: ...

  4. cb15a_c++_vector容器的自增长_每次增加百分之50

    cb15a_c++_vector容器的自增长_每次增加百分之50每次自动容量代销扩充,增加百分之50_for windows C++,vector是用数组做出来的->数组的缺点和优点优点:具有下 ...

  5. WIN10下如何解决PL2303驱动不可用的问题或者com口显示黄色感叹号usb-to-serial

    WIN10下如何解决PL2303驱动不可用的问题或者com口显示黄色感叹号usb-to-serial

  6. 1.二进制部署kubernetes

    目录 kubernetes的五个组件 master节点的三个组件 kube-apiserver kube-controller-manager kube-scheduler node节点的两个组件 k ...

  7. Kali中密码暴力破解工具hydra的使用

    前言 hydra是著名黑客组织thc的一款开源的暴力破解密码工具,功能非常强大,kali下是默认安装的,几乎支持所有协议的在线破解.密码能否破解,在于字典是否强大.本文仅从安全角度去讲解工具的使用,请 ...

  8. Spring AOP学习笔记04:AOP核心实现之创建代理

    上文中,我们分析了对所有增强器的获取以及获取匹配的增强器,在本文中我们就来分析一下Spring AOP中另一部分核心逻辑--代理的创建.这部分逻辑的入口是在wrapIfNecessary()方法中紧接 ...

  9. 底层剖析Python深浅拷贝

    底层剖析Python深浅拷贝 拷贝的用途 拷贝就是copy,目的在于复制出一份一模一样的数据.使用相同的算法对于产生的数据有多种截然不同的用途时就可以使用copy技术,将copy出的各种副本去做各种不 ...

  10. IDEA 2019版本永久破解教程

    1.第一步解压文件(文件网盘下载链接在下面) 2.运行IDEA安装包 3.点击Next 4.注意安装位置文件夹不要带中文-选择好点击Next 5.勾选64-bit launcher,勾选.java,点 ...