Tenancy for Laravel v3 数据库未创建?深入排查与解决
2025-04-11 03:32:50
搞定 Tenancy for Laravel v3 不建库:深入排查与解决方案
用 Tenancy for Laravel (stancl/tenancy) 构建多租户应用时,一个常见的需求就是给每个租户(比如每个客户部署)整一个独立的数据库。但有时候,跟着官方文档走,代码敲下去,诶?数据库咋没创建成功?迁移还跑到了主库上,报了一堆“表已存在”的错。别慌,这事儿不少人遇到过。
问题来了:租户数据库“难产”
你可能写了类似下面这样的代码来创建新租户(这里叫 Deployment)并初始化:
// ... (Validation logic) ...
DB::beginTransaction();
$tenant = null;
try {
Log::info("Creating deployment record in central database...");
// 创建租户记录(存主数据库)
$tenant = Deployment::create([
'slug' => $request->slug,
'database_name' => 'tenant_' . $request->slug, // 👈 注意这里,指定了数据库名
// ... 其他字段 ...
]);
Log::info("Deployment created: " . json_encode($tenant->toArray()));
Log::info("Initializing tenancy for tenant: " . $tenant->id);
tenancy()->initialize($tenant); // 👈 关键一步:初始化租户上下文
// 理想状态下,这里数据库连接应该切换到租户库了
Log::info("Current Database After Initialization: " . DB::connection()->getDatabaseName());
Log::info("Running migrations for tenant database: " . $tenant->database_name);
Artisan::call('tenants:migrate', ['--tenants' => [$tenant->id]]); // 👈 为新租户跑迁移
Log::info("Creating admin user for tenant: " . $tenant->id);
$tenant->run(function () use ($request) { // 👈 在租户库执行操作
// ... 创建租户管理员 ...
});
Log::info("Committing transaction...");
DB::commit();
tenancy()->end(); // 👈 清理租户上下文
// ... 成功跳转 ...
} catch (Exception $e) {
DB::rollBack();
Log::error("Error during deployment creation: " . $e->getMessage());
tenancy()->end();
if ($tenant != null) {
$tenant->delete();
}
// ... 错误处理 ...
}
然后一看日志,傻眼了:
[2025-02-18 13:28:18] local.INFO: Initializing tenancy for tenant: f823fb29-45d6-4174-88a2-60ce8a34f589
[2025-02-18 13:28:18] local.INFO: Current Database After Initialization: hirezy // 👈 咋还是主库?! (hirezy 是主库名)
[2025-02-18 13:28:18] local.INFO: Running migrations for tenant database: tenant_moops
[2025-02-18 13:28:18] local.ERROR: Error during deployment creation: SQLSTATE[42S01]: Base table or view already exists: 1050 Table 'users' already exists (Connection: mysql, ...) // 👈 在主库跑迁移,当然表已存在!
从日志看,tenancy()->initialize($tenant)
这步似乎没起作用,数据库连接压根没切换到预期的租户库(tenant_moops
)。后续的 tenants:migrate
自然就跑偏了,往主库(hirezy
)上怼,结果就是 users
表已存在,整个流程报错回滚。去 MySQL 里看,tenant_moops
这个数据库确实也没被创建出来。
刨根问底:为啥库没建起来?
Tenancy for Laravel
的工作流大概是这样的:
- 创建租户记录: 你在中央数据库(
mysql
连接)的deployments
表里插入一条记录,包含了租户的基本信息,可能还有数据库名。 - 初始化租户上下文 (
tenancy()->initialize($tenant)
): 这是核心。这一步会触发一系列Bootstrapper
。其中DatabaseTenancyBootstrapper
负责处理数据库切换。 - 数据库切换逻辑:
DatabaseTenancyBootstrapper
会找config/tenancy.php
里配置的TenantDatabaseManager
(比如MySQLDatabaseManager
)。- 这个 Manager 会去检查租户对应的数据库是否存在。
- 如果不存在,它会尝试创建 这个数据库。
- 然后,它会动态地修改
config/database.php
里那个模板租户连接 (template_tenant_connection
,这里是'tenant'
)的配置,主要是把database
字段设置成租户该用的数据库名。 - 最后,它会把默认数据库连接切换 到这个刚配置好的
'tenant'
连接上。
那问题出在哪儿呢?
关键在于 TenantDatabaseManager
需要知道应该创建/连接哪个数据库 。它怎么知道?通常是通过租户模型(你的 Deployment
Model)的一个方法来获取数据库配置信息。stancl/tenancy
提供的 HasDatabase
trait 里有这个逻辑。
查看你的 Deployment
模型:
<?php
namespace App\Models;
// ... (use statements) ...
use Stancl\Tenancy\Database\Concerns\HasDatabase; // 👈 这个 Trait 很关键
class Deployment extends BaseTenant implements TenantWithDatabase
{
use HasDatabase, HasDomains; // 👈 使用了 HasDatabase
protected $table = 'deployments';
protected $primaryKey = 'id'; // 显式指定主键
public $incrementing = false;
protected $keyType = 'string';
// ... (boot method for UUID) ...
public static function getCustomColumns(): array { /* ... */ }
// 👇👇👇 重点看这里!默认的 HasDatabase 可能不知道你的 database_name 字段
// public function getDatabaseName(): ?string
// {
// // 默认实现可能基于 ID 或其他约定
// // return config('tenancy.database.prefix') . $this->getTenantKey() . config('tenancy.database.suffix');
// }
}
你手动在创建 Deployment
记录时指定了 database_name
字段为 'tenant_' . $request->slug
。但 HasDatabase
trait 默认可能并不知道要去读这个 database_name
字段来获取数据库名!
默认情况下,HasDatabase
trait 可能会遵循 config/tenancy.php
里的 prefix
和 suffix
设定,结合租户的 id
(也就是那个 UUID f823fb29-45d6-4174-88a2-60ce8a34f589
)来生成一个预期的数据库名,比如 tenantf823fb29-45d6-4174-88a2-60ce8a34f589
。
因为你存的是 tenant_moops
,而 DatabaseTenancyBootstrapper
以为是 tenant<uuid>
,这两者对不上,或者更可能的是,DatabaseTenancyBootstrapper
根本没找到获取数据库名的正确方法,导致数据库创建和连接切换都失败了。日志里 Current Database After Initialization: hirezy
就是明证。
动手修复:对症下药
既然知道了问题根源,修复起来就简单了。核心思路是:让 Tenancy 框架知道你期望使用的数据库名称。
方案一:让 Tenancy 知道你的数据库名 (推荐)
这是最直接的方法:告诉 HasDatabase
trait 去读取你存好的 database_name
字段。怎么告诉?在你的 Deployment
模型里覆盖 getDatabaseName()
方法就行。
-
修改
app/Models/Deployment.php
模型:<?php namespace App\Models; use Stancl\Tenancy\Database\Models\Tenant as BaseTenant; use Stancl\Tenancy\Contracts\TenantWithDatabase; use Stancl\Tenancy\Database\Concerns\HasDatabase; use Stancl\Tenancy\Database\Concerns\HasDomains; use Illuminate\Database\Eloquent\Concerns\HasUuids; // 如果 Laravel 9+,可以用这个 Trait use Illuminate\Support\Str; class Deployment extends BaseTenant implements TenantWithDatabase { use HasDatabase, HasDomains; // use HasUuids; // Laravel 9+ 可选,替代下面的 boot 方法生成 UUID protected $table = 'deployments'; protected $primaryKey = 'id'; // 明确主键 public $incrementing = false; protected $keyType = 'string'; /** * Boot function to generate a UUID automatically (如果不用 HasUuids) */ protected static function boot() { parent::boot(); static::creating(function ($model) { if (empty($model->{$model->getKeyName()})) { // 确保 ID 为空时才生成 $model->{$model->getKeyName()} = Str::uuid()->toString(); } }); } /** * 定义存储在中央数据库的自定义列。 */ public static function getCustomColumns(): array { return [ 'id', 'slug', 'database_name', // 确保 database_name 包含在内 'tenant_name', 'address', 'contact_email', 'contact_number', 'country', 'language', 'subscription_start_date', 'subscription_end_date', 'status', ]; } /** * 告诉 Tenancy 框架从哪个字段读取数据库名。 * 覆盖 HasDatabase trait 中的默认行为。 * * @return string|null */ public function getDatabaseName(): ?string { // 直接返回你存储数据库名称的那个字段的值 return $this->database_name; } // 如果你用了 HasUuids Trait (Laravel 9+), 可以定义这个方法指定 UUID 列 // public function uniqueIds(): array // { // return [$this->getKeyName()]; // getKeyName() 返回 'id' // } }
原理说明:
通过重写getDatabaseName()
方法,当DatabaseTenancyBootstrapper
需要知道数据库名时,它会调用这个方法,拿到你明确存储在database_name
字段(比如tenant_moops
)里的值。这样,它就能正确地创建这个数据库,并配置好'tenant'
连接,顺利完成切换。注意:
- 确保你的
database_name
字段确实存在于deployments
表中,并且在getCustomColumns()
方法里列出来了。 - 使用 UUID 做主键时,确保
$primaryKey
,$incrementing
,$keyType
设置正确。如果使用 Laravel 9+,推荐使用HasUuids
trait 简化 UUID 处理。
- 确保你的
-
保持 Controller 代码不变:
你的store
方法中创建Deployment
时设置database_name
的逻辑 ('database_name' => 'tenant_' . $request->slug,
) 现在是正确的,因为它和模型里的getDatabaseName()
对应起来了。
实施这个方案后,再次尝试创建 Deployment,日志应该显示:
...
[... timestamp ...] local.INFO: Initializing tenancy for tenant: <some-uuid>
[... timestamp ...] local.INFO: Current Database After Initialization: tenant_moops // 👈 切换成功!
[... timestamp ...] local.INFO: Running migrations for tenant database: tenant_moops
// ... 后续迁移和用户创建日志 ...
方案二:随波逐流,用默认规则
如果你不执著于 tenant_
+ slug
这种数据库命名方式,可以完全交给 Tenancy 框架来管理。
-
修改 Controller (
store
方法):
在创建Deployment
记录时,不要 手动设置database_name
字段。// ... $tenant = Deployment::create([ 'slug' => $request->slug, // 'database_name' => 'tenant_' . $request->slug, // 👈 注释掉或者删掉这行 'tenant_name' => $request->tenant_name, // ... 其他字段 ... ]); // ...
-
确认
config/tenancy.php
配置:
检查database.prefix
和database.suffix
。// config/tenancy.php 'database' => [ // ... 'prefix' => 'tenant', // 默认前缀 'suffix' => '', // 默认后缀 // ... ],
在这种配置下,Tenancy 会根据租户的 ID(那个 UUID)自动生成数据库名。格式是
prefix + tenant_id + suffix
。例如,如果租户 ID 是f823fb29-45d6-4174-88a2-60ce8a34f589
,数据库名就会是tenantf823fb29-45d6-4174-88a2-60ce8a34f589
。 -
(可选)移除模型中的
getDatabaseName()
覆盖:
如果你之前添加了方案一中的getDatabaseName()
方法,现在应该移除它,让HasDatabase
trait 使用它的默认逻辑。
原理说明:
这种方式下,你不用关心数据库具体叫什么。框架自己根据配置和租户 ID 生成并管理。DatabaseTenancyBootstrapper
会用这个自动生成的名称去创建和连接数据库。
优点:
- 代码更简洁,少操心一件事。
- 不易出错,因为命名是自动化的。
缺点:
- 数据库名可读性差(一长串 UUID)。如果你需要手动访问数据库或者数据库名有特殊业务含义,可能不太方便。
关键配置检查:别忘了这些
无论用哪种方案,有几个基础配置一定要保证正确:
-
config/database.php
的tenant
连接:
你需要一个“模板”连接,Tenancy 框架会基于它动态配置每个租户的连接。通常命名为'tenant'
。// config/database.php 'connections' => [ 'mysql' => [ // 这是你的中央数据库连接 'driver' => 'mysql', 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'forge'), // 主库名 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), // ... 其他配置 ... ], 'tenant' => [ // 这是租户数据库的模板连接 'driver' => 'mysql', 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => null, // 👈 这里必须是 null!框架会动态填充 'username' => env('DB_USERNAME', 'forge'), // 👈 这个用户需要有创建数据库的权限! 'password' => env('DB_PASSWORD', ''), // ... 其他配置,通常和主库一致 ... ], // ... ],
重要:
'tenant'
连接里的database
必须设置为null
。'tenant'
连接使用的数据库用户(这里是env('DB_USERNAME')
)必须拥有足够的权限来创建新的数据库 。通常需要CREATE DATABASE
权限。如果你使用的是PermissionControlledMySQLDatabaseManager
(更安全,为每个租户创建独立用户),那么这个初始用户还需要CREATE USER
和GRANT OPTION
权限。
-
config/tenancy.php
的database
配置:
确认template_tenant_connection
指向你在config/database.php
定义的模板连接名。// config/tenancy.php 'database' => [ 'central_connection' => env('DB_CONNECTION', 'mysql'), // 中央连接 'template_tenant_connection' => 'tenant', // 👈 确认是 'tenant' 或你自定义的名称 // ... prefix, suffix, managers ... ],
并且
managers
部分为你使用的数据库类型(如mysql
)指定了正确的 Manager 类。 -
config/tenancy.php
的migration_parameters
:
确认租户迁移文件的路径设置正确。// config/tenancy.php 'migration_parameters' => [ '--force' => true, '--path' => [database_path('migrations/tenant')], // 👈 检查这个路径下是否有你的租户表迁移文件 '--realpath' => true, ],
你的
users
表迁移文件应该放在database/migrations/tenant
目录下,而不是database/migrations
(那是中央数据库的迁移)。
代码流程再梳理
修复后,你的 store
方法的执行流程应该是这样的:
- 验证请求数据。
- 开始数据库事务(在中央库)。
- 创建
Deployment
记录到中央库。 此时database_name
被存入(方案一)或留空(方案二)。 - 调用
tenancy()->initialize($tenant)
。DatabaseTenancyBootstrapper
触发。- 它调用
Deployment
模型的getDatabaseName()
(方案一) 或使用默认规则 (方案二) 获取目标数据库名。 MySQLDatabaseManager
(或其他 Manager) 被调用。- Manager 检查数据库是否存在,不存在则使用
'tenant'
连接配置中的有权限的用户名 去CREATE DATABASE <目标数据库名>
。 - Manager 更新内存中
'tenant'
连接的database
配置项为<目标数据库名>
。 - 框架将默认数据库连接切换到配置好的
'tenant'
连接。
Log::info("Current Database...")
输出<目标数据库名>
(如tenant_moops
)。 连接切换成功。- 执行
Artisan::call('tenants:migrate', ...)
。- 该命令现在在刚刚切换到的
<目标数据库名>
上下文中执行。 database/migrations/tenant
目录下的迁移文件被执行,创建users
等表。不会 再报“表已存在”错误,因为这是个全新的空数据库。
- 该命令现在在刚刚切换到的
- 执行
$tenant->run(...)
闭包。- 闭包内的代码也在
<目标数据库名>
的上下文中执行。User::create(...)
会将用户数据插入到租户的users
表中。
- 闭包内的代码也在
- 提交事务(在中央库的事务提交,租户库的操作通常也包含在内,取决于你的具体配置和操作)。
- 调用
tenancy()->end()
清理上下文,默认连接切回中央库。 - 返回成功响应。
按照这些步骤排查和修改,应该就能解决 Tenancy for Laravel v3 创建租户时数据库“难产”的问题了。选择哪个方案取决于你的项目需求和对数据库命名的偏好。通常来说,方案一(显式指定并读取 database_name
)提供了更多的控制和可读性,推荐优先考虑。