Springboot+mybatis-plus+dynamic-datasource+继承DynamicRoutingDataSource切换数据源 多租户

Springboot+mybatis-plus+dynamic-datasource+继承DynamicRoutingDataSource切换数据源 多租户

背景

最近公司要求支持saas,实现动态切换库的操作,默认会加载主租户的数据源,其他租户数据源在使用过程中自动创建加入。

解决问题

1.通过请求中设置租户id 查询对应的库

2.通过设置上下文租户id 查询对应的库

3.测试mybatisplus mapper,service继承后设置上下文能否正常 查询对应的库

4.测试RedissionClient,RedissionClient设置上下文租户id能否正常 设置对应的前缀

5.测试RedisTemplate,RedisTemplate设置上下文租户id能否正常 设置对应的前缀

解决要求

1.改造现有系统尽量少改动,避免过多的耦合代码
2.已有功能正常
3.不影响之前的@DS注解切换数据源的

实现流程

1.代码结构

请添加图片描述

2.引入依赖

 <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>transmittable-thread-local</artifactId>
            <version>2.14.3</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.3.1</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.47</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>dynamic-datasource-spring-boot-starter</artifactId>
            <version>4.2.0</version>
        </dependency>
        <dependency>
            <groupId>org.testng</groupId>
            <artifactId>testng</artifactId>
            <version>7.4.0</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>

3.mysql多租户代码

3.1.TenantContextHolder

用于将租户id设置为上下文,获取当前的租户id

package com.liuhm.context;

import com.alibaba.ttl.TransmittableThreadLocal;

/**
 * saas 上下文 Holder
 */
public class TenantContextHolder {
    /**
     * 当前租户编号
     */
    private static final ThreadLocal<String> TENANT_ID = new TransmittableThreadLocal<>();


    /**
     * 获得租户编号。
     *
     * @return 租户编号
     */
    public static String getTenantId() {
        return TENANT_ID.get();
    }


    /**
     * 获得租户编号。如果不存在,则抛出 NullPointerException 异常
     *
     * @return 租户编号
     */
    public static String getRequiredTenantId() {
        String tenantId = getTenantId();
        if (tenantId == null) {
            throw new NullPointerException("TenantContextHolder 不存在租户编号!");
        }
        return tenantId;
    }

    public static void setTenantId(String tenantId) {
        TENANT_ID.set(tenantId);
    }

    public static void clear() {
        TENANT_ID.remove();
    }

}

3.2.TenantWebFilter

拦截所有的请求获取header或者url中租户id的值,然后设置到上下文中。

(获取租户id可以改成获取token,并将租户id存入token值中,方便获取租户id)

package com.liuhm.config;

import com.liuhm.context.TenantContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;
/**
 * 前端发请求后设置租户上下文 
 */
public class TenantWebFilter extends OncePerRequestFilter {

    public static final String HEADER_TENANT_ID = "X-Tenant-Id";

    public static String getTenantId(HttpServletRequest request){
        String tenantId = StringUtils.hasLength(request.getHeader(HEADER_TENANT_ID)) ?
                request.getHeader(HEADER_TENANT_ID) :
                request.getHeader(HEADER_TENANT_ID.toLowerCase());
        if (StringUtils.isEmpty(tenantId)) {
            tenantId = getQueryParam(request.getQueryString(),HEADER_TENANT_ID);
        }
        return StringUtils.hasText(tenantId) ? tenantId : null;
    }

    public static String getQueryParam(String query,String key){
        if(Objects.isNull(query)){
            return null;
        }
        String[] params = query.split("&");
        for (String param : params) {
            String[] keyValue = param.split("=");
            if(Objects.equals(key.toLowerCase(),keyValue[0].toLowerCase()) && keyValue.length > 1){
                return keyValue[1];
            }
        }

        return null;
    }
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException{
        if (request.getRequestURI().equalsIgnoreCase("/harbor/clear")) {
            chain.doFilter(request, response);
        } else {
            String tenantId = getTenantId(request);
            if (tenantId != null) {
                TenantContextHolder.setTenantId(tenantId);
            }
            try {
                chain.doFilter(request, response);
            } finally {
                // 清理
                TenantContextHolder.clear();
            }
        }
    }
}

3.3 MyDynamicRoutingDataSource
  • MyDynamicRoutingDataSource继承DynamicRoutingDataSource 重新修改选择数据源的逻辑。

  • DynamicDataSourceContextHolder.peek()为空时,表示原功能默认的@DS没有设置,就通过tenantId去获取数据源

  • getDataSourceProperty 通过tenantId 获取数据源的配置信息

  • createDatasourceIfAbsent 通过配置信息去创建数据源并加入到dataSourceMap中

  • 通过对应的key去获取对应的数据源

package com.liuhm.config;



import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
import com.baomidou.dynamic.datasource.creator.DataSourceProperty;
import com.baomidou.dynamic.datasource.creator.DefaultDataSourceCreator;
import com.baomidou.dynamic.datasource.provider.DynamicDataSourceProvider;
import com.baomidou.dynamic.datasource.spring.boot.autoconfigure.DynamicDataSourceProperties;
import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
import com.liuhm.context.TenantContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;

import javax.annotation.Resource;
import javax.sql.DataSource;
import java.util.List;
import java.util.Set;


/**
 * @ClassName:MyDynamicRoutingDataSource
 * @Description: TODO
 * @Author: liuhaomin
 * @Date: 2024/5/9 8:44
 */
@Slf4j
public class MyDynamicRoutingDataSource extends DynamicRoutingDataSource {
    @Override
    public DataSource determineDataSource() {
        if(DynamicDataSourceContextHolder.peek() == null){
            String tenantId = TenantContextHolder.getTenantId();
            if(tenantId == null){
                // 错误日志后面打印sql的代码块需要检查
                log.error("记录 租户id不能为空");
                tenantId = dynamicDataSourceProperties.getPrimary();
            }
            if (tenantId.equals(dynamicDataSourceProperties.getPrimary())) {
                // 给默认的数据源
                return getDataSource(tenantId);
            }
            DataSourceProperty dataSourceProperty = getDataSourceProperty(tenantId);
            createDatasourceIfAbsent(dataSourceProperty);
            return getDataSource(tenantId);
        }else {
            DataSourceProperty dataSourceProperty = getDataSourceProperty(DynamicDataSourceContextHolder.peek());
            createDatasourceIfAbsent(dataSourceProperty);
            return super.determineDataSource();
        }
    }

    public MyDynamicRoutingDataSource(List<DynamicDataSourceProvider> providers) {
        super(providers);
    }



    /**
     * 用于创建租户数据源的 Creator
     */
    @Resource
    @Lazy
    private DefaultDataSourceCreator dataSourceCreator;

    @Resource
    @Lazy
    private DynamicDataSourceProperties dynamicDataSourceProperties;
    
    @Value("${spring.datasource.dynamic.primaryDatabase}")
    private String primaryDatabase;
  
    public DataSourceProperty getDataSourceProperty(String tenantId){
  
      DataSourceProperty dataSourceProperty = new DataSourceProperty();
      DataSourceProperty primaryDataSourceProperty = dynamicDataSourceProperties.getDatasource().get(dynamicDataSourceProperties.getPrimary());
      BeanUtils.copyProperties(primaryDataSourceProperty,dataSourceProperty);
      dataSourceProperty.setUrl(dataSourceProperty.getUrl().replace(primaryDatabase,tenantId));
      dataSourceProperty.setPoolName(tenantId);
  
      return dataSourceProperty;
    }


