4种多租户数据库设计方案对比及思考,一文全讲透

Posted 神州数码云基地

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了4种多租户数据库设计方案对比及思考,一文全讲透相关的知识,希望对你有一定的参考价值。

文章目录


前言

多租户是SaaS(Software-as-a-Service)下的一个概念,意思为软件即服务,即通过网络提供软件服务。

SaaS平台供应商将应用软件统一部署在自己的服务器上,客户可以根据工作的实际需求,通过互联网向厂商定购所需的应用软件服务,按定购的服务多少和时间长短向厂商支付费用,并通过互联网获得SaaS平台供应商提供的服务。SaaS服务尤其利于一些中小企业,以低成本实现自己的软件需求。

就如企业微信,它就是一个典型的多租户系统。每在企业微信上注册一个企业,也就是多租户下创建一个租户。企业微信提供各种插件、服务、第三方支持供租户购买,拓展系统功能,可以说是天花板的存在,他们的产品设计是很值得我们参考与学习的。

我本人也是对各种多租户开源项目进行过研究,参与开发过不少类型的多租户系统,对多租户系统的设计有一些自己的见解,本期就来跟大家分享一下关于多租户DB的设计方案。


一、设计方案

多租户对于用户来说,最主要的一点就在于数据隔离,不可以说,我登了A用户的号,但是看到了B用户的数据。

因此,多租户的数据库设计方案和代码实现就相当有必要考虑了。

当下,开发者们普遍接受的多租户设计方案,常见的大概就四种:

  • 所有租户使用同一数据源下同一数据库下共同数据表(单数据源单数据库单数据表)
  • 所有租户使用同一数据源下同一数据库下不同数据表(单数据源单数据库多数据表)
  • 所有租户使用同一数据源下不同数据库下不同数据表(单数据源多数据库多数据表)
  • 所有租户使用不同数据源下不同数据库下不同数据表(多数据源多数据库多数据表)

二、方案剖析

注:对于本节,我们主要采用开源的mysql数据库来分析设计

  • 方案一:单数据源单数据库单数据表

这种方案是我目前见过的最普遍的设计方案。

该系统只有一个数据库,所有租户共用数据表。在每一个数据表中增加一列租户ID,用以区分租户的数据。增删查改时,一定要带上租户ID,否则就会操作到其他租户的数据。因此,这里的设计一定要重点考虑!

一个重要的点:我们要保证的就是一定不要忘记带上租户ID。一个很好的方案就是通过AOP的方案,隐式的为我们的每一个SQL带上这个租户ID。

我个人是更喜欢使用MyBatis来操作数据库的。它提供了插件的机制,我们可以通过拦截它提供的四大组件的某些对象,某些方法,来操作SQL,动态的为我们的SQL拼接上租户ID字段。

当然,MyBatis-Plus高版本提供了更加方便的拦截器,并且已经将多租户插件放入JAR包,我们只需稍加实现,并将该插件加入到MyBatis的拦截器链中,就可以不用再显式的拼接租户ID字段了,降低了出错的概率。

因为篇幅有限,关于MyBatis和MyBatis-Plus插件这里就不做太多知识延伸,如果有兴趣可以留言我专门写一期MyBatis插件的文章。

这里我放一下MyBatis-Plus关于多租户的主要代码 ⬇

@Configuration
@MapperScan("com.baomidou.mybatisplus.samples.tenant.mapper")
public class MybatisPlusConfig 

    /**
     * 新多租户插件配置,一缓和二缓遵循mybatis的规则,需要设置 MybatisConfiguration#useDeprecatedExecutor = false 避免缓存万一出现问题
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() 
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() 
            @Override
            public Expression getTenantId() 
                return new LongValue(1);
            

            // 这是 default 方法,默认返回 false 表示所有表都需要拼多租户条件
            @Override
            public boolean ignoreTable(String tableName) 
                return !"user".equalsIgnoreCase(tableName);
            
        ));
        // 如果用了分页插件注意先 add TenantLineInnerInterceptor 再 add PaginationInnerInterceptor
        // 用了分页插件必须设置 MybatisConfiguration#useDeprecatedExecutor = false
        // interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        return interceptor;
    

      // @Bean
      // public ConfigurationCustomizer configurationCustomizer() 
      // return configuration -> configuration.setUseDeprecatedExecutor(false);
      // 

也就是说,我们只需要根据我们的业务,重写getTenantId()方法和ignoreTable()方法即可。

ignoreTable()其实没啥说的,主要还是getTenantId()这个方法。最方便的方法当然是使用ThreadLocal存储当前线程用户的租户信息,然后直接从里面取出即可。

如果使用了SpringSecurity,用户信息和租户信息也可以存他的内置ThreadLocal中。

一般来说,开源框架都会提供一个SecurityUtil工具,可以直接拿到当前线程的用户信息和租户信息。当然,也可以自己实现,这并不困难。

有了这个思路,其他的数据库也可以使用类似的方法实现隐式租户ID赋值,这里我就不再举例。

  • 方案二:单数据源单数据库多数据表

这个方案是我之前项目所使用的,架构师是一个十几年经验的大牛,我觉得这个思路也是很好的,我可以详细的说一下:

这个项目使用的是MongoDB数据库,MongoDB使用的是类似于Json的Bson语法,实现了类似SQL的功能,使用Bson进行增删查改,几乎可以实现类似关系数据库单表查询的绝大部分功能,而且还支持对数据建立索引。

它的好处就是,不用太过于关心数据库的建表,因为它的存储类似于一行一行的json对象,插入一条数据即可理解为建表。表增加一行或者减少一行这种变化也不需要去修改数据表的结构。其次,表名也可以自己来指定。

项目的设计方案是,**所有租户的数据都放在同一数据库中,不同租户的数据表使用后缀来区分。**比如租户A的用户表叫user_001,用户B的用户表叫user_002。这样,就做到了相对于方案一而言更可靠的设计。

缺点也很明显,这种数据库难以实现连表查询,只能进行多次单表查询,对事务的控制基本为0。

一般来说,对于小场景而言,这种架构的设计已经完全够用了。服务器和数据库都使用了阿里云的云服务器和云数据库,数据的可靠性由他们来保证,我们只需要专心写代码即可,发生错误的概率也是非常低的。就算真的以极小的概率发生了数据问题,对于当时项目的规模和成本考虑来说,查一下审计日志,手动做一下数据的处理就可以解决了。

实际上,这个项目还是一个电商项目,当时同时支持了3个租户同时使用,并没有出现很大的问题。因此,在我们的设计中,也要根据场景进行架构的选择,没必要追求过于超前的设计,毕竟越稳定越花钱,成本决定了很多因素,通过对这个项目的研究和与领导的沟通,我也收获颇多。

接下来,我们回头再说一下MySQL的设计方案,毕竟mysql免费,并且支持事务和多表查询,所以它永远是我们的最先考虑的。

对MySQL而言,新建租户时需要建表,每次建表,都要使用DDL建新租户所使用的所有表。我自己的做法是,定义建表的DDL,当需要创建租户时,使用MyBatis执行SQL脚本。

我自己的实现方案是在XML里面定义SQL语句,项目启动时,解析XML到配置,存为Key-Value形式,并提供一个执行SQL的工具,当需要创建租户时,调用SQL工具并传入配置参数,即可实现自动建表。

下面给出我的具体实现:

Step 1

提供一个XML,记录SQL的对应关系,这里我没有写DTD约束。如果怕出错,可以自己加上,也可以在里面定义标签,标注需要替换的键值。

我这里做的比较简单,**后期使用对应的键值替换SQL中的 x x x 元素 ∗ ∗ ,比如我这里后期使 用 0 01 替换这个 xxx元素**,比如我这里后期使用_001替换这个 xxx元素,比如我这里后期使001替换这个tenant_id,这样就实现了动态生成创建新租户表的DDL。

<?xml version="1.0" encoding="UTF-8"?>
<document>
    <statement>
        <name>create-tenant</name>
        <script>

            DROP TABLE IF EXISTS `sys_user$tenant_id`;
            CREATE TABLE `sys_user$tenant_id`
            (
            `user_id`         bigint(20) UNSIGNED                                           NOT NULL AUTO_INCREMENT COMMENT '用户编号',
            `account_user_id` bigint(20) UNSIGNED                                           NOT NULL COMMENT '账户用户编号',
            `nickname`        varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '昵称',
            `real_name`       varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '姓名',
            `phone`           char(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci     NOT NULL COMMENT '手机号',
            `avatar`          varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户头像',
            `email`           varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户邮箱',
            `gender`          tinyint(4) UNSIGNED                                           NOT NULL COMMENT '性别[enum]',
            `department_id`   bigint(20) UNSIGNED                                           NOT NULL COMMENT '部门编号',
            `enable`          tinyint(4) UNSIGNED                                           NOT NULL COMMENT '启用禁用[enum]',
            `last_login_time` datetime(0)                                                   NOT NULL COMMENT '最后登录时间',
            `last_login_ip`   varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '最后登录IP',
            `create_time`     datetime(0)                                                   NOT NULL COMMENT '创建时间',
            `update_time`     datetime(0)                                                   NOT NULL COMMENT '更新时间',
            `create_user_id`  bigint(20) UNSIGNED                                           NOT NULL COMMENT '创建人编号',
            `update_user_id`  bigint(20) UNSIGNED                                           NOT NULL COMMENT '更新人编号',
            `is_deleted`      tinyint(4) UNSIGNED                                           NOT NULL DEFAULT 0 COMMENT '逻辑删除[enum]',
            PRIMARY KEY (`user_id`) USING BTREE
            ) ENGINE = InnoDB
            AUTO_INCREMENT = 2
            CHARACTER SET = utf8mb4
            COLLATE = utf8mb4_general_ci
            ROW_FORMAT = Dynamic;

          ......
          
        </script>
    </statement>
</document>

Step 2

编写配置类,解析XML到JAVA,使用hashMap保存SQL的对应关系。这里我使用的是dom4j,当然你也可以选择其他解析器。


/**
 * @author 谷子毅
 * @date 2022/4/2
 */
@Component
public class ExecSqlConfig 

    private final Map<String, String> sqlContainer = new HashMap<>();

    private ExecSqlConfig() throws Exception 
        // 1.创建Reader对象
        SAXReader reader = new SAXReader();
        // 2.加载xml
        InputStream inputStream = new ClassPathResource("sql-script.xml").getInputStream();
        Document document = reader.read(inputStream);
        // 3.获取根节点
        Element root = document.getRootElement();

        // 4.遍历每个statement
        List<Element> statements = root.elements("statement");
        for (Element statement : statements) 
            String name = null;
            String sql = null;
            List<Element> elements = statement.elements();
            // 5.拿到name和script加载到内存中管理
            for (Element element : elements) 
                if ("name".equals(element.getName())) 
                    name = element.getText();
                 else if ("script".equals(element.getName())) 
                    sql = element.getText();
                
            
            sqlContainer.put(name, sql);
        
    

    public String get(String name) 
        return sqlContainer.get(name);
    

Step 3

编写SQL的执行器工具类,具体的用法就是,通过key从hashMap中取出对应DDL类型的SQL模板,对模版中的替换值进行替换,然后就生成了可以执行的SQL,使用MyBatis执行即可。


/**
 * sql脚本执行工具
 * @author 谷子毅
 * @date 2022/4/2
 */
public class ExecSqlUtil 

    private static final ExecSqlConfig EXEC_SQL_CONFIG = SpringContextHolder.getBean(ExecSqlConfig.class);

    private static final DataSource DATA_SOURCE = SpringContextHolder.getBean(DataSource.class);

    @SneakyThrows
    public static void execSql(String name, Map<String, String> replaceMap) 
        // 获取SQL脚本模板
        String sql = EXEC_SQL_CONFIG.get(name);

        // 替换模板变量
        for (Map.Entry<String, String> entity : replaceMap.entrySet()) 
            sql = sql.replace(entity.getKey(), entity.getValue());
        
        ScriptRunner scriptRunner = new ScriptRunner(DATA_SOURCE.getConnection());

        // 执行SQL
        scriptRunner.runScript(new StringReader(sql));
    


Step 4

最后,在创建租户的地方,使用SQL执行工具类执行SQL即可。

