saas 模式 -威尼斯人最新

thingskit · 2020年03月04日 · 最后由 回复于 2022年08月19日 · 239 次阅读
本帖已被设为精华帖!

前言

saas 模式是什么?
传统的软件模式是在开发出软件产品后,需要去客户现场进行实施,通常部署在局域网,这样开发、部署及维护的成本都是比较高的。

现在随着云服务技术的蓬勃发展,就出现了 saas 模式。

所谓 saas 模式即是把产品部署在云服务器上,从前的客户变成了 “租户”,我们按照功能和租用时间对租户进行收费。

这样的好处是,用户可以按自己的需求来购买功能和时间,同时自己不需要维护服务器,而我们作为 saas 提供商也免去了跑到客户现场实施的麻烦,运维的风险则主要由 iaas 提供商来承担。

saas 多租户数据库方案

多租户技术或称多重租赁技术,是一种软件架构技术。

是实现如何在多用户环境下共用相同的系统或程序组件,并且可确保各用户间数据的隔离性。

在当下云计算时代,多租户技术在共用的数据中心以单一系统架构与服务提供多数客户端相同甚至可定制化的服务,并且仍可以保障客户的数据隔离。

目前各种各样的云计算服务就是这类技术范畴,例如阿里云数据库服务(rds)、阿里云服务器等等。

多租户在数据存储上存在三种主要的方案,分别是:

独立数据库

这是第一种方案,即一个租户一个数据库,这种方案的用户数据隔离级别最高,安全性最好,但成本较高。
优点:
为不同的租户提供独立的数据库,有助于简化数据模型的扩展设计,满足不同租户的独特需求;如果出现故障,恢复数据比较简单。
缺点:
增多了数据库的安装数量,随之带来维护成本和购置成本的增加。
这种方案与传统的一个客户、一套数据、一套部署类似,差别只在于软件统一部署在运营商那里。如果面对的是银行、医院等需要非常高数据隔离级别的租户,可以选择这种模式,提高租用的定价。如果定价较低,产品走低价路线,这种方案一般对运营商来说是无法承受的。

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

这是第二种方案,即多个或所有租户共享 database,但是每个租户一个 schema(也可叫做一个 user)。
优点:
为安全性要求较高的租户提供了一定程度的逻辑数据隔离,并不是完全隔离;每个数据库可支持更多的租户数量。
缺点:
如果出现故障,数据恢复比较困难,因为恢复数据库将牵涉到其他租户的数据;
如果需要跨租户统计数据,存在一定困难。

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

这是第三种方案,即租户共享同一个 database、同一个 schema,但在表中增加 tenantid 多租户的数据字段。这是共享程度最高、隔离级别最低的模式。
优点:
三种方案比较,第三种方案的维护和购置成本最低,允许每个数据库支持的租户数量最多。
缺点:
隔离级别最低,安全性最低,需要在设计开发时加大对安全的开发量;
数据备份和恢复最困难,需要逐表逐条备份和还原。
如果希望以最少的服务器为最多的租户提供服务,并且租户接受牺牲隔离级别换取降低成本,这种方案最适合。

选择合理的实现模式

衡量三种模式主要考虑的因素是隔离还是共享。

成本角度因素

隔离性越好,设计和实现的难度和成本越高,初始成本越高。共享性越好,同一运营成本下支持的用户越多,运营成本越低。

安全因素

要考虑业务和客户的安全方面的要求。安全性要求越高,越要倾向于隔离。

从租户数量上考虑

主要考虑下面一些因素:
系统要支持多少租户?上百?上千还是上万?可能的租户越多,越倾向于共享。
平均每个租户要存储数据需要的空间大小。存贮的数据越多,越倾向于隔离。
每个租户的同时访问系统的最终用户数量。需要支持的越多,越倾向于隔离。
是否想针对每一租户提供附加的服务,例如数据的备份和恢复等。这方面的需求越多, 越倾向于隔离

多租户方案之共享数据库,隔离数据架构
技术选型
mycat 中间件 (社区活跃,完全开源的分布式数据库架构)

