PHP如何设计支持多租户隔离的SaaS底层数据架构

数字百货商场的底层玄机:如何用PHP打造坚不可摧的SaaS多租户架构

各位老铁,各位从需求文档里爬出来的程序员,还有那些试图用“一个表搞定所有业务”来糊弄老板的架构师们,大家好。

今天我们不谈那些虚头巴脑的“高可用”、“高并发”的PPT名词,咱们来聊聊一个在SaaS圈子里最性感、也最让人半夜惊醒的话题——多租户数据隔离

想象一下,你手里握着一家“数字百货商场”。A租户是卖高端家具的,B租户是卖……嗯,卖破解软件的(别学),C租户是卖猫咪零食的。你的一台数据库服务器、一套代码、一个后端服务,就是这家商场的“地皮”和“柜台”

这时候,A租户的老板拍着桌子说:“我要把我的家具库存导出来!”,B租户的老板大喊:“我要把我的软件列表发到群里!”

如果你搞不定多租户,服务器会变成你的噩梦:A租户的查询把CPU干满了,卡死了B租户的猫咪零食下载;或者更糟,A租户的黑客通过SQL注入,顺手把B租户的账单给删了。

在PHP的世界里,要实现这种“共享数据库、共享模式”的架构,听起来像是要在一根针尖上跳舞。别慌,今天我就带大家拆解一下,如何用PHP构建一个既省钱又安全,还能让老板点头笑的SaaS底层数据架构。

一、 核心哲学:拒绝“独栋别墅”,拥抱“合租公寓”

首先,我们要明确一个原则:99%的SaaS应用,不需要为每个租户单独买一套数据库服务器。 如果你的老板要求这么做,先让他买瓶水,然后慢慢给他科普“单位成本”的概念。

我们采用业界最通用的架构模式:共享数据库,共享模式

这就好比所有租户都住在一个小区里,共用上下水和电网(数据库和代码)。你的任务,就是确保张三(租户A)不能进李四(租户B)的屋子,也不能去撬李四的冰箱。

在这个模式下,数据隔离通常通过两种方式实现:

  1. 行级隔离(Implicit): 每张表里都有一个 tenant_id 字段。查询时强制带上这个条件。这是最常用的,也是我们今天要讲的重点。
  2. Schema级隔离: 每个租户一套表结构。这在PHP里玩起来比较麻烦,性能损耗大,通常不推荐,除非你的租户数据量级大到离谱。

下面,我们进入硬核模式。

二、 架构的“守门员”:SQL拦截器

如果你完全依赖开发人员在写代码时手动添加 WHERE tenant_id = ?,那我敢打赌,不出一个月,你的代码库里就会有一堆忘记加这个条件的地方。这是人类的天性,别怪自己。

所以,我们需要一个守门员,一个中间件。不管是谁(是代码写的不规范,还是直接连数据库的脚本),只要想查数据,必须先通过这个守门员的检查。

在PHP中,我们通常通过PDO预处理语句的拦截或者ORM的事件钩子来实现。

1. 打造你的专属 PDO Wrapper

假设我们用原生PDO。我们要写一个 TenantAwarePDO 类,它继承自 PDO,并重写 preparequery 方法。

class TenantAwarePDO extends PDO
{
    private $tenantId;

    public function __construct(...$args)
    {
        parent::__construct(...$args);
        // 设置默认行为
        $this->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    }

    /**
     * 设置当前请求的租户上下文
     */
    public function setTenantContext(int $tenantId): void
    {
        $this->tenantId = $tenantId;
    }

    /**
     * 重写 Prepare,注入 WHERE 条件
     */
    public function prepare(string $statement, array $options = []): PDOStatement
    {
        // 1. 解析 SQL 语句,看看是不是 SELECT/UPDATE/DELETE
        // 2. 如果是,且原语句没有带 tenant_id 条件,那就加一个
        // 3. 返回预处理语句

        // 注意:为了代码演示简洁,这里并没有做完整的 SQL 解析器。
        // 实际生产中,建议使用 ProxyQuery 或者在 ORM 层做,不要在原生 PDO 里写解析器。
        // 这里用个简单的正则替换演示“黑魔法”:

        $statement = $this->injectTenantFilter($statement);

        return parent::prepare($statement, $options);
    }

