SpringBoot集成Mybatis-Plus实现多租户动态数据源

news2024/11/17 11:23:07

1. 概述

最近接手一个多租户系统,多租户主要的就是租户之间的数据是相互隔离的,每个租户拥有自己独立的数据,相互之间不干扰。目前实现多租户主要有三种方案:
独立数据库
每个租户拥有自己单独的数据库,从物理上隔离了自己的数据,安全性最高,但是成本比较高,容易浪费数据库资源
同一数据库,不同表
每个租户的数据都在同一个数据库里,每个租户拥有一个独立的表,同样也实现了数据的隔离,安全性和成本其次
同一数据库,同一张表,字段区分
租户使用同一个数据库和同一张表,在每张表里添加进一个字段,例如tenant来区分每个租户的数据,安全性和成本都比较低,维护性也较高,单表的数据量也比较大,给查询和数据迁移都来带了麻烦
基于以上方案,本文选择第一种方案实现多租户系统

2. 开发环境

本文使用使用的开发工具/组件如表所示:

名称版本
Idea2020
JDK11
SpringBoot2.7.10
mybatis-plus-boot-starter3.5.3.1
dynamic-datasource-spring-boot-starter3.6.1
druid-spring-boot-starter1.2.14
mapstruct1.5.3.Final
postgresql15.2
redis7.0.10

3. 搭建项目

3.1. 新建数据库和表

先建几个数据库,分别是dynamic-master、dynamic-slave-1和dynamic-slave-2,在master库中新建tenant表,在slave库中建customer表,建表sql如下:

CREATE SEQUENCE IF NOT EXISTS tenant_id_seq;
CREATE TABLE public.tenant (
   id bigint NOT null DEFAULT nextval('tenant_id_seq'),
   tenant_id varchar(30) NOT NULL,
   data_source_url varchar(100) NOT NULL,
   data_source_username varchar(30) NOT NULL,
   data_source_password varchar(68) NOT NULL,
   data_source_driver varchar(50) NOT NULL,
   data_source_poolname varchar(50) NOT NULL,
   CONSTRAINT tenant_pk PRIMARY KEY (id),
   CONSTRAINT tenant_un UNIQUE (tenant_id)
);
COMMENT ON TABLE "tenant" IS '租户表';
COMMENT ON COLUMN "tenant"."tenant_id" IS '租户id';
COMMENT ON COLUMN "tenant"."data_source_url" IS '数据源URL';
COMMENT ON COLUMN "tenant"."data_source_username" IS '数据源用户名';
COMMENT ON COLUMN "tenant"."data_source_password" IS '数据源密码';
COMMENT ON COLUMN "tenant"."data_source_driver" IS '数据源驱动';
COMMENT ON COLUMN "tenant"."data_source_poolname" IS '数据源池名称';

CREATE SEQUENCE IF NOT exists customer_id_seq;
CREATE TABLE public.customer (
    id bigint NOT NULL DEFAULT nextval('customer_id_seq'),
    customer_name varchar(30) NOT NULL,
    CONSTRAINT customer_pk PRIMARY KEY (id)
);
COMMENT ON TABLE public.customer IS '客户表';
COMMENT ON COLUMN public.customer.id IS '客户ID';
COMMENT ON COLUMN public.customer.customer_name IS '客户名称';

3.2. 引入核心依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.3.1</version>
</dependency>
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>dynamic-datasource-spring-boot-starter</artifactId>
    <version>3.6.1</version>
    <exclusions>
        <exclusion>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>1.2.14</version>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.5.3.Final</version>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

3.3. 编写application.yml文件

server:
  port: 8000
spring:
  application:
    name: SPRINGBOOT-TENANT
  datasource:
    dynamic:
      primary: master
      strict: false
      datasource:
        master:
          url: jdbc:postgresql://xxxxx:5432/dynamic-master
          username: xxxx
          password: xxxx
          driver-class-name: org.postgresql.Driver
      druid:
        initial-size: 1
        max-active: 20
        min-idle: 1
        max-wait: 6000
        pool-prepared-statements: true
        max-pool-prepared-statement-per-connection-size: 20
        validation-query: select 1
        validation-query-timeout: 10
logging:
  config: classpath:log4j2.xml

