SaaS-多租户可伸缩的数据隔离方案

一、SaaS背景

SaaS 是一个全球性趋势,在 SaaS 领域也诞生了众多全球化的公司。现在越来越多的公司开始去做Saas了,作为技术er来讲最重要的是保证系统的数据隔离性、稳定性、安全性、扩展性。其中,数据隔离一个就是我们首要要解决的第一问题,随着数据量的增加,数据库的扩展性是一个技术卡点。相对于应用服务器层的水平扩展,数据库层的水平扩展更难实现。笔者就数据隔离问题提出一些解决思路、以及具体的解决方案,以便你在设计SaaS系统时有一个参考。

二、解决方案

1.共享数据库、共享数据表

image.png

在项目的初期,或者Saas租户数据不是很大的情况下,这个方案是实施最快、复杂度最低的一个。这个方案只需要在每个表里面添加一个tenant_id列,每次写入、查询的时候都带上tenantId,就很轻松的实现了数据隔离。
由于所有的租户相同类型数据都是存放在同一张表里面的,当数据达到千万级别时会导致查询性能直线下降。

2.共享数据库、不同的数据表

图片.png

由于前面这种方案有一个致命的问题,就是相同数据存储在同一个表里,数据的查询性能受到极大限制。于是乎,优秀的开发工程师们想到了另外一个方案:使用相同的database,但是租户的数据可以存放在不同的表里面,这样就解决了呀(可以一个租户对应一套表,也可以将多个租户对应一套表)。

Schema隔离使用PostgreSQL来实现非常简单,因为他天生支持这种方式,具体可以去PostgreSQL了解。

虽解决了单表数据容量的问题,随着租户的用户量越来多时,我们的查询请求呈线性增长,此时我们可以通过数据库的主从方式来解决。但是当租户的写入请求变大时,这个方案的性能瓶颈就卡在主库了。此外,这种模式没办法去解决部分大流量租户吸血问题(突入起来的流量暴增),导致影响其他租户的使用体验。

3.独立数据库(每个租户一个数据库,多个租户对应一个数据库)

为了解决前面提到的问题,架构师们将问题向上抽象了一下,将问题提升到db这层来解决。这里面有细分两种方案:

1).每一个租户一个数据库

图片.png

这个隔离方案很安全、数据互相不受影响、性能也不受影响,但是成本相当昂贵,这个适用于超级有钱的大户使用。

2).通过规则,将一群租户统一放在一个数据库里(推荐)

图片.png

通过规则,我们可以通过后台配置设定规则,将一批租户的数据存放在一个数据库中,将流量大的租户单独隔离出来,这样就完美解决了租户前面提到的大表问题、部分大流量租户吸血问题,让他们实现请求、存储隔离。
这个方案相比前面的几种方案,在扩展性、隔离性、安全性上的表现都是最优的,但是实现复杂度较大。

4.综合对比

对比.png

三、实操

1.隐藏陷阱

如果应用规模进一步扩大,租户数量在持续增加,应用服务器层和数据库层都在持续地水平扩展。再增加新的服务器的话,该架构还是存在一丝隐患的。由于数据库连接是一种创建成本较高且较为稀缺的资源,而上述架构中的每台应用服务器需要连接到每台数据库服务器上。这样,当应用服务器数量扩展到一定数量时,数据库服务器的连接数将可能成为系统的瓶颈。
出现这个问题的原因是,尽管应用服务器层和数据库层已经分别实现了水平扩展,但是由于其彼此之间没有任何对应关系,导致所有的应用服务器要与所有的数据库服务器关联(以m 台应用服务器和n 台数据库服务器为例,它们之间的关联有 mxn 个,也是就是每台应用服务器至少要有n个数据库链接 )。

2. 解决

要解决这个问题,只需对上面的架构做出微小的调整。所有的应用服务器不应该是完全平等的,应该与数据库服务器对应。也就是说,不同的租户可能不仅有不同的应用服务器,还有可能有不同的数据库服务器。每组应用服务器都仅连接对应的数据库服务器。调整后的整体架构模型如下:


图片.png

3.具体实现

要实现将同一应用的不同实例链接到不同的数据库节点上,我们需要通个配置规则,在应用启动的时候,读取配置文件内容,然后获取该节点链接数据库的配置信息。这个配置文件,可以是项目本地也可以使用配置中心来实现,推荐使用配置中心来完成,因为这个在分布式上很有优势且可以做成可视化界面操作。

我们用项目本地文件为例子来实现:
application.yml

#数据库配置
node.mapping: 127.0.0.1,db-1;192.168.12.1,db-2;

spring:
  datasource:
   node:
     -
        db-1:
        username: abc
        password: abc@123
        url: jdbc:mysql://xxxx:3306/xxx?useUnicode=true&characterEncoding=UTF-8&useSSL=false
        driver-class-name: com.mysql.jdbc.Driver 
     -
        db-2:
        username: 2b
        password: 2p
        url: 2u
        driver-class-name: com.mysql.jdbc.Driver 
server:
  port: 9999

spring.application.name: tenants-test

dataSource核心配置:

package com.example.config;

import lombok.Data;

/**
 * @author 
 *
 */

@Data
public class DataSourceInfo {
    private String nodeName;
    private String url;
    private String username;
    private String password;
}
@Data
@ConfigurationProperties(prefix = "spring.datasource")
@Configuration
public class CustomerDataSourceProperties {

    private List<Map<String, String>> node;

}
package com.example.config;

import com.alibaba.druid.pool.DruidDataSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

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

/**
 * 
 */

@EnableConfigurationProperties(value = CustomerDataSourceProperties.class)
@Configuration
public class MultiTenantConfiguration {

    /**
     * node.mapping : 192.168.1.2,mysql1;192.168.1.2,mysql2; spring.data.driver.mysql1: xxx data.url.mysql1: xxx
     * data.username.mysql1: xxx data.password.mysql1: xxx
     *
     * @param
     * @return
     */

    @Autowired
    private CustomerDataSourceProperties customerDataSourceProperties;

    @Bean
    public DataSource getDataSource(@Value("${node.mapping}") String dataMapping) {
        String dbName = getDbInfo(dataMapping);
        Map<String, DataSourceInfo> dataSourceInfoMap = getAllDataSourceInfo();
        DataSourceInfo dataSourceInfo = dataSourceInfoMap.get(dbName);

        DruidDataSource druidDataSource = new DruidDataSource();
        druidDataSource.setUrl(dataSourceInfo.getUrl());
        druidDataSource.setUsername(dataSourceInfo.getUsername());
        druidDataSource.setPassword(dataSourceInfo.getPassword());
        druidDataSource.setDriverClassName("com.mysql.jdbc.Driver");
        return druidDataSource;
    }

    private Map<String, DataSourceInfo> getAllDataSourceInfo() {
        List<Map<String, String>> data = customerDataSourceProperties.getNode();
        Map<String, DataSourceInfo> dataSourceInfos = new HashMap();
        for (Map<String, String> map : data) {
            DataSourceInfo dataSourceInfo = new DataSourceInfo();
            map.forEach((key, value) -> {
                if ("username".equalsIgnoreCase(key)) {
                    dataSourceInfo.setUsername(value);
                } else if ("password".equalsIgnoreCase(key)) {
                    dataSourceInfo.setPassword(value);
                } else if ("url".equalsIgnoreCase(key)) {
                    dataSourceInfo.setUrl(value);
                } else if (key.startsWith("db-")) {
                    dataSourceInfo.setNodeName(key);
                }
            });
            dataSourceInfos.put(dataSourceInfo.getNodeName(), dataSourceInfo);
        }
        return dataSourceInfos;
    }

    private String getDbInfo(String dataMapping) {
        String ip = "127.0.0.1";
        String[] dataMappingArray = dataMapping.split(";");
        for (String mapping : dataMappingArray) {
            String[] kvArray = mapping.split(",");
            if (ip.equalsIgnoreCase(kvArray[0])) {
                return kvArray[1];
            }
        }

        return null;
    }
}

我们将应用程序的节点ip和数据库进行配置绑定,这里在k8s环境下会有问题,因为每次pod重启ip会发生改变。在云原生场景下,我们可以通过hostname和数据库进行映射方式来解决。

应用层和数据库层的水平扩展只是应用可伸缩性的一部分,上面提到的几种实现伸缩性的方案,也仅仅是 SaaS 应用中最常用的方案。对于更大型、更复杂的应用,可能需要更为通用的分布式解决方案。这些方案不仅仅适用于 SaaS 应用,而且普遍适用于大型互联网应用。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容