【解决方案】多租户技术架构设计入门(二)

目录

前言

对于整个多租户技术架构的设计而言,笔者认为最关键的就是 3 点:底层数据隔离模式(策略) + 统一的用户&权限体系(认证鉴权) + 业务层调用时的行为隔离(请求拦截)。

其次可以拓展的有:租户管理系统 + 门户系统 + 角色配置中心等。基本的一些概念我在上篇文章中已经有过较为详细的介绍,此处便不再赘述了。

作为入门系列的第二篇,本文主要分享的是在业务系统的应用内部如何对多数据源进行切换,而底层的数据库硬件资源管理这部分会简单带过(一般由运维团队来负责搭建)。

下面我就从多数据源设计、技术选型、应用配置、具体实现这几个方面来做一个详细的分享。

一、多数据源设计

1.1概念模型

首先我们要先明确:所有接进来的租户,都是使用同一套代码,即同一套服务,但每个租户会拥有属于自己的数据库。

本小节先介绍概念模型,数据隔离模式的分析会在1.2小节展开。

在单租户的时候,每个系统只为一个客户服务,我们只需要在每个业务系统的配置文件上写一个数据库连接,就可以确保该系统的数据会进到这个对应的库表里。

在多租户的背景下,这里所有业务系统也都只有一个连接,即一个多数据源库,根据系统所在的不同环境和租户连接不同的库。

这个库里面只有一张表,每一行数据里放的是所有业务系统各自的数据库连接,这个设计是不同的系统找到各自库Url连接的第一步。

下面对几个关键的字段进行解读:

  • system_code:每个业务系统的标识,要求唯一
  • data_code:其实就是数据源的标识,一般使用租户编码作为标识
  • data_name:系统的中文名称,更有助于区别是哪个系统
  • data_url:每个系统对应的数据库连接 url 地址
  • data_env:所属的运行环境,可以分为 dev、test 和 prod 这3种

怎么样才能让每个系统找到属于自己的库呢?请看本文的第二、三、四这3个小节。

1.2隔离模式分析

**结论先行:本文采用的是共享数据库实例独立数据架构的隔离模式。**即:所有业务系统的数据都在一个数据库实例集群中,但是一个数据库实例里面可以有很多个数据库,且可以根据租户对每个数据库做权限组控制。原因主要有以下几点:

  • 数据量的要求:租户多、系统多、用户量大
  • 隔离度的要求:要求较高,行业的特殊性会对数据安全比较敏感
  • 业务的复杂度:关联的系统多达上百个,上下游的数据交互十分频繁
  • 成本的考虑:成本虽可以负担,但既要满足上面几点要求,又不能太贵
  • 便于计量计费:有了各自的数据库,方便对客户做计量计费的统计

数据库实例集群的规格要高、性能要强,目前主流云厂商如阿里云和华为云等,都有自己 MySQL for RDS 产品,基本可以完美解决数据隔离和数据库角色权限的需求。


二、技术选型

**结论先行:选择 baomidou(对,Mybatis Plus 就是他们的杰作) 下的 Dynamic Datasource 动态数据库方案,**引入 3 个依赖:

xml 复制代码
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>dynamic-datasource-spring</artifactId>
            <version>4.2.0</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>dynamic-datasource-creator</artifactId>
            <version>4.2.0</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>dynamic-datasource-spring-boot-starter</artifactId>
            <version>4.2.0</version>
        </dependency>

Maven 的中央仓库:https://mvnrepository.com/ 搜索关键词,如下图所示:

多数据源切换技术选型

下面介绍几个核心的类以及 api:

java 复制代码
    //从请求头中获取当前租户编码
    String tenantCode = request.getHeader("Tenantcode");
    //切换多数据源的核心工具类,此处将租户编码作为数据源key
    DynamicDataSourceContextHolder.push(tenantCode);
