Heycm

Heycm

聊聊 SaaS 系统中数据源切换

2024-08-28
聊聊 SaaS 系统中数据源切换

引言

  这个话题,其实也可以叫做 单应用多数据源的切换,也适合业务分库、读写分离的场景。

  我们知道,在多租户系统中,租户数据需要做隔离,常见的方式有:

  • 业务逻辑隔离,这种方式是在数据表中,增加一个租户ID字段作为标识,查询时携带租户ID作为必须的查询条件;

  • 数据物理隔离,这种方式是为每个租户创建不同的数据库实例,在线程执行上下文中识别出当前租户,并设置对应的数据源。

  两种方式的优势和不足都很明显,逻辑隔离使用更少的资源成本、获得更低的系统复杂度,但需要付出更复杂的业务逻辑;而物理隔离使用了更多的数据库实例,提高的系统组件的复杂度,但获得了更安全的数据隔离级别、更加简单的业务逻辑以及多租户之间更低的耦合度和更高的性能稳定。

  因此,并没有说哪种方式更加优秀,需要结合业务需求和实际情况来做出最合适的选择。

  本文聊的是在数据物理隔离的场景下,怎么做数据源自动切换,让各租户的数据落到自己的数据库中,租户之间数据不交叉、不影响、不越权,同时,我们需要尽量做到自动切换、代码无感,避免侵入业务逻辑。

分析

  主要看这一句“让各租户的数据落到自己的数据库中”,这里边有 3 个问题:

  1. 怎么知道当前的租户是哪个?

  2. 怎么知道租户对应的数据库是哪个?

  3. 怎么为当前租户设置自己的数据库?

OK,3 个问题,我们一个一个解决。

实现

识别当前租户

  在 SaaS 系统中,我们可以在请求头中携带租户ID,当请求到达后端时,通过解析请求头中的租户ID来识别当前租户

@GetMapping("/demo")
public String demo(HttpServletRequest request) {
	String currentTenantId = request.getHeader("X-Tenant-Id");
	log.info("currentTenantId: {}", currentTenantId);
	return "ok";
}

至于,如何将租户ID设置到请求头,那就多种多样了。比如,由前端控制在发起请求前动态设置;如果为每一个租户部署一套前端服务,还可以在前端打包时当做环境变量配置;还可以 Nginx 转发时指定域名添加租户请求头等等。

注册多租户数据源

数据源

  SpringBoot 对多数据源的支持是相当友好,在 spring-boot-starter-data-jdbc 依赖中,提供了一个 AbstractRoutingDataSource 类,顾名思义:抽象数据源路由,通过继承它实现多数据源注册和当前数据源选择。

  它的 3 个关键方法:

// 1.设置数据源Map集合,key为数据源标识,value为数据源 DataSource 对象
public void setTargetDataSources(Map<Object, Object> targetDataSources) {
	this.targetDataSources = targetDataSources;
}

// 2.设置默认数据源实例,当在数据源Map中找不到指定数据源时,使用该数据源
public void setDefaultTargetDataSource(Object defaultTargetDataSource) {
    this.defaultTargetDataSource = defaultTargetDataSource;
}

// 3.选择当前数据源Key,需要自行实现数据源切换逻辑;
//   以租户ID为Key,其实就是要返回当前请求上下文中的租户ID
protected abstract Object determineCurrentLookupKey();

我们要做的就是,定义一个类去继承此抽象类,为每个租户创建 DataSource 对象,并以租户ID为 Key 注册到数据源Map集合 targetDataSources 中,然后按照业务需求设置一个默认数据源

package cn.heycm.tenant.common.datasource;

import cn.heycm.tenant.common.context.TenantContextHolder;
import cn.heycm.tenant.common.datasource.entity.DataSourceItem;
import cn.heycm.tenant.common.datasource.entity.DataSourceItemPool;
import cn.heycm.tenant.common.datasource.util.DataSourceUtil;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.util.StringUtils;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 多租户数据源
 *
 * @author hey
 * @version 1.0
 * @since 2024/8/24 22:54
 */
public class TenantDataSource extends AbstractRoutingDataSource {

    public TenantDataSource() {
        init();
    }

    /**
     * 获取当前租户
     *
     * @return 租户ID
     */
    @Override
    protected Object determineCurrentLookupKey() {
        return TenantContextHolder.get();
    }