    private function injectTenantFilter(string $sql): string
    {
        // 如果租户ID不存在,或者 SQL 已经包含 tenant_id,则放行
        if (empty($this->tenantId)) {
            return $sql;
        }

        // 简单的逻辑:检查 SQL 是否包含 tenant_id 且后面没有紧跟 LIMIT 或 JOIN 等
        // 实际生产环境极其复杂,这里只是抛砖引玉

        // 我们可以简单粗暴地在末尾加一个 OR 1=0 (防止只查表头)
        // 但更聪明的做法是解析 SQL,把 tenant_id 插入到 WHERE 子句里。

        // 这里为了演示,我们假设 SQL 必须有 WHERE 子句(这是最佳实践)
        if (stripos($sql, 'WHERE') === false) {
            return $sql . " WHERE tenant_id = {$this->tenantId}";
        }

        // 如果有 WHERE,看看是不是已经带上了 tenant_id
        if (stripos($sql, 'tenant_id') !== false) {
            return $sql;
        }

        // 否则,追加 AND tenant_id = ...
        // 注意:这里要处理 SQL 里的注释和特殊字符,极其危险,务必小心。
        // 生产环境建议用 SQL Parser 库。

        return $sql . " AND tenant_id = {$this->tenantId}";
    }
}

上面的代码非常危险,千万别直接在生产环境用! 真正的SQL解析器比写个PHP框架还复杂。在PHP生态里,我们有更优雅的方式。

2. Laravel/ThinkPHP 生态下的拦截

如果你用的是Laravel,千万别自己造轮子写PDO Wrapper,太痛苦了。我们要用ScopesGlobal Scopes

namespace AppModels;

use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentBuilder;

class TenantModel extends Model
{
    /**
     * 应用全局作用域
     */
    protected static function boot()
    {
        parent::boot();

        // 监听查询事件,自动注入租户ID
        static::addGlobalScope('tenant', function (Builder $builder) {
            // 假设我们有个 Context 类存租户ID
            $tenantId = AppFacadesTenantContext::id();

            if ($tenantId) {
                $builder->where('tenant_id', $tenantId);
            }
        });
    }
}

原理揭秘:
当你写 $user = User::find(1); 时,Laravel 内部会生成 SELECT * FROM users WHERE tenant_id = ? LIMIT 1。你根本不需要手动加条件!这就像给你的数据库加了一层隐形的防弹玻璃。

三、 核心设计:主键与唯一索引的博弈

多租户架构中最玄学的地方来了:主键(Primary Key)怎么设计?

很多新手(包括曾经的我自己)喜欢用自增ID作为主键。比如用户表:
id (自增), name, email, tenant_id

这是自杀式行为。 为什么?因为如果租户A修改了ID为1的用户资料,租户B的用户ID也是1。虽然我们加了 WHERE tenant_id = ? 的条件,但在代码层面,$user = User::find(1); 这个操作在两个租户眼里看到的都是“ID为1的用户”。

这会导致什么?权限混淆。 你以为你在查租户A的用户,其实因为某种奇怪的竞态条件或者缓存问题,你拿到了租户B的数据。

解决方案:UUID 或 联合主键

方案 A:使用 UUID
id 字段改成 uuid。每个租户生成的ID都是随机且唯一的,互不干扰。这样即便两个租户都用ID 550e8400-e29b-41d4-a716-446655440000,也互不冲突。

CREATE TABLE `users` (
  `uuid` char(36) NOT NULL COMMENT 'UUID主键',
  `tenant_id` int(11) NOT NULL COMMENT '租户ID',
  `name` varchar(50) NOT NULL,
  PRIMARY KEY (`uuid`)
) ENGINE=InnoDB;

方案 B:联合主键(自增ID + 租户ID)
如果你一定要自增ID(为了性能和索引),那就把 tenant_id 加到主键里。

CREATE TABLE `users` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `tenant_id` int(11) NOT NULL COMMENT '租户ID',
  `name` varchar(50) NOT NULL,
  PRIMARY KEY (`id`, `tenant_id`) -- 注意这里,联合主键!
) ENGINE=InnoDB;

重点来了: 这种设计下,你的数据在物理存储上依然是“堆叠”的,比如:

  1. (1, 10, 张三)
  2. (2, 10, 李四)
  3. (1, 20, 王五)
  4. (2, 20, 赵六)

这会导致什么问题? 数据页分裂!如果租户10的数据一直在增长,而租户20突然来了很多数据,可能会导致表文件膨胀严重。

我的建议: 在大多数SaaS场景下,直接上 UUID。现在的硬件性能足够支撑UUID索引,而且逻辑清晰,不会搞出权限Bug。

四、 缓存架构:Redis 键的命名战争

