SaaS多租户架构实践:字段隔离方案(共享数据库+共享Schema)

本项目是SaaS模式下,基于多租户架构技术,采用字段隔离(共享数据库,共享Schema)方案的demo,旨在了解字段隔离方案的基本工作流程和实现原理,仅做入门使用,不进行深入研究。

项目源码地址:multi-tenancy

一、Mybatis-Plus实现

  1. maven依赖

    xml 复制代码
    <!-- Mybatis-Plus依赖 -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.4.1</version>
    </dependency>
    <!-- mysql驱动 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.15</version>
    </dependency>
    <!-- 简洁java代码 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.16</version>
    </dependency>
  2. 配置 mybatis 拦截器,并设置租户拦截器MyTenantLineHandler

    java 复制代码
    import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
    import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
    import com.tenancy.multi.common.interceptor.MyTenantLineHandler;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    /**
     * MyBatis配置类
     * 配置MyBatis-Plus的多租户拦截器
     */
    @Configuration // 表明这是一个配置类
    public class MyBatisConfig {
        /**
         * 配置MyBatis-Plus拦截器
         * 添加多租户拦截器以实现SQL自动添加租户条件
         *
         * @return MybatisPlusInterceptor 配置好的MyBatis-Plus拦截器
         */
        @Bean // 将返回的拦截器注册为Spring容器中的Bean
        public MybatisPlusInterceptor mybatisPlusInterceptor() {
            // 创建MyBatis-Plus拦截器
            MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
            // 添加多租户内部拦截器,传入自定义的租户处理器
            interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MyTenantLineHandler()));
            return interceptor;
        }
    }
  3. 租户拦截器 MyTenantLineHandler 代码。实现 mybatis 自带的租户 Handler,实现 getTenantId() 方法,mybatis 执行sql 时会通过此方法将得到的租户id条件插入到sql里。

    java 复制代码
    import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
    import com.tenancy.multi.common.context.TenantContext;
    import net.sf.jsqlparser.expression.Expression;
    import net.sf.jsqlparser.expression.StringValue;
    
    /**
     * 自定义租户处理器
     * 实现mybatis自带的租户Handler(TenantLineHandler接口),用于处理多租户SQL拦截逻辑
     *
     */
    public class MyTenantLineHandler implements TenantLineHandler {
    
        /**
         * 获取当前租户ID
         * MyBatis-Plus执行SQL时会通过此方法获取租户ID并自动添加到SQL条件中
         *
         * @return Expression 租户ID的表达式,用于SQL条件拼接
         */
        @Override
        public Expression getTenantId() {
            // 从租户上下文中获取当前租户ID,并包装为SQL表达式
            return new StringValue(TenantContext.getCurrentTenant());
        }
    
        /**
         * 获取租户ID字段名
         * 指定数据库中用于存储租户ID的列名
         *
         * @return String 租户ID字段名
         */
        @Override
        public String getTenantIdColumn() {
            // 返回数据库中租户ID的列名
            return "tenant_id";
        }
    
        /**
         * 忽略租户过滤的表
         * 指定哪些表不需要添加租户条件过滤
         *
         * @param tableName 表名
         * @return boolean true表示忽略(不添加租户条件),false表示需要添加租户条件
         */
        @Override
        public boolean ignoreTable(String tableName) {
            // 这里可以添加不需要租户隔离的表名判断逻辑
            // 例如:系统表、公共配置表等可以跳过租户过滤
            return false; // 默认所有表都需要租户隔离
        }
    }
  4. 租户上下文代码。租户上下文会保存当前请求线程里从请求头获取的租户id。

    java 复制代码
    /**
     * 租户上下文管理类
     * 用于在多线程环境中存储和获取当前请求的租户信息
     * 基于ThreadLocal实现线程隔离的租户信息存储
     */
    public class TenantContext {
        /**
         * 线程本地变量,用于存储当前线程的租户ID
         * InheritableThreadLocal确保子线程可以继承父线程的租户信息
         */
        private static final ThreadLocal<String> currentTenant = new InheritableThreadLocal<>();
    
        /**
         * 获取当前租户ID
         *
         * @return String 当前线程的租户ID,如果没有设置则返回null
         */
        public static String getCurrentTenant() {
            return currentTenant.get();
        }
    
        /**
         * 设置当前租户ID
         *
         * @param tenantId 租户ID
         */
        public static void setCurrentTenant(String tenantId) {
            currentTenant.set(tenantId);
        }
    
        /**
         * 清除当前租户信息
         * 防止内存泄漏,应在请求处理完成后调用
         */
        public static void clear() {
            currentTenant.remove();
        }
    }
  5. 配置过滤器,过滤器负责将请求头传过来的租户id放入租户上下文。

    java 复制代码
    import com.tenancy.multi.common.context.TenantContext;
    import org.springframework.core.annotation.Order;
    
    import javax.servlet.*;
    import javax.servlet.http.HttpServletRequest;
    import java.io.IOException;
    
    /**
     * 租户过滤器
     * 用于从HTTP请求中提取租户ID并设置到当前线程上下文中
     * 优先级设置为1,确保在其他过滤器之前执行
     */
    @Order(1) // 设置过滤器执行顺序,数值越小优先级越高
    public class TenantFilter implements Filter {
    
        /**
         * 过滤器核心方法
         * 从请求中提取租户ID并设置到线程上下文中,然后继续执行过滤链
         *
         * @param servletRequest  HTTP请求对象
         * @param servletResponse HTTP响应对象
         * @param filterChain     过滤器链
         * @throws IOException      输入输出异常
         * @throws ServletException Servlet异常
         */
        @Override
        public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
            // 从请求中提取租户ID并设置到当前线程上下文
            TenantContext.setCurrentTenant(getHeaderOrParam(servletRequest));
            // 继续执行过滤链
            filterChain.doFilter(servletRequest, servletResponse);
        }
    
        /**
         * 从HTTP请求头或参数中获取租户ID
         * 优先从请求头中获取,如果不存在则可以从参数中获取
         *
         * @param request HTTP请求对象
         * @return String 租户ID,如果不存在则返回null
         */
        private String getHeaderOrParam(ServletRequest request) {
            HttpServletRequest httpRequest = (HttpServletRequest) request;
            // 从HTTP请求头中获取租户ID
            String tenantId = httpRequest.getHeader("tenant_id");
    
            // 如果请求头中没有租户ID,可以尝试从请求参数中获取
            if (tenantId == null || tenantId.trim().isEmpty()) {
                tenantId = httpRequest.getParameter("tenant_id");
            }
    
            return tenantId;
        }
    }
  6. 添加过滤器规则

    java 复制代码
    import com.tenancy.multi.common.filter.TenantFilter;
    import org.springframework.boot.web.servlet.FilterRegistrationBean;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    /**
     * 过滤器配置类
     * 用于注册自定义的租户过滤器到Spring容器中
     */
    @Configuration // 表明这是一个配置类,Spring Boot启动时会自动加载
    public class FilterConfig {
        /**
         * 注册租户过滤器
         * 创建FilterRegistrationBean实例,配置自定义的TenantFilter
         *
         * @return FilterRegistrationBean 过滤器注册对象
         */
        @Bean // 将返回的对象注册为Spring容器中的Bean
        public FilterRegistrationBean registrationBean() {
            // 创建过滤器注册Bean,传入自定义的TenantFilter实例
            FilterRegistrationBean reg = new FilterRegistrationBean(new TenantFilter());
            // 设置过滤器拦截的URL模式,这里拦截所有以/tenant/开头的请求
            reg.addUrlPatterns("/tenant/*");
            return reg;
        }
    }
  7. 创建两张表,并插入数据。每张表都需要带有租户id(tenant_id)字段,和 MyTenantLineHandler 的 getTenantIdColumn() 方法设置的一样。

    sql 复制代码
    -- 公司表
    CREATE TABLE `company` (
      `id` int(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
      `tenant_id` varchar(60) NOT NULL COMMENT '租户ID',
      `company_name` varchar(30) DEFAULT NULL COMMENT '公司',
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
     
    INSERT INTO `tenant`.`company`(`id`, `tenant_id`, `company_name`) VALUES (1, '00001', '腾讯');
    INSERT INTO `tenant`.`company`(`id`, `tenant_id`, `company_name`) VALUES (2, '00002', '阿里');
     
    -- 员工表
    CREATE TABLE `staff` (
      `id` int(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
      `tenant_id` varchar(60) NOT NULL COMMENT '租户ID',
      `staff_id` varchar(60) NOT NULL COMMENT '员工id',
      `staff_name` varchar(30) DEFAULT NULL COMMENT '员工名称',
      PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;
     
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (1, '00001', '1', '马化腾');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (2, '00001', '2', '张小龙');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (3, '00002', '1', '马云');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (4, '00002', '2', '蔡崇信');
  8. 查询员工信息的Mapper

    员工表实体类:

    java 复制代码
    import com.baomidou.mybatisplus.annotation.IdType;
    import com.baomidou.mybatisplus.annotation.TableField;
    import com.baomidou.mybatisplus.annotation.TableId;
    import lombok.Data;
    
    /**
     * 员工表实体类
     */
    @Data
    public class Staff {
        /**
         * 主键ID
         */
        @TableId(value = "id", type = IdType.AUTO)
        private Integer id;
    
        /**
         * 租户ID
         */
        private String tenantId;
    
        /**
         * 员工id
         */
        private String staffId;
    
        /**
         * 员工名称
         */
        private String staffName;
    
        /**
         * 公司名称
         */
        @TableField(exist = false)
        private String companyName;
    }

    Mapper.java

    java 复制代码
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.tenancy.multi.module.entity.Staff;
    import org.apache.ibatis.annotations.Param;
    
    import java.util.List;
    
    public interface StaffMapper extends BaseMapper<Staff> {
        List<Staff> findStaff(@Param("staffName") String staffName);
    }

    Mapper.xml

    xml 复制代码
    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE mapper
            PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
            "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    <mapper namespace="com.tenancy.multi.module.mapper.StaffMapper">
    
        <resultMap type="com.tenancy.multi.module.entity.Staff" id="BaseResultMap">
            <result property="id" column="id"/>
            <result property="tenantId" column="tenant_id"/>
            <result property="staffId" column="staff_id"/>
            <result property="staffName" column="staff_name"/>
            <result property="companyName" column="company_name"/>
        </resultMap>
    
        <select id="findStaff" resultMap="BaseResultMap">
            select b.*, a.company_name
            from company a
            join staff b on a.tenant_id = b.tenant_id
            <where>
                <if test="staffName != null and staffName != ''">
                    and b.staff_name = #{staffName}
                </if>
            </where>
        </select>
    </mapper>
  9. 接口测试,注意携带请求头tenant_id

    返回结果如下。可见已经按照预期只查询出来了腾讯这家公司的员工信息,和请求头里传递的租户id保持一致。

    json 复制代码
    {
      "code": 200,
      "message": "操作成功",
      "data": [
        {
          "id": 2,
          "tenantId": "00001",
          "staffId": "2",
          "staffName": "张小龙",
          "companyName": "腾讯"
        },
        {
          "id": 1,
          "tenantId": "00001",
          "staffId": "1",
          "staffName": "马化腾",
          "companyName": "腾讯"
        }
      ]
    }

    sql日志打印结果如下。和原始sql比较后发现,最终的sql不仅在where条件里加入了a.tenant_id = '00001这个条件,还在关联表时on关键字后加了一个AND b.tenant_id = '00001'条件。

    sql 复制代码
    SELECT
      b.*,
      a.company_name 
    FROM
      company a
      JOIN staff b ON a.tenant_id = b.tenant_id 
      AND b.tenant_id = '00001' 
    WHERE
      a.tenant_id = '00001' 

二、分页

  1. 增加测试数据以方便查看分页效果

    sql 复制代码
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (5, '00001', '5', '腾讯员工5');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (6, '00001', '6', '腾讯员工6');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (7, '00001', '7', '腾讯员工7');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (8, '00001', '8', '腾讯员工8');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (9, '00001', '9', '腾讯员工9');
    INSERT INTO `tenant`.`staff`(`id`, `tenant_id`, `staff_id`, `staff_name`) VALUES (10, '00001', '10', '腾讯员工10');
  2. 分页插件的maven依赖

    xml 复制代码
    <!-- 分页插件 -->
    <dependency>
          <groupId>com.github.pagehelper</groupId>
          <artifactId>pagehelper-spring-boot-starter</artifactId>
          <version>1.3.0</version>
          <!--
          若报错日志显示:java.lang.NoSuchMethodError: net.sf.jsqlparser.statement.update.Update.getTable()Lnet/sf/jsqlparser/schema/Table;
          则可以放开下面的注释,这是由于分页插件pagehelper-spring-boot-starter和mybatis-plus的包有冲突导致的,我们将分页插件的maven依赖添加一个排除。
          -->
          <!--            <exclusions>-->
          <!--                <exclusion>-->
          <!--                    <artifactId>jsqlparser</artifactId>-->
          <!--                    <groupId>com.github.jsqlparser</groupId>-->
          <!--                </exclusion>-->
          <!--            </exclusions>-->
      </dependency>
  3. mybatis 拦截器配置中增加分页拦截器

    java 复制代码
    import com.baomidou.mybatisplus.annotation.DbType;
    import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
    import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
    import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
    import com.tenancy.multi.common.interceptor.MyTenantLineHandler;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    /**
     * MyBatis配置类
     * 配置MyBatis-Plus的多租户拦截器
     */
    @Configuration // 表明这是一个配置类
    public class MyBatisConfig {
        /**
         * 配置MyBatis-Plus拦截器
         * 添加多租户拦截器以实现SQL自动添加租户条件
         *
         * @return MybatisPlusInterceptor 配置好的MyBatis-Plus拦截器
         */
        @Bean // 将返回的拦截器注册为Spring容器中的Bean
        public MybatisPlusInterceptor mybatisPlusInterceptor() {
            // 创建MyBatis-Plus拦截器
            MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
            // 添加多租户内部拦截器,传入自定义的租户处理器
            interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MyTenantLineHandler()));
            // 添加分页内部拦截器
            interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
            /*
            避坑!!!
            拦截器的配置顺序必须是租户拦截器在前面,分页拦截器在后面。
             */
            return interceptor;
        }
    }
  4. Service层增加分页逻辑代码

    java 复制代码
    /**
     * 查分页询员工信息
     *
     * @param staffName 员工姓名
     * @return List<Staff> 返回员工列表
     * @version 2.0
     */
    @Override
    public List<Staff> findStaff(String staffName) {
        try (Page<Staff> pg = PageHelper.startPage(1, 5, "id desc")) {
            List<Staff> list = getBaseMapper().findStaff(staffName);
            PageInfo<Staff> pageInfo = new PageInfo<>(list);
            return pageInfo.getList();
        }
    }
  5. 启动项目测试分页接口

    启动项目时若发现上述报错,请参考Spring Boot 2.6+ 整合 PageHelper 启动报错:循环依赖解决方案全解析

  6. 查询结果

    json 复制代码
    {
      "code": 200,
      "message": "操作成功",
      "data": [
        {
          "id": 10,
          "tenantId": "00001",
          "staffId": "10",
          "staffName": "腾讯员工10",
          "companyName": "腾讯"
        },
        {
          "id": 9,
          "tenantId": "00001",
          "staffId": "9",
          "staffName": "腾讯员工9",
          "companyName": "腾讯"
        },
        {
          "id": 8,
          "tenantId": "00001",
          "staffId": "8",
          "staffName": "腾讯员工8",
          "companyName": "腾讯"
        },
        {
          "id": 7,
          "tenantId": "00001",
          "staffId": "7",
          "staffName": "腾讯员工7",
          "companyName": "腾讯"
        },
        {
          "id": 6,
          "tenantId": "00001",
          "staffId": "6",
          "staffName": "腾讯员工6",
          "companyName": "腾讯"
        }
      ]
    }
  7. 打印sql:多了ORDER BY id DESCLIMIT 5

    sql 复制代码
    SELECT
      b.*,
      a.company_name 
    FROM
      company a
      JOIN staff b ON a.tenant_id = b.tenant_id 
      AND b.tenant_id = '00001' 
    WHERE
      a.tenant_id = '00001' 
    ORDER BY
      id DESC 
      LIMIT 5

三、插入和更新

  1. 插入数据时,同样不需要在参数里传入租户id,Service代码如下

    java 复制代码
    @Override
    public boolean saveStaff(Staff staff) {
        staff.setStaffId(IdUtil.simpleUUID());
        return save(staff);
    }

    传参如下。没有在参数体里传租户id,而是和查询时一样将租户id放在了请求头。

    json 复制代码
    {
        "staffName": "腾讯果果"
    }

    打印sql日志如下

    sql 复制代码
    INSERT INTO staff ( staff_id, staff_name, tenant_id )
    VALUES
      ( '0623989066284d239609de8735bcfaa5', '腾讯果果', '00001' )
  2. 更新的Service层代码如下

    java 复制代码
    @Override
    public boolean updateStaff(Staff staff) {
        return updateById(staff);
    }

    传参如下。把插入时新加入的腾讯员工"腾讯果果"改名成"腾讯大石榴"。

    json 复制代码
    {
      "id": 11,
      "staffName": "腾讯大石榴"
    }

    sql打印如下。自动加上了租户条件tenant_id = '00001'

    sql 复制代码
    UPDATE staff 
    SET staff_name = '腾讯大石榴' 
    WHERE
      tenant_id = '00001' 
      AND id = 11
相关推荐
brave_zhao3 小时前
达梦数据库,子查询作为删除条件的sql案例,使用了mybatis批量删除
1024程序员节
爬山算法3 小时前
Redis(80)如何解决Redis的缓存穿透问题?
1024程序员节
007php0073 小时前
京东面试题解析:同步方法、线程池、Spring、Dubbo、消息队列、Redis等
开发语言·后端·百度·面试·职场和发展·架构·1024程序员节
小丁爱养花3 小时前
Redis 内部编码/单线程模型/string
数据库·redis·缓存·1024程序员节
小二_沏杯二锅头3 小时前
CentOS7.9部署Mysql8(二进制方式)
1024程序员节
萧寂1733 小时前
vue导出数据到excel
1024程序员节
wwlsm_zql3 小时前
「赤兔」Chitu 框架深度解读(十二):分布式并行初始化与管理
人工智能·1024程序员节
siriuuus3 小时前
MySQL 慢查询日志及优化
mysql·1024程序员节
筵陌3 小时前
MYSQL表的操作
数据库·mysql·1024程序员节