3.4. 初始化数据源

新建DynamicDataSource配置类,将master库tenant表中数据源初始化

@Configuration
public class DynamicDataSource {

    @Value("${spring.datasource.dynamic.datasource.master.driver-class-name}")
    private String driverName;
    @Value("${spring.datasource.dynamic.datasource.master.url}")
    private String url;
    @Value("${spring.datasource.dynamic.datasource.master.username}")
    private String username;
    @Value("${spring.datasource.dynamic.datasource.master.password}")
    private String password;

    @Bean
    public DynamicDataSourceProvider dynamicDataSourceProvider() {
        return new AbstractJdbcDataSourceProvider(driverName, url, username, password) {
            @Override
            protected Map<String, DataSourceProperty> executeStmt(Statement statement) throws SQLException {
                Map<String, DataSourceProperty> dataSourceMap = new HashMap<>();
                ResultSet resultSet = statement.executeQuery("select * from tenant");
                while (resultSet.next()) {
                    String tenant = resultSet.getString("tenant_id");
                    DataSourceProperty sourceProperty = new DataSourceProperty();
                    sourceProperty.setDriverClassName(resultSet.getString("data_source_driver"));
                    sourceProperty.setUrl(resultSet.getString("data_source_url"));
                    sourceProperty.setUsername(resultSet.getString("data_source_username"));
                    sourceProperty.setPassword(resultSet.getString("data_source_password"));
                    dataSourceMap.put(tenant, sourceProperty);
                }
                return dataSourceMap;
            }
        };
    }
}

3.5. 存储当前数据源

因为每次请求需要访问的数据库可能都不一样,所以需要在每次请求操作时需要指定需要访问哪个数据库,新建一个拦截器

@Log4j2
public class DynamicDataSourceInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String headerTenant = request.getHeader("X-Tenant-Id");
        if (StringUtils.hasText(headerTenant)) {
            DynamicDataSourceContextHolder.push(headerTenant);
            return true;
        }
        writerMessage(response, ResponseEntity.status(HttpStatus.BAD_REQUEST).body("X-Tenant-Id in request header cannot be empty!"));
        log.warn("X-Tenant-Id in request header cannot be empty, The path is {}", request.getRequestURL());
        return false;
    }

    private void writerMessage(HttpServletResponse response, ResponseEntity<String> errorMessage) {
        try (PrintWriter writer = response.getWriter()) {
            response.setStatus(errorMessage.getStatusCodeValue());
            response.setCharacterEncoding("UTF-8");
            response.setContentType("text/html; charset=utf-8");
            writer.print(errorMessage.getBody());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        DynamicDataSourceContextHolder.clear();
    }
}

将自定义拦截器加入配置类,新建一个Web配置类

@Configuration
public class WebAutoConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(dynamicDataSourceInterceptor()).addPathPatterns("/**");
    }

    @Bean
    public DynamicDataSourceInterceptor dynamicDataSourceInterceptor() {
        return new DynamicDataSourceInterceptor();
    }
}

3.6. 编写数据源Controller

@RestController
@RequestMapping(value = "/datasource")
public class DataSourceController {

    @Autowired
    private DataSource dataSource;
    @Autowired
    private DefaultDataSourceCreator dataSourceCreator;
    @Autowired
    private TenantService tenantService;

    @GetMapping(value = "/getAllDataSources")
    public Set<String> getAllDataSources() {
        DynamicRoutingDataSource routingDataSource = (DynamicRoutingDataSource) dataSource;
        return routingDataSource.getDataSources().keySet();
    }

    @PostMapping(value = "/addDataSource")
    public ResponseEntity<String> addDataSource(@RequestBody DataSourceDto dataSourceDto) {
        DataSourceProperty sourceProperty = TenantMapper.TENANT_MAPPER.dataSourceDtoToDataSourceProperty(dataSourceDto);
        DynamicRoutingDataSource routingDataSource = (DynamicRoutingDataSource) dataSource;
        DataSource propertyDataSource = dataSourceCreator.createDataSource(sourceProperty);
        routingDataSource.addDataSource(dataSourceDto.getTenantId(), propertyDataSource);

        Tenant tenant = TenantMapper.TENANT_MAPPER.dataSourceDtoToTenant(dataSourceDto);
        tenantService.saveOrUpdate(tenant);
        String dataSourceStr = routingDataSource.getDataSources().keySet().stream().collect(Collectors.joining(","));
        return ResponseEntity.ok(dataSourceStr);
    }
}

