Migraciones de base de datos sin downtime en Laravel
Migraciones de base de datos sin downtime en Laravel
Las migraciones sin downtime son una habilidad esencial para equipos que deployan múltiples veces por día. En este artículo explico estrategias prácticas para modificar esquemas de PostgreSQL en producción sin interrumpir el servicio, usando Laravel como framework de aplicación.
El problema del downtime en migraciones
Cuando ejecutas php artisan migrate en producción, varias operaciones bloquean tablas completas. En PostgreSQL, comandos como ALTER TABLE adquieren bloqueos de nivel ACCESS EXCLUSIVE que impiden lecturas y escrituras concurrentes. En tablas grandes, esto puede significar minutos u horas de servicio interrumpido.
Los escenarios más críticos incluyen:
- Agregar columnas con valores por defecto en tablas con millones de registros
- Crear índices en tablas de alto tráfico
- Renombrar columnas que el código activo está utilizando
- Eliminar columnas que aún referencia la versión anterior de la aplicación
- Modificar tipos de datos requiriendo reescritura de la tabla
Principios fundamentales
Antes de mostrar código, estos son los principios que guían cada estrategia:
Nunca realices operaciones destructivas y constructivas en el mismo deploy. Primero agregás lo nuevo, luego actualizás el código, finalmente eliminás lo viejo.
Diseñá las migraciones pensando en la compatibilidad bidireccional. La versión nueva del código debe funcionar con el esquema viejo, y viceversa, al menos transitoriamente.
Usá bloqueos de bajo impacto siempre que sea posible. PostgreSQL ofrece opciones como CONCURRENTLY para crear índices sin bloquear.
Estrategia 1: Expandir, luego contratar
El patrón expand-contract separa los cambios destructivos en múltiples deploys. Illustrémoslo con un caso real: renombrar la columna email a contact_email en la tabla users.
Paso 1: Expandir (Deploy 1)
Creás la nueva columna y mantenés ambas sincronizadas mediante triggers:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('contact_email')->nullable();
});
DB::statement('
CREATE OR REPLACE FUNCTION sync_contact_email()
RETURNS TRIGGER AS $$
BEGIN
NEW.contact_email = NEW.email;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
');
DB::statement('
CREATE TRIGGER trigger_sync_contact_email
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION sync_contact_email();
');
DB::statement('
UPDATE users SET contact_email = email WHERE contact_email IS NULL;
');
}
public function down(): void
{
DB::statement('DROP TRIGGER IF EXISTS trigger_sync_contact_email ON users');
DB::statement('DROP FUNCTION IF EXISTS sync_contact_email()');
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('contact_email');
});
}
};
El código de la aplicación continúa usando email. La nueva columna se completa con datos en segundo plano.
Paso 2: Actualizar código (Deploy 2)
Modificás la aplicación para escribir en ambas columnas y leer desde contact_email con fallback:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $fillable = ['email', 'contact_email'];
protected static function booted(): void
{
static::saving(function ($user) {
$user->contact_email = $user->email;
});
}
public function getEffectiveEmailAttribute(): string
{
return $this->contact_email ?? $this->email;
}
}
Paso 3: Contratar (Deploy 3)
Una vez confirmado que todo funciona, eliminás la columna vieja:
<?php
return new class extends Migration
{
public function up(): void
{
DB::statement('DROP TRIGGER IF EXISTS trigger_sync_contact_email ON users');
DB::statement('DROP FUNCTION IF EXISTS sync_contact_email()');
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('email');
$table->renameColumn('contact_email', 'email');
});
}
};
Estrategia 2: Índices concurrentes en PostgreSQL
La creación estándar de índices bloquea escrituras. PostgreSQL permite CREATE INDEX CONCURRENTLY que no adquiere bloqueos exclusivos, a costa de mayor tiempo de ejecución y uso de CPU.
En Laravel, necesitás escapar del constructor de esquemas para usar esta opción:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement('CREATE INDEX CONCURRENTLY idx_users_created_at ON users(created_at)');
}
public function down(): void
{
DB::statement('DROP INDEX CONCURRENTLY idx_users_created_at');
}
};
Importante: CONCURRENTLY no funciona dentro de transacciones explícitas. Si tu migración falla, dejará un índice inválido que debés eliminar manualmente con DROP INDEX CONCURRENTLY.
Para centralizar esto, creé un trait reutilizable:
<?php
namespace App\Database;
trait UsesPostgresConcurrentIndexes
{
public function createIndexConcurrently(string $table, array $columns, string $name): void
{
$columnList = implode(', ', $columns);
\DB::statement("CREATE INDEX CONCURRENTLY {$name} ON {$table}({$columnList})");
}
public function dropIndexConcurrently(string $name): void
{
\DB::statement("DROP INDEX CONCURRENTLY IF EXISTS {$name}");
}
}
Estrategia 3: Agregar columnas con valores por defecto
En PostgreSQL 11+, agregar columnas con valores por defecto es instantáneo si no son NOT NULL. El valor por defecto se almacena en catálogo sin reescribir filas existentes.
Para PostgreSQL 10 o versiones anteriores (donde esta operación reescribe la tabla completa), usá este patrón de tres pasos:
<?php
// Paso 1: Agregar columna nullable sin default
return new class extends Migration
{
public function up(): void
{
Schema::table('orders', function ($table) {
$table->string('tracking_number')->nullable();
});
}
};
// Paso 2: Aplicar default por lotes desde un job o comando
// Evita un UPDATE masivo que bloquee la tabla
// Paso 3: Hacer NOT NULL y agregar default en catálogo
return new class extends Migration
{
public function up(): void
{
DB::statement('
ALTER TABLE orders
ALTER COLUMN tracking_number
SET DEFAULT \'PENDING\'
');
// Solo si todas las filas tienen valor
DB::statement('
ALTER TABLE orders
ALTER COLUMN tracking_number
SET NOT NULL
');
}
};
Estrategia 4: Migraciones de tablas grandes con batching
Para migrations que deben modificar millones de registros, procesá en lotes con pausas para no saturar la base de datos:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const BATCH_SIZE = 5000;
private const SLEEP_MICROSECONDS = 100000; // 0.1 segundos
public function up(): void
{
$lastId = 0;
$maxId = DB::table('events')->max('id');
while ($lastId < $maxId) {
$affected = DB::statement('
UPDATE events
SET processed_at = created_at
WHERE id > ? AND id <= ? AND processed_at IS NULL
', [$lastId, $lastId + self::BATCH_SIZE]);
$lastId += self::BATCH_SIZE;
if ($affected > 0) {
usleep(self::SLEEP_MICROSECONDS);
}
}
}
};
Este enfoque permite que el query planner use el índice primario eficientemente y da respiro al sistema entre lotes.
Estrategia 5: Uso de vistas para abstracción
Las vistas de PostgreSQL permiten refactorizar tablas subyacentes sin cambiar la interfaz que la aplicación consume. Es útil para particionamiento o normalización progresiva.
<?php
// Crear vista que mantiene compatibilidad con código existente
return new class extends Migration
{
public function up(): void
{
// Tabla nueva con estructura optimizada
Schema::create('users_v2', function ($table) {
$table->id();
$table->string('email');
$table->jsonb('profile_data');
$table->timestamps();
});
// Vista que proyecta la interfaz esperada
DB::statement('
CREATE VIEW users AS
SELECT
id,
email,
profile_data->>\'name\' as name,
profile_data->>\'phone\' as phone,
created_at,
updated_at
FROM users_v2
');
// Triggers INSTEAD OF para operaciones DML
DB::statement('
CREATE OR REPLACE FUNCTION users_view_insert()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO users_v2 (email, profile_data, created_at, updated_at)
VALUES (
NEW.email,
jsonb_build_object(\'name\', NEW.name, \'phone\', NEW.phone),
COALESCE(NEW.created_at, NOW()),
COALESCE(NEW.updated_at, NOW())
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
');
DB::statement('
CREATE TRIGGER users_insert_trigger
INSTEAD OF INSERT ON users
FOR EACH ROW EXECUTE FUNCTION users_view_insert();
');
}
};
Integración con pipelines de deploy
La secuencia de deploy que usamos en producción garantiza consistencia entre código y esquema:
#!/bin/bash
set -euo pipefail
# 1. Pre-deploy: migraciones expansivas (agregar, nunca eliminar)
php artisan migrate --step
# 2. Deploy de código nuevo (compatible con esquema anterior y actual)
# [Aquí corre el swap de contenedores, blue/green, etc.]
# 3. Warmup de cachés y verificación de health checks
# 4. Post-deploy validación de métricas
php artisan db:check-migration-status
# 5. Solo en deploy posterior: migraciones contractivas (eliminar columnas viejas)
Implementamos un comando personalizado para verificar que no existan migraciones pendientes de contracción:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class CheckMigrationStatus extends Command
{
protected $signature = 'db:check-migration-status';
protected $description = 'Verifica columnas marcadas para eliminación';
public function handle(): int
{
$pendingDrops = DB::select("
SELECT column_name, table_name
FROM information_schema.columns
WHERE column_name LIKE '%_deprecated_%'
");
if (empty($pendingDrops)) {
$this->info('No hay columnas pendientes de eliminación.');
return self::SUCCESS;
}
foreach ($pendingDrops as $col) {
$this->warn("Pendiente: {$col->table_name}.{$col->column_name}");
}
return self::SUCCESS;
}
}
Herramientas complementarias
Algunas herramientas que incorporamos al workflow:
pt-online-schema-changede Percona: Aunque está pensado para MySQL, existen adaptaciones conceptuales para PostgreSQL que replican tablas en background.pg-osc: Implementación nativa de cambio de esquema online para PostgreSQL, útil cuando las técnicas nativas no alcanzan.- Laravel Resque o Horizon: Para ejecutar backfills de datos en background fuera del ciclo de deploy.
Consideraciones finales
Las migraciones sin downtime requieren disciplina de equipo. No es solo técnica: es un proceso que afecta cómo planificás features, cómo coordinás deploys y cómo comunicás cambios.
Comenzá con las operaciones más simples: usar CONCURRENTLY para índices, agregar columnas como NULLABLE antes de hacerlas requeridas. Con el tiempo, el patrón expand-contract se vuelve natural y tu capacidad de deployar con confianza aumenta sustancialmente.
La meta no es eliminar completamente el riesgo, sino reducirlo hasta que sea manejable dentro de una cultura de deploy continuo.