mybatis

简要描述
多租户方案采用的是 mybatis mycat。demo 是基于 spring mvc 的 web 项目。

在用户操作过程中获取用户的 id 信息,利用 mycat 强大的注解功能,根据用户 id 将 sql 语句路由到对应该用户的 schema 或者 database 去执行。

对 sql 加注解的实现则交由 mybatis 的插件功能完成,通过自定义 mybatis 的 interceptor 类,拦截要执行的 sql 语句加上对应注解。这样就实现了数据库的多租户改造。下面分几个部分来说明。

mycat 与 mysql 设置

mycat 是一个开源的分布式数据库系统,是一个实现了 mysql 协议的服务器,前端用户可以把它看作是一个数据库代理,用 mysql 客户端工具和命令行访问,而其后端可以用 mysql 原生协议与多个 mysql 服务器通信,也可以用 jdbc 协议与大多数主流数据库服务器通信,其核心功能是分表分库,即将一个大表水平分割为 n 个小表,存储在后端 mysql 服务器里或者其他数据库里。

mycat 相当于一个逻辑上的大数据库,又 n 多个物理数据库组成,可以通过各种分库分表规则 (rule)将数据存到规则对应的数据库或 schema 或表中。

mycat 对自身不支持的 sql 语句提供了一种澳门人威尼斯3966的解决方案——在要执行的 sql 语句前添加额外的一段由注解 sql 组织的代码,这样 sql 就能正确执行,这段代码称之为 “注解”。

注解的使用相当于对 mycat 不支持的 sql 语句做了一层透明代理转发,直接交给目标的数据节点进行 sql 语句执行,其中注解 sql 用于确定最终执行 sql 的数据节点。

注解使用方式如下:

/*!mycat: schema=node1*/真正执行sql

由于这个项目是根据 mycat 的 sql 注解来选择在哪个 schema 或者 database 上执行的,所以不需要设置 rule.xml。

数据库设置

create database db01;  
 create table `bom`  (
  `cate_id` bigint(20) not null auto_increment comment '物料编码',
  `parent_id` bigint(20) null default null comment '父物料id,一级物料为0',
  `cate_code` varchar(50) character set utf8 collate utf8_general_ci null default null comment '物料编码',
  `name` varchar(50) character set utf8 collate utf8_general_ci null default null comment '物料名称',
  `unit` varchar(20) character set utf8 collate utf8_general_ci null default null comment '计量单位',
  `used_count` double(32, 4) null default null,
  `specify` varchar(50) character set utf8 collate utf8_general_ci null default null comment '规格',
  `property` tinyint(4) null default null comment '2=自制件,1=采购件',
  `status` tinyint(4) null default null comment '状态(0:开启 1:禁用)',
  `description` varchar(100) character set utf8 collate utf8_general_ci null default null comment '描述',
  primary key (`cate_id`) using btree
) engine = innodb auto_increment = 101 character set = utf8 collate = utf8_general_ci comment = '物料表' row_format = dynamic;
create database db02;  
create table `bom`  (
  `cate_id` bigint(20) not null auto_increment comment '物料编码',
  `parent_id` bigint(20) null default null comment '父物料id,一级物料为0',
  `cate_code` varchar(50) character set utf8 collate utf8_general_ci null default null comment '物料编码',
  `name` varchar(50) character set utf8 collate utf8_general_ci null default null comment '物料名称',
  `unit` varchar(20) character set utf8 collate utf8_general_ci null default null comment '计量单位',
  `used_count` double(32, 4) null default null,
  `specify` varchar(50) character set utf8 collate utf8_general_ci null default null comment '规格',
  `property` tinyint(4) null default null comment '2=自制件,1=采购件',
  `status` tinyint(4) null default null comment '状态(0:开启 1:禁用)',
  `description` varchar(100) character set utf8 collate utf8_general_ci null default null comment '描述',
  primary key (`cate_id`) using btree
) engine = innodb auto_increment = 101 character set = utf8 collate = utf8_general_ci comment = '物料表' row_format = dynamic;

