TL;DR

  • 共享数据库多租户 = 一个数据库,每行都打上 tenant_id 标签,通过全局作用域自动过滤。
  • 三个核心组件:一个 TenantContext 单例、一个 BelongsToTenant trait,以及复合唯一约束。
  • 真正的安全保障不是靠纪律——而是一个 CI 测试:当某个带有 tenant_id 列的表忘记使用 trait(反之亦然)时,测试就会失败。

本周我发布了共享数据库多租户的基础层。不是路由,不是解析器——只是确保“一行数据属于一个组织”这一规则成立且不易出错的部分。下面是它的结构。

为什么选择共享数据库

目前有三种主流策略。快速对比:

策略 隔离性 运维成本 适用场景
Database-per-tenant 最强 最高(迁移 × N) 少量大型租户,严格合规要求
Schema-per-tenant 中等 Postgres,中等租户数量
Shared DB + tenant_id 最弱 最低 大量租户,单代码库,单次迁移运行

对于面向多组织、共享同一代码库的绿地应用,共享数据库在简洁性上占优。代价是:隔离现在由你负责,在应用层实现。一旦遗漏一个 where tenant_id = ?,一个组织就能看到另一个组织的数据。因此,整个设计的核心就是让这种遗漏变得不可能。

上下文对象

所有操作都从同一个单例中读取当前租户,而不是从请求、会话或全局变量中读取。这样可以让解析器保持可替换——本地部署用配置驱动,SaaS 用域名驱动——而不需要改动下游任何代码。

class TenantContext
{
    private ?Tenant $tenant = null;
    private bool $scopeDisabled = false;

    public function id(): ?int { return $this->tenant?->getKey(); }
    public function has(): bool { return $this->tenant instanceof Tenant; }

    public function shouldScope(): bool
    {
        return ! $this->scopeDisabled && $this->has();
    }

    public function withoutScope(Closure $callback): mixed
    {
        // suspend scoping for genuine cross-tenant work, then restore
    }
}

Enter fullscreen mode Exit fullscreen mode

shouldScope() 在没有租户时返回 false 非常重要:控制台启动、数据填充和早期测试都可能在租户存在之前运行。没有租户就意味着没有可过滤的对象——而不是“按 null 过滤”。

Trait

一个 trait 完成两件事:读取时添加全局作用域,写入时填充 tenant_id

trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        $context = app(TenantContext::class);

        static::addGlobalScope('tenant', function (Builder $q) use ($context) {
            if (! $context->shouldScope()) return;
            $q->where($q->getModel()->getTable().'.tenant_id', $context->id());
        });

        static::creating(function ($model) use ($context) {
            if ($model->tenant_id === null && $context->has()) {
                $model->tenant_id = $context->id();
            }
        });
    }
    // ...
}

Enter fullscreen mode Exit fullscreen mode

对列名进行表限定(table.tenant_id)可以避免在联接两个已作用域的表时出现列名歧义错误。

唯一性变得微妙

在共享数据库模式下,“唯一”被一分为二。租户可复用的人类可读标识符必须在租户内唯一。安全令牌必须全局唯一——令牌碰撞是安全风险,而非用户体验问题。

列类型 作用域 示例
人类可读标识符 unique([tenant_id, x]) 会员编号、发票编号、slug
UUID 与令牌 全局唯一 二维码令牌、幂等键、网关引用

用户本身仍然是中心化的:一个邮箱、一个登录凭证,可通过中间表跨多个组织拥有成员身份。用户本身不按租户隔离;用户的成员身份才按租户隔离。

真正保障安全的部分

Trait 和约束只有在被正确应用时才有效。所以真正的护栏是一个测试,而不是检查清单:

it('scopes every model whose table carries tenant_id', function () {
    $missing = [];
    foreach (tenancyModelClasses() as $class) {
        $table = (new $class)->getTable();
        if (Schema::hasColumn($table, 'tenant_id') && ! usesBelongsToTenant($class)) {
            $missing[] = $class;
        }
    }
    expect($missing)->toBe([]); // CI fails, names the offenders
});

Enter fullscreen mode Exit fullscreen mode

它同时检查两个方向:带有 tenant_id 列却缺少 trait 的表,以及使用了 trait 却缺少对应列的情况。六个月后新增模型时,如果忘记添加 trait,CI 会在代码评审前就指出问题。

总结

共享数据库多租户运行成本低,但也容易泄露数据。解决方案不是“更小心”,而是把“是否记住了”变成一个会失败的测试。解析器、作用域和唯一性是核心组件;而护栏则让你能安心入睡。