4. 测试

在postman中输入地址http://localhost:8000/datasource/getAllDataSources,在请求头新增X-Tenant-Id=master参数,发起GET请求
初始获取数据源信息
租户张三加入系统后,只需要为张三新建一个数据库,调用新增数据源接口就行,在postman中输入地址http://localhost:8000/datasource/addDataSource,发起POST请求
新增数据源
此时租户张三就可以查询自己的数据信息了,在postman中输入地址http://localhost:8000/tenant/customer/getCustomerInfo/:id,发起GET请求
查询张三信息
注意:请求头必须携带需要操作的数据源标识,否则会提出错误
无X-Tenant-Id访问
以上示例就简单实现了单体部署多租户系统的集成,如果是多实例部署是否有问题呢?

5. 多实例部署

5.1. 存在的问题

在Idea中同时启动两个实例8000和9000,8000服务新增租户李四数据源,分别查询8000服务和9000服务的数据源信息
8000服务数据源信息
再次查询9000服务数据源信息
9000服务数据源信息
对比发下在8000服务上新增了数据源,9000服务查询不到,且无法使用新增的数据源,这是因为服务一启动就将数据源信息初始化进了内存,8000服务和9000服务内存是相互独立的,故而8000服务上操作的数据无法同步到9000服务。如果将新增后的数据源存放到8000服务和9000服务都能访问到的第三方服务上,请求进入服务后执行前先对比本地内存数据源和远程服务数据源是否相等,若不等,就先将远程服务的数据源信息同步到本地内存,这样问题是不就解决了呢!

5.2. 同步数据源信息

本示例引入redis作为第三方服务,在拦截器中增加同步数据源的操作

@Log4j2
public class DynamicDataSourceInterceptor implements HandlerInterceptor {

    @Autowired
    private TenantService tenantService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (StringUtils.hasText(headerTenant)) {
            tenantService.reloadDataSource();
            //其他代码省略...
        }
    }
}

同步数据源的代码如下:

public void reloadDataSource() {
    DynamicRoutingDataSource routingDataSource = (DynamicRoutingDataSource) dataSource;
    Set<String> dataSourceTypeSet = routingDataSource.getDataSources().keySet();
    String dataSourceType = dataSourceTypeSet.stream().collect(Collectors.joining(","));
    String redisDataSourceType = redisTemplate.opsForValue().get("dataSourceType");
    if (!dataSourceType.equals(redisDataSourceType)) {
        dataSourceTypeSet.stream().filter(sourceType -> !sourceType.equals("master")).forEach(routingDataSource::removeDataSource);
        List<Tenant> tenantList = this.list();
        tenantList.stream().filter(tenant -> !tenant.getTenantId().equals("master")).forEach(tenant -> {
            DataSourceProperty sourceProperty = new DataSourceProperty();
            sourceProperty.setDriverClassName(tenant.getDataSourceDriver());
            sourceProperty.setUrl(tenant.getDataSourceUrl());
            sourceProperty.setUsername(tenant.getDataSourceUsername());
            sourceProperty.setPoolName(tenant.getDataSourcePoolname());
            sourceProperty.setPassword(tenant.getDataSourcePassword());
            DataSource propertyDataSource = dataSourceCreator.createDataSource(sourceProperty);
            routingDataSource.addDataSource(tenant.getTenantId(), propertyDataSource);
        });
        redisTemplate.opsForValue().set("dataSourceType", tenantList.stream().map(tenant -> tenant.getTenantId()).collect(Collectors.joining(",")));
    }
}

同时需要在新增数据源的地方将数据源信息set进redis

@PostMapping(value = "/addDataSource")
public ResponseEntity<String> addDataSource(@RequestBody DataSourceDto dataSourceDto) {
    //其他代码省略....
    redisTemplate.opsForValue().set("dataSourceType", dataSourceStr);
    //......
}

重启两个示例,再次新增数据源和查询数据源信息
同步收查询9000服务数据源信息

后记