  private String createDatasourceIfAbsent(DataSourceProperty dataSourceProperty){
        // 1. 重点:如果数据源不存在,则进行创建
        if (isDataSourceNotExist(dataSourceProperty)) {
            // 问题一:为什么要加锁?因为,如果多个线程同时执行到这里,会导致多次创建数据源
            // 问题二:为什么要使用 poolName 加锁?保证多个不同的 poolName 可以并发创建数据源
            // 问题三:为什么要使用 intern 方法?因为,intern 方法,会返回一个字符串的常量池中的引用
            // intern 的说明,可见 https://www.cnblogs.com/xrq730/p/6662232.html 文章
            synchronized(dataSourceProperty.getPoolName().intern()){
                if (isDataSourceNotExist(dataSourceProperty)) {
                    log.debug("创建数据源:{}", dataSourceProperty.getPoolName());
                    DataSource dataSource = null;
                    try {
                        dataSource = dataSourceCreator.createDataSource(dataSourceProperty);
                    }catch (Exception e){
                        log.error("e {}",e);
                        if(e.getMessage().contains("Unknown database")){
                            throw new RuntimeException("租户不存在");
                        }
                        throw e;
                    }
                    addDataSource(dataSourceProperty.getPoolName(), dataSource);
                }
            }
        } else {
            log.debug("数据源已存在,无需创建:{}", dataSourceProperty.getPoolName());
        }
        // 2. 返回数据源的名字
        return dataSourceProperty.getPoolName();
    }

    private boolean isDataSourceNotExist(DataSourceProperty dataSourceProperty){
        return !getDataSources().containsKey(dataSourceProperty.getPoolName());
    }
}

3.4.TenantAutoConfiguration
  • TenantWebFilter加入FilterRegistrationBean
  • 创建 MyDynamicRoutingDataSource Bean
package com.liuhm.config;

import com.baomidou.dynamic.datasource.provider.DynamicDataSourceProvider;
import com.baomidou.dynamic.datasource.spring.boot.autoconfigure.DynamicDataSourceProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;
import java.util.List;

@Configuration
public class TenantAutoConfiguration {

    @Bean
    public FilterRegistrationBean<TenantWebFilter> tenantContextWebFilter() {
        FilterRegistrationBean<TenantWebFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(new TenantWebFilter());
        registrationBean.setOrder(-104);
        return registrationBean;
    }

    @Autowired
    private DynamicDataSourceProperties properties;
    @Bean
    public DataSource dataSource(List<DynamicDataSourceProvider> providers) {
        MyDynamicRoutingDataSource dataSource = new MyDynamicRoutingDataSource(providers);
        dataSource.setPrimary(properties.getPrimary());
        dataSource.setStrict(properties.getStrict());
        dataSource.setStrategy(properties.getStrategy());
        dataSource.setP6spy(properties.getP6spy());
        dataSource.setSeata(properties.getSeata());
        dataSource.setGraceDestroy(properties.getGraceDestroy());
        return dataSource;
    }
}

4. redis多租户代码

4.1. Redisson 多租户设置

NameMapper 在 Redisson 中是一个用于自定义分布式对象在 Redis 中键名称映射的接口,其主要作用是通过添加前缀、后缀或修改键格式来实现键空间的管理和隔离,从而避免键冲突或支持多环境部署(如开发、测试和生产环境)。具体功能包括:

  • 键名称自定义‌:允许为 RMap、RBucket 等分布式对象的 Redis 键添加统一前缀(例如 "env:dev:myMap"),确保键的可读性和组织性。
  • 命名空间隔离‌:在多租户或微服务架构中,通过不同前缀分隔不同业务模块的键,防止数据覆盖或冲突。
  • 简化键管理‌:在配置类中定义 NameMapper 规则后,RedissonClient 会自动应用该规则到所有新创建的分布式对象上,无需手动修改每个键的名称。
RedissionClientTenantTests 测试类
4.1.1.MyPrefixMapper 代码
package com.liuhm.config.redis;

import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.liuhm.context.TenantContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.NameMapper;

/**
 * @ClassName:MyPrefixMapper
 * @Description: TODO
 * @Author: liuhaomin
 * @Date: 2025/7/1 10:15
 */
@Slf4j
public class MyPrefixMapper implements NameMapper {

    private final static String GLOBAL="GLOBAL";
    public String getPrefix(String key) {
        if (key.startsWith(GLOBAL)) {
            return "";
        }

        String tenantId = TenantContextHolder.getTenantId();
        if (StringUtils.isBlank(tenantId)) {
            tenantId = "master";
            log.error("[tenant] redission该代码块上下没有指定租户id {}",key);
        }
        return tenantId + ":";
    }

    @Override
    public String map(String key) {
        return getPrefix(key) + key;
    }