public void addTenant(TenantInsertDTO dto) 
  
    // 在租户表中新增租户信息
    ...... 

    // 执行sql语句创建新租户的数据库表
    ExecSqlUtil.execSql(SqlStatement.CREATE_TENANT, Collections.singletonMap("$tenant_id", dto.getTenantId()));
  
    // 向新建的数据表中插入租户的一些初始数据
    ...... 

以上即为建表方案,不过需要注意,如果租户表有变化,所有的租户表都需要同时进行相应修改,否则就会出现问题。

说完建表,那下一步需要关心的还是增删查改的问题。这个问题同样也可以使用MyBatis的插件解决,MyBatis-Plus同样给我们写好了代码放到了JAR包中,也就是动态表名插件。

这里我也只放一下MyBatis-Plus关于动态表名插件的主要代码 ⬇


@Configuration
@MapperScan("com.baomidou.mybatisplus.samples.dytablename.mapper")
public class MybatisPlusConfig 

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() 
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor = new DynamicTableNameInnerInterceptor();
        dynamicTableNameInnerInterceptor.setTableNameHandler((sql, tableName) -> 
            // 获取参数方法
            Map<String, Object> paramMap = RequestDataHelper.getRequestData();
            paramMap.forEach((k, v) -> System.err.println(k + "----" + v));

            String year = "_2018";
            int random = new Random().nextInt(10);
            if (random % 2 == 1) 
                year = "_2019";
            
            return tableName + year;
        );
        interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
        // 3.4.3.2 作废该方式
        // dynamicTableNameInnerInterceptor.setTableNameHandlerMap(map);
        return interceptor;
    

它这里的动态表名还可以实现类似于分表的操作,比如按时间分表,2021年的数据放到order_001_2021,2022年的数据放到order_001_2011。

我们这里主要讨论的还是关于租户的分表,我们将代码稍加改造,即为:

dynamicTableNameInnerInterceptor.setTableNameHandler((sql, tableName) -> 
    if(IgnoreTables.contains(tableName)) 
      return tableName;
    
    String tenantId = SecurityUtil.getTenantId();
    return tableName + "_" + tenantId;
);

如果当前操作的表名在忽略表名集合中,则不拼接租户ID,否则所有的表名后面都会自动的被插件拼接上租户ID。

如原SQL的查询select * from user就会变成select * from user_001。

  • 方案三:单数据源多数据库多数据表

方案三则是将租户数据彻底隔离,给每个租户创建一个数据库。基本上和方案二类似,关于好处和坏处我们后面统一讨论,还是先说一下这个实现方案。

同样的,新建租户的时候需要建库建表,那么我们是可以直接继续使用方案二的,将SQL模板稍加改造即可,经过上一方案的代码剖析,这个应该没有难度。

