MyBatis-Plus 实现多租户管理的实践

本文主要讲解使用Mybatis-Plus结合dynamic-datasource来实现多租户管理
在现代企业应用中,多租户(Multi-Tenant)架构已经成为一个非常重要的设计模式。多租户架构允许多个租户共享同一应用程序实例,但每个租户的数据彼此隔离。实现这一点可以大大提高资源利用率并降低运营成本。在本文中,我们将探讨如何使用 MyBatis-Plus 结合 Dynamic-Datasource 来实现多租户管理。
MyBatis-Plus 是 MyBatis 的增强工具,提供了很多开箱即用的功能,如 CRUD 操作、分页插件、逻辑删除等,使开发人员能够更加专注于业务逻辑,而无需过多关注底层的数据库操作细节。Dynamic-Datasource 是一个功能强大的动态数据源切换框架,能够方便地在多个数据源之间进行切换,非常适合实现多租户数据库管理。
本文将通过一个具体的例子,详细讲解如何配置和使用 MyBatis-Plus 以及 Dynamic-Datasource 来实现多租户管理。我们将首先创建租户信息表,并为每个租户分别创建用户信息表。然后,我们将配置 MyBatis-Plus 和 Dynamic-Datasource 实现动态数据源切换和多租户数据隔离。最后,我们会展示如何通过代码动态地切换数据源,以确保每个租户的数据操作都在各自的数据库中进行。
通过本文的学习,您将掌握:
- 如何配置 MyBatis-Plus 和 Dynamic-Datasource 实现动态数据源切换
- 如何在代码中实现多租户数据隔离
让我们开始吧!
环境
本文演示开发工具环境如下
IntelliJ IDEA 2023.3.6
Maven 3.8.6
JDK 17
依赖包如下
<properties>
<druid.version>1.1.22</druid.version>
<fastjson.version>2.0.39</fastjson.version>
<dynamic.ds.version>3.5.1</dynamic.ds.version>
<mybatis-plus.generator.version>3.5.1</mybatis-plus.generator.version>
</properties>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot-starter</artifactId>
<version>${dynamic.ds.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>${mybatis-plus.generator.version}</version>
</dependency>
初始sql语句如下
CREATE TABLE `tenant` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`tenant_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '租户名称',
`tenant_desc` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '租户详情',
`db_info` varchar(2047) COLLATE utf8mb4_general_ci DEFAULT NULL,
`redis_info` varchar(2047) COLLATE utf8mb4_general_ci DEFAULT NULL,
`version` int NOT NULL DEFAULT '0' COMMENT '版本号',
`created_time` datetime NOT NULL COMMENT '创建时间',
`created_by` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
`modified_time` datetime NOT NULL COMMENT '修改时间',
`modified_by` varchar(64) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '修改人',
`is_deleted`TINYINT(4) not null DEFAULT 0 COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='租户信息';
INSERT INTO `tenant` ( `tenant_name`, `tenant_desc`, `db_info`, `redis_info`, `version`, `created_time`, `created_by`, `modified_time`, `modified_by` )
VALUES
( '测试租户1', '租户说明信息', '{\"dbUrl\": \"jdbc:mysql://127.0.0.1:3306/tenant-one?rewriteBatchedStatements=true\",\"dbUsername\": \"root\",\"dbPassword\": \"0c0bb39488e6dbfb\"}', '{\"host\": \"localhost\",\"port\": 6379,\"pwd\": \"123456\",\"db\": 1}', 0, NOW(), '1', NOW(), '1' );
INSERT INTO `tenant` ( `tenant_name`, `tenant_desc`, `db_info`, `redis_info`, `version`, `created_time`, `created_by`, `modified_time`, `modified_by` )
VALUES
( '测试租户2', '租户说明信息', '{\"dbUrl\": \"jdbc:mysql://127.0.0.1:3306/tenant-two?rewriteBatchedStatements=true\",\"dbUsername\": \"root\",\"dbPassword\": \"0c0bb39488e6dbfb\"}', '{\"host\": \"localhost\",\"port\": 6379,\"pwd\": \"123456\",\"db\": 1}', 0, NOW(), '1', NOW(), '1' );
use `tenant-one`;
CREATE TABLE IF NOT EXISTS user_info (
id BIGINT NOT NULL PRIMARY KEY COMMENT '主键Id',
user_no VARCHAR(255) NOT NULL DEFAULT '' COMMENT '编号',
user_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '姓名',
description VARCHAR(512) DEFAULT '' COMMENT '备注',
created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
created_by BIGINT NOT NULL DEFAULT 0 COMMENT '记录创建者Id,默认为0',
modified_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录修改时间',
modified_by BIGINT DEFAULT NULL COMMENT '记录修改者Id,可以为空',
is_deleted TINYINT(4) NOT NULL DEFAULT 0 COMMENT '是否删除,默认为0,1表示删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='用户信息';
use `tenant-two`;
CREATE TABLE IF NOT EXISTS user_info (
id BIGINT NOT NULL PRIMARY KEY COMMENT '主键Id',
user_no VARCHAR(255) NOT NULL DEFAULT '' COMMENT '编号',
user_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '姓名',
description VARCHAR(512) DEFAULT '' COMMENT '备注',
created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
created_by BIGINT NOT NULL DEFAULT 0 COMMENT '记录创建者Id,默认为0',
modified_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录修改时间',
modified_by BIGINT DEFAULT NULL COMMENT '记录修改者Id,可以为空',
is_deleted TINYINT(4) NOT NULL DEFAULT 0 COMMENT '是否删除,默认为0,1表示删除'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='用户信息';
use `tenant-one`;
INSERT INTO `user_info` (`id`, `user_no`, `user_name`, `description`, `created_time`, `created_by`, `modified_time`, `modified_by`, `is_deleted`) VALUES (1, 'test_no', '租户1测试用户', '租户1测试用户', '2024-05-15 03:45:06', 0, '2024-05-15 03:45:06', NULL, 0);
use `tenant-two`;
INSERT INTO `user_info` (`id`, `user_no`, `user_name`, `description`, `created_time`, `created_by`, `modified_time`, `modified_by`, `is_deleted`) VALUES (1, 'test_no', '租户2测试用户', '租户2测试用户', '2024-05-15 03:45:06', 0, '2024-05-15 03:45:06', NULL, 0);
配置文件如下
server:
port: 8080
servlet:
context-path: /
# undertow 配置
undertow:
# HTTP post内容的最大大小。当值为-1时,默认值为大小是无限的
max-http-post-size: -1
# 每块buffer的空间大小,越小的空间被利用越充分
buffer-size: 512
# 是否分配的直接内存
direct-buffers: true
threads:
# 设置IO线程数, 它主要执行非阻塞的任务,它们会负责多个连接, 默认设置每个CPU核心一个线程
io: 8
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
worker: 256
base:
redis:
host: 127.0.0.1
port: 6379
password: 123456
db: 0
db:
url: jdbc:mysql://127.0.0.1:3306/tenant?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
pwd: 0c0bb39488e6dbfb
spring:
datasource:
dynamic:
primary: 0
strict: true
hikari:
connection-timeout: 30000
max-pool-size: 10
min-idle: 5
idle-timeout: 180000
max-lifetime: 1800000
connection-test-query: SELECT 1
datasource:
0:
url: ${base.db.url}
username: ${base.db.username}
password: ${base.db.pwd}
driver-class-name: com.mysql.cj.jdbc.Driver
type: com.alibaba.druid.pool.DruidDataSource
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
cache-enabled: true
use-generated-keys: true
default-executor-type: simple
log-impl: org.apache.ibatis.logging.log4j2.Log4j2Impl
mapperLocations: classpath*:mapper/*Mapper.xml
typeAliasesPackage: com.simple.mybaitsdynamicdatasource.infrastructure.db.entity
type-aliases-package: ${application.base-package}.entity
global-config:
db-config:
logic-delete-field: is_deleted
logic-not-delete-value: 0
logic-delete-value: 1
logging:
level:
org.springframework: warn
代码如下
首先我的代码框架具体如下

其中实现动态切换数据源的操作主要在我们的TenantServiceImpl中,具体代码如下,其中主要是我们会通过当前获取到的TenantId来调用changeDsByTenantId方法进行修改动态数据源
package com.simple.mybaitsdynamicdatasource.infrastructure.service.impl;
import com.alibaba.fastjson2.JSON;
import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
import com.baomidou.dynamic.datasource.creator.DefaultDataSourceCreator;
import com.baomidou.dynamic.datasource.spring.boot.autoconfigure.DataSourceProperty;
import com.baomidou.dynamic.datasource.spring.boot.autoconfigure.druid.DruidConfig;
import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.simple.mybaitsdynamicdatasource.infrastructure.config.TenantContext;
import com.simple.mybaitsdynamicdatasource.infrastructure.db.entity.TenantEntity;
import com.simple.mybaitsdynamicdatasource.infrastructure.db.mapper.TenantMapper;
import com.simple.mybaitsdynamicdatasource.infrastructure.db.model.DbInfo;
import com.simple.mybaitsdynamicdatasource.infrastructure.service.TenantService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import javax.sql.DataSource;
@Slf4j
@Service
@AllArgsConstructor
public class TenantServiceImpl extends ServiceImpl<TenantMapper, TenantEntity> implements TenantService {
private TenantMapper tenantMapper;
private DynamicRoutingDataSource dataSource;
private DefaultDataSourceCreator dataSourceCreator;
/**
* 根据租户ID切换数据源
*
* @param tenantId 租户ID
*/
@Override
public void changeDsByTenantId(String tenantId) {
//当前租户ID对应的数据源已存在,则直接切换
if (existInMemory(tenantId)) {
//切换数据源
changeTenantDs(tenantId);
return;
}
DataSource dataSource = queryTenantIdToDataSource(tenantId);
if (!ObjectUtils.isEmpty(dataSource)) {
//动态添加数据源
this.dataSource.addDataSource(tenantId, dataSource);
//切换数据源
this.changeTenantDs(tenantId);
return;
}
// todo 抛出异常信息
throw new RuntimeException("数据源不存在");
}
/**
* 判断是否存在内存中
* @param dsName
* @return
*/
@Override
public Boolean existInMemory(String dsName) {
return StringUtils.hasText(dsName) && dataSource.getDataSources().containsKey(dsName);
}
/**
* 清理当前调用上下文中的数据源缓存
*/
@Override
public void clearDsContext() {
//清空当前线程数据源
DynamicDataSourceContextHolder.clear();
TenantContext.remove();
}
/**
* 移除对应的数据源信息
*
* @param dsName 数据源名称
*/
@Override
public void removeDs(String dsName) {
dataSource.removeDataSource(dsName);
}
/**
* 切换租户对应的数据源
*
* @param tenantId 租户ID即对应数据源名称
*/
private void changeTenantDs(String tenantId) {
log.debug("切换数据源:{}", tenantId);
//设置租户上下文
TenantContext.setTenant(tenantId);
//根据tenantId切换数据源
DynamicDataSourceContextHolder.push(tenantId);
}
/**
* 根据租户ID查询数据源连接信息,并生成数据源
*
* @param tenantId
* @return
*/
private DataSource queryTenantIdToDataSource(String tenantId) {
TenantEntity tenant = tenantMapper.selectById(tenantId);
log.debug("find db tenant info by tenantId:{}", tenantId);
//租户为空则直接返回空
if (!StringUtils.hasText(tenantId) || ObjectUtils.isEmpty(tenant)) {
// todo 返回业务异常信息
return null;
}
DbInfo dbInfo = JSON.parseObject(tenant.getDbInfo(), DbInfo.class);
DataSourceProperty dataSourceProperty = new DataSourceProperty();
dataSourceProperty.setUrl(dbInfo.getDbUrl());
dataSourceProperty.setUsername(dbInfo.getDbUsername());
dataSourceProperty.setPassword(dbInfo.getDbPassword());
dataSourceProperty.setDriverClassName("com.mysql.cj.jdbc.Driver");
dataSourceProperty.setDruid(new DruidConfig());
return this.dataSourceCreator.createDataSource(dataSourceProperty);
}
}
然后我们会通过实现HandlerInterceptor创建我们自己的TenantDsInterceptor来处理每个请求来的时候TenantId信息
package com.simple.mybaitsdynamicdatasource.infrastructure.config.handler;
import com.simple.mybaitsdynamicdatasource.infrastructure.config.TenantContext;
import com.simple.mybaitsdynamicdatasource.infrastructure.service.TenantService;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Slf4j
@Component
@AllArgsConstructor
public class TenantDsInterceptor implements HandlerInterceptor {
private TenantService tenantDsService;
/**
* 在请求处理之前进行调用(Controller方法调用之前)
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
//todo 从请求中获取租户ID
String tenantId = "1";
TenantContext.setTenant(tenantId);
//根据tenantId切换数据源
tenantDsService.changeDsByTenantId(tenantId);
return true;
}
/**
* 在整个请求结束之后被调用,也就是在DispatcherServlet 渲染了对应的视图之后执行(主要是用于进行资源清理工作)
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
//清空当前线程数据源
tenantDsService.clearDsContext();
}
}
然后将我们的TenantDsInterceptor进行注册,
package com.simple.mybaitsdynamicdatasource.infrastructure.config;
import com.simple.mybaitsdynamicdatasource.infrastructure.config.handler.TenantDsInterceptor;
import lombok.AllArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@AllArgsConstructor
public class WebConfigurer implements WebMvcConfigurer {
private TenantDsInterceptor tenantDsInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tenantDsInterceptor).addPathPatterns("/**");
}
}
最后我们通过如下方法来进行测试
package com.simple.mybaitsdynamicdatasource.web.controller;
import com.simple.mybaitsdynamicdatasource.infrastructure.db.entity.UserInfoEntity;
import com.simple.mybaitsdynamicdatasource.infrastructure.service.TenantService;
import com.simple.mybaitsdynamicdatasource.infrastructure.service.UserInfoService;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping("/user-info")
@AllArgsConstructor
public class UserInfoController {
private UserInfoService userInfoService;
private TenantService tenantService;
@GetMapping("/query/{tenantId}")
public List<UserInfoEntity> query(@PathVariable String tenantId) {
tenantService.changeDsByTenantId(tenantId);
return userInfoService.list();
}
@GetMapping("/query")
public List<UserInfoEntity> queryAll() {
return userInfoService.list();
}
}


最后
我们需要约定好获取TenantId的方式,通过再TenantDsInterceptor中来给上下文进行注入让其能够依据不同的TenantId进行切换数据库
如有哪里讲得不是很明白或是有错误,欢迎指正
本文所有的演示代码皆在github 地址如下:https://github.com/benxionghu/mybaits-dynamic-datasource
如您喜欢的话不妨点个赞收藏一下吧
MyBatis-Plus 实现多租户管理的实践的更多相关文章
- Android 6.0 权限管理最佳实践
博客: Android 6.0 运行时权限管理最佳实践 github: https://github.com/yanzhenjie/AndPermission
- paip.快捷方式分组管理最佳实践ObjectDock
paip.快捷方式分组管理最佳实践ObjectDock /////挑选:除了od,还有个Berokyo ,但是bk无crash..只能使用1月.. Jumplist_Launcher_v7.2_rep ...
- Atitit。团队建设--管理最佳实践--如何留住关键人才,防止人才外流 ??
Atitit.团队建设--管理最佳实践--怎样留住核心人才,防止人才流失 ?? 1. 1.人才流失后果 1 1. 1.员工的离职带走商业技术秘密和客户等资源 1 2. 2.影响在职员工的情绪.极大挫伤 ...
- myBatis系列之七:事务管理
myBatis系列之七:事务管理 如果在操作时,如果运行时错误自动进行回滚,需要以下两个配置 @Transactional()public void save(User user) { userDao ...
- ABP Zero 多租户管理
ABPZero - 多租户管理 启用多租户 ASP.NET Boilerplate和module-zero可以运行多租户或单租户模式.多租户默认为禁用.我们可以在我们的模块PreInitialize方 ...
- 番外篇--Moddule Zero多租户管理
番外篇--Moddule Zero多租户管理 2.1.1 关于多租户 强烈建议阅读这个文件前阅读多租户文档. 2.1.2 启用多租户 ASP.NET Boilerplate和module-zero可以 ...
- 其他综合-使用Xshell远程连接管理Linux实践
使用Xshell远程连接管理Linux实践 1. Xshell整体优化 1)点击 工具 ,然后选择 选项 2)在 常规 选项中,下面的存放路径根据个人爱好修改(可选默认) 3)在 更新 选项中,将 √ ...
- 其它综合-使用Putty远程连接管理Linux实践
使用Putty远程连接管理Linux实践 1.获取putty 获取 putty有很多方法,以下是我为大家提供的下载地址: 个人网盘地址,提取码:tz83 官方下载地址 解释: 官方下载的是 zip 压 ...
- [转载]理解 Git 分支管理最佳实践
原文 理解 Git 分支管理最佳实践 Git 分支有哪些 在进行分支管理讲解之前,我们先来对分支进行一个简单的分类,并明确每一类分支的用途. 分支分类 根据生命周期区分 主分支:master,deve ...
- Atitit。团队建设--管理最佳实践--如何留住核心人才,防止人才流失 ??
Atitit.团队建设--管理最佳实践--如何留住核心人才,防止人才流失 ?? 1. 1.人才流失后果 1 1. 1.员工的离职带走商业技术秘密和客户等资源 1 2. 2.影响在职员工的情绪,极大挫伤 ...
随机推荐
- C#利用自动化接口编写OPC客户端,OPC Client,源码直接放网盘
引用:https://www.cnblogs.com/flh1/p/12409266.html 链接: https://pan.baidu.com/s/1Vs08c7qjShEc9GQ8dvCkdg ...
- 【FAQ】集成分析服务的常见问题及解决方案
常见问题一:如何验证Analytics是否上报/接入成功?以及关键日志含义是什么? 在初始化Analytics SDK前添加SDK日志开关如下: HiAnalyticsTools.enableLog ...
- Qt线程简单使用三:QRunnable~线程池
需求: 点击QPushButton按钮,QLabel中的数字,不断累加,一直到999. 做法: 创建任务类,点击QPushButton后,将任务类放入线程池中运行,通过任务类while循环,不断发 ...
- django admin后台自定义数据保存方式
故事背景是这样的: 为了方便工作中数据的整理,需要开发一个 管理系统,用于记录一些事情. 该系统不需要精美的前端的页面,只需要使用django的admin后台管理就可以了. 我需要在添加数据的时候,把 ...
- 实时 3D 深度多摄像头跟踪 Real-time 3D Deep Multi-Camera Tracking
实时 3D 深度多摄像头跟踪 Real-time 3D Deep Multi-Camera Tracking 论文url https://arxiv.org/abs/2003.11753 论文简述: ...
- UJCMS 9.1.0 发布,国内开源 java cms
许可协议从GPL-2改为Apache-2.0,更宽松的协议,方便用户将系统集成到自身的应用中. 修复了已知bug,系统更加稳定. 升级日志(9.1.0) 修复前台全文搜索没有结果 web.xml se ...
- 官方直播丨“Hello Ability:从页面跳转开始”周三晚不见不散
12月8日 19:00-20:30,Hello HarmonyOS系列课程的第四期"Hello Ability:从页面跳转开始"线上直播,将带你学习如何快速通过JS page间.A ...
- 错误 内存溢出 vendor/composer/autoload_real.php on line 66
前言 首先声明我没怎么写过php,只是当时室友做php的时候跟他一起学了几分钟. 有时候部署一些php项目的时候,发生一些错误,但是百度一下五花八门的. 这个错误的完整版是: Fatal error: ...
- hibernate4升级5带来的一些参数变化
public String hqlToHibernate5(String hql) { String[] tmp = hql.split(" "); String hqlTmp = ...
- 【Azure APIM】列举几种在APIM 策略中的主动生产的错误语句
问题描述 在为APIM服务配置了诊断日志(Diagnostic Setting),把日志收集在Log A Workspace中,需要验证日志中是否能查看到请求的错误信息. 所以想人为的来制造一些错误. ...