    @Override
    public String unmap(String key) {
        return key.startsWith(getPrefix(key)) ? key.substring(getPrefix(key).length()) : key;
    }
}


4.1.2. MyRedisAutoConfiguration 代码
package com.liuhm.config.redis;

import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.ClusterServersConfig;
import org.redisson.config.Config;
import org.redisson.config.SentinelServersConfig;
import org.redisson.config.SingleServerConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import java.util.List;
import java.util.Objects;


/**
 * @ClassName:MyRedisAutoConfiguration
 * @Description: TODO
 * @Author: liuhaomin
 * @Date: 2025/7/1 10:00
 */
@Configuration
@AutoConfigureAfter(RedisAutoConfiguration.class)
@Slf4j
public class MyRedisAutoConfiguration {

    private static final String ADDRESS_PREFIX = "redis://";
    @Autowired
    private RedisProperties redisProperties;

    

    /**
     * RedissonClient 入口
     */
    @Bean
    @Primary
    public RedissonClient redisson(){
        // 哨兵模式
        RedisProperties.Sentinel sentinel = redisProperties.getSentinel();
        if (Objects.nonNull(sentinel)) {
            log.info("redis is sentinel mode");
            return redissonSentinel();
        }
        // 集群模式
        RedisProperties.Cluster cluster = redisProperties.getCluster();
        if (Objects.nonNull(cluster)) {
            log.info("redis is cluster mode");
            return redissonCluster();
        }
        // 单机模式
        String host = redisProperties.getHost();
        if (StringUtils.isNotBlank(host)) {
            log.info("redis is single mode");
            return redissonSingle();
        }

        log.error("redisson config can not support this redis mode");
        return null;
    }

    /**
     * 单机模式
     */
    private RedissonClient redissonSingle(){
        // 声明一个配置类
        Config config = new Config();
        SingleServerConfig serverConfig = config.useSingleServer()
                .setAddress(ADDRESS_PREFIX + redisProperties.getHost() + ":" + redisProperties.getPort())
                .setDatabase(redisProperties.getDatabase())
                .setTimeout((int) redisProperties.getTimeout().toMillis())
                .setPingConnectionInterval((int) redisProperties.getLettuce()
                        .getPool()
                        .getMaxWait()
                        .toMillis())
                .setConnectionPoolSize(redisProperties.getLettuce()
                        .getPool()
                        .getMaxActive())
                .setConnectionMinimumIdleSize(redisProperties.getLettuce()
                        .getPool()
                        .getMinIdle());
        // 判断密码
        if (!StringUtils.isEmpty(redisProperties.getPassword())) {
            serverConfig.setPassword(redisProperties.getPassword());
        }
        return Redisson.create(config);
    }

    /**
     * 哨兵模式
     */
    private RedissonClient redissonSentinel(){
        String masterName = redisProperties.getSentinel().getMaster();
        List<String> nodes = redisProperties.getSentinel().getNodes();
        log.info("redis nodes: {}", String.join(",",nodes));
        String password = redisProperties.getPassword();

        // 声明一个配置类
        Config config = new Config();
        SentinelServersConfig sentinelServersConfig = config.useSentinelServers();
        // 扫描间隔
        sentinelServersConfig.setScanInterval(2000);
        // 判断密码
        if (!StringUtils.isEmpty(password)) {
            sentinelServersConfig.setPassword(password);
        }
        sentinelServersConfig.setMasterName(masterName);

        // 添加redis节点
        sentinelServersConfig.addSentinelAddress(nodes.toArray(new String[0]));
        sentinelServersConfig.setTimeout((int) redisProperties.getTimeout().toMillis());
        return Redisson.create(config);
    }

    /**
     * 集群模式
     */
    private RedissonClient redissonCluster(){
        List<String> nodes = redisProperties.getCluster().getNodes();
        String password = redisProperties.getPassword();
        // 声明一个配置类
        Config config = new Config();
        ClusterServersConfig clusterServersConfig = config.useClusterServers();
        // 扫描间隔
        clusterServersConfig.setScanInterval(2000);
        // 判断密码
        if (!StringUtils.isEmpty(password)) {
            clusterServersConfig.setPassword(password);
        }
        // 添加redis节点
        for(String node : nodes){
            clusterServersConfig.addNodeAddress(ADDRESS_PREFIX + node);
        }
        clusterServersConfig.setTimeout((int) redisProperties.getTimeout().toMillis());
        return Redisson.create(config);
    }