多租户环境下,缓存是性能的引擎,也是灾难的源头。

想象一下,你用Redis缓存了用户信息。

现在,租户A的用户改了邮箱,Redis更新了。
过了0.1秒,租户B的请求进来了,它缓存里有个脏数据(或者是租户A的旧数据,如果没做隔离),直接读到了 user:1001 的缓存。

解决方案:在Redis Key里塞入 tenant_id

// 键名变成了:user:1001:tenant_10
$key = "user:{$userId}:tenant_{$tenantId}";

public function getUser($userId)
{
    $tenantId = TenantContext::id();
    $key = "user:{$userId}:tenant_{$tenantId}";

    $user = $this->redis->get($key);

    if (!$user) {
        $user = $this->db->queryOne("SELECT * FROM users WHERE uuid = ? AND tenant_id = ?", [$userId, $tenantId]);
        $this->redis->setex($key, 3600, json_encode($user));
    }

    return json_decode($user, true);
}

进阶技巧: 还要考虑缓存穿透缓存雪崩
穿透意味着恶意请求不存在的ID,导致全部查数据库。
雪崩意味着大量租户同时过期。
记住,既然有 tenant_id,你的布隆过滤器(Bloom Filter)或者缓存Key的过期时间,都要带上租户ID,不要搞成全局的。

五、 混合模式:大客户与小客户的“分家”

随着业务发展,你可能会遇到这种尴尬:你一个数据库实例里塞了50个租户。其中5个租户是“鲸鱼客户”(Big Fish),数据量巨大,占用了80%的存储;剩下的45个是“沙丁鱼客户”(Sardines)。

这时候,全量共享模式就成了瓶颈。

这时候,你需要引入混合模式

策略:

  1. 默认: 所有新租户走共享模式(成本最低)。
  2. 触发器: 如果某个租户的数据量超过 10GB,或者查询耗时超过 2秒,自动触发“分家”流程。
  3. 迁移: 原本在共享数据库里的数据,被原地迁移到该租户独享的数据库实例中。

在PHP里的实现:
你不需要写一个复杂的ETL工具。你可以利用数据库的 CREATE TABLE ... AS SELECT ... 语法,结合定时任务,把大租户的数据导出到一个新的数据库连接中。

// 伪代码:把租户 100 的大表导出
public function migrateTenantToDedicatedDB(int $tenantId)
{
    $dedicatedDb = new PDO('mysql:host=dedicated-server-100.com', ...);

    // 获取源表结构
    $structure = $this->getStructureFromSharedDB($tenantId);

    // 在独享库创建表
    $dedicatedDb->exec($structure);

    // 迁移数据
    $sharedDb = $this->getSharedDb();
    $stmt = $sharedDb->query("SELECT * FROM orders WHERE tenant_id = {$tenantId}");

    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        // 插入到独享库
        $dedicatedDb->prepare("INSERT INTO orders ...")->execute($row);
    }

    // 更新配置:租户 100 现在指向 dedicated-server-100.com
    TenantConfig::update($tenantId, 'db_host', 'dedicated-server-100.com');
}

这时候,你的中间件逻辑要升级一下,增加一个“路由层”:

public function getConnection($tenantId) {
    if (TenantConfig::isDedicated($tenantId)) {
        return $this->getDedicatedConnection($tenantId);
    }
    return $this->getSharedConnection();
}

这就好比,一开始大家都在大食堂吃饭(共享库),后来有人胖了吃不完(数据大),老板给他专门开了一间小厨房(独享库),但还是统一由大厨(中间件)给做饭,大厨只给他端菜,不给他吃别人盘子里的。

六、 权限控制:ACL 与 数据过滤

多租户不仅仅是物理隔离,更是逻辑隔离。作为开发者,你不能把 tenant_id = 1 的数据给租户1的普通员工看。

这时候,你需要一个强大的 ACL(访问控制列表) 系统。

如果你的表结构是 orders,你需要确保:

  1. 普通员工 A(user_id=100)只能看到 orders 表里 tenant_id=1created_by=100 的数据。
  2. 老板 B(user_id=200)可以看到租户1所有订单。

在PHP代码里,这就是“Scoped Queries”。

class OrderService
{
    public function getUserOrders($userId, $tenantId)
    {
        return Order::query()
            ->where('tenant_id', $tenantId)
            ->where('created_by', $userId) // 逻辑过滤
            ->get();
    }
}