设置两个数据库分表为 db01,db02,两个库中都有 bom。

mycat 的配置

server.xml


 xmlns:mycat="http://io.mycat/">
    
     name="usesqlstat">0 
     name="useglobletablecheck">0  
     name="sequncehandlertype">2
     name="handledistributedtransactions">0
     name="useoffheapformerge">1
         name="memorypagesize">1m
         name="spillsfilebuffersize">1k
         name="usestreamoutput">0
         name="systemreservememorysize">384m
         name="usezkswitch">true
    
     name="root">
         name="password">james
         name="schemas">james
    


server.xml 主要是设置登录用户名密码,登录端口之类的信息。

重头戏是 schema.xml 的设置

xml version="1.0"?>
doctype mycat:schema system "schema.dtd">
<mycat:schema xmlns:mycat="http://io.mycat/">
    <schema name="james" checksqlschema="false" sqlmaxlimit="100">
        <table name="bom" datanode="dn1,dn2" />
    schema>
    <datanode name="dn1" datahost="localhost1" database="db01" />
    <datanode name="dn2" datahost="localhost1" database="db02" />
    <datahost name="localhost1" maxcon="1000" mincon="10" balance="0"
              writetype="0" dbtype="mysql" dbdriver="native" switchtype="1"  slavethreshold="100">
        <heartbeat>select user()heartbeat>
        <writehost host="hosts1" url="localhost:3306" user="root"
                   password="jamesmsw" />
    datahost>
mycat:schema>

这里配置好两个数据库节点 dn1,dn2 对应的就是这前面建立的数据库 db02,db03.

这样数据库和 mycat 就设置好了,我们可以测试一下,向两个库中插入一些数据:
这是 db01 的数据,共 40 条.

这是 db02 中的数据,共 8 条.

这是 mycat 的逻辑库 james 中的数据,可以看到,包含了所有的 db01 和 db02 的数据。

再来试试 mycat 的注解:

在 mycat 的逻辑库 testdb 中分别执行以下语句:

mysql> select count(*) from bom;
/*!mycat: datanode=dn1*/select count(*) from bom;
/*!mycat: datanode=dn2*/select count(*) from bom;

可以看到,注解实实在在地把 sql 语句路由到对应的数据库中去执行了,而不加注解的 sql 则在整个逻辑库上执行。

mybatis 设置插件拦截器
mybatis 要使用 mycat 很方便,springboot 下,只需要将对应的 url 改成 mycat 的端口就行了。

spring.thymeleaf.mode=legacyhtml5
spring.datasource.url=jdbc:mysql://localhost:8066/james?servertimezone=gmt
spring.datasource.username=root
spring.datasource.password=james
spring.datasource.driver-class-name=com.mysql.jdbc.driver
mybatis.config-location=classpath:mybatis.xml

mybatis 允许你在已映射语句执行过程中的某一点进行拦截调用。

默认情况下,mybatis 允许使用插件来拦截的方法调用包括:

executor (update, query, flushstatements, commit, rollback,gettransaction, close, isclosed)
parameterhandler (getparameterobject, setparameters)
resultsethandler (handleresultsets, handleoutputparameters)
statementhandler (prepare, parameterize, batch, update, query)
这些类中方法的细节可以通过查看每个方法的签名来发现,或者直接查看 mybatis 的发行包中的源代码。

假设你想做的不仅仅是监控方法的调用,那么你应该很好的了解正在重写的方法的行为。

因为如果在试图修改或重写已有方法的行为的时候,你很可能在破坏 mybatis 的核心模块。 这些都是更低层的类和方法,所以使用插件的时候要特别当心。

通过 mybatis 提供的强大机制,使用插件是非常简单的,只需实现 interceptor 接口,并指定了想要拦截的方法签名即可。

在这里为了实现 sql 的改造增加注解,executor 通过调度 statementhandler 来完成查询的过程,通过调度它的 prepare 方法预编译 sql,因此我们可以拦截 statementhandler 的 prepare 方法,在此之前完成 sql 的重新编写。

package org.apache.ibatis.executor.statement;
import java.sql.connection;
import java.sql.sqlexception;
import java.sql.statement;
import java.util.list;
import org.apache.ibatis.cursor.cursor;
import org.apache.ibatis.executor.parameter.parameterhandler;
import org.apache.ibatis.mapping.boundsql;
import org.apache.ibatis.session.resulthandler;
public interface statementhandler {
    statement prepare(connection var1, integer var2) throws sqlexception;
    void parameterize(statement var1) throws sqlexception;
    void batch(statement var1) throws sqlexception;
    int update(statement var1) throws sqlexception;
    <e> list<e> query(statement var1, resulthandler var2) throws sqlexception;
    <e> cursor<e> querycursor(statement var1) throws sqlexception;
    boundsql getboundsql();
    parameterhandler getparameterhandler();
}


以上的任何方法都可以拦截。从接口定义而言,prepare 方法有一个参数 connection 对象,因此按如下代码设计拦截器:

package com.sanshengshui.multitenant.interceptor;
import com.sanshengshui.multitenant.utils.sessionutil;
import org.apache.ibatis.executor.statement.statementhandler;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.reflection.metaobject;
import org.apache.ibatis.reflection.systemmetaobject;
import java.sql.connection;
import java.util.properties;
@intercepts(value = {
        @signature(type = statementhandler.class,
                method = "prepare",
                args = {connection.class,integer.class})})
public class myinterceptor implements interceptor {
    private static final string prestate="/*!mycat:datanode=";
    private static final string afterstate="*/";
    @override
    public object intercept(invocation invocation) throws throwable {
        statementhandler statementhandler=(statementhandler)invocation.gettarget();
        metaobject metastatementhandler=systemmetaobject.forobject(statementhandler);
        object object=null;
        //分离代理对象链
        while(metastatementhandler.hasgetter("h")){
            object=metastatementhandler.getvalue("h");
            metastatementhandler=systemmetaobject.forobject(object);
        }
        statementhandler=(statementhandler)object;
        string sql=(string)metastatementhandler.getvalue("delegate.boundsql.sql");
        /*
        string node=(string)testcontroller.threadlocal.get();
        */
        string node=(string) sessionutil.getsession().getattribute("corp");
        if(node!=null) {
            sql = prestate  node  afterstate  sql;
        }
        system.out.println("sql is "sql);
        metastatementhandler.setvalue("delegate.boundsql.sql",sql);
        object result = invocation.proceed();
        system.out.println("invocation.proceed()");
        return result;
    }
    @override
    public object plugin(object target) {
        return plugin.wrap(target, this);
    }
    @override
    public void setproperties(properties properties) {
        string prop1 = properties.getproperty("prop1");
        string prop2 = properties.getproperty("prop2");
        system.out.println(prop1  "------"  prop2);
    }
}

简单说明一下:
intercept 真个是插件真正运行的方法,它将直接覆盖掉你真实拦截对象的方法。里面有一个 invocation 对象,利用它可以调用你原本要拦截的对象的方法 plugin 它是一个生成动态代理对象的方法,setproperties 它是允许你在使用插件的时候设置参数值。

@intercepts(value = {
        @signature(type = statementhandler.class,
                method = "prepare",
                args = {connection.class,integer.class})})

这段说明了要拦截的目标类和方法以及参数。

statementhandler 是一个借口,真实的对象是 routingstatementhandler,但它不是真实的服务对象,里面的 delegate 是 statementhandler 中真实的 statementhandler 实现的类,有多种,它里面的 boundsql 中存储着 sql 语句。具体的可以参考 mybatis 的源码。

metaobject 是一个工具类,由于 mybatis 四大对象提供的 public 设置参数的方法很少,难以通过自身得到相关属性信息,但是有个 metaobject 这个工具类就可以通过其他的技术手段来读取和修改这些重要对象的属性。

