Patrón robusto para transacciones SQL con defer

Una transacción en Go es un bloque de operaciones atómicas que se ejecutan sobre una base de datos mediante el tipo *sql.Tx. A diferencia de usar directamente un pool de conexiones (*sql.DB), una transacción asegura que todas las sentencias utilicen la misma conexión física y que los cambios solo sean permanentes si se confirma la operación de forma explícita.

Para que una transacción sea segura en producción, debemos garantizar la atomicidad: si algo falla (un error de lógica, un error de red o un panic del runtime), la base de datos debe volver a su estado original. El diseño de Go para sql.Tx requiere que el desarrollador gestione el ciclo de vida manualmente mediante Commit o Rollback. Si una función retorna prematuramente debido a un error o un panic sin haber llamado a Rollback, la conexión podría quedar bloqueada o en un estado inconsistente hasta que el driver la libere por timeout, lo cual es un desastre en sistemas de alto tráfico.

El patrón idiomático para evitar esto es utilizar un bloque defer con una función anónima que capture el valor de retorno de la función. Este mecanismo es fundamental cuando manejas lógica de negocio compleja donde un error en el paso 3 debe revertir los pasos 1 y 2. Debes usar este patrón siempre que realices múltiples escrituras relacionadas que dependan entre sí. Si implementas mal este ciclo de vida, corres el riesgo de sufrir “fugas de conexiones” (connection leaks) o, peor aún, inconsistencia de datos donde una parte de la operación se guarda y la otra no.

package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
)

// TransferFunds gestiona el movimiento de dinero entre dos cuentas.
// Usamos un retorno nombrado (err error) para que el bloque defer pueda
// modificar el error devuelto por la función.
func TransferFunds(ctx context.Context, db *sql.DB, fromID, toID int, amount float64) (err error) {
	// Iniciamos la transacción con un contexto para permitir la cancelación
	// si la petición HTTP o el proceso padre se cancela.
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}

	// El patrón de limpieza definitivo.
	defer func() {
		if p := recover(); p != nil {
			// Si ocurre un panic, hacemos rollback para asegurar la integridad
			// y luego relanzamos el panic para que el supervisor lo capture.
			_ = tx.Rollback()
			panic(p)
		} else if err != nil {
			// Si la función termina con un error (en el cuerpo de la función),
			// abortamos la transacción.
			_ = tx.Rollback()
		} else {
			// Si no hubo errores, intentamos el commit.
			// Es vital asignar el resultado de Commit a 'err' para capturar
			// errores de escritura final en la base de datos.
			err = tx.Commit()
		}
	}()

	// Operación 1: Retirar dinero
	if err = withdraw(ctx, tx, fromID, amount); err != nil {
		return err
	}

	// Operación 2: Depositar dinero
	if err = deposit(ctx, tx, toID, amount); err != nil {
		return err
	}

	return nil
}

func withdraw(ctx context.Context, tx *sql.Tx, accountID int, amount float64) error {
	// Usamos tx.ExecContext, no db.ExecContext, para que la operación 
	// se ejecute dentro del contexto de la transacción.
	_, err := tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, accountID)
	return err
}

func deposit(ctx context.Context, tx *sql.Tx, accountID int, amount float64) error {
	_, err := tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, accountID)
	return err
}

func main() {
	// Simulación de conexión (en producción usarías sql.Open con un driver real)
	var db *sql.DB 
	ctx := context.Background()

	if err := TransferFunds(ctx, db, 101, 202, 500.00); err != nil {
		log.Printf("Transferencia fallida: %v", err)
		return
	}
	fmt.Println("Transferencia completada con éxito")
}

En el ejemplo anterior, la clave de la robustez reside en el retorno nombrado (err error). En Go, los valores de retorno normales son copias locales; sin embargo, al declarar err en la firma de TransferFunds, el bloque defer tiene acceso a la variable real que la función devolverá al llamador. Esto nos permite ejecutar err = tx.Commit() dentro del defer. Si el commit falla (por ejemplo, por una restricción de clave foránea que solo se valida al cerrar la transacción), el error se asigna a err y el usuario final recibe el error real del Commit, no un nil engañoso.

Las funciones withdraw y deposit reciben *sql.Tx como argumento. Esto es deliberado. Al pasar la transacción y no el objeto *sql.DB, garantizamos que estas funciones no tengan la capacidad de ejecutar comandos fuera del bloque transaccional, manteniendo la integridad de la unidad de trabajo.

El uso de recover() dentro del defer es tu última línea de defensa. Si una operación lanzara un panic (quizás por un error de segmentación en un driver de CGo o un error de lógica inesperado), el defer capturará el pánico, ejecutará el Rollback para liberar la conexión y el lock de la base de datos, y luego permitirá que el panic siga su curso, evitando dejar conexiones colgadas en el pool.

El error frecuente

Un error clásico es pasar el pool de conexiones *sql.DB a las funciones helper en lugar de la transacción *sql.Tx.

// ANTI-PATRÓN: Esta función usa el pool, no la transacción activa.
func updateBalance(db *sql.DB, id int, amount float64) error {
    _, err := db.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, id)
    return err
}

// ... dentro de TransferFunds ...
tx, _ := db.Begin()
updateBalance(db, 1, 100.0) // ¡ERROR! Esta ejecución usa una conexión distinta del pool.
// Si algo falla después, tx.Rollback() NO revertirá el updateBalance.
tx.Commit()

Cuando llamas a db.Exec dentro de una transacción activa, el driver solicita una conexión nueva del pool de conexiones. Esa operación se ejecuta de forma independiente y se confirma inmediatamente (autocommit). Si luego llamas a tx.Rollback(), la transacción principal se cancela, pero el cambio realizado por db.Exec ya es permanente. Esto rompe la atomicidad y es una causa común de corrupción de datos en sistemas financieros.

198

Dejar un comentario

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

Scroll al inicio