  /**
     * RedissonClient设置前缀的重点
     * 设置NameMapper
     * @param redisson
     * @return
     */
    @Bean
    @ConditionalOnBean(RedissonClient.class)
    public RedissonClient setRedissonClient(Redisson redisson){
        redisson.getServiceManager().getConfig().setNameMapper(new MyPrefixMapper());
        return redisson;
    }

}

4.2. RedisTemplate 多租户设置

  • 在RedisTemplate中,RedisSerializer是核心的序列化组件,负责实现Java对象与Redis存储数据之间的双向转换,其作用主要体现在以下方面:‌
  1. 数据格式转换
    将Java对象序列化为字节数组(byte[])以适配Redis的二进制安全存储,并通过反序列化将字节数组还原为Java对象。例如:
  • 键(Key)序列化:通常使用StringRedisSerializer将字符串键转为UTF-8字节数组。
  • 值(Value)序列化:支持JSON、JDK原生序列化等格式,如GenericJackson2JsonRedisSerializer将对象转为JSON字符串存储。
  1. 跨语言兼容性
    通过JSON或字符串序列化器(如Jackson2JsonRedisSerializer)生成通用数据格式,确保其他语言(如Python、Node.js)可读取Redis中的数据
RedisTemplateTests 测试类
4.2.1.MyStringRedisSerializer代码
package com.liuhm.config.redis;

import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.liuhm.context.TenantContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;

import java.nio.charset.Charset;


/**
 * @ClassName:MyStringRedisSerializer
 * @Description: TODO
 * @Author: liuhaomin
 * @Date: 2025/7/1 10:03
 */
@Slf4j
public class MyStringRedisSerializer implements RedisSerializer<String> {

    private final static String GLOBAL="GLOBAL";
    /**
     * 全局的redis缓存只需要加前缀
     * GlobalConsts.GLOBAL + "xx"
     * @param s object to serialize. Can be {@literal null}.
     * @return
     * @throws SerializationException
     */

    @Override
    public byte[] serialize(String s) throws SerializationException{
        if (s == null) {
            return new byte[0];
        }
        String originalKey;
        String tenantId = TenantContextHolder.getTenantId();
        String commonPrefix = tenantId + ":";
        if (StringUtils.isNotBlank(tenantId) && !s.startsWith(GLOBAL)) {
            originalKey = commonPrefix + s;
        } else {
            originalKey = s;
        }
        return originalKey.getBytes(Charset.defaultCharset());
    }

    @Override
    public String deserialize(byte[] bytes) throws SerializationException{
        String s = bytes == null ? null : new String(bytes);
        if (StringUtils.isBlank(s) || s.startsWith(GLOBAL)) {
            return s;
        }
        String tenantId = TenantContextHolder.getTenantId();
        String commonPrefix = tenantId + ":";
        int index = s.indexOf(commonPrefix);
        if (index != -1) {
            return s.substring(commonPrefix.length());
        }
        return s;
    }
}

4.2.2. MyRedisAutoConfiguration 代码
package com.liuhm.config.redis;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;



/**
 * @ClassName:MyRedisAutoConfiguration
 * @Description: TODO
 * @Author: liuhaomin
 * @Date: 2025/7/1 10:00
 */
@Configuration
@AutoConfigureAfter(RedisAutoConfiguration.class)
@Slf4j
public class MyRedisAutoConfiguration {


    /**
     * 多租户模式下 redisTemplate 配置
     * @param redisConnectionFactory
     * @return
     */
    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        MyStringRedisSerializer myStringRedisSerializer = new MyStringRedisSerializer();
        // 使用Jackson2JsonRedisSerialize 替换默认序列化
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);

        // 设置value的序列化规则和 key的序列化规则
        // key采用String的序列化方式
        redisTemplate.setKeySerializer(myStringRedisSerializer);
        // value序列化方式采用jackson
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        // hash的key也采用String的序列化方式
        redisTemplate.setHashKeySerializer(myStringRedisSerializer);
        // hash的value序列化方式采用jackson
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);

        redisTemplate.setDefaultSerializer(myStringRedisSerializer);
        redisTemplate.setEnableDefaultSerializer(true);

        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }

}

