多租户

注意:本教程使用的数据库脚本、数据模型和环境信息请参考 “MyBatis Plus环境准备” 章节,点击下载示例源码

本章节将介绍多租户基础知识、以及怎样通过 MyBatis Plus 插件快速实现多租户。

什么是多租户?

多租户简单来说是指一个单独的实例可以为多个组织服务。多租户技术为共用的数据中心内如何以单一系统架构与服务提供多数客户端相同甚至可定制化的服务,并且仍然可以保障客户的数据隔离。一个支持多租户技术的系统需要在设计上对它的数据和配置进行虚拟分区,从而使系统的每个租户或称组织都能够使用一个单独的系统实例,并且每个租户都可以根据自己的需求对租用的系统实例进行个性化配置。

例如:在一台服务器上面部署一个学生系统,这套学生系统可以支持多个学校使用。这里的一个学校就是一个租户,每个登录系统的用户必然属于某个租户(超级管理员例外)。因此,登录用户也就只能看见自己租户下面的内容,其他租户的内容对他是不可见的。

多租户实现方式

独立数据库

一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本也高。

  • 优点:为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。

  • 缺点:增大了数据库的安装数量,随之带来维护成本和购置成本的增加。

共享数据库,隔离数据架构

多个或所有租户共享Database,但一个 Tenant 一个 Schema。

  • 优点:为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可以支持更多的租户数量。

  • 缺点:如果出现故障,数据恢复比较困难,因为恢复数据库将牵扯到其他租户的数据;如果需要跨租户统计数据,存在一定困难。

共享数据库,共享数据架构

租户共享同一个 Database、同一个 Schema,但在表中通过 TenantID 区分租户的数据。这是共享程度最高、隔离级别最低的模式。

  • 优点:三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。

  • 缺点:隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;数据备份和恢复最困难,需要逐表逐条备份和还原。如果希望以最少的服务器为最多的租户提供服务,并且租户接受以牺牲隔离级别换取降低成本,这种方案最适合。

MyBatis Plus 多租户实现

在 MyBatis Plus 中,采用“共享数据库,共享数据架构”方式实现多租户。该种实现方式,需要我们在要实现多租户的表中添加 tenant_id(租户ID)字段,每次在对数据库操作时都需要在 where 后面添加租户判断条件“tenant_id=用户的租户ID”。然而,使用了 MyBatis Plus 后,我们就不需要每次都手动在 wehre 后面添加 tenant_id 条件。

在 MyBatis Plus 中,提供了 TenantLineInnerInterceptor 插件和 TenantLineHandler 接口。其中,TenantLineInnerInterceptor 插件用来自动向每个 SQL 的 where 后面添加判断条件“tenant_id=用户的租户ID”。而 TenantLineHandler 接口用来给 TenantLineInnerInterceptor 插件提供租户ID、租户字段名。

TenantLineHandler 接口定义如下:

package com.baomidou.mybatisplus.extension.plugins.handler;

import net.sf.jsqlparser.expression.Expression;

/**
 * 租户处理器( TenantId 行级 )
 * @author hubin
 * @since 3.4.0
 */
public interface TenantLineHandler {

    /**
     * 获取租户 ID 值表达式,只支持单个 ID 值
     * @return 租户 ID 值表达式
     */
    Expression getTenantId();

    /**
     * 获取租户字段名。默认字段名叫: tenant_id
     * @return 租户字段名
     */
    default String getTenantIdColumn() {
        return "tenant_id";
    }

    /**
     * 根据表名判断是否忽略拼接多租户条件。 默认都要进行解析并拼接多租户条件
     * @param tableName 表名
     * @return 是否忽略, true:表示忽略,false:需要解析并拼接多租户条件
     */
    default boolean ignoreTable(String tableName) {
        return false;
    }
}

我们要实现自己的租户管理器,只需要实现 getTenantId() 方法,该方法将返回当前用户的租户ID。TenantLineInnerInterceptor 插件的用法如下:

@Bean
public MybatisPlusInterceptor paginationInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    // 多租户插件
    TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor();
    tenantInterceptor.setTenantLineHandler(new TenantLineHandler() {
        @Override
        public Expression getTenantId() {
            // 返回当前用户的租户ID
            return new LongValue(tenantIdManager.getCurrentTenantId());
        }
    });
    interceptor.addInnerInterceptor(tenantInterceptor);
    return interceptor;
}