java 复制代码
    public DynamicRoutingDataSource dataSource() {
        //根据特定的规则选择要使用的数据源标识(如数据库名称、租户编码等),根据路由规则,每个数据访问操作将使用相应的数据源
        DynamicRoutingDataSource dynamicRoutingDataSource = new DynamicRoutingDataSource(Collections.emptyList());
        DataSourceProperty dataSourceProperty = new DataSourceProperty();
        //配置文件的 driver-class-name 驱动名
        dataSourceProperty.setDriverClassName(this.dataSourceProperties.getDriverClassName());
        //数据库连接 url
        dataSourceProperty.setUrl(this.dataSourceProperties.getUrl());
        //连接数据库的用户名/密码
        dataSourceProperty.setUsername(this.dataSourceProperties.getUsername());
        dataSourceProperty.setPassword(this.dataSourceProperties.getPassword());
        //创建多数据源连接,即所有的数据源都可以获取到
        DataSource ds = dataSourceCreator.createDataSource(dataSourceProperty);
        dynamicRoutingDataSource.addDataSource(this.dynamicDataSourceProperties.getPrimary(), ds);
        return dynamicRoutingDataSource;
    }

三、应用配置

相较于 Spring 的各种 xml 配置,Spring boot 引入的约定大于配置的这一重大升级,是多数据源切换的重要基础。

之前单租户的时候,无论是分布式的单体还是微服务,应用的 application.yml 或者 application.properties 都有一个本系统的数据库连接。外部发起请求或者被别的系统调用时,本系统产生的数据都会根据这个配置文件里的数据库连接去进行增删改查。具体如下:

yml 复制代码
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://host:port/本系统的数据库名称
    username: 本系统数据库账号
    password: 本系统数据库密码

那么,在多租户下,是不是有多少个租户就要在 applicationyml 里写多少个数据库连接呢?

答案当然是否定的。

基于第一章选择的数据隔离模式,显然将每个租户的数据库连接都维护在一个地方是最方便的,于是便有了第一章的多数据源库。

所以,基于多租户的业务系统的 applicationyml 里该怎么写数据库连接呢?可以这样写:

yml 复制代码
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://host:port/多数据源库名称
    username: 多数据源库账号
    password: 多数据源库密码
initial:
  saas:
    system-code: springboot-initial ##这是系统的唯一标识,很关键 

这样就可以根据租户标识(data_code)与系统标识(system_code)来唯一确定属于本系统的数据库了,具体怎么做,下一节会给出 demo。


四、具体实现

牢牢把握这 4 点:请求拦截 + 租户编码 + 本地线程 + 切换数据源。这4点贯穿了整个多租户数据源切换的全过程,是数据源切换策略的核心。

