laravelconcurrenciabloqueosmodelos

Laravel Lock: Sincronización de Modelos con Bloqueos Distribuidos

Laravel Lock: Sincronización de Modelos con Bloqueos Distribuidos

Las condiciones de carrera son uno de los problemas más insidiosos en aplicaciones web modernas. Cuando múltiples procesos acceden simultáneamente a los mismos datos, pueden ocurrir inconsistencias difíciles de detectar. Laravel Lock es una solución elegante que proporciona bloqueos distribuidos integrados directamente en tus modelos Eloquent y rutas.

¿Qué es Laravel Lock?

Laravel Lock es un paquete que permite crear bloqueos distribuidos con alcance a modelos Eloquent específicos o rutas. A diferencia de los bloqueos simples en base de datos, Laravel Lock ofrece:

  • Bloqueos con alcance: Asociados directamente a modelos o rutas
  • Almacenamiento flexible: Compatible con caché o base de datos
  • API fluida: Sintaxis clara y legible
  • Middleware integrado: Protege rutas automáticamente
  • Prevención de deadlocks: Timeouts y configuración avanzada

Instalación y Configuración

Primero, instala el paquete via Composer:

composer require spatie/laravel-lock

Publica la configuración:

php artisan vendor:publish --provider="Spatie\LaravelLock\LaravelLockServiceProvider"

Esto crea el archivo config/lock.php donde puedes definir el almacenamiento de bloqueos:

return [
    'driver' => env('LOCK_DRIVER', 'cache'),
    
    'cache' => [
        'store' => 'default',
    ],
    
    'database' => [
        'connection' => 'default',
        'table' => 'locks',
    ],
];

Bloqueos en Modelos Eloquent

El caso de uso más común es proteger operaciones críticas en tus modelos. Imagina una tienda online donde necesitas decrementar inventario de forma segura:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Spatie\LaravelLock\Concerns\HasLocks;

class Product extends Model
{
    use HasLocks;
    
    protected $fillable = ['name', 'stock', 'price'];
}

Ahora puedes usar el bloqueo en tus operaciones:

$product = Product::find(1);

$product->lock('stock-update')->block(function () {
    if ($product->stock >= 1) {
        $product->decrement('stock');
        return true;
    }
    
    return false;
});

El método block() espera indefinidamente a obtener el bloqueo. Si prefieres un timeout:

$result = $product->lock('inventory')->get(timeout: 5, callback: function () {
    $product->decrement('stock');
    return ['success' => true];
});

if ($result === null) {
    // El bloqueo expiró o no se pudo obtener
    Log::warning("No se pudo adquirir bloqueo para inventario");
}

Proteger Rutas con Middleware

Puedes proteger rutas completas automáticamente:

use Spatie\LaravelLock\Middleware\EnsureLocked;

Route::middleware([EnsureLocked::class])
    ->post('/checkout', CheckoutController::class);

El middleware genera automáticamente una clave de bloqueo basada en la ruta y el usuario:

protected function lockKey(): string
{
    return "checkout-{$this->request->user()->id}";
}

Esto previene que un usuario intente completar dos pagos simultáneamente.

Bloqueos Personalizados con Claves Dinámicas

Para casos más complejos, personaliza la generación de claves:

public function transferirFondos(Account $to, float $amount)
{
    // Crea una clave que agrupa ambas cuentas
    $lockKey = "transfer-" . implode('-', [
        min($this->id, $to->id),
        max($this->id, $to->id),
    ]);
    
    return auth()->user()->lock($lockKey)->block(function () use ($to, $amount) {
        if ($this->balance < $amount) {
            throw new InsufficientFundsException();
        }
        
        $this->decrement('balance', $amount);
        $to->increment('balance', $amount);
        
        Transaction::create([
            'from_account_id' => $this->id,
            'to_account_id' => $to->id,
            'amount' => $amount,
        ]);
    });
}

Esta clave asegura que las transferencias bidireccionales entre dos cuentas nunca causan deadlock.

Almacenamiento en Base de Datos vs Caché

Los bloqueos en caché (Redis) son más rápidos pero se pierden si el caché reinicia. Los bloqueos en base de datos son persistentes pero más lentos.

Para usar almacenamiento en base de datos, primero crea la tabla:

php artisan migrate --path=database/migrations/laravel-lock

Luego cambia el driver en .env:

LOCK_DRIVER=database

En operaciones críticas de dinero, usa base de datos:

Config::set('lock.driver', 'database');

$this->lock('payment-processing')->block(function () {
    // Procesar pago de forma segura
});

Evitar Deadlocks

Los deadlocks ocurren cuando dos procesos esperan mutuamente. Laravel Lock los previene:

// ❌ Riesgo de deadlock
$account1->lock('transfer')->block(function () {
    $account2->lock('transfer')->block(function () {
        // ...
    });
});

// ✅ Seguro contra deadlock
$lockKey = "transfer-" . implode('-', [
    min($account1->id, $account2->id),
    max($account1->id, $account2->id),
]);

$account1->lock($lockKey)->block(function () {
    // Una única clave para ambas cuentas
});

Casos de Uso Reales

1. Procesamiento de Pagos

class ProcessPaymentAction
{
    public function execute(Order $order, PaymentMethod $method): bool
    {
        return $order->lock('payment-processing')->get(
            timeout: 10,
            callback: function () use ($order, $method) {
                if ($order->status !== 'pending') {
                    return false;
                }
                
                try {
                    $charge = $method->charge($order->total);
                    $order->update(['status' => 'completed']);
                    
                    return true;
                } catch (PaymentException $e) {
                    Log::error("Payment failed: " . $e->getMessage());
                    return false;
                }
            }
        ) ?? false;
    }
}

2. Reservas de Recursos

class BookingController
{
    public function store(BookingRequest $request)
    {
        $slot = TimeSlot::find($request->slot_id);
        
        $booked = $slot->lock('availability')->block(function () use ($slot, $request) {
            if ($slot->is_available) {
                $slot->markAsBooked();
                
                return Booking::create([
                    'user_id' => auth()->id(),
                    'slot_id' => $slot->id,
                ]);
            }
            
            return null;
        });
        
        if (!$booked) {
            return back()->with('error', 'Esta disponibilidad ya fue reservada');
        }
        
        return redirect()->route('bookings.show', $booked);
    }
}

3. Sincronización de Datos Externos

class SyncExternalDataJob
{
    public function handle(Partner $partner)
    {
        // Solo un job sincroniza datos de este partner a la vez
        $partner->lock('external-sync')->block(function () use ($partner) {
            $remoteData = $this->fetchFromExternal($partner);
            
            foreach ($remoteData as $item) {
                $partner->products()->updateOrCreate(
                    ['external_id' => $item['id']],
                    ['name' => $item['name'], 'stock' => $item['stock']]
                );
            }
            
            $partner->update(['last_sync_at' => now()]);
        });
    }
}

Monitoreo y Debugging

Inspect bloqueos activos en Tinker:

php artisan tinker

>>> Spatie\LaravelLock\Facades\Lock::getLocks()
=> Collection of active locks

>>> $product->lock('debug')->peek()
=> null or lock_id

Para debugging avanzado, habilita logging:

// En config/lock.php
'log' => env('LOG_LOCK_ACTIVITY', false),

Mejores Prácticas

1. Mantén los bloques cortos

// ❌ Malo - bloqueo demasiado largo
$user->lock('update')->block(function () {
    $data = $this->fetchFromExternalAPI(); // Lento
    $user->update($data);
});

// ✅ Bien - bloqueo solo para lo crítico
$data = $this->fetchFromExternalAPI();
$user->lock('update')->block(function () use ($data) {
    $user->update($data);
});

2. Usa nombres de bloqueos descriptivos

// ❌ Genérico
$user->lock('update')->block(...);

// ✅ Claro
$user->lock('profile-picture-upload')->block(...);
$user->lock('email-verification')->block(...);

3. Siempre define timeouts en operaciones no bloqueantes

$success = $order->lock('finalize')->get(timeout: 5, callback: function () {
    // ...
});

Conclusión

Laravel Lock resuelve un problema fundamental en aplicaciones distribuidas: la sincronización segura de datos. Con su API fluida, integración directa en modelos, y soporte para múltiples backends, es una herramienta esencial para cualquier desarrollador Laravel.

Ya sea procesando pagos, gestionando reservas o sincronizando datos, los bloqueos distribuidos garantizan que tus operaciones críticas sean seguras contra condiciones de carrera. Implementa Laravel Lock hoy y duerme tranquilo sabiendo que tus datos están protegidos.

Puntos clave

  • Laravel Lock proporciona bloqueos distribuidos con alcance a modelos y rutas
  • Usa lock('clave')->block() para bloqueos que esperan indefinidamente
  • Implementa lock('clave')->get(timeout: N) para operaciones no bloqueantes
  • Elige almacenamiento en caché para rendimiento o base de datos para persistencia
  • Prevén deadlocks usando claves ordenadas consistentemente
  • Mantén los bloques cortos para evitar contención de recursos
  • Usa nombres descriptivos para facilitar debugging y monitoreo
  • Perfecto para pagos, reservas, inventario y sincronización de datos externos