Estrategias de migraciones de bases de datos en Go

Gestionar la evolución del esquema de una base de datos es, en esencia, aplicar control de versiones a la infraestructura de datos. Si el esquema de tu base de datos no está en perfecta sintonía con los modelos de tus estructuras en Go, la aplicación fallará al intentar acceder a columnas inexistentes o tipos de datos incompatibles. Para evitar esto, utilizamos migraciones, que son una serie de scripts secuenciales que transforman el estado actual de la base de datos al siguiente estado deseado.

Existen tres enfoques predominantes en el ecosistema de Go. El enfoque imperativo (usado por golang-migrate/migrate) consiste en archivos SQL que definen explícitamente cómo subir (up) y bajar (down) la versión. Es predecible y el estándar en sistemas donde el control total de cada comando SQL es crítico. pressly/goose sigue una lógica similar, pero con una ventaja clave: permite escribir migraciones en Go puro, lo que es vital cuando la migración no es solo un cambio de esquema, sino una transformación compleja de datos (por ejemplo, migrar un campo JSON a múltiples columnas) que requiere lógica de negocio o llamadas a librerías externas. Por último, ariga.io/atlas propone un enfoque declarativo (schema-first): tú defines el estado final deseado (en HCL o SQL) y la herramienta calcula el “diff” y genera el plan de ejecución para llegar de tu estado actual al objetivo.

Debes usar migraciones siempre que operes en producción. La elección depende de la complejidad: si solo cambias tablas, golang-migrate es suficiente; si transformas datos, usa goose; si quieres que la herramienta gestione la lógica del “diff” por ti, usa atlas. Sin embargo, si aplicas mal este proceso, especialmente en entornos distribuidos, podrías causar un schema drift (desviación del esquema) o, peor aún, bloquear la base de datos durante un despliegue debido a bloqueos de tabla prolongados.

En sistemas distribuidos, como un cluster de Kubernetes, nunca debes ejecutar las migraciones en el main() de tu aplicación durante el arranque (startup). Si lanzas 20 réplicas de un microservicio simultáneamente, todas intentarán ejecutar el mismo ALTER TABLE al mismo tiempo, provocando errores de “table already exists” o bloqueos masivos. Lo profesional es ejecutar las migraciones como un Job de Kubernetes o dentro de tu pipeline de CI/CD antes de que el nuevo código de la aplicación se despliegue.

Para lograr despliegues zero-downtime (sin tiempo de inactividad), debes seguir un patrón de migración expansiva. Si necesitas cambiar una columna status de VARCHAR a un tipo ENUM o añadir una restricción NOT NULL, no puedes hacerlo en un solo paso. El proceso correcto es:
1. Añadir la nueva columna como nullable.
2. Desplegar el código que escribe tanto en la columna vieja como en la nueva.
3. Ejecutar un script de backfill para llenar los datos en la nueva columna.
4. Desplegar el código que solo lee de la nueva columna.
5. Eliminar la columna vieja o aplicar la restricción NOT NULL.

package main

import (
	"database/sql"
	"fmt"
	"log"
	"time"

	_ "github.com/lib/pq" // Driver para PostgreSQL
)

// Migration representa una unidad de cambio en el esquema.
type Migration struct {
	ID   string
	Up   func(tx *sql.Tx) error
	Down func(tx *sql.Tx) error
}

// MigrationRunner gestiona la ejecución segura de migraciones.
type MigrationRunner struct {
	db *sql.Tx
}