4.总结

4.1.多租户切换的方法
  1. dynamic-datasource 跨库进行切换数据源可以用DynamicDataSourceContextHolder.push()
  • 在过滤器[filter]里切换
  • 拦截器里切换数据源
  • 方法内部硬编码切换
  • 通过service,mapper加注解进行切换@DS (不推荐,有切面没有切成功的,如本类调用自己的方法)
  1. 重写DynamicRoutingDataSource选择器,自定义上下文获取租户id获取对应的DataSource
4.2.上诉方法中都可以实现
  • 过滤器和拦截器切换数据源的时候,线程执行的方法不容切换,需要手动切换,或者在设置租户id的时候进行切换数据源。(耦合性过大,代码不够单一,如果在设置租户id的时候去切换数据源)
  • 重写DynamicRoutingDataSource选择器,只是在执行sql前进行数据源获取的切换,耦合性小,代码单一性好,且不影响之前的功能。
4.3.设置租户id需要注意的
  • 所有请求需要拦截进行设置
  • 所有线程需要相关的需要进行重写并设置租户上下文
  • 所有fegin需要进行设置租户上下文
  • 以上4.3的操作可以学习一下mdc链路追踪日志的代码

编码不易,有问题多多指教

博客地址

代码下载

下面的springcloud_dynamic_datasource_tenant

