Transacciones en Perl

Las transacciones permiten agrupar múltiples operaciones SQL en una unidad atómica: o todas se ejecutan correctamente, o ninguna se aplica. En Perl, DBI ofrece un soporte completo para transacciones a través de los métodos begin_work(), commit() y rollback(). En este artículo aprenderás a usar transacciones para garantizar la integridad de tus datos en operaciones complejas.

Requisitos previos

Necesitas una conexión DBI configurada con AutoCommit desactivado o usando begin_work() para iniciar transacciones explícitas. Asegúrate de que tus tablas usen el motor InnoDB, ya que MyISAM no soporta transacciones.

Código completo

Este ejemplo realiza una transferencia de stock entre dos productos dentro de una transacción:

#!/usr/bin/perl
use strict;
use warnings;
use DBI;
 
my $dbh = DBI->connect(
    "DBI:mysql:database=tienda;host=localhost",
    "root", "tu_contraseña",
    { RaiseError => 1, AutoCommit => 1, mysql_enable_utf8mb4 => 1 }
);
 
eval {
    $dbh->begin_work();
 
    # Verificar stock del origen
    my ($stock_origen) = $dbh->selectrow_array(
        "SELECT stock FROM productos WHERE id = ? FOR UPDATE",
        undef, 1
    );
 
    die "Stock insuficiente" if $stock_origen < 10;
 
    # Descontar del origen
    $dbh->do("UPDATE productos SET stock = stock - ? WHERE id = ?", undef, 10, 1);
 
    # Agregar al destino
    $dbh->do("UPDATE productos SET stock = stock + ? WHERE id = ?", undef, 10, 2);
 
    $dbh->commit();
    print "Transferencia completada: 10 unidades del producto 1 al producto 2\n";
};
 
if ($@) {
    eval { $dbh->rollback() };
    print "Error en la transacción: $@\n";
}
 
$dbh->disconnect();

Salida esperada:

Transferencia completada: 10 unidades del producto 1 al producto 2

Explicación paso a paso

Una transacción en DBI se inicia con begin_work(), que desactiva temporalmente el AutoCommit. A partir de ese momento, ningún cambio se hace permanente hasta que llamas a commit(). Si ocurre un error, rollback() deshace todos los cambios desde el último begin_work().

El patrón recomendado es envolver la transacción en un bloque eval para capturar cualquier error y hacer rollback automáticamente:

eval {
    $dbh->begin_work();
 
    # Operaciones SQL aquí...
 
    $dbh->commit();
};
 
if ($@) {
    eval { $dbh->rollback() };
    warn "Transacción fallida: $@";
}

El segundo eval alrededor de rollback() es importante porque el rollback también podría fallar si la conexión se perdió. Sin él, podrías obtener una excepción no capturada.

AutoCommit desactivado

Otra forma de trabajar con transacciones es desactivar AutoCommit al crear la conexión:

my $dbh = DBI->connect(
    "DBI:mysql:database=tienda;host=localhost",
    "root", "tu_contraseña",
    {
        RaiseError => 1,
        AutoCommit => 0,    # Cada operación requiere commit explícito
        mysql_enable_utf8mb4 => 1
    }
);
 
# Cada operación es parte de una transacción implícita
$dbh->do("UPDATE productos SET precio = precio * 1.10 WHERE categoria = ?", undef, 'Periféricos');
$dbh->do("UPDATE productos SET precio = precio * 1.05 WHERE categoria = ?", undef, 'Accesorios');
 
# Confirmar cambios
$dbh->commit();
# O deshacer: $dbh->rollback();

Con AutoCommit => 0, cada operación es parte de una transacción implícita. Debes llamar a commit() explícitamente para hacer permanentes los cambios, o rollback() para deshacerlos.

Transacciones anidadas con savepoints

MySQL soporta savepoints, que permiten crear puntos de restauración dentro de una transacción:

eval {
    $dbh->begin_work();
 
    $dbh->do("INSERT INTO pedidos (cliente_id, estado) VALUES (?, ?)", undef, 1, 'pendiente');
    my $pedido_id = $dbh->{mysql_insertid};
 
    # Crear savepoint antes de los detalles
    $dbh->do("SAVEPOINT antes_detalles");
 
    eval {
        $dbh->do(
            "INSERT INTO detalle_pedido (pedido_id, producto_id, cantidad) VALUES (?, ?, ?)",
            undef, $pedido_id, 1, 2
        );
        $dbh->do(
            "INSERT INTO detalle_pedido (pedido_id, producto_id, cantidad) VALUES (?, ?, ?)",
            undef, $pedido_id, 999, 1  # Producto inexistente
        );
    };
 
    if ($@) {
        # Revertir solo los detalles, mantener el pedido
        $dbh->do("ROLLBACK TO SAVEPOINT antes_detalles");
        print "Error en detalles, pedido creado sin líneas: $@\n";
    }
 
    $dbh->commit();
};
 
if ($@) {
    eval { $dbh->rollback() };
    print "Error completo: $@\n";
}

Caso práctico

Veamos un ejemplo realista de procesamiento de pedido completo con transacción:

#!/usr/bin/perl
use strict;
use warnings;
use DBI;
 
my $dbh = DBI->connect(
    "DBI:mysql:database=tienda;host=localhost",
    "root", "tu_contraseña",
    { RaiseError => 1, AutoCommit => 1, mysql_enable_utf8mb4 => 1 }
);
 
sub procesar_pedido {
    my ($dbh, $cliente_id, $items) = @_;
 
    eval {
        $dbh->begin_work();
 
        # Calcular total del pedido
        my $total = 0;
        for my $item (@$items) {
            $total += $item->{cantidad} * $item->{precio_unitario};
        }
 
        # Crear el pedido
        $dbh->do(
            "INSERT INTO pedidos (cliente_id, fecha, estado, total) VALUES (?, NOW(), ?, ?)",
            undef, $cliente_id, 'pendiente', $total
        );
        my $pedido_id = $dbh->{mysql_insertid};
 
        # Preparar sentencias reutilizables
        my $sth_detalle = $dbh->prepare(
            "INSERT INTO detalle_pedido (pedido_id, producto_id, cantidad, precio_unitario) VALUES (?, ?, ?, ?)"
        );
        my $sth_stock = $dbh->prepare(
            "UPDATE productos SET stock = stock - ? WHERE id = ? AND stock >= ?"
        );
 
        # Procesar cada línea
        for my $item (@$items) {
            # Insertar detalle
            $sth_detalle->execute(
                $pedido_id, $item->{producto_id},
                $item->{cantidad}, $item->{precio_unitario}
            );
 
            # Descontar stock
            $sth_stock->execute(
                $item->{cantidad}, $item->{producto_id}, $item->{cantidad}
            );
 
            if ($sth_stock->rows() == 0) {
                die "Stock insuficiente para producto $item->{producto_id}";
            }
        }
 
        $sth_detalle->finish();
        $sth_stock->finish();
 
        $dbh->commit();
 
        print "Pedido #$pedido_id procesado exitosamente\n";
        print "  Cliente: $cliente_id\n";
        print "  Items: ", scalar(@$items), "\n";
        printf "  Total: \$%.2f\n", $total;
 
        return $pedido_id;
    };
 
    if ($@) {
        my $error = $@;
        eval { $dbh->rollback() };
        print "Error al procesar pedido: $error\n";
        return undef;
    }
}
 
# Uso
procesar_pedido($dbh, 1, [
    { producto_id => 1, cantidad => 1, precio_unitario => 12999.99 },
    { producto_id => 2, cantidad => 2, precio_unitario => 1599.00 },
]);
 
$dbh->disconnect();

Salida esperada:

Pedido #1 procesado exitosamente
  Cliente: 1
  Items: 2
  Total: $16197.99

Reintentos automáticos para deadlocks

Los deadlocks pueden ocurrir cuando dos transacciones intentan bloquear los mismos registros en orden diferente. Una buena práctica es reintentar automáticamente:

sub ejecutar_con_reintentos {
    my ($dbh, $codigo, $max_intentos) = @_;
    $max_intentos ||= 3;
 
    for my $intento (1 .. $max_intentos) {
        eval {
            $dbh->begin_work();
            $codigo->($dbh);
            $dbh->commit();
        };
 
        if ($@) {
            eval { $dbh->rollback() };
 
            if ($@ =~ /Deadlock/ && $intento < $max_intentos) {
                print "Deadlock detectado, reintentando ($intento/$max_intentos)...\n";
                sleep(1);
                next;
            }
 
            die $@;  # Error no recuperable
        }
 
        return 1;  # Éxito
    }
 
    return 0;
}
 
# Uso
ejecutar_con_reintentos($dbh, sub {
    my ($dbh) = @_;
    $dbh->do("UPDATE productos SET stock = stock - 1 WHERE id = ?", undef, 1);
    $dbh->do("UPDATE productos SET stock = stock + 1 WHERE id = ?", undef, 2);
}, 3);

Manejo de errores

Los errores en transacciones requieren siempre un rollback antes de reportar el error:

sub transaccion_segura {
    my ($dbh, $operaciones) = @_;
 
    eval {
        $dbh->begin_work();
 
        for my $op (@$operaciones) {
            $dbh->do($op->{sql}, undef, @{$op->{params} || []});
        }
 
        $dbh->commit();
    };
 
    if ($@) {
        my $error = $@;
        eval { $dbh->rollback() };
 
        if ($error =~ /Deadlock/) {
            return { error => 'DEADLOCK', mensaje => 'Interbloqueo detectado. Intenta de nuevo' };
        } elsif ($error =~ /Lock wait timeout/) {
            return { error => 'TIMEOUT', mensaje => 'Registro bloqueado por otra operación' };
        } elsif ($error =~ /foreign key constraint/) {
            return { error => 'REFERENCIA', mensaje => 'Violación de clave foránea' };
        } else {
            return { error => 'INTERNO', mensaje => "Error: $error" };
        }
    }
 
    return { exito => 1 };
}

En la siguiente sección veremos cómo conectar MySQL con Java usando JDBC, una de las combinaciones más utilizadas en aplicaciones empresariales.

Escrito por Eduardo Lázaro