示例代码

(1)向 user 表添加 tenant_id 字段,sql 如下:

-- 添加一个 tenant_id 字段,保存租户ID,用来实现多租户
ALTER TABLE `user`
ADD COLUMN `tenant_id`  varchar(1) NULL COMMENT '租户ID,用来实现多租户';

(2)定义一个租户ID管理器,即可以动态改变当前租户ID。便于我们测试时,动态修改租户ID。实际环境中,租户ID是在用户登录成功后写入Session中。代码如下:

package com.hxstrive.mybatis_plus.service;

import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 管理当前用户的租户ID
 */
@Component
public class TenantIdManager {
    /** 当前用户租户 KEY */
    private static final String KEY_CURRENT_TENANT_ID = "KEY_CURRENT_PROVIDER_ID";
    /** 保存当前租户ID */
    private static final Map<String, Object> TENANT_MAP = new ConcurrentHashMap<>();

    /**
     * 设置租户
     * @param tenantId 租户ID
     */
    public void setCurrentTenantId(Long tenantId) {
        TENANT_MAP.put(KEY_CURRENT_TENANT_ID, tenantId);
    }

    /**
     * 返回当前用户租户ID
     * @return
     */
    public Long getCurrentTenantId() {
        return (Long) TENANT_MAP.get(KEY_CURRENT_TENANT_ID);
    }

}

(3)使用 @Configuration 和 @Bean 注解配置 MyBatis Plus 的多租户插件,代码如下:

package com.hxstrive.mybatis_plus;

import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.hxstrive.mybatis_plus.service.TenantIdManager;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {
    /* 租户ID管理器 */
    @Autowired
    private TenantIdManager tenantIdManager;

    @Bean
    public MybatisPlusInterceptor paginationInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 多租户插件
        TenantLineInnerInterceptor tenantInterceptor = new TenantLineInnerInterceptor();
        tenantInterceptor.setTenantLineHandler(new TenantLineHandler() {
            @Override
            public Expression getTenantId() {
                // 返回当前用户的租户ID
                return new LongValue(tenantIdManager.getCurrentTenantId());
            }
        });
        interceptor.addInnerInterceptor(tenantInterceptor);
        return interceptor;
    }

}

(4)单元测试,验证多租户是否生效。代码如下:

package com.hxstrive.mybatis_plus.plugins;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.hxstrive.mybatis_plus.mapper.TenantMapper;
import com.hxstrive.mybatis_plus.model.TenantUserBean;
import com.hxstrive.mybatis_plus.service.TenantIdManager;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import java.util.List;

@RunWith(SpringRunner.class)
@SpringBootTest
class PluginDemo {

    @Autowired
    private TenantMapper mapper;

    @Autowired
    private TenantIdManager tenantIdManager;

    @Test
    void contextLoads() {
        findUser(1); // 租户ID=1
        findUser(8); // 租户ID=8
    }

    private void findUser(long tenantId) {
        // 设置租户ID
        tenantIdManager.setCurrentTenantId(tenantId);
        // 查询用户列表
        QueryWrapper<TenantUserBean> wrapper = new QueryWrapper<>();
        wrapper.lt("user_id", 20);
        List<TenantUserBean> userBeanList = mapper.selectList(wrapper);
        for(TenantUserBean userBean : userBeanList) {
            System.out.println(userBean);
        }
    }

}

上面测试使用两个租户ID分别去查询用户列表。执行上面代码,将执行下面 SQL 语句:

# 查询租户ID为 1 的用户信息
Preparing: SELECT user_id, name, sex, age FROM user WHERE (user_id < ?) AND tenant_id = 1
Parameters: 20(Integer)

# 查询租户ID为 8 的用户信息
Preparing: SELECT user_id, name, sex, age FROM user WHERE (user_id < ?) AND tenant_id = 8
Parameters: 20(Integer)

说说我的看法
全部评论(
没有评论
关于
本网站专注于 Java、数据库(MySQL、Oracle)、Linux、软件架构及大数据等多领域技术知识分享。涵盖丰富的原创与精选技术文章,助力技术传播与交流。无论是技术新手渴望入门,还是资深开发者寻求进阶,这里都能为您提供深度见解与实用经验,让复杂编码变得轻松易懂,携手共赴技术提升新高度。如有侵权,请来信告知:hxstrive@outlook.com
公众号