    private void init() {

        // 设置租户ID对应数据源
        Map<Object, Object> target = new HashMap<>();

        List<DataSourceItem> list = getDataSourceItems();
        String defaultTenant = null;
        for (DataSourceItem item : list) {
            target.put(item.getTenant(), DataSourceUtil.createDataSource(item));
            if (item.isPrimary()) {
                defaultTenant = item.getTenant();
            }
        }

        if (!StringUtils.hasText(defaultTenant)) {
            throw new RuntimeException("Default datasource not configured.");
        }

        // 设置租户数据源
        this.setTargetDataSources(target);
        // 设置默认租户
        this.setDefaultTargetDataSource(target.get(defaultTenant));
    }

    private static List<DataSourceItem> getDataSourceItems() {
        // 从配置文件、缓存、Nacos中获取数据源配置

        DataSourceItem item1 = new DataSourceItem();
        item1.setTenant("tenant1");
        item1.setPrimary(true);
        item1.setHost("localhost");
        item1.setPort(3306);
        item1.setSchema("tenant1");
        item1.setUsername("root");
        item1.setPassword("root");
        item1.setItemPool(new DataSourceItemPool());
        item1.getItemPool().setPoolName("tenant1-hk-pool");

        DataSourceItem item2 = new DataSourceItem();
        item2.setTenant("tenant2");
        item2.setHost("localhost");
        item2.setPort(3306);
        item2.setSchema("tenant2");
        item2.setUsername("root");
        item2.setPassword("root");
        item2.setItemPool(new DataSourceItemPool());
        item2.getItemPool().setPoolName("tenant2-hk-pool");

        return List.of(item1, item2);
    }
}

事务管理

  我们需要为数据源配置事务管理器,否则事务不生效。

package cn.heycm.tenant.common.datasource;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;

/**
 * 数据源配置
 *
 * @author hey
 * @version 1.0
 * @since 2024/8/24 23:48
 */
@Configuration
public class DataSourceConfiguration {


    /**
     * 租户动态数据源
     *
     * @return dataSource
     */
    @Bean
    public DataSource dataSource() {
        return new TenantDataSource();
    }

    /**
     * 全局事务管理器,跨租户会失效
     *
     * @param dataSource 租户动态数据源
     * @return transactionManager
     */
    @Bean
    public PlatformTransactionManager transactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

数据源切换

  现在,我们已经拿到了当前请求的租户ID,并注册了租户数据源。接下来,我们要做的就是:在执行数据库操作之前,将数据源路由的选择逻辑切换为选择当前租户数据源。

  没错,你肯定也想到了用切面处理数据源切换逻辑,这样做的好处就是业务隔离,业务无需关注数据源,编写业务代码时的考虑,就像使用单数据源一样无感。

  首先,我们需要一个保存当前租户上下文信息的 ThreadLocal 容器,基于其线程安全的特点,适合多线程环境中维护当前线程的租户上下文信息:

package cn.heycm.tenant.common.context;

import org.springframework.util.StringUtils;

/**
 * 多租户上下文,维护当前的租户信息
 *
 * @author hey
 * @version 1.0
 * @since 2024/8/24 22:55
 */
public class TenantContextHolder {

    private static final ThreadLocal<String> TENANT = new ThreadLocal<>();

    public static void set(String tenant) {
        TenantContextHolder.TENANT.set(tenant);
    }

    public static String get() {
        return TenantContextHolder.TENANT.get();
    }

    public static void clear() {
        TenantContextHolder.TENANT.remove();
    }

    public static boolean exist() {
        return StringUtils.hasText(TenantContextHolder.get());
    }
}

这个 TenantContextHolder 就是为 TenantDataSource 提供当前租户上下文数据的,它维护当前线程应该访问的数据源Key。

  其次,我们定义一个方法注解 @Tenant,用来标记需要织入的目标方法:

package cn.heycm.tenant.common.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 租户注解,在方法上,指定租户ID
 *
 * @author hey
 * @version 1.0
 * @since 2024/8/24 22:47
 */
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface Tenant {

    /**
     * 指定租户ID
     *
     * @return 租户ID
     */
    String value() default "";
}

  最后,我们定义一个切面,织入注解标记的目标方法,在方法执行前从请求头解析并设置租户上下文,在方法执行后清除租户上下文:

package cn.heycm.tenant.common.aspect;

import cn.heycm.tenant.common.annotation.Tenant;
import cn.heycm.tenant.common.constant.TenantConstant;
import cn.heycm.tenant.common.context.TenantContextHolder;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

/**
 * 租户切面,设置租户上下文,设置切面最高优先级(否则与事务注解在同一个方法使用时会失效)
 *
 * @author hey
 * @version 1.0
 * @since 2024/8/24 22:50
 */
@Component
@Aspect
@Order(Ordered.HIGHEST_PRECEDENCE)
@Slf4j
public class TenantAspect {