由于作者能力有限,文中难免会出现一些错误,欢迎各位大佬不吝赐教,也希望各位大佬就多实例部署如何同步数据源问题在评论处留言讨论

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/438280.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Java语言数据类型与c语言数据类型的不同

目录 一、c语言数据类型 1.基本类型&#xff1a; 2.枚举类型&#xff1a; 3.空类型&#xff1a; 4.派生类型&#xff1a; 二、C语言编程需要注意的64位和32机器的区别 三、 不同之处 一、c语言数据类型 首先&#xff0c;先来整体介绍一下C语言的数据类型分类。 1.基…

空间金字塔池化(Spatial Pyramid Pooling)

1. 前言 当前的深度神经网络一般都需要固定的输入图像尺寸&#xff08;如224*224&#xff09;。这种需求很明显是人为的&#xff0c;潜在性的弊端会降低识别精度&#xff08;为了使图像尺寸相同&#xff0c;一定会涉及到图像的比例/非比例放缩&#xff0c;这就引入了尺度误差和…

机器学习中的三个重要环节:训练、验证、测试

本文重点 模型训练、验证和测试是机器学习中的三个重要环节。这三个环节之间存在着紧密的关系,它们相互作用,共同构建出一个完整的机器学习模型。在本文中,我们将详细介绍模型训练、验证和测试之间的关系。 模型训练、验证和测试之间的关系 模型训练是机器学习中最基本的…

原生Java使用Mybatis操作数据库接口注解形式,与SpringBoot类似且无需管理SqlSession连接的工具类

Hi I’m Shendi https://sdpro.top/blog/html/article/1044.html 需求描述 用 SpringBoot 整合 Mybatis 使用久了&#xff0c;再编写没有Spring但需要操作数据库的程序时就会想着使用接口注解的形式&#xff0c;这样效率比较高和简单 Spring 中只需要编写好接口映射&#xff…

字节跳动正式开源分布式训练调度框架 Primus

动手点关注 干货不迷路 项目地址&#xff1a;https://github.com/bytedance/primus 随着机器学习的发展&#xff0c;模型及训练模型所需的数据量越来越大&#xff0c;也都趋向于通过分布式训练实现。而算法工程师通常需要对这些分布式框架涉及到的底层文件存储和调度系统有较深…

剑指 Offer 52. 两个链表的第一个公共节点 / LeetCode 160. 相交链表(双指针 / 哈希集合)

题目&#xff1a; 链接&#xff1a;剑指 Offer 52. 两个链表的第一个公共节点&#xff1b;LeetCode 160. 相交链表 难度&#xff1a;简单 输入两个链表&#xff0c;找出它们的第一个公共节点。 如下面的两个链表&#xff1a; 在节点 c1 开始相交。 示例 1&#xff1a; 输入…

Spring MVC Bean加载控制

回顾一下我们一般写的项目包括那些包吧&#xff1a; config目录存入的是配置类,写过的配置类有: ServletContainersInitConfigSpringConfigSpringMvcConfigJdbcConfigMybatisConfig controller目录存放的是SpringMVC的controller类service目录存放的是service接口和实现类dao目…

Doo Prime 德璞资本:股指期货交易如何管理好个人情绪

在股指期货交易中&#xff0c;我们可以感觉到心态随着交易的成败而变化。有时心态对交易影响不大&#xff0c;但有时影响很大&#xff0c;一个好的心态&#xff0c;能够应对各种变化&#xff0c;各种损益和市场判断的正确和错误&#xff0c;不会对心态产生很大的影响&#xff0…

ArcGIS中的土地利用变化分析详解

本篇主要是针对矢量数据的分析。 一、不同时期的土地利用矢量数据&#xff0c;如何分析其图形及属性变化&#xff1f; 土地利用图&#xff08;左图为1993年&#xff0c;右图为2003年&#xff09; 思路如下&#xff1a; 可以先对2个图层进行Union操作&#xff0c;然后在结果中…

【三十天精通Vue 3】第十四天 Vue 3 的单元测试详解

✅创作者&#xff1a;陈书予 &#x1f389;个人主页&#xff1a;陈书予的个人主页 &#x1f341;陈书予的个人社区&#xff0c;欢迎你的加入: 陈书予的社区 &#x1f31f;专栏地址: 三十天精通 Vue 3 文章目录 引言一、为什么要进行单元测试1.1 单元测试的概念1.2 单元测试的优…

