TL;DR
- 共享数据库多租户 = 一个数据库,每行都打上
tenant_id标签,通过全局作用域自动过滤。 - 三个核心组件:一个
TenantContext单例、一个BelongsToTenanttrait,以及复合唯一约束。 - 真正的安全保障不是靠纪律——而是一个 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 会在代码评审前就指出问题。
总结
共享数据库多租户运行成本低,但也容易泄露数据。解决方案不是“更小心”,而是把“是否记住了”变成一个会失败的测试。解析器、作用域和唯一性是核心组件;而护栏则让你能安心入睡。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.