您当前的位置:首页 > 电脑百科 > 程序开发 > 编程百科

聊聊 SaaS 多租户系统数据隔离实现方案

时间:2023-06-07 13:54:52  来源:微信公众号  作者:码猿技术专栏


开发过SaaS系统平台的小伙伴一定对多租户这个概念不陌生,简单来说一个租户就是一个公司客户,多个租户共用同一个SaaS系统,一旦SaaS系统不可用,那么所有的租户都不可用。你可以这么理解SaaS系统就像一栋大楼,而租户就是大楼里面租办公楼层的公司,平时每家公司做着自己的业务,互不干扰,但是一旦大楼的电梯坏了,那么影响到的就是所有的公司。

多租户问题,其是一种架构设计方式,就是在一台或者一组服务器上运行的SaaS系统,可以为多个租户(客户)提供服务,目的是为了让多个租户在互联网环境下使用同一套程序,且保证租户间的数据隔离。从这种架构设计的模式上,不难看出来,多租户架构的重点就是同一套程序下多个租户数据的隔离。由于租户数据是集中存储的,所以要实现数据的安全性,就是看能否实现对租户数据的隔离,防止租户数据不经意或被他人恶意地获取和篡改。在讲多租户数据隔离实现之前,先来看看什么是SaaS系统。

什么是SaaS系统?

SaaS平台是运营saas软件的平台。SaaS提供商为企业搭建信息化所需要的所有网络基础设施及软件、硬件运作平台,并负责所有前期的实施、后期的维护等一系列服务,租户(企业)无需购买软硬件、建设机房、招聘IT人员,即可通过互联网使用信息系统。SaaS 是一种软件布局模型,其应用专为网络交付而设计,便于用户通过互联网托管、部署及接入。

简单来说就是租户给SaaS平台付租金就能使用平台提供的功能服务,当下比较典型就是各种云平台、云服务厂商。

多租户数据隔离架构设计

目前saas多租户系统的数据隔离有三种架构设计,即为每个租户提供独立的数据库、独立的表空间、按字段区分租户,每种方案都有其各自的适用情况。

一个租户独立一个数据库

一个租户独立使用一个数据库,那就意味着我们的SaaS系统需要连接多个数据库,这种实现方案其实就和分库分表架构设计是一样的,好处就是数据隔离级别高、安全性好,毕竟一个租户单用一个数据库,但是物理硬件成本,维护成本也变高了。

独立的表空间

这种方案的实现方式,就是所有租户共用一个数据库系统,但是每个租户在数据库系统中拥有一个独立的表空间。

按租户id字段隔离租户

这种方案是多租户方案中最简单的数据隔离方法,即在每张表中都添加一个用于区分租户的字段(如tenant_id或org_id啥的)来标识每条数据属于哪个租户,当进行查询的时候每条语句都要添加该字段作为过滤条件,其特点是所有租户的数据全都存放在同一个表中,数据的隔离性是最低的,完全是通过字段来区分的,很容易把数据搞串或者误操作。

三种数据隔离架构设计的对比如下:

隔离方案

成本

支持租户数量

优点

缺点

独立数据库系统

数据隔离级别高,安全性,可以针对单个租户开发个性化需求

数据库独立安装,物理成本和维护成本都比较高

独立的表空间

较多

提供了一定程度的逻辑数据隔离,一个数据库系统可支持多个租户

数据库管理比较困难,表繁多,同时数据修复稍复杂

按租户id字段区分

维护和购置成本最低,每个数据库能够支持的租户数量最多

隔离级别最低,安全性也最低

大部分公司都是采用第三种:按租户id字段隔离租户架构设计实现多租户数据隔离的。接下来我们就来看看代码层面怎么实现多租户数据隔离的。关注公众号:码猿技术专栏,回复关键词:1111 获取阿里内部JAVA性能调优手册!

MyBatis-plus优雅实现多租户数据权限隔离

上面我们说过按租户id字段隔离租户这种方式就是在获取数据的时候对每一条SQL语句添加租户id作为过滤条件来隔离租户数据的。但是这样意味着每个查询SQL都必须加上租户id这个过滤条件,如果漏加就意味着会查询出不同租户的数据,这是绝对不允许的,同时每个查询接口都需要手动设置过滤条件,重复劳动,一点都不够优雅。这时候就不得不说说mybatis-plus的多租户插件了,看看它如何优雅实现多租户隔离的?再讲述之前,我们先思考一下如何优雅实现数据隔离?首先我们要求每一条SQL都加上租户id这个过滤条件,这意味着我们需要解析原始SQL在合适的地方加上租户id过滤条件,我们知道mybatis提供扩展点就是拦截器,可以对SQL语句处理前后进行增强逻辑,分页插件就是这么做的,所以我们这里要增强SQL自然也是这样,接下来我们就来看看mybatis-plus多租户插件是怎么实现多租户数据隔离的,插件官网介绍地址:https://www.baomidou.com/pages/aef2f2/#tenantlineinnerinterceptor,该拦截器部分源码如下:

public class TenantLineInnerInterceptor extends JsqlParserSupport implements InnerInterceptor {
    // 多租户处理器
    private TenantLineHandler tenantLineHandler;

    // 改SQL,添加多租户id条件
    public void beforeQuery(Executor executor, MAppedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
        if (!InterceptorIgnoreHelper.willIgnoreTenantLine(ms.getId())) {
            MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql);
            mpBs.sql(this.parserSingle(mpBs.sql(), (Object)null));
        }
    }

    public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
        MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);
        MappedStatement ms = mpSh.mappedStatement();
        SqlCommandType sct = ms.getSqlCommandType();
        if (sct == SqlCommandType.INSERT || sct == SqlCommandType.UPDATE || sct == SqlCommandType.DELETE) {
            if (InterceptorIgnoreHelper.willIgnoreTenantLine(ms.getId())) {
                return;
            }

            MPBoundSql mpBs = mpSh.mPBoundSql();
            mpBs.sql(this.parserMulti(mpBs.sql(), (Object)null));
        }

    }
  
  // 碍于篇幅问题,下面省略的代码就是继承抽象类JsqlParserSupport解析SQL然后添加多租户id条件的,可以自行查看源码
  ......
}

接着我们来看看处理器TenantLineHandler,这是一个接口,需要我们提供自定义实现,指定多租户相关配置:

public class TenantDatabaseHandler implements TenantLineHandler {
    private final Set<String> ignoreTables = new HashSet<>();

    public TenantDatabaseHandler(TenantProperties properties) {
        // 将配置文件配置的忽略表名同步大小写,适配不同写法
        properties.getIgnoreTables().forEach(table -> {
            ignoreTables.add(table.toLowerCase());
            ignoreTables.add(table.toUpperCase());
        });
    }

    /**
     * 获取租户字段名
     * <p>
     * 默认字段名叫: tenant_id,我这里使用org_id
     *
     * @return 租户字段名
     */
    @Override
     public String getTenantIdColumn() {
        return "org_id";
    }


    @Override
    public Expression getTenantId() {
        // 这里通过登录信息上下文返回租户id给多租户拦截器增强SQL使用
        return new LongValue(RequestUserHolder.getCurrentUser().getOrgId());
    }

    @Override
    public boolean ignoreTable(String tableName) {
        // 忽略多租户的表
        return CollUtil.contAIns(ignoreTables, tableName);
    }
}

配置属性如下:

@ConfigurationProperties(prefix = "ptc.tenant")
@Data
public class TenantProperties {


    /**
     * 全局控制是否开启多租户功能
     */
    private Boolean enable = Boolean.TRUE;

    /**
     * 需要忽略多租户的表
     *
     * 即默认所有表都开启多租户的功能,所以记得添加对应的 tenant_id 字段哟
     */
    private Set<String> ignoreTables = Collections.emptySet();
}

接下来注入拦截器插件即可:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties properties) {
        MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
        // 必须保证多租户插件在分页插件之前,这个是 MyBatis-plus 的规定
        if (properties.getEnable()) {
            mybatisPlusInterceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantDatabaseHandler(properties)));
        }
        // 分页插件
        mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return mybatisPlusInterceptor;
    }

使用示例如下:这里提供了一个常见的案例:用户和角色关联查询的SQL:getUserList()

