返回

Tenancy for Laravel v3 数据库未创建?深入排查与解决

mysql

搞定 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 的工作流大概是这样的:

  1. 创建租户记录: 你在中央数据库(mysql 连接)的 deployments 表里插入一条记录,包含了租户的基本信息,可能还有数据库名。
  2. 初始化租户上下文 (tenancy()->initialize($tenant)): 这是核心。这一步会触发一系列 Bootstrapper。其中 DatabaseTenancyBootstrapper 负责处理数据库切换。
  3. 数据库切换逻辑:
    • 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 里的 prefixsuffix 设定,结合租户的 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() 方法就行。

  1. 修改 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 处理。
  2. 保持 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 框架来管理。

  1. 修改 Controller (store 方法):
    在创建 Deployment 记录时,不要 手动设置 database_name 字段。

    // ...
    $tenant = Deployment::create([
        'slug' => $request->slug,
        // 'database_name' => 'tenant_' . $request->slug, // 👈 注释掉或者删掉这行
        'tenant_name' => $request->tenant_name,
        // ... 其他字段 ...
    ]);
    // ...
    
  2. 确认 config/tenancy.php 配置:
    检查 database.prefixdatabase.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

  3. (可选)移除模型中的 getDatabaseName() 覆盖:
    如果你之前添加了方案一中的 getDatabaseName() 方法,现在应该移除它,让 HasDatabase trait 使用它的默认逻辑。

原理说明:
这种方式下,你不用关心数据库具体叫什么。框架自己根据配置和租户 ID 生成并管理。DatabaseTenancyBootstrapper 会用这个自动生成的名称去创建和连接数据库。

优点:

  • 代码更简洁,少操心一件事。
  • 不易出错,因为命名是自动化的。

缺点:

  • 数据库名可读性差(一长串 UUID)。如果你需要手动访问数据库或者数据库名有特殊业务含义,可能不太方便。

关键配置检查:别忘了这些

无论用哪种方案,有几个基础配置一定要保证正确:

  1. config/database.phptenant 连接:
    你需要一个“模板”连接,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 USERGRANT OPTION 权限。
  2. config/tenancy.phpdatabase 配置:
    确认 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 类。

  3. config/tenancy.phpmigration_parameters:
    确认租户迁移文件的路径设置正确。

    // config/tenancy.php
    'migration_parameters' => [
        '--force' => true,
        '--path' => [database_path('migrations/tenant')], // 👈 检查这个路径下是否有你的租户表迁移文件
        '--realpath' => true,
    ],
    

    你的 users 表迁移文件应该放在 database/migrations/tenant 目录下,而不是 database/migrations(那是中央数据库的迁移)。

代码流程再梳理

修复后,你的 store 方法的执行流程应该是这样的:

  1. 验证请求数据。
  2. 开始数据库事务(在中央库)。
  3. 创建 Deployment 记录到中央库。 此时 database_name 被存入(方案一)或留空(方案二)。
  4. 调用 tenancy()->initialize($tenant)
    • DatabaseTenancyBootstrapper 触发。
    • 它调用 Deployment 模型的 getDatabaseName() (方案一) 或使用默认规则 (方案二) 获取目标数据库名。
    • MySQLDatabaseManager (或其他 Manager) 被调用。
    • Manager 检查数据库是否存在,不存在则使用 'tenant' 连接配置中的有权限的用户名CREATE DATABASE <目标数据库名>
    • Manager 更新内存中 'tenant' 连接的 database 配置项为 <目标数据库名>
    • 框架将默认数据库连接切换到配置好的 'tenant' 连接。
  5. Log::info("Current Database...") 输出 <目标数据库名> (如 tenant_moops)。 连接切换成功。
  6. 执行 Artisan::call('tenants:migrate', ...)
    • 该命令现在在刚刚切换到的 <目标数据库名> 上下文中执行。
    • database/migrations/tenant 目录下的迁移文件被执行,创建 users 等表。不会 再报“表已存在”错误,因为这是个全新的空数据库。
  7. 执行 $tenant->run(...) 闭包。
    • 闭包内的代码也在 <目标数据库名> 的上下文中执行。User::create(...) 会将用户数据插入到租户的 users 表中。
  8. 提交事务(在中央库的事务提交,租户库的操作通常也包含在内,取决于你的具体配置和操作)。
  9. 调用 tenancy()->end() 清理上下文,默认连接切回中央库。
  10. 返回成功响应。

按照这些步骤排查和修改,应该就能解决 Tenancy for Laravel v3 创建租户时数据库“难产”的问题了。选择哪个方案取决于你的项目需求和对数据库命名的偏好。通常来说,方案一(显式指定并读取 database_name)提供了更多的控制和可读性,推荐优先考虑。