Javase学习文档------面象对象再探

再续前缘面向对象 书接上回构造器 在Java中&#xff0c;可以通过在空参构造方法中使用 this() 关键字来调用类中其它的构造方法。 使用 this() 关键字来调用其它构造方法时&#xff0c;需要注意以下几点&#xff1a;1.this() 必须是构造方法的第一条语句&#xff1b; 2.一个构…

经典文献阅读之--NORLAB-ICP(重力约束ICP)

0. 简介 最近几年IPC相关的文章也出了不少&#xff0c;最近作者有看到了一篇比较有意思的ICP论文—《Gravity-constrained point cloud registration》&#xff0c;这篇论文将传统的ICP考虑了重力因素&#xff0c;高频率的IMU数据弥补了低频的传感器数据。除此之外&#xff0c…

4K分辨率搭配光学变焦功能,极米H6成旗舰家用投影首选

近几年&#xff0c;我国投影机市场产品竞争日趋激烈&#xff0c;以极米为代表的国产品牌迅速崛起并逐步超越国际品牌成为中国投影机市场的领跑者。虽然目前国产投影仪品牌比较多&#xff0c;但其中极米科技旗下的产品最受消费者青睐。IDC数据显示&#xff0c;2022年上半年&…

easyexcel导出中自定义合并单元格,通过重写AbstractRowWriteHandler

针对 阿里的easyexcel 自定义处理 任意单元格合并 官方给出的合并单元格 只给出固定规律的单元格合并,当然官方也指出可以自定义合并单元格的策略,我们跟进LoopMergeStrategy 这个合并策略的实例类,发现里面继承了AbstractRowWriteHandler,官方示例代码如下 /*** 合并单元格…

揭秘!Chrome 调试的11+隐藏技巧,让你在开发中如虎添翼!

前言 chrome 浏览器作为前端童鞋的老婆&#xff0c;相信你一定不陌生。调页面、写BUG、画样式、看php片少了它整个世界都不香了。 不信&#xff1f;一起来看看我们的老婆有多厉害… 1、一键重新发起请求 在与后端接口联调或排查线上 BUG 时&#xff0c;你是不是也经常听到他…

Python中的主函数

在Python代码中&#xff0c;我们常常看到主函数是以if __name__ __main__开头的&#xff0c;比如 它的原理是什么呢&#xff1f; 首先要知道&#xff0c;__name__是内置变量&#xff0c;用于表示当前模块的名字。在一个模块中运行以下语句&#xff0c;你会发现输出的是__main…

CSS:横向导航栏

横向导航栏&#xff08;盗版导航栏&#xff0c;B站仿写。&#xff09; 原视频链接 <html><head><title>demo</title><style>*{margin: 0;padding: 0;list-style: none;text-decoration: none;}body{display: flex;justify-content: center;a…

模型蒸馏与压缩简单介绍

目录 一、概述 二、DistilBERT模型介绍 2.1 基本结构 2.2 知识蒸馏方法 一、概述 预训练语言模型虽然在众多自然语言任务中取得了很好的效果&#xff0c;但通常这类模型的参数量较大&#xff0c;很难满足实际应用中的时间和空间需求。 下图给出了常见预训练语言模型参数量的…

JDK11 下载与安装、环境配置(全网最详情,值得收藏)

目录 一、下载JDK11 二、安装JDK11 三、配置环境变量 四、验证环境配置是否成功 五、答疑&#xff0c;为什么不配置 CLASSPATH 什么是JDK JDK是 Java 语言的软件开发工具包&#xff0c;主要用于移动设备、嵌入式设备上的java应用程序。JDK是整个java开发的核心&#xff0…

【算法训练(day3)】快速排序模版选择及不同版本快排对比

目录 一.划分区间的选取 二.代码实现lomuto版本快速排序 三.hoare版本快速排序 四.竞赛模板的选取 五.竞赛模板的代码实现 一.划分区间的选取 目前市面上常用的有两种划分区间&#xff0c;一种是hoare划分另一种是Lomuto划分。常见快速排序实现模版比如挖坑法和经典快速排…