<select id="getUserList" resultType="com.plasticene.textile.entity.User">
        select u.* from user u
        left join user_role r on u.id = r.user_id
        <where>
            <if test="query.status != null">
                and u.status = #{query.status}
            </if>
            <if test="query.roleId != null">
                and r.role_id = #{query.roleId}
            </if>
            <if test="query.keyword != null">
                and ((u.name like concat('%',#{query.keyword},'%')) or (u.mobile like concat(#{query.keyword},'%')))
            </if>
            <if test="query.startEntryTime != null">
                and u.entry_time >= #{query.startEntryTime}
            </if>
            <if test="query.endEntryTime != null">
                <![CDATA[ and u.entry_time <= #{query.endEntryTime}]]>
            </if>

        </where>
        group by u.id
        order by u.id desc
    </select>

启动项目,先登录之后使用token掉接口执行下面代码逻辑:

public PageResult<UserDTO> getList(UserQuery query) {
        Page<UserDTO> page = new Page<>(query.getPageNo(), query.getPageSize());
        List<User> userList = userDAO.getUserList(page, query);
        List<UserDTO> userDTOS = toUserDTOList(userList);
        return new PageResult<>(userDTOS, page.getTotal(), page.getPages());
    }

查看控制台发现:

[1658720355293990912] [DEBUG] [2023-05-17 14:25:25.504] [http-nio-16688-exec-1@23652]  com.plasticene.textile.dao.UserDAO.getUserList debug : ==>  Preparing: SELECT u.* FROM user u LEFT JOIN user_role r ON u.id = r.user_id AND r.org_id = 3 WHERE u.org_id = 3 GROUP BY u.id ORDER BY u.id DESC LIMIT ?
[1658720355293990912] [DEBUG] [2023-05-17 14:25:25.505] [http-nio-16688-exec-1@23652]  com.plasticene.textile.dao.UserDAO.getUserList debug : ==> Parameters: 20(Long)

user表u加上u.org_id=3这个多租户过滤条件,user_role也同样加上了,说明多租户插件起作用了。

当然如果想忽略掉表user,我们只需要在配置文件如下配置即可:

ptc:
  tenant:
    ignore-tables: user

这样user表u就不会再加上u.org_id=3这个多租户过滤条件,但是这里有一个细节需要注意,由于user在MySQL中是关键字,所以我有时候为了规范书写SQL,会按照如下编写:

select u.* from `user` u
        left join user_role r on u.id = r.user_id

这时候你会发现上面配置的忽略表user不起作用,还是会加上u.org_id=3这个多租户过滤条件,跟源码才发现我们上面自定义的多租户处理器TenantLineHandler只对表名进行了大小写适配,然而这里SQL解析出来的表名是: **user**,所以匹配不到配置不起作用。

当然我们有可能需要针对单一SQL语句不加多租户过滤条件,可以使用@InterceptorIgnore注解:

public interface UserDAO extends BaseMapperX<User> {

    @InterceptorIgnore(tenantLine = "true")
    List<User> getUserList(IPage<UserDTO> userPage, @Param("query") UserQuery query);
}

这样调用getUserList()不再会加多租户过滤条件了。

通过上面我们知道了这个多租户插件其实就是通过解析SQL,然后进行拼接多租户id过滤条件来实现SQL增强从而做到数据隔离,解析SQL的框架叫:JSqlParser,官方文档:https://Github.com/JSQLParser/JSqlParser/wiki,之前我总结过一篇关于 Druid解析动态SQL。Druid也可以解析SQL,我们都知道SQL语句会生成语法树,两者对SQL解析的孰强孰弱(特别是复杂SQL)不得而知,可以自行验证对比,我这里给出一个JSqlParser解析出错的情况,把上面的SQL语句user_role r 改为 user_role ur

select u.* from user u
        left join user_role ur on u.id = ur.user_id

按照上面一样调用执行getUserList(), 会报解析错误:

Caused by: com.baomidou.mybatisplus.core.exceptions.MybatisPlusException: Failed to process, Error SQL: select u.* from user u
left join user_role ur on u.id = ur.user_id
group by u.id
order by u.id desc
at com.baomidou.mybatisplus.core.toolkit.ExceptionUtils.mpe(ExceptionUtils.java:39)
at com.baomidou.mybatisplus.extension.parser.JsqlParserSupport.parserSingle(JsqlParserSupport.java:52)
at com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor.beforeQuery(TenantLineInnerInterceptor.java:65)
at com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor.intercept(MybatisPlusInterceptor.java:78)
at org.Apache.ibatis.plugin.Plugin.invoke(Plugin.java:62)
at com.sun.proxy.$Proxy178.query(Unknown Source)
at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:151)
... 101 common frames omitted
Caused by:.NET.sf.jsqlparser.parser.ParseException: Encountered unexpected token: "ur" <K_ISOLATION>
at line 2, column 29.

我在mybatis-plus的官方提了一个issue:https://github.com/baomidou/mybatis-plus/issues/5086,也得到官方维护者的迅速回应说是JSqlParser解析的问题,不是mybatis-plus的问题~~~,给出的建议就是把别名ur改成别的,或者升级到JSqlParser的最新版本。

4.总结

至此,我们对多租户系统数据隔离实现方案,架构设计,以及如何优雅实现全局操作数据隔离都讲完了,同时也对mybati-plus的多租户插件实现原理和源码流程套路进行了浅析,也对实际应用案例中进行了举证并阐述了相关细节点。当然数据权限不止停留在租户(公司)层面上面,大多数系统的数据权限会按照业务组织架构角色来控制,数据权限其套路和根据角色判断菜单权限一回事。由于数据权限通常与公司业务相关,比较个性化,每家公司业务组织架构不尽相同,所以实际开发项目的数据权限隔离还需要大家按实际需求进行修改,但总的来说我们可以模仿多租户隔离实现方式,比如说一个业务系统组织架构有公司(org_id),公司下有多个部门(dept_id),部门下有多个团队分组(team_id),团队下有多个人员(user_id)。不同角色只能看到不同数据,部门经理只能看到自己部门的数据,小组长只能看到自己小组的数据,这些实现逻辑套路都可以模仿多租户插件的方式进行优雅实现,这也是我后面有时间想研究的,后续会再出一篇数据权限的实现方案总结。



Tags:SaaS   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,不构成投资建议。投资者据此操作,风险自担。如有任何标注错误或版权侵犯请与我们联系,我们将及时更正、删除。
▌相关推荐
CSaaS架构:数字孪生软件架构的革命性突破
BS(Browser/Server)和CS(Client/Server)是两种不同的软件架构模式,具有不同的特点和优缺点。BS(Browser/Server)架构BS架构指的是基于浏览器和服务器的软件架构,客户端通常是一个Web...【详细内容】
2023-10-20  Search: SaaS  点击:(224)  评论:(0)  加入收藏
SaaS还是定制?不要听SaaS胡吹和定制瞎掰,告知你4个残忍真相。
这篇文章就不偏不倚,站在客观角度上分析二者该如何选择。一、市场上的两种对立声音SaaS:无所不能,低代码、模块化、费用低,几天就上线、成熟稳定、可升级&hellip;&hellip;定制:功...【详细内容】
2023-08-30  Search: SaaS  点击:(347)  评论:(0)  加入收藏
质疑产品“造假”,千亿美元 SaaS 巨头副总裁被裁:“因为说真话,我被解雇了!
整理 | 郑丽媛出品 | CSDN(ID:CSDNnews)“我被解雇了,就因为我说了真话。”&mdash;&mdash;很难相信,这是一个拥有 15 万家企业客户、市值高达 2065 亿美元、世界排名第一 CRM(客户...【详细内容】
2023-08-16  Search: SaaS  点击:(73)  评论:(0)  加入收藏
聊聊 SaaS 多租户系统数据隔离实现方案
开发过SaaS系统平台的小伙伴一定对多租户这个概念不陌生,简单来说一个租户就是一个公司客户,多个租户共用同一个SaaS系统,一旦SaaS系统不可用,那么所有的租户都不可用。你可以...【详细内容】
2023-06-07  Search: SaaS  点击:(473)  评论:(0)  加入收藏
选择源码还是SAAS账号?你不知道的惊人真相!
在运营社交电子商务平台之前,企业选择软件是唯一的途径,市场上的报价也参差不齐。为什么会有这么大的区别?部分原因是saas账户和源代码之间的区别。让我们与您分享帐户和源代码...【详细内容】
2023-05-30  Search: SaaS  点击:(312)  评论:(0)  加入收藏
SaaS 真的崩塌了吗?
图片来源@视觉中国文 | 牛透社,作者 | 倪天旸今年 4 月,在一级市场被寄予厚望的国内 HR SaaS 北森云终于完成了港股上市,但想象中的 SaaS 盛宴并没有出现,反而是几天内腰斩的股...【详细内容】
2023-05-19  Search: SaaS  点击:(120)  评论:(0)  加入收藏
被投资人遗弃的中国SaaS
好的SaaS,不如好用的SaaS。@科技新知 原创作者丨苌乐 编辑丨伊页“SaaS市场已经没什么好说的了。”一位投资人在不经意间和&lsquo;科技新知&rsquo;表露了如今SaaS市场的寒气...【详细内容】
2023-01-06  Search: SaaS  点击:(162)  评论:(0)  加入收藏
SaaS服务搭建nft平台的5大优势
不可替代令牌 (NFT) 是区块链上的独特数据单元,可以链接到数字和物理对象以提供不可变的所有权证明。NFT 包含的数据可以与数字图像、歌曲、视频、头像等相关联。但是,它们也...【详细内容】
2022-10-08  Search: SaaS  点击:(270)  评论:(0)  加入收藏
如何开发 SaaS 应用程序
对于许多公司而言,在线提供服务不仅仅是一种营销趋势。这为企业带来了新的机遇,使他们能够快速扩大客户群、扩展到新市场并增加收入。软件开发人员也开始认识到在线迁移的价值...【详细内容】
2022-09-12  Search: SaaS  点击:(414)  评论:(0)  加入收藏
聊聊这个SaaS领域爆火的话题
导读:本文主要介绍近几年全球SaaS赛道非常热门的话题&mdash;&mdash;产品驱动增长(Product-Lead Growth,PLG)。本文从PLG的定义与适用范围入手。作者:田原来源:华章科技 PLG是企业J...【详细内容】
2022-08-03  Search: SaaS  点击:(302)  评论:(0)  加入收藏
▌简易百科推荐
即将过时的 5 种软件开发技能!
作者 | Eran Yahav编译 | 言征出品 | 51CTO技术栈(微信号:blog51cto) 时至今日,AI编码工具已经进化到足够强大了吗?这未必好回答,但从2023 年 Stack Overflow 上的调查数据来看,44%...【详细内容】
2024-04-03    51CTO  Tags:软件开发   点击:(6)  评论:(0)  加入收藏
跳转链接代码怎么写?
在网页开发中,跳转链接是一项常见的功能。然而,对于非技术人员来说,编写跳转链接代码可能会显得有些困难。不用担心!我们可以借助外链平台来简化操作,即使没有编程经验,也能轻松实...【详细内容】
2024-03-27  蓝色天纪    Tags:跳转链接   点击:(13)  评论:(0)  加入收藏
中台亡了,问题到底出在哪里?
曾几何时,中台一度被当做“变革灵药”,嫁接在“前台作战单元”和“后台资源部门”之间,实现企业各业务线的“打通”和全域业务能力集成,提高开发和服务效率。但在中台如火如荼之...【详细内容】
2024-03-27  dbaplus社群    Tags:中台   点击:(9)  评论:(0)  加入收藏
员工写了个比删库更可怕的Bug!
想必大家都听说过删库跑路吧,我之前一直把它当一个段子来看。可万万没想到,就在昨天,我们公司的某位员工,竟然写了一个比删库更可怕的 Bug!给大家分享一下(不是公开处刑),希望朋友们...【详细内容】
2024-03-26  dbaplus社群    Tags:Bug   点击:(5)  评论:(0)  加入收藏
我们一起聊聊什么是正向代理和反向代理
从字面意思上看,代理就是代替处理的意思,一个对象有能力代替另一个对象处理某一件事。代理,这个词在我们的日常生活中也不陌生,比如在购物、旅游等场景中,我们经常会委托别人代替...【详细内容】
2024-03-26  萤火架构  微信公众号  Tags:正向代理   点击:(11)  评论:(0)  加入收藏
看一遍就理解:IO模型详解
前言大家好,我是程序员田螺。今天我们一起来学习IO模型。在本文开始前呢,先问问大家几个问题哈~什么是IO呢?什么是阻塞非阻塞IO?什么是同步异步IO?什么是IO多路复用?select/epoll...【详细内容】
2024-03-26  捡田螺的小男孩  微信公众号  Tags:IO模型   点击:(9)  评论:(0)  加入收藏
为什么都说 HashMap 是线程不安全的?
做Java开发的人,应该都用过 HashMap 这种集合。今天就和大家来聊聊,为什么 HashMap 是线程不安全的。1.HashMap 数据结构简单来说,HashMap 基于哈希表实现。它使用键的哈希码来...【详细内容】
2024-03-22  Java技术指北  微信公众号  Tags:HashMap   点击:(11)  评论:(0)  加入收藏
如何从头开始编写LoRA代码,这有一份教程
选自 lightning.ai作者:Sebastian Raschka机器之心编译编辑:陈萍作者表示:在各种有效的 LLM 微调方法中,LoRA 仍然是他的首选。LoRA(Low-Rank Adaptation)作为一种用于微调 LLM(大...【详细内容】
2024-03-21  机器之心Pro    Tags:LoRA   点击:(12)  评论:(0)  加入收藏
这样搭建日志中心,传统的ELK就扔了吧!
最近客户有个新需求,就是想查看网站的访问情况。由于网站没有做google的统计和百度的统计,所以访问情况,只能通过日志查看,通过脚本的形式给客户导出也不太实际,给客户写个简单的...【详细内容】
2024-03-20  dbaplus社群    Tags:日志   点击:(4)  评论:(0)  加入收藏
Kubernetes 究竟有没有 LTS?
从一个有趣的问题引出很多人都在关注的 Kubernetes LTS 的问题。有趣的问题2019 年,一个名为 apiserver LoopbackClient Server cert expired after 1 year[1] 的 issue 中提...【详细内容】
2024-03-15  云原生散修  微信公众号  Tags:Kubernetes   点击:(6)  评论:(0)  加入收藏
站内最新
站内热门
站内头条