Migration 零停机最佳实践
High Contrast
Dark Mode
Light Mode
Sepia
Forest
1 min read164 words

Migration 零停机最佳实践

Laravel Migration 是数据库 Schema 的版本控制系统。但在生产环境上不当执行 Migration 会锁表——对一个有 1000 万行数据的表执行 ADD COLUMN NOT NULL,可能导致几分钟的服务中断。


创建 Migration

# 创建表
php artisan make:migration create_tasks_table
# 添加列(命名规范:add_xxx_to_yyy_table)
php artisan make:migration add_completed_at_to_tasks_table
# 修改列
php artisan make:migration change_status_column_in_tasks_table

TaskFlow 核心表结构

<?php
// database/migrations/2024_01_01_000001_create_tasks_table.php
return new class extends Migration
{
public function up(): void
{
Schema::create('tasks', function (Blueprint $table) {
$table->id();                              // BIGSERIAL PRIMARY KEY
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete();                 // 用户删除时级联删除任务
$table->foreignId('project_id')
->nullable()
->constrained()
->nullOnDelete();                    // 项目删除时设为 NULL
$table->string('title', 200);
$table->text('description')->nullable();
$table->string('status', 20)->default('pending');
$table->unsignedTinyInteger('priority')->default(3);
$table->date('due_date')->nullable();
$table->timestamp('completed_at')->nullable();
$table->boolean('is_pinned')->default(false);
$table->jsonb('metadata')->nullable();     // PostgreSQL JSONB
$table->timestamps();
$table->softDeletes();
// 索引
$table->index(['user_id', 'status']);
$table->index('due_date');
});
}
public function down(): void
{
Schema::dropIfExists('tasks');
}
};

安全的列变更(分步策略)

// ❌ 危险:直接添加 NOT NULL 约束(大表会锁表)
Schema::table('tasks', function (Blueprint $table) {
$table->string('team_id')->after('user_id');  // 大表锁表风险
});
// ✅ 分三步安全迁移:
// Migration 1:添加允许 NULL 的列(安全,不锁表)
// 2024_03_01_create_add_team_id_to_tasks.php
Schema::table('tasks', function (Blueprint $table) {
$table->foreignId('team_id')->nullable()->after('user_id')
->constrained()->nullOnDelete();
});
// Migration 2:在应用层同时写入新旧字段(部署代码)
// 此时 team_id 可以为 NULL,旧代码继续正常工作
// Migration 3:回填历史数据(分批)
// 2024_03_05_backfill_team_id_in_tasks.php
public function up(): void
{
// 分批更新,避免长时间锁
$batchSize = 5000;
DB::table('tasks')
->whereNull('team_id')
->orderBy('id')
->lazyById($batchSize)
->each(function ($task) {
DB::table('tasks')
->where('id', $task->id)
->update(['team_id' => DB::table('team_user')
->where('user_id', $task->user_id)
->value('team_id')
]);
});
}
// Migration 4:回填完成后,添加 NOT NULL(如果需要)
// 用 NOT VALID 策略,比直接 NOT NULL 更安全
public function up(): void
{
// 添加 CHECK 约束(跳过现有行验证)
DB::statement('ALTER TABLE tasks ADD CONSTRAINT tasks_team_id_not_null CHECK (team_id IS NOT NULL) NOT VALID');
// 后台验证(不锁写操作)
DB::statement('ALTER TABLE tasks VALIDATE CONSTRAINT tasks_team_id_not_null');
}

修改列类型(需要 doctrine/dbal)

composer require doctrine/dbal
// 修改列类型(会重写整列,大表谨慎)
Schema::table('tasks', function (Blueprint $table) {
$table->text('title')->change();   // varchar(200) → text
});
// 重命名列(PostgreSQL 安全)
Schema::table('tasks', function (Blueprint $table) {
$table->renameColumn('old_name', 'new_name');
});
// 删除列(PostgreSQL:标记删除,不重写表,相对安全)
Schema::table('tasks', function (Blueprint $table) {
$table->dropColumn('deprecated_field');
// 同时删除多列:$table->dropColumn(['col1', 'col2']);
});

CREATE INDEX CONCURRENTLY(不锁表)

// ❌ 普通方式(会锁表,大表生产环境有风险)
Schema::table('tasks', function (Blueprint $table) {
$table->index(['user_id', 'due_date']);
});
// ✅ 不锁表创建索引(PostgreSQL)
public function up(): void
{
// Laravel 默认在事务中执行 Migration
// CONCURRENTLY 不能在事务中用,需要禁用事务
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_user_due ON tasks (user_id, due_date)');
}
// 注意:LaravelMigration 默认包裹在事务中
// 如果你的 Migration 文件需要 CONCURRENTLY,需要禁用事务:
// 在 Migration 类中覆盖属性:
class AddIndexToTasks extends Migration
{
// 禁用 Migration 事务包裹
public $withinTransaction = false;  // Laravel 10.38+
public function up(): void
{
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_user_due ON tasks (user_id, due_date)');
}
public function down(): void
{
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS idx_tasks_user_due');
}
}

常用 Blueprint 方法速查

Schema::create('xxx', function (Blueprint $table) {
// 主键
$table->id();                              // BIGSERIAL + PRIMARY KEY
$table->uuid('id')->primary();             // UUID 主键
// 外键
$table->foreignId('user_id')->constrained();          // user_id + FK
$table->foreignUlid('user_id')->constrained('users'); // ULID 外键
// 字符串
$table->string('name', 100);     // VARCHAR(100)
$table->text('bio');             // TEXT
// 数值
$table->integer('count');
$table->bigInteger('views');
$table->decimal('price', 10, 2);  // NUMERIC(10,2)
$table->unsignedTinyInteger('priority');  // 0-255
// 日期时间
$table->date('due_date');
$table->timestamp('published_at')->nullable();
$table->timestampsTz();   // created_at/updated_at with timezone(推荐)
// 特殊类型
$table->boolean('is_active')->default(true);
$table->json('settings');          // JSON
$table->jsonb('metadata');         // JSONB(PostgreSQL,支持 GIN 索引)
$table->enum('status', ['a', 'b']);  // 推荐用 string + CHECK 替代
$table->ipAddress();               // INET(PostgreSQL)
$table->macAddress();
// 软删除
$table->softDeletes();             // deleted_at TIMESTAMP NULL
$table->timestamps();              // created_at, updated_at
// 索引
$table->index('email');
$table->index(['user_id', 'status'], 'idx_tasks_user_status');
$table->unique('email');
$table->unique(['user_id', 'project_id']);
$table->fullText('body');          // 全文搜索索引
});

下一节Factory 与 Faker 测试数据生成——Migration 定义了表结构,Factory 定义了怎么生成真实感的测试数据。没有好的 Factory,写测试会很痛苦。