<?xml version="1.0" encoding="UTF-8"?>
<document>
    <statement>
        <name>create-tenant</name>
        <script>
            CREATE DATABASE $database;

            USE $database;
            
            DROP TABLE IF EXISTS `sys_user`;
            CREATE TABLE `sys_user`
            (
            `user_id`         bigint(20) UNSIGNED                                           NOT NULL AUTO_INCREMENT COMMENT '用户编号',
            `account_user_id` bigint(20) UNSIGNED                                           NOT NULL COMMENT '账户用户编号',
            `nickname`        varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '昵称',
            `real_name`       varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '姓名',
            `phone`           char(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci     NOT NULL COMMENT '手机号',
            `avatar`          varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户头像',
            `email`           varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户邮箱',
            `gender`          tinyint(4) UNSIGNED                                           NOT NULL COMMENT '性别[enum]',
            `department_id`   bigint(20) UNSIGNED                                           NOT NULL COMMENT '部门编号',
            `enable`          tinyint(4) UNSIGNED                                           NOT NULL COMMENT '启用禁用[enum]',
            `last_login_time` datetime(0)                                                   NOT NULL COMMENT '最后登录时间',
            `last_login_ip`   varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '最后登录IP',
            `create_time`     datetime(0)                                                   NOT NULL COMMENT '创建时间',
            `update_time`     datetime(0)                                                   NOT NULL COMMENT '更新时间',
            `create_user_id`  bigint(20) UNSIGNED                                           NOT NULL COMMENT '创建人编号',
            `update_user_id`  bigint(20) UNSIGNED                                           NOT NULL COMMENT '更新人编号',
            `is_deleted`      tinyint(4) UNSIGNED                                           NOT NULL DEFAULT 0 COMMENT '逻辑删除[enum]',
            PRIMARY KEY (`user_id`) USING BTREE
            ) ENGINE = InnoDB
            AUTO_INCREMENT = 2
            CHARACTER SET = utf8mb4
            COLLATE = utf8mb4_general_ci
            ROW_FORMAT = Dynamic;
          
          ......
          
        </script>
    </statement>
</document>

不过我们需要注意的是,当前是在MySQL环境下,Database的概念和Scheme的概念是等价的。其实在其他数据库中,还可以存在database-scheme-table的结构,这里不说太多,如果你真正用过多种数据库后,你就理解我的意思了。

在完成数据表的创建后,然后就是具体的增删改查问题。我们还是可以使用MyBatis插件,动态的改造表名,在表名前拼接上数据库名,具体的实现也是非常简单,直接拿方案二稍微改改即可。

最后的效果是,将原SQL语句select * from user 动态改成了select * from db_001.user,连表查询也是可用的。

dynamicTableNameInnerInterceptor.setTableNameHandler((sql, tableName) -> 
    if(IgnoreTables.contains(tableName)) 
      return tableName;
    
    String tenantId = SecurityUtil.getTenantId();
    return tenantId + "." + tableName;
);

以上三个方案都是单数据源的情况,使用这种单数据源方案可以轻松实现跨数据库执行SQL和事务执行。

我自己的开源项目sherly-SpringBoot目前也是这样设计的。不过我的项目主要是针对中小型项目设计的,因此,我认为这已经完全够用了,后面我还会开发针对大数据量的微服务框架来专门解决大数据量的问题。

  • 方案四:多数据源多数据库多数据表

在单数据源中,跨数据库操作和数据库事务是已经被支持的。

但是在多数据源中,传统的跨库操作和数据库事务已经难以适用,甚至有时候会出现动态的新增租户,并且租户数据源动态配置等问题。

这时,光靠简单的几百行代码已经无法实现。首先我们试着想想如何自己实现该需求,这里我给大家提供几个方案来看看:

  • 使用AOP加注解动态切换数据源
  • 使用MyBatis插件切换数据源(可以比较方便的判断SQL类型,区分读写操作)
  • 多个MyBatis配置来引入多个数据源(比较占内存)

具体的实现方案就是在配置文件中配一个公用数据源。项目启动后,从数据库加载所有租户的数据源信息,创建DataSource并注入到容器,供三种方案下一步使用。

好的,我们已经解决了一个多数据源切换问题,那么多数据源的事务应该怎么控制呢?

如果不考虑多租户和分布式事务方案的话,简单的业务多数据源处理,我们可以使用声明式事务,稍微麻烦一点,A数据源操作->B数据源操作->出现异常。

这种场景,我们使用一下编程式事务貌似也可以解决,但是代码已经很丑了,更别说多租户这种动态选择数据源的场景了,简直难度陡升。

通过以上分析,我们发现多数据源下多租户确实难以简单实现,光涉及到我提到的两点,就已经让人头痛了。

所以,我们就需要借助第三方框架了,那就是Dynamic-datasource。它是一个基于SpringBoot的快速集成多数据源的启动器,通过它的辅助,我们就可以实现方案四的多数据源下多租户问题。

下面我先把他的一些特性列出来:

这个开源组件的功能是非常强大的,大家可以自己拉Git仓库研究,在本节中,我们只讨论它是如何帮助我们实现多租户的多数据源问题。

我的设计方案是:

首先,默认只加载公共数据源,然后创建一个数据源工厂接口,提供一个获取租户数据源接口,实现就是传入租户ID,查询数据库获取租户数据库连接信息,创建并返回租户数据源,存入数据源容器。若淘汰,从数据源容器删除即可。

再通过Seata的分布式事务,就可以完美解决我们的问题了。

在创建租户时,也可以初始化表结构和数据库。当然,你也可以项目启动后,直接把所有的租户数据源查出来并放到容器,这个根据自己的业务来选择。

这里设计非常复杂,我就先提供思路吧。我会在我的下一个微服务开源项目中实现这一方案,可以期待一下。这时有人问,那如果是超大型系统,微服务,多部署又怎么办呢?怎么做到数据源容器的新增和删除同步呢?

当然,我考虑到了,可以试试Redis的listener,让所有的机器监听一个事件消息。通过这个第三方组件,再根据自己的业务稍加改造,所有问题便迎刃而解了。


三、方案总结

上面说了那么多,是时候做一下总结。

从方案一到方案四,数据的隔离性是越来越好的,成本也越来越高,但是也会出现越来越多的问题,就比方说有数据汇总的需求时,租户数据都在一个表中和分散在不同表中,处理难度是不一样的。

  • 方案一总结

该方案成本最低,安全性也是最低,所有数据都放在一起,单个租户的数据恢复和备份会很复杂。

比如,有ABC三个租户共同使用,当C租户已经确定以后不再使用了,他的数据也不好从数据库中剔除。如果某一数据出现了问题,也不便于租户问题的排查。

这就得保证对开发者的严苛要求,最主要还是增加了对数据的管理复杂度。尤其注意,尽量不要去手动显式的设置租户ID到SQL,否则一旦出现问题,纠正是很复杂的,在这上面我吃过亏…

如果要做数据汇总,我们就想办法使用插件或其他方案去解决就行了。这和数据出错的影响比起来,这点设计也不值一提了。

  • 方案二总结

该方案提供了一定的数据隔离,但不是完全的隔离,由于数据库是共享的,所以成本也不是很高。

  • 方案三总结

该方案其实和方案二大同小异,对单数据源来说,都不是彻底的隔离。成本和方案二差不多。

不过我认为这样可以更方便的删除测试租户数据库和设置数据库权限,比如给A租户管理员的A数据库的查看权限,给B租户管理员的B数据库的查看权限,这就涉及到数据库的权限问题了,其实很多时候,很多的公司,都给了开发者数据库的root权限。

  • 方案四总结

这种多数据源方案,目前也有一些开源项目实现了这一方案。就是为不同的租户提供独立的数据库,数据库由租户自己购买,或商家购买,然后配置到系统中,满足不同租户的独特需求,如果出现故障,数据恢复也比较简单。

代价是什么呢?

这种方案增加了数据库的安装数量,单个数据库的资源利用率就不高了,而且数据库的数量一多,成本就高了,安全性的代价就是成本。还要考虑到多数据库的事务问题,多数据库的数据源切换问题,这代码复杂度和成本噌噌噌就上来了,相当费钱。


四、方案选型

  • 数据隔离性方面:

关于隔离性最常见的场景就是给多个银行开发的系统,那安全性是不言而喻的,甚至是容不得半点闪失。

此时的方案,我们一二三都不用考虑,可以直接选择方案四。方案四

一文讲透推荐系统提供web服务的2种方式

一文讲透推荐系统提供web服务的2种方式


作者丨gongyouliu

编辑丨zandy

来源 | 大数据与人工智能(ID: ai-big-data)


推荐系统是一种信息过滤技术,通过从用户行为中挖掘用户兴趣偏好,为用户提供个性化的信息,减少用户的找寻时间,降低用户的决策成本,让用户更加被动地消费信息。


推荐系统是随着互联网技术的发展及应用深入而出现的,并在当前得到广泛的关注,它是一种软件解决方案,是toC互联网产品上的一个模块。用户通过与推荐模块交互,推荐系统通过提供的web服务,将与用户兴趣匹配的标的物筛选出来,组装成合适的数据结构,最终展示给用户。推荐系统web服务是前端和后端沟通的桥梁,是推荐结果传输的最后通道,信息传输是否通畅,传输是否足够快速,对用户体验是有极大影响的。


本文我们就来讲解推荐系统提供web服务的两种主要方式,这两种方式是企业级推荐系统最常采用的两种形式。


具体来说,这篇文章我们会从什么是推荐系统web服务、推荐系统提供web服务的两种方式、事先计算型web服务、实时装配型web服务、两种web服务方式的优劣对比、影响web服务方案的因素及选择原则等6个部分来讲解。通过本文的介绍,期望读者可以深刻理解这两种web服务方式的具体实现方案以及它们之间的差别,并具备结合具体的业务场景来决策采用哪种方式的能力。

 

一文讲透推荐系统提供web服务的2种方式

什么是推荐系统web服务


作者在《》第一节中已经对推荐系统web服务进行了简单介绍,这里为了让读者更好地理解本文的知识点,以及为了内容的完整性,对推荐系统web服务进行简略介绍。


用户与推荐系统交互的服务流程见下面图1,用户在使用产品过程中与推荐模块(产品上提供推荐能力的功能点)交互,前端(手机、PC、Pad、智能电视等)请求推荐web服务,推荐web服务获取该用户的推荐结果,将推荐结果返回给前端,前端通过适当的渲染将最终的推荐结果按照一定的样式和排列规则在产品上展示出来,这时用户就可以看到推荐系统给他的推荐结果了。


一文讲透推荐系统提供web服务的2种方式

图1:用户通过推荐web服务获取推荐结果的数据交互流程


上图中的绿色虚线框中的数据交互能力就是推荐web服务的范畴,它是前端(也叫终端)与后端的互动,图中蓝色方块(推荐web服务模块)是部署在服务器上的一类软件服务,它提供HTTP接口,让前端可以实时与之交互。用户与终端的交互属于视觉及交互设计范畴,虽然与推荐web服务无直接关系,但是是整个推荐服务能力完整实现必不可少的一环,也是用户可以肉眼直接感知到的部分,在整个推荐系统中非常重要,对推荐系统发挥价值有极大影响,不过不在我们这篇文章的讨论范围,对这一块感兴趣的读者可以参考《》这篇文章。


为了给前端提供个性化推荐服务,上图中的推荐web服务模块需要完成3件事情。首先需要获得该用户的推荐结果(直接获得已经计算好的推荐结果,这就是第三节要讲的,或者通过临时计算获得推荐结果,这就是第四节要讲的),其次是将结果组装成前端最终需要的数据结构(第一步获得的推荐结果一般是标的物id的列表,实际展示给前端还需要标的物的各种metadata信息,如名称,价格,海报图等,这些信息的组装就是在这一步完成的,这些信息一般会存放到关系型数据库中,或者采用json的形式组织存放到Redis、文档型NoSQL中,所以这里至少还有一次额外的数据库访问),最后是响应前端的HTTP请求(一般是GET请求),将最终推荐结果返回给前端。本文我们讲解的推荐系统提供web服务的两种方式,就是这里讲的第一件事情,即推荐web服务怎么获得给用户的推荐结果。

推荐web服务模块是最终为用户提供推荐能力的部分,它设计得好不好直接影响用户体验,一般来说,该模块需要满足稳定、响应及时、容错、可以随着用户规模线性扩容等多个条件,具体的细节读者可以参考《 》这篇文章。这里提一下,随着Docker等容器技术及kubernetes等容器管理软件的发展和成熟,推荐web服务中的各个子模块都可以分别部署在容器中,采用微服务的方式进行数据交互,这样就可以高效管理这些服务,更好地进行服务的监控、错误恢复、线性扩容等。

上图只是一种简化的交互模型,在实际企业级服务中,往往比这个更加复杂,在前端和后端之间往往存在一层CDN层做缓存加速,以减轻前端服务对后端并发访问的压力(在用户量大的情况下,推荐系统属于高并发服务),并且一般推荐web服务中还存在一层Nginx代理层,通过Nginx代理,让推荐web服务可以水平扩容,以满足推荐系统高并发的要求。下面图2就是一种可行的完整推荐系统服务方案。

一文讲透推荐系统提供web服务的2种方式
图2:完整的推荐系统业务架构图

如前面所讲,虽然推荐web服务包含前端与后端的交互,前端与后端一般还会有CDN层和Nginx代理层,但本文我们着重关注的是后端真正提供Web服务接口模块及数据存储模块的实现方案,也即上图中红色模块怎么获取推荐结果的架构实现方案。该模块的实现方案可以多样,主流的实现方式有两种,我们在下面分三节来进行介绍。


一文讲透推荐系统提供web服务的2种方式
推荐系统提供web服务的两种方式


推荐系统提供web服务一般有两种方式,一种是事先计算型,另一种是实时装配型。在具体介绍之前,这里我先举一个比较形象的例子,让大家更好地理解这两种实现方式。

假设我们开了一家餐厅专门送外卖,餐厅提供10种不同的备选套餐。在午市或者晚市叫餐高峰时段,餐厅可以采用如下两种方案来准备套餐:第一种方案是事先将这10种套餐每种都做若干份,当有客户叫外卖时,将该客户叫的这个套餐(已经做好了)直接送出去;第二种方式是将这10种套餐需要的原材料都准备好,部分材料做成半成品(比如比较花时间的肉类),当有用户叫餐时,将该套餐需要的原材料下锅快速做好再送出去。

通过上面非常简化的案例介绍,大家应该不难理解,上面提到的第一种准备套餐的方式就是“事先计算型”,事先将套餐做好,而第二种方式就是“实时装配型”,当用户叫餐时,临时做并快速做好。

现在让我们回到推荐web服务上,来介绍两种推荐web服务方案。事先计算型就是将用户的推荐结果事先计算好,放到数据库中存放起来,当该用户在使用产品过程中访问推荐模块时,推荐web服务模块直接将该用户计算好的推荐结果取出来,进行适当加工(比如过滤掉用户已经看过的视频),将最终推荐结果展示给用户。实时装配型是将计算推荐结果需要的数据(一般是各种特征)提前准备好,当用户访问推荐模块时,推荐web服务通过简单的计算和组装(利用前面准备好的各种特征灌入推荐模型),生成该用户的推荐结果,再将推荐结果返回给前端并展示给用户。

理解了这两种不同的web服务方式的基本原理,我们在接下来的两节中分别对它们的实现细节进行详细介绍,让读者更好地理解它们的特性及技术实现细节。


一文讲透推荐系统提供web服务的2种方式
事先计算型web服务


这一节我们来讲解推荐系统事先计算型web服务的架构实现与基本原理(参见下面图3)。这种方式可能是业界比较多地采用的一种推荐web服务架构实现方式,作者所在公司的所有推荐服务基本都是采用的该模式。

一文讲透推荐系统提供web服务的2种方式
图3:事先计算型web服务架构(绿色虚线框中的模块即是图2中的红色模块的细化)

该模式最大的特点是事先将每个用户的推荐结果计算出来,存到数据库(一般是NoSQL,如Redis、CouchBase等NoSQL数据库,采用key-value的方式存储,key就是用户id,value就是给用户的推荐结果,如果是用Redis存,value的数据结构可以使Sorted Sets,这种数据结构比较适合推荐系统,Sorted Sets中的element可以是推荐的标的物id,score是标的物的预测评分或者预测概率值等,还可以根据Sorted Sets中的score进行分页筛选等操作)中,当有用户请求时,前端访问web接口服务器(前端会带上用户的唯一识别id进行HTTP请求,这样就知道是哪个用户,方便找到该用户的推荐结果),web服务器从推荐结果库中获取该用户的推荐结果(推荐结果一般只存储给用户推荐的标的物id列表及部分需要的其他信息,比如算法标识,方便后面做AB测试。

下面图4就是一种推荐结果存储的数据格式,其中id就是标的物的唯一识别id),同时还需要访问标的物metadata数据库(一般存放在关系型数据库中),将前端展示需要的其他信息(如标的物的名称、价格、缩略图等)拼接完整,最终以json的形式(下面图5就是视频推荐系统最终拼接好的json格式,互联网企业一般采用的数据交互协议,也可以是其他协议,Google内部就采用protobuf协议)返回给前端展示给用户。

一文讲透推荐系统提供web服务的2种方式
图4:推荐结果存储的数据结构(json形式存储)


一文讲透推荐系统提供web服务的2种方式
图5:最终返回给用户的推荐结果(json格式)

该架构既可以支持T+1推荐模式和实时推荐模式,对于T+1型推荐产品形态,每天为用户生成一次推荐结果,生成推荐结果时直接替换昨天的推荐结果就可以了。而对于实时推荐,情况会复杂一些,实时推荐可能会调整用户的推荐结果(而不是完全替换),对用户推荐结果进行增删形成新的推荐结果,这时可行的方法有两个。

一是从推荐结果存储数据库中读出该用户的推荐结果,按照实时推荐算法逻辑对推荐结果进行修改,再将推荐结果存进去替换掉,另外一种做法是,增加一个中间的镜像存储(可以采用HBase等,现在业界很多推荐算法都基于Hadoop/Spark平台实现,用大数据生态系的HBase是比较好的选择),所有的算法逻辑修改只对镜像存储进行操作,操作完成后,将镜像存储中修改后的推荐结果同步到最终的推荐库中,这跟T+1更新就保持一致了,只不过现在是实时推荐,同一个用户可能一天会更新多次推荐结果。作者公司的短视频实时推荐更新就是采用后面的这种方案,感兴趣的读者可以参考《
》这篇文章第三节1中的介绍。


一文讲透推荐系统提供web服务的2种方式
实时装配型web服务


本节我们来讲解实时装配型web服务的实现原理与架构(参考下面图6)。这种方式事先不计算用户的推荐结果,当有用户请求时,web接口服务器从特征数据库(一般也是存放在Redis、HBase这种非关系型数据库中)中将该用户需要的特征取出来,并将特征灌入推荐模型,获得该用户的推荐结果,跟事先计算型一样,还需要加载推荐标的物的metadata信息,拼接成完整的推荐结果,并返回给前端展示给用户。

一文讲透推荐系统提供web服务的2种方式
图6:实时装配型web服务架构(web接口服务加载推荐模型)

该web服务架构需要将推荐模型加载到web接口服务中,可以实时基于用户特征获得推荐结果,这就要求推荐模型可以在极短的时间(毫秒级)内获得推荐结果,计算一定要快,否则会影响用户体验。当然另外一种可行的方案是,将推荐模型做成独立的web模型服务,web接口服务通过HTTP或者RPC访问模型服务获得推荐结果。具体架构如下面图7,这种方式的好处是推荐模型服务跟web服务解耦,可以分别独立升级模型服务和推荐接口服务,互相之间不会影响,只要保证它们之间数据交互的协议不变就可以了。

一文讲透推荐系统提供web服务的2种方式
图7:通过推荐模型服务来获取推荐结果的实时装配型web服务架构

实时装配型架构在实际提供推荐服务时就与具体的推荐范式是T+1推荐还是实时推荐没有关系了,因为在任何时候web接口服务都是临时调用推荐模型为用户生成推荐结果,只不过T+1推荐的模型可以一天训练一次,而实时推荐的模型是实时训练的(用户的每一次操作行为都会产生日志,通过实时日志处理,生成实时特征,灌入实时模型训练流程中,最终完成对模型的实时训练,让实时模型得到更新)。

业界流行的TensorFlow Serving就是一种实时装配型服务架构,它提供web服务的架构模式类似上面图6的形式,下面对其进行简单介绍,让读者更好地理解这种模式。读者可以查看参考资料1、2、3对TensorFlow Serving进行更深入的了解。

TensorFlow Serving是一个灵活的、高性能的机器学习模型在线服务框架,设计用于生产系统,可以与训练好的TensorFlow模型高效整合,将训练好的模型部署到线上,使用gRPC作为接口接受外部调用。TensorFlow Serving支持模型热更新与自动模型版本管理。

下图为TensorFlow Serving整个框架图。Client端会不断给Manager发送请求,Manager会根据版本管理策略管理模型更新,并将最新的模型计算结果返回给Client端。

一文讲透推荐系统提供web服务的2种方式
图8:TensorFlow Serving架构,图片来源于TensorFlow Serving官方文档

FaceBook开源的FAISS(见参考资料4)框架也是业界用的比较多的一款用于实时装配型web服务的框架。FAISS包含几种相似性搜索方法,它假设用户或者标的物被表示为向量并由整数标识(用户和标的物用整数来唯一标识,即用户id和标的物id),可以在海量向量库中搜索出按照某种相似性计算的最相似的向量列表。FAISS提供了向量之间计算L2(欧几里德)距离或点积距离的方法,与查询向量最相似的向量是那些与查询向量具有最小L2距离或最大点积的向量。FAISS具备在极短的时间(毫秒级)内计算某个向量最相似的一组向量的能力。它还支持cosine余弦相似性查询,因为cosine余弦只不过是向量内积的归一化。

FAISS之所以能够用于推荐系统提供实时推荐服务,主要是因为很多推荐算法最终将用户和标的物都表示为向量,通过用户向量与标的物向量的内积来衡量用户对标的物的偏好程度,典型的矩阵分解算法就是这种形式。FAISS所起的作用相当于图7中的推荐模型服务,利用它进行推荐的web服务架构就是图7这种架构。最终的推荐模型用数学公式表示就是

一文讲透推荐系统提供web服务的2种方式

  一文讲透推荐系统提供web服务的2种方式  是内积计算,u、v分别是用户和标的物标向量,它们之间的内积表示用户对标的物的偏好程度。FAISS提供计算用户最相似的标的物的能力,并基于该相似度降序排列,取TopN最相似的标的物作为最终的推荐结果。

 

一文讲透推荐系统提供web服务的2种方式
两种web服务方式的优劣对比


前面两节已经对推荐系统两种提供web服务方案的技术细节进行了详细介绍,在真实业务场景中可能比这个更复杂,可能不是单纯的某种方案,会有一些变体,在这两种方案的基础上做适当调整与变化,可能同一产品的不同推荐形态采用不同的方式,同一种推荐方案也可能会融合这两种方式。

在这一节我们对比一下这两个方案的优缺点,让大家更好地理解这两种web服务方案,同时也为大家在具体推荐业务中进行选择提供参考。

1.事先计算型web服务的优缺点

事先计算型最大的优势是提前将推荐结果准备好了,这样在提供推荐服务时可以直接获取推荐结果,因此大大提升了接口服务的响应速度,减少了响应时间,对用户体验是有极大帮助的。另外,事先计算好了,当模型出现问题(比如调度模型计算的服务挂了),最坏的情况是不更新推荐结果(这时无法插入最新推荐结果),用户访问时还是可以获得推荐的,只不过给用户的是过去一天的推荐结果。如果是实时计算推荐结果(实时装配型),当模型出现问题时就无法获得推荐结果,如果接口没做保护,这时接口可能会挂掉,导致前端出现无法展示任何推荐结果的故障,出现开天窗现象(不过好的推荐系统web服务一般会增加保护,在这种极端情况下,给定一组默认数据作为推荐结果,默认推荐是提前缓存在前端的,不受短期网络故障影响),因此,有更好的鲁棒性。

事先计算型另一个优点是架构更加简单,web接口服务跟生成推荐过程解耦,可以分别对web接口和推荐结果计算优化升级,而不会互相影响。

事先计算型最大的缺点是,由于要事先为每个用户生成推荐,实际上很多用户不是每天都访问的,真正日活用户占总活跃用户(比如月活用户)的比例是很低的(当然像微信这类国民级APP除外),推荐模块访问用户数一般也远小于当天日活数,这就浪费了很多计算和存储资源,特别是有海量用户的APP,这时有大量的用户没有登录反而需要每天为其计算推荐结果,这时浪费是非常明显的。

事先计算型另外一个缺点是,事先计算好了,这就失去了灵活性,要调整修改用户的推荐结果成本代价更高(信息流推荐等实时推荐产品是需要对推荐结果进行近实时调整的)。就像前面的案例讲的,套餐做好了,没法满足用户特定的口味了,比如用户想要重辣,那也没办法了。

2.实时装配型web服务的优缺点

实时装配型跟事先计算型基本是对称的,事先计算型的优点是它的缺点,事先计算型的缺点反而是它的优点。

实时装配型需要临时为用户生成推荐结果,因此web接口服务需要多做一步处理,对接口性能有一定负面影响。另外,当推荐模型需要升级调整或者模型服务出现问题时(实时装配型另一种实现方案是推荐模型作为一个独立web服务),会有短暂时间的不可用,这时会导致推荐web接口无法计算出推荐结果,进而无法给前端提供反馈信息。这两种情况都会影响用户体验(当然做得好的系统会有热更新,模型升级不会导致无法响应的情况出现,TensorFlow Serving就具备这种能力)。

实时装备型的架构也更加复杂,耦合度更高(在推荐web接口整合了推荐模型这种实时装配型中,推荐web接口跟推荐结果计算是完全耦合在一起的,参见图6)。

实时装配型由于是实时为用户计算推荐结果,因此相比事先计算型不会占用太多的存储、计算资源,对于节省费用是有极大帮助的,特别是在海量用户场景下,这种节省更加明显。它的另一个优点是对推荐结果调整空间大,因为是临时计算,可以在计算过程中增加一些场景化的处理逻辑,对推荐算法有更好的干预能力,更加适合实时推荐场景。

上面介绍完了这两种方案的优缺点,我们用一个表格整理一下,方便对比查看它们之间的异同点。

一文讲透推荐系统提供web服务的2种方式
表1:事先计算型和实时装配型的优缺点对比六、影响web服务方案的因素及选择原则

 

一文讲透推荐系统提供web服务的2种方式
影响web服务方案的因素及选择原则


在上一节中,我们对两种推荐web方案的优缺点进行了对比介绍,每种方案都有各自的优缺点,没有哪一个方案是完全胜于另一个方案的。那么在实际业务落地时,有哪些因素是会影响我们选择具体的方案呢?我们应该怎样选择?有什么判断依据和准则吗?在这一节中我们试图从多个角度来回答这些问题。

1.推荐产品形态的实效性对推荐web服务选择的影响

如果推荐产品形态是T+1型推荐,由于每天只更新一次推荐结果,可以选择事先计算型先将推荐结果计算出来。如果产品形态是实时信息流推荐,需要整合用户的实时兴趣变化,用户的每一次行为都会触发更新推荐结果,这时采用临时装配型是更好的选择。当然这也不是绝对的,作者所在公司的短视频信息流推荐,就采用的事先计算型,事先计算型也可以做到近实时更新用户推荐结果,前面已经提到,对实现细节感兴趣的读者可以参考《 》这篇文章。

2.团队架构能力、工程实现能力对推荐web服务选择的影响

实时装配型架构相对复杂,耦合度相对更高,在推荐时需要处理的逻辑更多,因此各个子模块都需要相当稳定,并且需要具备较高的性能,因此对整个推荐软件系统的要求更高。因此,如果推荐团队架构能力强,人力比较充足的情况下可以选择实时装配型方案。

为了更好地整合用户的实时行为,为用户提供可见即所得的推荐服务,很多信息流推荐需要对推荐算法进行实时训练,比如Google在2013年推广的FTRL算法就是对logistic在实时推荐场景下的工程实现,具备更高的工程实现难度,因此,对推荐团队的工程实现能力是有较高要求的。实时装配型一般需要处理用户的实时行为日志,用于挖掘用户实时兴趣,构建实时模型,这就要求整个系统有更高的实时性,需要一套完善的实时处理架构体系的支撑, 这也增加了构建这类系统的复杂性。

前面也提到实时计算型一般需要有一套类似FAISS这样的实时匹配库,为用户在极短的时间内搜寻到最喜欢的标的物。而搭建这样一套系统,需要将推荐模型做成独立的服务,并且保证推荐模型web服务具备稳定性、高并发能力、可拓展性等能力,这也对架构能力有极高要求。如果希望采用容器等新技术来更好地管理推荐模型服务,这也需要新的学习成本和运维成本。

3.推荐阶段对推荐web服务选择的影响

我们知道企业级推荐系统生成推荐结果的过程一般分为召回和排序两个阶段(参考《 》这篇文章第一节的介绍),先使用召回推荐算法从海量标的物中筛选出一组(一般几百上千个)用户可能感兴趣的标的物,然后在排序阶段利用更加精细化的推荐算法对结果进行重排序。

由于召回是从所有标的物中筛选用户可能感兴趣的,当标的物数量庞大时(比如今日头条有千亿级文本、淘宝有上亿级商品),即使召回算法简单,计算量也是非常大的,一般可以采用事先计算型召回策略(为了整合用户最近的行为,也可以基于用户的兴趣标签或者用户最近浏览的标的物进行近实时召回,这类召回策略也属于事先计算型,比如根据用户最近浏览的标的物召回相似的标的物,每个标的物相似标的物是事先计算好的)。而对于排序推荐算法,只需要从有限的(成百上千)的标的物中过滤出用户最喜欢的几十个,可以在较短时间内计算完,因此排序算法可以采用实时装配型策略。

当然,排序阶段也是可以采用事先计算型的,这就相当于先召回,再排序将推荐结果计算好,只不过整个推荐过程将事先计算拆解为召回和排序两个阶段来进行了。

其实,直接跟推荐接口衔接的是排序阶段,召回阶段是不直接参与web服务的,因此根据第二节的定义,严格意义上事先计算型、实时装配型是不能用于描述召回阶段的。不过有些产品的标的物数量不大(比如电影只有几万个),也可以将召回排序融合为一个阶段,只用一个算法就可以获得推荐结果,或者排序可以采用简单的规则和策略,这时排序逻辑可以整合到推荐web接口中,这两种情况召回阶段所起的作用就相当于排序阶段的作用了,这时可以说召回直接跟web接口进行了交互,因此也可以用事先计算型、实时装配型来描述召回阶段。

4.算法形态对推荐web服务选择的影响

推荐算法种类繁多,从简单的KNN、item-based协同过滤到复杂的深度学习、强化学习推荐算法,不同的算法实现方式、需要的数据来源、计算复杂度等都不一样。这也导致了算法的使用场景不一样。

像深层深度学习这种模型结构非常复杂的推荐算法,即使为单个标的物打分(即计算出用户对标的物的偏好度),计算时间也是简单算法的若干倍,这时在短时间内(比如100毫秒之内)为大量的标的物打分是不现实的,因此这类算法一般用于排序阶段(排序阶段只对成百上千的标的物打分),因此比较适合实时装配性的策略。

简单的推荐算法,如item-based协同过滤、矩阵分解,由于计算复杂度低,一般用于召回阶段,因此是比较适合事先计算型的。

 

一文讲透推荐系统提供web服务的2种方式

总结


本文讲解了推荐系统提供web服务的两种主要方式,一种是事先计算型,提前将用户的推荐结果计算出来并存放到NoSQL中,当用户使用推荐模块时,推荐web服务直接将该用户的推荐结果取出来并组装成合适的数据结构最终在前端展示给用户,另一种是实时装配型,我们需要将计算推荐结果需要的原材料准备成“半成品”(就是各种特征),将这些中间结果事先存起来,当用户使用推荐服务时,推荐web服务通过简单的组装与计算(调用封装好的推荐模型),将“半成品”加工成该用户的推荐结果,并最终给到用户。

这两种提供web服务的推荐方案各有优缺点,我们需要根据公司现在的技术储备、人员能力、团队规模、产品形态等多个维度进行评估和选择。不管采用哪种方式,最终的目的是一样的,我们需要为用户提供个性化的、响应及时的优质推荐服务。
 
参考资料
  • [基于TensorFlow Serving的深度学习在线预估] https://zhuanlan.zhihu.com/p/46591057
  • [手把手教你使用TF服务将TensorFlow模型部署到生产环境] https://zhuanlan.zhihu.com/p/60542828
  • https://www.tensorflow.org/serving
  • https://github.com/facebookresearch/faiss

【end】



精彩推荐


明晚7点,直播连麦贾扬清 ,讲讲人工智能在近几年当中的算法和相应系统的演进过程,并从技术角度阐述产品形态和用户场景。参与公开课还有机会向贾扬清老师提问~ 提交听课笔记还有可能获得阿里马克杯、天猫精灵智能音箱哦~
点击阅读原文,快速报名!
推荐阅读


    你点的每个“在看”,我都认真当成了AI

以上是关于4种多租户数据库设计方案对比及思考,一文全讲透的主要内容,如果未能解决你的问题,请参考以下文章

一文讲透 Dubbo 负载均衡之最小活跃数算法

一文讲透Dubbo负载均衡之最小活跃数算法

一文讲透推荐系统提供web服务的2种方式

一文讲透支付宝沙箱的基本应用

真香啊,一文讲透金融风控建模全流程(Python)

ServiceStack 多租户的实现方案