<think>我们需要整合Mybatis-PlusSpring Boot 3,并实现动态数据源。动态数据源意味着在运行时可以根据条件切换不同的数据源(例如,根据不同的租户、不同的业务模块等)。 在Spring Boot中,我们可以使用AbstractRoutingDataSource来实现动态数据源切换。主要步骤如下: 1. 配置多个数据源(如主数据源、从数据源等)。 2. 创建一个动态数据源类,继承AbstractRoutingDataSource,并重写determineCurrentLookupKey方法,该方法返回当前线程需要使用数据源的key。 3. 使用ThreadLocal(或类似的机制)来保存每个线程设置的数据源key。 4. 配置Spring Boot,将动态数据源作为主数据源,并设置默认数据源。 5. 整合Mybatis-Plus使用我们配置的动态数据源。 注意:Spring Boot 3需要Java 17及以上版本,并且使用Jakarta EE(javax包替换为jakarta包)。 下面是一个基本的实现步骤代码示例: 步骤1:添加依赖(pom.xml) 我们需要添加Spring Boot Starter、Mybatis-Plus Starter以及数据库驱动(这里以MySQL为例)。 步骤2:配置多个数据源 在application.yml中配置多个数据源,例如: ```yaml spring: datasource: master: url: jdbc:mysql://localhost:3306/master_db?useSSL=false&serverTimezone=UTC username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver slave: url: jdbc:mysql://localhost:3306/slave_db?useSSL=false&serverTimezone=UTC username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver ``` 步骤3:创建动态数据源配置类 首先,定义一个数据源上下文持有者(使用ThreadLocal)来存储当前线程的数据源key。 ```java public class DynamicDataSourceContextHolder { private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>(); public static void setDataSourceKey(String key) { CONTEXT_HOLDER.set(key); } public static String getDataSourceKey() { return CONTEXT_HOLDER.get(); } public static void clearDataSourceKey() { CONTEXT_HOLDER.remove(); } } ``` 然后,创建动态数据源类: ```java public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.getDataSourceKey(); } } ``` 步骤4:配置数据源Bean 创建一个配置类,用于创建主数据源、从数据源,并将它们设置到动态数据源中。 ```java @Configuration @AutoConfigureAfter(DataSourceAutoConfiguration.class) public class DataSourceConfig { @Bean @ConfigurationProperties(prefix = "spring.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } @Bean @ConfigurationProperties(prefix = "spring.datasource.slave") public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } @Bean public DataSource dynamicDataSource(DataSource masterDataSource, DataSource slaveDataSource) { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("master", masterDataSource); targetDataSources.put("slave", slaveDataSource); DynamicDataSource dynamicDataSource = new DynamicDataSource(); dynamicDataSource.setDefaultTargetDataSource(masterDataSource); // 默认数据源 dynamicDataSource.setTargetDataSources(targetDataSources); return dynamicDataSource; } } ``` 步骤5:配置Mybatis-Plus使用动态数据源Mybatis-Plus的配置中,使用我们创建的dynamicDataSource。 ```java @Configuration @MapperScan("com.example.mapper") public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } // 使用动态数据源 @Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean sessionFactory = new MybatisSqlSessionFactoryBean(); sessionFactory.setDataSource(dataSource); // 其他配置,如mapper.xml位置等 return sessionFactory.getObject(); } } ``` 步骤6:使用AOP或拦截器在需要的地方切换数据源 我们可以通过自定义注解AOP来实现数据源切换。例如: 定义注解: ```java @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface TargetDataSource { String value() default "master"; } ``` 定义切面: ```java @Aspect @Component public class DataSourceAspect { @Before("@annotation(targetDataSource)") public void beforeSwitchDataSource(JoinPoint point, TargetDataSource targetDataSource) { String dataSourceKey = targetDataSource.value(); DynamicDataSourceContextHolder.setDataSourceKey(dataSourceKey); } @After("@annotation(targetDataSource)") public void afterSwitchDataSource(JoinPoint point, TargetDataSource targetDataSource) { DynamicDataSourceContextHolder.clearDataSourceKey(); } } ``` 然后在需要切换数据源的方法上使用@TargetDataSource("slave")即可。 注意事项: 1. 事务管理:动态数据源切换事务管理需要特别注意。通常,我们希望在开启事务之前就已经确定了数据源,所以切换数据源的操作应该在事务开始之前。因此,建议将@Transactional@TargetDataSource注解同时使用的时候,确保切换数据源的切面优先级高于事务切面(可以通过@Order设置,值越小优先级越高)。或者,在事务管理器中也使用动态数据源。 2. 配置事务管理器: ```java @Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } ``` 3. 在Spring Boot 3中,注意包名变化(javax->jakarta)。 以上就是一个基本的整合步骤。当然,实际项目中可能还需要考虑更多的细节,比如连接池配置、多数据源的事务处理(可以使用分布式事务)等。 最后,根据要求,在回答末尾提出3-5个相关问题。</think>### MyBatis-PlusSpring Boot 3整合动态数据源指南 #### 核心实现步骤 1. **添加依赖** (`pom.xml`) ```xml <dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>4.3.0</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> </dependency> ``` 2. **配置数据源** (`application.yml`) ```yaml spring: datasource: dynamic: primary: master # 默认数据源 strict: false # 是否严格匹配数据源 datasource: master: url: jdbc:mysql://localhost:3306/db1 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver slave1: url: jdbc:mysql://localhost:3306/db2 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver ``` 3. **使用注解切换数据源** ```java @Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> { // 默认使用master数据源 @DS("slave1") // 切换数据源 public List<User> getSlaveUsers() { return baseMapper.selectList(null); } } ``` 4. **动态数据源配置类** (可选扩展) ```java @Configuration public class DataSourceConfig { @Bean @ConfigurationProperties(prefix = "spring.datasource.dynamic.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } // 可添加自定义数据源加载逻辑 } ``` #### 关键注意事项 1. **事务管理**: - 在`@Transactional`方法内切换数据源需添加`@DS`注解 - 建议在Service方法入口处指定数据源 2. **MyBatis-Plus配置**: ```java @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); return interceptor; } ``` 3. **动态数据源原理**: - 基于`AbstractRoutingDataSource`实现 - 通过`DataSourceContextHolder`保存线程级数据源标识 - 使用AOP拦截`@DS`注解实现切换 4. **Spring Boot 3适配**: - Jakarta EE 9+ 包路径 (`jakarta.persistence.*`) - 需JDK 17+环境 - 兼容Spring Boot 3自动配置机制
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

liuhm~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值