由于篇幅,以下只演示核心的步骤:

  1. 拦截器+租户编码

    java 复制代码
    public class TenantInterceptor implements HandlerInterceptor {
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            boolean predHandle = super.preHandle(request, response, handler);
            if (!CorsUtils.isPreFlightRequest(request)) {
                String tenantHeader = request.getHeader("Tenantcode");
                if (StringUtils.isBlank(tenantHeader)) {
                    throw new RuntimeException("请求错误");
                }
                //本地线程设置值
                ThreadLocalUtils.setValue(tenantCode);
                //切换多数据源的核心类,此处将租户编码作为数据源 key
                DynamicDataSourceContextHolder.push(tenantCode);
            }
            return predHandle;
        }
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                    Object handler, Exception ex) throws Exception {
            super.afterCompletion(request, response, handler, ex);
            //请求完成后清除
            ThreadLocalUtils.removeValue();
            //同样是清除本次调用线程中的数据源 key
            DynamicDataSourceContextHolder.clear();
        }
    }
  2. 本地线程

    java 复制代码
    public class ThreadLocalUtils {
        /** 
         * 不熟悉的同学可以再去复习一下 ThreadLocal 的相关知识
         */
        private static final ThreadLocal<String> THREADLOCAL = new ThreadLocal<>();
        public static void setValue(String value) {
            THREADLOCAL.set(value);
        }
        public static String getValue() {
            return THREADLOCAL.get();
        }
        public static void removeValue() {
            THREADLOCAL.remove();
        }
    }
  3. 切换数据源

    这里其实就是第一节中那张多数据源库表的具体实现,实现类还 implements 了 InitializingBean 所以会有 afterPropertiesSet() 方法。

    java 复制代码
        @Override
        public void afterPropertiesSet() {
            LambdaQueryWrapper<DynamicTenantDatasource> wrapper = new LambdaQueryWrapper<>();
            RunTimeEnv env = RunEnv.searchRunEnv(Collections.singletonList(this.environment.getActiveProfiles()));
            log.info("当前数据源所处环境:{}", env);
            assert env != null;
            wrapper.eq(DynamicTenantDatasource::getRunTimeEnv, env.getValue())
                    .eq(DynamicTenantDatasource::getSystemCode, this.dynamicProperties.getSystemCode());
            this.list(wrapper).forEach(val -> {
                DataSourceProperty dataSourceProperty = new DataSourceProperty();
                //下面是数据源配置
                dataSourceProperty.setDriverClassName(val.getDriverClassName());
                dataSourceProperty.setUrl(val.getUrl());
                dataSourceProperty.setUsername(val.getUserName());
                dataSourceProperty.setPassword(val.getPassword());
                DataSource dataSource = dataSourceCreator.createDataSource(dataSourceProperty);
                //这里就会拿到当前系统的所有租户编码了
                this.dynamicRoutingDataSource.addDataSource(val.getDataCode(), dataSource);
            });
        }

由于在请求经过拦截器的时候,当前线程已经获取了当前的租户编码,且已经将这个租户编码push到了多数据源工具类,那么只要本次请求涉及到数据库操作,就能唯一确定数据源了,即能唯一确定本次数据会连接到具体哪个库。


五、文章小结

如果你也对基于多租户的动态数据源切换有过思考,那么希望我们的思维能迸出一些火花。

作为整个多租户的数据隔离模式的重要部分,本篇文章尽可能地将笔者的思考由浅到深与大家分享。为了实现整个数据隔离模式的落地,需要大量的实践来论证可行性,并且需要相当的资源投入才能真正作为成熟的框架部署到生产环境。

其中就少不了运维团队以及云原生团队的支持,基于K8s容器的服务治理、镜像打包、持续的 CI/CD(GitLab+Jenkins)以及整个 DevOps 平台的搭建,才能让这套方案和架构发挥最大的作用。

接下来请期待本系列文章的续作,文章如有不足和错误,还请大家指正。或者你有其它想说的,也欢迎大家在评论区交流!

相关推荐
淘源码A6 天前
一套SaaS多租户医疗云his源码,基于云计算的医院信息管理系统(云HIS)
saas·云his·源代码·医院信息系统·区域医疗·his源码
阿里云大数据AI技术7 天前
爱橙科技基于 MaxCompute 智能物化视图最佳实践
大数据·架构·saas
全栈ing小甘1 个月前
微服务概览与治理
微服务·架构·grpc·多集群·cqrs·多租户
Amd7942 个月前
应用中的 PostgreSQL项目案例
postgresql·数据分析·数据库管理·最佳实践·技术架构·实际应用·项目案例
青云交2 个月前
大数据新视界 -- Hive 多租户资源分配与隔离(2 - 16 - 16)
大数据·hive·资源隔离·多租户·资源分配·监控评估·资源隔离机制·监控指标体系
TiDB_PingCAP3 个月前
唐刘:TiDB 的 2024 - Cloud、SaaS 与 AI
数据库·人工智能·ai·tidb·saas
PersistJiao3 个月前
多租户架构是什么?
架构·多租户
雷袭月启3 个月前
SpringBoot3动态切换数据源
springboot3·多租户·动态切换数据源
Light603 个月前
云途领航:现代应用架构助力企业转型新篇
微服务·架构·saas·paas·iaas·ipaas·apaas