概述

随着云计算和SaaS(Software as a Service)模型的兴起,多租户系统成为了构建灵活、高效应用的重要架构。在构建多租户SaaS平台时,数据库方案的选择直接关系到数据隔离、性能和可扩展性。

在SaaS平台项目中,根据前端不同的域名查询不同的数据库,通常涉及到多租户架构的实现。在这种架构中,一个应用实例可以服务多个客户(租户)【数据库】,每个租户的数据需要隔离存储。实现这一目标的关键技术之一就是动态切换数据库连接。

设计多租户数据模型

在数据库设计阶段,你需要决定数据隔离的级别。通常有以下几种隔离级别:

  • 独立数据库:每个租户拥有一个独立的数据库实例。
  • 共享数据库,独立Schema:所有租户共享同一个数据库,但每个租户有独立的Schema。
  • 共享数据库,共享Schema,共享数据表:所有租户共享数据库、Schema和数据表,但通过租户ID字段进行数据隔离。

共享数据库,独立Schema

"共享数据库,独立Schema" 是一种在SaaS平台中实现多租户架构的策略,它在数据库层面上提供了一种折中的数据隔离方法。

Oracle数据库:在Oracle中一个数据库可以具有多个用户,那么一个用户一般对应一个Schema,表都是建立在Schema中的,(可以简单的理解:在Oracle中一个用户一套数据库表

在 MySQL 中,SchemaDatabase 可以认为是相同的概念。在 SQL 语句中,CREATE DATABASECREATE SCHEMA 基本上是等效的。所以,当你创建一个数据库时,你也在事实上创建了一个模式。模式是一个逻辑上的容器,用于组织和管理数据库对象,如表、视图、存储过程等。在 MySQL 中,模式和数据库可以互换使用。

共享数据库

在这种模式下,所有的租户(即SaaS平台的客户)共享同一个物理数据库服务器或数据库实例。这意味着,尽管每个租户都有自己的数据,但这些数据都存储在同一个数据库文件或数据库集群中。这样做的好处是可以减少硬件资源和维护成本,因为不需要为每个租户单独设置和维护数据库实例。

独立Schema

尽管数据库是共享的,但每个租户都有自己独立的SchemaSchema是数据库中的一种逻辑分组,它包含了一系列的数据库对象,如表、视图、索引、存储过程等。在这个模式下,每个租户的数据都存储在自己的Schema中,这样可以保证租户之间的数据逻辑上是隔离的。

例如,假设有两个租户A和B,他们共享同一个数据库"SaaSDB"。在"SaaSDB"中,可以分别为租户A租户B创建两个Schema(数据库),分别是"SchemaA""SchemaB"。租户A的所有数据都存储在"SchemaA"中,而租户B的数据存储在"SchemaB"中。

优缺点

优点
  1. 资源利用率高:由于所有租户共享同一个数据库,硬件资源和数据库维护成本较低。
  2. 易于管理:数据库管理员只需要管理一个数据库实例,简化了维护和升级的工作。
  3. 隔离性:每个租户的数据存储在独立的Schema中,逻辑上实现了数据隔离,减少了数据交叉污染的风险。
缺点
  1. 隔离性不如独立数据库:虽然Schema提供了一定程度的隔离,但如果Schema之间存在依赖关系或需要进行复杂的数据操作,隔离性可能不如完全独立的数据库。
  2. 性能问题:如果租户数量增多,可能会导致数据库性能问题,因为所有租户都在竞争同一个数据库资源。

总体来说,"共享数据库,独立Schema" 的模式在SaaS平台中是一种常见的多租户数据隔离策略,它在资源利用率和数据隔离性之间取得了平衡。开发者需要根据具体的业务需求和预期的租户规模来决定是否采用这种模式。

SaaS多租户架构数据库设计

重点:在 SQL 语句中,CREATE DATABASECREATE SCHEMA 基本上是等效的。所以,当你创建一个SCHEMA时,就是在一个RDS实例下创建一个数据库DATABASE

newtrain.tinywan.comhz_newtrain.tinywan.combj_newtrain.tinywan.com三个域名为例,每个域名对应一个租户平台站点,分别对应各自的数据源数据库newtrain.tinywan.comhangzhou.tinywan.combeijing.tinywan.com

实施方案

域名解析与路由

  • 在DNS系统中为每个域名配置A记录,指向SaaS平台的服务器
  • 在服务器上部署Web应用,并根据请求的Host头部信息,确定租户身份。

数据源配置

  • 在应用程序的配置文件中,定义每个租户的数据源配置,包括数据库URL、用户名和密码
  • 可以使用环境变量或配置中心来动态加载这些配置。

动态数据源切换

根据请求的域名或其他标识符,动态确定使用哪个数据库连接。这通常通过中间件、拦截器或全局函数来实现。

示例:使用PHP实现域名路由中间件

<?php
/**
 * @desc 域名路由中间件
 * @author Tinywan(ShaoBo Wan)
 * @date 2024/11/20 18:14
 */
declare(strict_types=1);

namespace app\middleware;

use app\common\model\SaasModel;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;

class ConnectionMiddleware implements MiddlewareInterface
{
    /**
     * @param Request $request
     * @param callable $handler
     * @return Response
     */
    public function process(Request $request, callable $handler): Response
    {
        $domain = $request->header()['x-site-domain']?? 'https://newtrain.tinywan.com';
        $platform = SaasModel::where('domain', $domain)->field('id, domain, website')->findOrEmpty();
        if (!$platform->isEmpty()) {
            $request->website = $platform['website'];
        }
        return $handler($request);
    }
}

以上根据前端请求的域名标识符x-site-domain,动态确定使用哪个数据库连接,通过中间件动态赋予全局请求对象$request->website,后续就可以使用。

项目应用

框架数据

项目使用超高性能可扩展PHP框架webman。webman是一款基于workerman开发的高性能HTTP服务框架。webman用于替代传统的php-fpm架构,提供超高性能可扩展的HTTP服务。你可以用webman开发网站,也可以开发HTTP接口或者微服务。

数据库连接使用ThinkORM。ThinkORM是一个基于PHP和PDO的数据库中间层和ORM类库,之前一直作为ThinkPHP5.*系列的内置ORM类,以优异的功能和突出的性能著称,现已经支持独立使用,并作了升级改进,提供了更优秀的性能和开发体验,最新版本要求PHP7.1+。

数据库连接中间

示例:域名路由中间件

<?php
/**
 * @desc 域名路由中间件
 * @author Tinywan(ShaoBo Wan)
 * @date 2024/11/20 18:14
 */
declare(strict_types=1);

namespace app\middleware;

use app\common\model\SaasModel;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;

class ConnectionMiddleware implements MiddlewareInterface
{
    /**
     * @param Request $request
     * @param callable $handler
     * @return Response
     */
    public function process(Request $request, callable $handler): Response
    {
        $domain = $request->header()['x-site-domain']?? 'https://newtrain.tinywan.com';
        $platform = SaasModel::where('domain', $domain)->field('id, domain, website')->findOrEmpty();
        if (!$platform->isEmpty()) {
            $request->website = $platform['website'];
        }
        return $handler($request);
    }
}

数据库配置

ThinkORM配置文件:config/thinkorm.php

<?php
/**
 * @desc ThinkORM配置文件
 * @author Tinywan(ShaoBo Wan)
 * @date 2024/11/14 15:14
 */
declare(strict_types=1);

return [
    'default' => 'train',
    'connections' => [
        'train' => [
            'type' => 'mysql',
            'hostname' => '127.0.0.1',
            'database' => 'newtrain.tinywan.com',
            'username' => 'root',
            'password' => '123456'
        ],
        'hangzhou' => [
            'type' => 'mysql',
            'hostname' => '127.0.0.1',
            'database' => 'hangzhou.tinywan.com',
            'username' => 'root',
            'password' => '123456'
        ],
        'beijing' => [
            'type' => 'mysql',
            'hostname' => '127.0.0.1',
            'database' => 'beijing.tinywan.com',
            'username' => 'root',
            'password' => '123456'
        ]
    ],
];

Model模型使用

BaseModel.php 基础模型

<?php
/**
 * @desc 基础模型
 * @author Tinywan(ShaoBo Wan)
 * @date 2024/11/2 15:09
 */
declare(strict_types=1);

namespace app\common\model;

use think\Model;

class BaseModel extends Model
{
    /**
     * 设置当前模型的数据库连接
     * @var string
     */
    protected $connection;

    /**
     * BaseModel constructor.
     * @param array $data
     */
    public function __construct(array $data = [])
    {
        $this->connection = \request()->website ?? 'train';
        parent::__construct($data);
    }
}

CityModel.php 公共模型,对应数据库表名common_city

<?php
/**
 * @desc 市模型
 * @author Tinywan(ShaoBo Wan)
 * @date 2024/12/13 14:59
 */
declare(strict_types=1);

namespace app\common\model;

use think\Model;

class CityModel extends Model
{
    /** 数据库配置 */
    protected $connection = 'train';

    /** 设置当前模型对应的完整数据表名称 */
    protected $table = 'common_city';
}

公共模型CityModel类里面定义了connection属性,则该模型操作的时候会自动按照给定的数据库配置进行连接,而不是配置文件中设置的默认连接信息.

业务 MeetingModel.php 会议模型类。对应数据库表名resty_meeting

<?php
/**
 * @desc 会议模型类
 * @author Tinywan(ShaoBo Wan)
 * @date 2024/11/17 11:55
 */
declare(strict_types=1);

namespace app\common\model;

class MeetingModel extends BaseModel
{
    /** 设置当前模型对应的完整数据表名称 */
    protected $table = 'resty_meeting';
}

业务控制器或者服务使用

<?php
/**
 * @desc 会议
 * @author Tinywan(ShaoBo Wan)
 * @date 2023/11/9 16:57
 */
declare(strict_types=1);

public function meetingList(\support\Request $request, int $organizationId) : \support\Response
{
    $meetingList = \app\common\model\MeetingModel::where([
        'organization_id' => $organizationId,
        'create_user_id' => $this->userId
    ])->select();
    return json($meetingList->toArray());
}

Db类使用

可以调用Db::connect方法动态配置数据库连接信息

<?php
/**
 * @desc 会议
 * @author Tinywan(ShaoBo Wan)
 * @date 2023/11/9 16:57
 */
declare(strict_types=1);

public function datasetList(\support\Request $request) : \support\Response
{
    $res = \think\facade\Db::connect(\request()->website)
        ->table('resty_meeting')
        ->field('id,name')
        ->select();
    return json($res->toArray());
}

connect方法必须在查询的最开始调用,而且必须紧跟着调用查询方法,否则可能会导致部分查询失效或者依然使用默认的数据库连接。动态连接数据库的connect方法仅对当次查询有效。这种方式的动态连接和切换数据库比较方便,经常用于多数据库连接的应用需求。

动态连接到目标数据库

在SaaS平台中,如果需要根据前端传递的配置信息动态连接到目标数据库并将数据拉取到本地数据库,可以采用以下步骤实现

  • 前端传递配置信息。前端在用户操作时,将目标数据库的连接信息作为请求参数发送到后端。这些配置信息通常包括数据库类型、主机地址、端口、数据库名、用户名和密码等。
  • 验证和解析配置信息。后端接收到配置信息后,首先进行验证,确保其合法性和安全性。解析配置信息,并准备用于数据库连接的参数。
  • 动态数据源管理。创建一个动态数据源管理器,它可以根据传入的配置信息动态创建数据库连接。
  • 数据同步。根据目标数据库的连接信息,建立连接并执行数据查询操作。然后将查询结果同步到本地数据库。这可能涉及到以下步骤:
    • 建立连接:使用动态数据源管理器创建的目标数据库连接。
    • 执行查询:在目标数据库上执行SQL查询,获取所需数据。
    • 映射数据:将查询结果映射到本地数据库的表结构中。
    • 写入本地数据库:将映射后的数据插入到本地数据库中。
  • 异常处理和日志记录。在整个数据同步过程中,需要妥善处理可能出现的异常情况,并记录相关操作日志,以便于问题追踪和系统维护。
  • 安全性考虑
    • 加密敏感信息:确保所有的数据库凭证信息在存储和传输过程中都是加密的。
    • 权限控制:确保只有授权的用户或服务才能访问数据同步功能。
    • SQL注入防护:对动态执行的SQL进行严格的安全检查,避免SQL注入攻击。

自定义函数

函数配置文件app/functions.php新增函数dynamic_connect_db()

/**
 * @desc: 动态切换数据库
 * @param string $name
 * @param array $connection
 * @return \think\db\ConnectionInterface
 * @author Tinywan(ShaoBo Wan)
 */
function dynamic_connect_db(string $name, array $connection): \think\db\ConnectionInterface
{
    try {
        $connect = \think\facade\Db::connect($name);
    } catch (Throwable $e) {
        // 获取配置参数
        $config  = \think\facade\Db::getConfig();

        // 配置具体的数据库连接信息
        $config['connections'][$name] = $connection;

        // 初始化配置参数
        \think\facade\Db::setConfig($config);

        // 创建/切换数据库连接查询
        $connect = \think\facade\Db::connect($name);
    }
    return $connect;
}

动态使用

调用自定义函数dynamic_connect_db()方法动态数据库连接查询,这里查询一个不存在的配置数据库zhejiang 浙江站点。

/**
 * @desc: 动态切换数据库
 * @param Request $request
 * @return Response
 * @throws DataNotFoundException
 * @throws DbException
 * @throws ModelNotFoundException
 * @author Tinywan(ShaoBo Wan)
 */
public function dynamicConnectDb(Request $request): Response
{
    $connection = [
        'type' => 'mysql',
        'hostname' => '127.0.0.1',
        'database' => 'zhejiang.tinywan.com',
        'username' => 'root',
        'password' => '123456'
    ];
    $connect = dynamic_connect_db('zhejiang', $connection);
    $result = $connect->table('resty_meeting')->where('id', 1)->find();
    var_dump($result);
    return json($result->toArray());
}

在实际应用中,数据同步操作可能涉及到复杂的数据映射和处理逻辑,需要根据具体的业务需求进行设计和实现。同时,为了保障系统的稳定性和性能,可能还需要考虑引入事务管理、批量处理和异步处理等机制。

Last Updated:
贡献者: Tinywan