sessionutil 的 getsession 方法是用来获取之前用户登录时获得的记录在 session 中的 corp 信息,根据这个信息拼装 sql 注解达到多租户的目的。

interceptor 写好后,写入到 mybatis.xml 的 plugin 中



    
        
         name="logimpl" value="stdout_logging" />
    
    
         alias="bomdo" type="com.sanshengshui.multitenant.pojo.bomdo"/>
    
    
         interceptor="com.sanshengshui.multitenant.interceptor.myinterceptor">
        
    

实际运行

写一个实体类.

package com.sanshengshui.multitenant.pojo;
import lombok.data;
import java.io.serializable;
@data
public class bomdo implements serializable {
    private static final long serialversionuid = 1l;
    //物料编码
    private long cateid;
    //父物料id,一级物料为0
    private long parentid;
    //物料编码
    private string catecode;
    //物料名称
    private string name;
    //计量单位
    private string unit;
    //规格
    private string specify;
    //状态(0:开启 1:禁用)
    private integer status;
    //使用数量
    private double usedcount;
    //描述
    private string description;
    //2=自制件,1=采购件
    private integer property;
    public long getcateid() {
        return cateid;
    }
    public void setcateid(long cateid) {
        this.cateid = cateid;
    }
    public long getparentid() {
        return parentid;
    }
    public void setparentid(long parentid) {
        this.parentid = parentid;
    }
    public string getcatecode() {
        return catecode;
    }
    public void setcatecode(string catecode) {
        this.catecode = catecode;
    }
    public string getname() {
        return name;
    }
    public void setname(string name) {
        this.name = name;
    }
    public string getunit() {
        return unit;
    }
    public void setunit(string unit) {
        this.unit = unit;
    }
    public string getspecify() {
        return specify;
    }
    public void setspecify(string specify) {
        this.specify = specify;
    }
    public integer getstatus() {
        return status;
    }
    public void setstatus(integer status) {
        this.status = status;
    }
    public double getusedcount() {
        return usedcount;
    }
    public void setusedcount(double usedcount) {
        this.usedcount = usedcount;
    }
    public string getdescription() {
        return description;
    }
    public void setdescription(string description) {
        this.description = description;
    }
    public integer getproperty() {
        return property;
    }
    public void setproperty(integer property) {
        this.property = property;
    }
}

写一个 mapper

package com.sanshengshui.multitenant.mapper;
import com.sanshengshui.multitenant.pojo.bomdo;
import org.apache.ibatis.annotations.mapper;
import java.util.list;
import java.util.map;
@mapper
public interface bommapper {
    list<bomdo> list(map<string, object> map);
    int count(map<string, object> map);
}package com.sanshengshui.multitenant.mapper;
import com.sanshengshui.multitenant.pojo.bomdo;
import org.apache.ibatis.annotations.mapper;
import java.util.list;
import java.util.map;
@mapper
public interface bommapper {
    list<bomdo> list(map<string, object> map);
    int count(map<string, object> map);
}

以及对应的 bommapper.xml,配置好 sql 语句。


 namespace="com.sanshengshui.multitenant.mapper.bommapper">
     id="get" resulttype="com.sanshengshui.multitenant.pojo.bomdo">
        select `cate_id`,`parent_id`,`cate_code`,`name`,`unit`,`used_count`,`specify`,`property`,`status`,`description` from bom where cate_id = #{value}
    
     id="list" resulttype="com.sanshengshui.multitenant.pojo.bomdo">
        select `cate_id`,`parent_id`,`cate_code`,`name`,`unit`,`used_count`,`specify`,`property`,`status`,`description` from bom
          
                   test="cateid != null and cateid != ''"> and cate_id = #{cateid} 
                   test="parentid != null and parentid != ''"> and parent_id = #{parentid} 
                   test="catecode != null and catecode != ''"> and cate_code = #{catecode} 
                   test="name != null and name != ''"> and name = #{name} 
                   test="unit != null and unit != ''"> and unit = #{unit} 
                   test="usedcount != null and usedcount != ''"> and used_count = #{usedcount} 
                   test="specify != null and specify != ''"> and specify = #{specify} 
                   test="property != null and property != ''"> and property = #{property} 
                   test="status != null and status != ''"> and status = #{status} 
                   test="description != null and description != ''"> and description = #{description} 
                
        
             test="sort != null and sort.trim() != ''">
                order by ${sort} ${order}
            
            
                order by cate_id desc
            
        
         test="offset != null and limit != null">
            limit #{offset}, #{limit}
        
    
    
     id="count" flushcache="true" resulttype="int">
        select count(*) from bom
           
                   test="cateid != null and cateid != ''"> and cate_id = #{cateid} 
                   test="parentid != null and parentid != ''"> and parent_id = #{parentid} 
                   test="catecode != null and catecode != ''"> and cate_code = #{catecode} 
                   test="name != null and name != ''"> and name = #{name} 
                   test="unit != null and unit != ''"> and unit = #{unit} 
                   test="usedcount != null and usedcount != ''"> and used_count = #{usedcount} 
                   test="specify != null and specify != ''"> and specify = #{specify} 
                   test="property != null and property != ''"> and property = #{property} 
                   test="status != null and status != ''"> and status = #{status} 
                   test="description != null and description != ''"> and description = #{description} 
                
    
     id="save" parametertype="com.sanshengshui.multitenant.pojo.bomdo" usegeneratedkeys="true" keyproperty="cateid">
        insert into bom
        (
            `parent_id`, 
            `cate_code`, 
            `name`, 
            `unit`, 
            `used_count`, 
            `specify`, 
            `property`, 
            `status`, 
            `description`
        )
        values
        (
            #{parentid}, 
            #{catecode}, 
            #{name}, 
            #{unit}, 
            #{usedcount}, 
            #{specify}, 
            #{property}, 
            #{status}, 
            #{description}
        )
    

这里还是来测试 count 方法。

写一个 controller

package com.sanshengshui.multitenant.controller;
import com.sanshengshui.multitenant.mapper.bommapper;
import com.sanshengshui.multitenant.pojo.bomdo;
import com.sanshengshui.multitenant.utils.sessionutil;
import org.springframework.beans.factory.annotation.autowired;
import org.springframework.stereotype.controller;
import org.springframework.web.bind.annotation.getmapping;
import org.springframework.web.bind.annotation.pathvariable;
import org.springframework.web.bind.annotation.responsebody;
import javax.servlet.http.httpsession;
import java.util.hashmap;
import java.util.list;
import java.util.map;
@controller
public class testcontroller {
    @autowired
    bommapper bommapper;
    //简化,直接通过这里设置session
    @getmapping("/set/{sess}")
    @responsebody
    public object setsession(@pathvariable("sess") string sess){
        httpsession httpsession= sessionutil.getsession();
        httpsession.setattribute("corp",sess);
        return "ok";
    }
    @responsebody
    @getmapping("/list")
    public list<bomdo> list(){
        map<string, object> query = new hashmap<>(16);
        list<bomdo> bomlist = bommapper.list(query);
        return bomlist;
    }
    @getmapping("/count")
    @responsebody
    public object getcount(){
        //要测试的方法
        map<string, object> map = new hashmap<string, object>();
        return bommapper.count(map);
    }   
}

首先通过 localhost:8080/set/{sess}设置 session,假设 session 设置为 dn1.

浏览器中输入 localhost:8080/set/dn1.

之后,输入 localhost:8080/count.

结果如下:

来看下打印的 sql 语句:

可以看到,sql 注解已经成功添加进去了。

在设置 session 为 dn2

结果如下。

打印的 sql 语句:

本文作者: 穆书伟
本文链接:
澳门人威尼斯3966的版权声明: 本博客所有文章除特别声明外,均采用 by-nc-sa 许可协议。转载请注明出处!

thingskit 将本帖设为了精华贴 03月04日 12:41
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册
网站地图