如果这个逻辑很复杂(比如涉及到角色、部门、甚至临时的审批流),你的SQL就写不完了。这时候,必须上 Row-Level Security (RLS) 或者 EAV (Entity-Attribute-Value) 模式。

RLS太重了,咱们聊聊 权限中间件

// 在路由中间件里
public function handle($request, Closure $next)
{
    $tenantId = $request->header('X-Tenant-ID');
    $userId = auth()->id();

    // 1. 注册全局查询作用域
    Order::addGlobalScope('tenant_ownership', function ($query) use ($tenantId, $userId) {
        $query->where('tenant_id', $tenantId)
              ->where(function($q) use ($userId) {
                  // 管理员看所有,员工只看自己的
                  $q->where('created_by', $userId)
                    ->orWhere('is_admin', true);
              });
    });

    return $next($request);
}

七、 数据安全:混淆攻击与审计日志

这是多租户架构中最脆弱的一环。

场景:
用户A在页面里点击了一个“删除”按钮。这是一个AJAX请求。
你的后端代码检查了权限,也检查了 tenant_id,一切正常,执行了 DELETE FROM users WHERE id = 123

但是!
黑客A(不是用户A,而是用户A的账号被黑了)在浏览器控制台里手动发了一个请求:
DELETE FROM users WHERE id = 123

注意,这里没有 tenant_id 条件!
因为黑客A知道,在共享模式下,如果ID不唯一(比如自增ID),SQL会默认去全局查。结果呢?整个数据库里ID为123的记录被删了,不管它是哪个租户的!

防御手段:

  1. 自增ID变UUID:从根本上解决ID冲突问题。
  2. 宽松的约束: 在数据库层面,不要加 PRIMARY KEY (id)。加 PRIMARY KEY (id, tenant_id)。这样即使SQL里漏了tenant_id,数据库也会报错,根本执行不了。这是最后一道防线。
  3. 审计日志: 无论租户还是管理员,任何写操作必须记录日志,包含 operator(操作者)、target(目标)、tenant_id(关联租户)。

八、 灾难恢复与数据清理

当租户决定不再续费,或者搞垮了你的数据库,你该怎么处理?

直接 DELETE?
千万小心!在共享模式下,你删的是这个 tenant_id 下的所有数据。如果你因为疏忽,在删除前执行了 TRUNCATE 或者 DROP TABLE,嘿,你可能顺便把隔壁租户的数据也清空了。

正确的做法:
不要物理删除。使用“软删除”标记。或者,为每个租户的数据加一个“租户字段”,执行清理时,只更新这些字段,不触碰表结构。

更高级的做法是 分区表。利用 MySQL 的分区功能,按 tenant_id 分区。
删除租户时,直接 ALTER TABLE ... DROP PARTITION tenant_id_xxx;
这非常快,而且逻辑上物理隔离了,不会误伤其他分区。

CREATE TABLE orders (
    id BIGINT,
    amount DECIMAL,
    tenant_id INT,
    -- ... 其他字段
    PRIMARY KEY (id, tenant_id)
) PARTITION BY LIST COLUMNS(tenant_id) (
    PARTITION p_tenant_1 VALUES IN (1),
    PARTITION p_tenant_2 VALUES IN (2),
    PARTITION p_default VALUES IN (DEFAULT)
);

九、 总结(不总结也行,划个重点)

好了,老铁们,今天的讲座就到这儿。回顾一下,要搞定PHP多租户架构,你得:

  1. 守住底线: 绝不依赖开发人员的手动写 WHERE 条件,必须用 Global ScopeMiddleware 做底层拦截。
  2. 选对钥匙: 主键别用自增,UUID 走起,或者 联合主键,别给自己挖坑。
  3. 管理缓存: Redis Key 里必须带上 tenant_id,别搞成单例模式。
  4. 伺候大客户: 也就是混合模式,别死磕一种架构,小客户共享,大客户独享,灵活切换。
  5. 小心行凶者: 防止 SQL 注入带来的混淆攻击,数据库约束要严格。

记住,多租户架构不是一蹴而就的。它像是一棵树,刚开始只是个嫩芽,随着租户的增加,你要给它修剪枝叶(优化SQL),给它施肥(加索引),必要时还得把它移植到大盆里(混合模式)。

保持敬畏之心,写好代码,让那个在月黑风高夜试图攻击你数据库的黑客,在门口看着你密不透风的“租户堡垒”,只能望洋兴叹。

现在,赶紧回去把你的 User 模型检查一遍,看看有没有忘记加 tenant_id 滴!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注