    /**
     * 环绕通知
     * 1.执行前设置租户上下文
     * 2.执行后清除租户上下文
     */
    @Around("@annotation(cn.heycm.tenant.common.annotation.Tenant)")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        String tenant = this.getTenant(pjp);
        if (!StringUtils.hasText(tenant)) {
            throw new RuntimeException("Tenant context is missing.");
        }
        if (TenantContextHolder.exist()) {
            log.debug("Tenant context switches from [{}] to [{}]", TenantContextHolder.get(), tenant);
        } else {
            log.debug("Tenant context switches to [{}]", tenant);
        }
        TenantContextHolder.set(tenant);
        try {
            return pjp.proceed();
        } finally {
            if (TenantContextHolder.exist()) {
                log.debug("Tenant context [{}] removed.", TenantContextHolder.get());
                TenantContextHolder.clear();
            } else {
                log.warn("Tenant context has already been removed, Please check the nested use of @Tenant.");
            }
        }
    }

    /**
     * 获取租户
     * 1.优先取注解设定的租户
     * 2.取不到时,从请求头取
     *
     * @return 租户ID
     */
    private String getTenant(ProceedingJoinPoint pjp) {
        // 优先取注解设定的租户
        MethodSignature signature = (MethodSignature) pjp.getSignature();
        Tenant tenant = signature.getMethod().getAnnotation(Tenant.class);
        if (StringUtils.hasText(tenant.value())) {
            return tenant.value();
        }
        // 取不到时,从请求头取
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (null == attributes) {
            return null;
        }
        HttpServletRequest request = attributes.getRequest();
        return request.getHeader(TenantConstant.HEADER);
    }
}

这一块逻辑很简单,就不啰嗦了。用法也很简单,在业务层或仓储层的方法上打上 @Tenant 注解即可

// service
@Tenant
public void save(SaveUserCmd cmd) {
	// 省略其他...    
    repository.save(user);
}

// repository
@Transactional
public void save(User user) {
	// 省略其他...
	userMapper.insert(xx);
    xxxxMapper.insert(xx);
}

隐藏问题

这里面有一些隐藏逻辑,值得关注的。

租户注解 @Tenant 嵌套使用

  @Tenant 是基于 AOP 实现数据源切换,环绕通知在目标方法执行完成后会清除租户上下文。在方法调用链中,外层方法和内层方法嵌套使用 @Tenant 是一种错误的用法,因为外层方法在调用内层方法之后,外层方法会丢失租户上下文,此时如果还有其他数据库操作的话,会使用默认数据源,有可能导致租户数据错乱

  这里的切面逻辑很简单,不像事务注解那样有维护传递性(也没必要),我们只好通过开发规范去约束(这个约束确实不够有力量,我在工作中也发现了有些同事没有关注到这个点)。

事务失效问题

  一、先有数据源,获取连接,才能开启事务;

  二、跨租户的事务操作,属于分布式事务。

  所以,在方法调用链中,@Transactional 不能出现在 @Tenant 之前(可以在同一个方法上使用),且 @Transactional 的作用域内,不能切换租户,否则会导致事务失效。

  @Transactional 和 @Tenant 可以在同一个方法上使用,但 TenantAspect 切面必须设置高优先级 @Order(Ordered.HIGHEST_PRECEDENCE) ,作用是让数据源切换发生在开启事务之前,这是没有问题的。

MQ消费等无HTTP请求场景

  第一种做法是,发消息时把租户上下文写到消息中,消费时从消息中读出租户上下文,手动 TenantContextHolder.set(tenant) 设置,相当与手动AOP,但是记得在 finally 代码块中 clear 掉上下文信息;

  第二种做法,可以将MQ消息收发包装一下,造个轮子,做个统一的消息事件模型,这样的话在切面中也可以识别这个模型,拿到租户上下文,这是题外话了。

其他问题

  可能有小伙伴看到,为啥要在 @Tenant 注解中设计一个固定租户的属性,通篇下来好像没起啥作用。

  是的,这个设计不是很适合 SaaS 项目场景,它比较适合读写分离场景,主库固定写,从库固定读,将主库设置为默认库,在读场景标记读库就行。

代码

点我点我点我就跳转

https://gitee.com/heycm/cn-heycm-tenant/tree/master

完。