func main() {
	// En un entorno real, la conexión vendría de variables de entorno.
	connStr := "postgres://user:password@localhost:5432/myapp?sslmode=disable"
	db, err := sql.Open("postgres", connStr)
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// Definimos las migraciones de nuestro sistema.
	migrations := []Migration{
		{
			ID: "20240520100001_create_users_table",
			Up: func(tx *sql.Tx) error {
				_, err := tx.Exec(`CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT UNIQUE)`)
				return err
			},
			Down: func(tx *sql.Tx) error {
				_, err := tx.Exec(`DROP TABLE users`)
				return err
			},
		},
		{
			ID: "20240520100002_add_status_to_users",
			Up: func(tx *sql.Tx) error {
				// Paso 1 de zero-downtime: columna nullable.
				_, err := tx.Exec(`ALTER TABLE users ADD COLUMN status TEXT`)
				return err
			},
			Down: func(tx *sql.Tx) error {
				_, err := tx.Exec(`ALTER TABLE users DROP COLUMN status`)
				return err
			},
		},
	}

	if err := runAll(db, migrations); err != nil {
		log.Fatalf("Fallo crítico en la migración: %v", err)
	}

	fmt.Println("Esquema actualizado con éxito.")
}

// runAll orquesta la ejecución de las migraciones dentro de una transacción
// y utiliza un lock de asesoría (Advisory Lock) para evitar ejecuciones concurrentes.
func runAll(db *sql.DB, migrations []Migration) error {
	// Empezamos una transacción para asegurar la atomicidad del proceso.
	// En un entorno real, primero se debería adquirir un lock de la base de datos.
	tx, err := db.Begin()
	if err != nil {
		return err
	}

	// Simulamos la adquisición de un PostgreSQL Advisory Lock.
	// Esto evita que múltiples instancias intenten migrar la misma DB a la vez.
	_, err = tx.Exec("SELECT pg_advisory_xact_lock(123456)")
	if err != nil {
		tx.Rollback()
		return fmt.Errorf("no se pudo adquirir el lock: %w", err)
	}

	for _, m := range migrations {
		// Aquí normalmente verificaríamos en una tabla 'schema_migrations'
		// si la migración m.ID ya fue aplicada.
		fmt.Printf("Aplicando migración: %s\n", m.ID)
		if err := m.Up(tx); err != nil {
			tx.Rollback() // Si una falla, revertimos todo para mantener integridad.
			return fmt.Errorf("error en %s: %w", m.ID, err)
		}
	}

	return tx.Commit()
}

Desglose del código

El ejemplo implementa un orquestador de migraciones robusto. Lo más importante es el uso de tx.Exec("SELECT pg_advisory_xact_lock(123456)"). Este comando de PostgreSQL es fundamental en sistemas distribuidos; le dice a la base de datos que bloquee ese ID específico para cualquier otra transacción que intente pedir el mismo lock. Si tienes tres pods de tu API intentando ejecutar runAll, dos de ellos se quedarán esperando en esa línea hasta que el primero haga Commit o Rollback.

En la estructura Migration, separamos la lógica de Up y Down. El método runAll encapsula todo el proceso en una única transacción de base de datos (db.Begin()). Esto es vital: si la migración número 5 de un total de 10 falla, el tx.Rollback() asegura que la base de datos no quede en un estado intermedio (la mitad migrada, la mitad no), lo cual es un desastre para la consistencia.

Observa cómo la migración 20240520100002_add_status_to_users implementa la primera fase del patrón zero-downtime: añade la columna status permitiendo valores NULL. Esto permite que el código de la versión actual (que no sabe nada de status) siga funcionando perfectamente, mientras que el código de la nueva versión ya puede empezar a procesar esa columna.

El error frecuente

Un error clásico es intentar ejecutar migraciones de tipo “destructive” (que eliminan o modifican tipos de datos de forma incompatible) directamente en la base de datos de producción durante una ventana de mantenimiento, asumiendo que el downtime es aceptable. Sin embargo, en arquitecturas modernas de microservicios, el despliegue de la aplicación y el cambio de esquema no son síncronos. Si ejecutas un ALTER TABLE users DROP COLUMN email, los pods que aún están corriendo la versión vieja de tu aplicación fallarán inmediatamente al intentar insertar un email. La clave no es evitar los cambios, sino hacerlos de forma que la base de datos siempre sea compatible con la versión anterior y la nueva simultáneamente.

Para sistemas de alta criticidad, considera usar atlas para comparar tu código de infraestructura contra la base de datos real y detectar desviaciones antes de que lleguen al entorno de staging.

252

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio