Saltar al contenido

El Problema de la Concurrencia en E-commerce

Si administras una tienda en línea con Laravel, probablemente hayas enfrentado (o temas enfrentar) uno de los problemas más comunes en el e-commerce: la sobreventa de productos debido a accesos concurrentes.

Imagina este escenario: tienes un producto muy popular con solo 5 unidades en stock. Durante una campaña promocional, 10 clientes intentan comprar este producto exactamente al mismo tiempo. Sin los mecanismos adecuados de control de concurrencia, todos podrían completar sus compras, resultando en 5 clientes decepcionados cuando descubras que has vendido más de lo que tienes disponible.

Este es un problema real que puede afectar seriamente la reputación de tu negocio y la experiencia del cliente. Afortunadamente, Laravel ofrece herramientas poderosas para manejar estas situaciones.

Modelando Nuestro Sistema

Para ilustrar la solución, modelaremos un sistema básico de inventario con Laravel. Tendremos dos modelos principales:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    protected $fillable = [
        'name',
        'description',
        'price',
        'stock',
    ];
    
    public function casts(): array
    {
        return [
            'price' => 'float',
            'stock' => 'integer',
        ];
    }
}

class Order extends Model
{
    protected $fillable = [
        'product_id',
        'quantity',
        'total_price',
        'status',
    ];
    
    public function casts(): array
    {
        return [
            'quantity' => 'integer',
            'total_price' => 'float',
        ];
    }
    
    public function product()
    {
        return $this->belongsTo(Product::class);
    }
}

Y crearemos un servicio para manejar las órdenes:

<?php

namespace App\Services;

use App\Models\Product;
use App\Models\Order;

class OrderService
{
    public function createOrder(Product $product, int $quantity)
    {
        if ($product->stock < $quantity) {
            return false; // No hay suficiente stock
        }
        
        // Reducir el stock
        $product->update(['stock' => $product->stock - $quantity]);
        
        // Crear la orden
        Order::create([
            'product_id' => $product->id,
            'quantity' => $quantity,
            'total_price' => $product->price * $quantity,
            'status' => 'pending',
        ]);
        
        return true;
    }
}

El Problema de Concurrencia

A simple vista, nuestro servicio parece funcionar correctamente, pero no es así:

<?php

// PRIMERA SOLICITUD
$product = Product::find(1); // 'stock' => 5
$orderService = new OrderService();
$orderService->createOrder($product, 3); // true, stock = 2

// SEGUNDA SOLICITUD (simultánea)
$product = Product::find(1); // 'stock' => 5 (valor leído antes de que se actualizara)
$orderService = new OrderService();
$orderService->createOrder($product, 4); // true, pero debería ser false

Ambas solicitudes leen que hay 5 unidades disponibles, y ambas proceden con sus órdenes, resultando en un stock negativo y productos vendidos que no existen.

Solución 1: Transacciones de Base de Datos

El primer paso para solucionar este problema es envolver nuestras operaciones en transacciones de base de datos:

<?php

namespace App\Services;

use App\Models\Product;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class OrderService
{
    public function createOrder(Product $product, int $quantity)
    {
        if ($product->stock < $quantity) {
            return false; // No hay suficiente stock
        }
        
        return DB::transaction(function () use ($product, $quantity) {
            // Reducir el stock
            $product->update(['stock' => $product->stock - $quantity]);
            
            // Crear la orden
            Order::create([
                'product_id' => $product->id,
                'quantity' => $quantity,
                'total_price' => $product->price * $quantity,
                'status' => 'pending',
            ]);
            
            return true;
        });
    }
}

Esto garantiza que si alguna de las operaciones falla, se revertirán todas las operaciones anteriores. Sin embargo, todavía no resuelve nuestro problema de concurrencia.

Solución 2: Bloqueos Pesimistas

Aquí es donde entra en juego lockForUpdate(). Este método nos permite bloquear la fila que estamos consultando para que ninguna otra operación pueda leerla o modificarla hasta que completemos nuestra transacción:

<?php

namespace App\Services;

use App\Models\Product;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class OrderService
{
    public function createOrder(int $productId, int $quantity)
    {
        return DB::transaction(function () use ($productId, $quantity) {
            // Obtener el producto con bloqueo
            $product = Product::lockForUpdate()->find($productId);
            
            if (!$product || $product->stock < $quantity) {
                return false; // No hay suficiente stock
            }
            
            // Reducir el stock
            $product->update(['stock' => $product->stock - $quantity]);
            
            // Crear la orden
            Order::create([
                'product_id' => $product->id,
                'quantity' => $quantity,
                'total_price' => $product->price * $quantity,
                'status' => 'pending',
            ]);
            
            return true;
        });
    }
}

Con esta implementación, lo que sucederá en caso de solicitudes concurrentes es:

<?php

// PRIMERA SOLICITUD
DB::transaction(function () {
    $product = Product::lockForUpdate()->find(1); // 'stock' => 5
    // El producto se bloquea para otras transacciones
    $orderService = new OrderService();
    $orderService->createOrder($product, 3); // true, stock = 2
});

// SEGUNDA SOLICITUD (simultánea)
DB::transaction(function () {
    // Esta consulta esperará hasta que la primera transacción se complete
    $product = Product::lockForUpdate()->find(1); // 'stock' => 2
    $orderService = new OrderService();
    $orderService->createOrder($product, 4); // false, no hay suficiente stock
});

¿Qué Hace Exactamente lockForUpdate()?

Según la documentación de Laravel:

Un bloqueo "para actualización" evita que los registros seleccionados se modifiquen o se seleccionen con otro bloqueo hasta que se confirme la transacción.

Esto significa que cuando usamos lockForUpdate(), cualquier otra consulta que intente seleccionar el mismo registro con lockForUpdate() o sharedLock() deberá esperar hasta que nuestra transacción se complete.

¿Y Qué Hay de sharedLock()?

El método sharedLock() es ligeramente diferente:

Un bloqueo compartido evita que se modifiquen las filas seleccionadas hasta que se confirme la transacción.

A diferencia de lockForUpdate(), las consultas con sharedLock() pueden compartir el bloqueo con otras consultas que también usen sharedLock(). Esto permite múltiples lecturas simultáneas, pero impide cualquier modificación.

<?php

// Ejemplo de uso de sharedLock
DB::transaction(function () {
    // Esta consulta solo lee datos sin modificarlos
    $product = Product::sharedLock()->find(1);
    
    // Hacemos algo con el producto sin modificarlo
    $someCalculation = calculateSomething($product);
    
    return $someCalculation;
});

¿Cuándo Usar lockForUpdate() vs sharedLock()?

  • Usa lockForUpdate() cuando planeas modificar los registros que estás consultando y necesitas asegurarte de que no se modifiquen mientras realizas tus operaciones.

  • Usa sharedLock() cuando solo necesitas leer los registros y quieres asegurarte de que no se modifiquen mientras los lees, pero no te importa que otros procesos también los lean.

Caso Práctico Extendido: Reserva Temporal de Inventario

En sistemas de e-commerce más sofisticados, a menudo se implementa un sistema de "reserva temporal" para mantener productos apartados durante un tiempo mientras el cliente completa su pago.

Podemos extender nuestro ejemplo para manejar este caso:

<?php

namespace App\Services;

use App\Models\Product;
use App\Models\Order;
use App\Models\Reservation;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;

class InventoryService
{
    // Tiempo de reserva en minutos
    const RESERVATION_TIME = 15;
    
    public function reserveProduct(int $productId, int $quantity)
    {
        return DB::transaction(function () use ($productId, $quantity) {
            // Obtener el producto con bloqueo
            $product = Product::lockForUpdate()->find($productId);
            
            // Liberar reservas expiradas primero
            $this->releaseExpiredReservations($product);
            
            // Calcular stock disponible (stock - reservas activas)
            $availableStock = $product->stock - $product->reservations()
                ->where('expires_at', '>', now())
                ->sum('quantity');
            
            if ($availableStock < $quantity) {
                return false; // No hay suficiente stock disponible
            }
            
            // Crear reserva
            $reservation = Reservation::create([
                'product_id' => $product->id,
                'quantity' => $quantity,
                'expires_at' => Carbon::now()->addMinutes(self::RESERVATION_TIME),
            ]);
            
            return $reservation;
        });
    }
    
    public function confirmReservation(Reservation $reservation)
    {
        return DB::transaction(function () use ($reservation) {
            // Verificar que la reserva no haya expirado
            if ($reservation->expires_at < now()) {
                return false;
            }
            
            $product = Product::lockForUpdate()->find($reservation->product_id);
            
            // Actualizar stock
            $product->update(['stock' => $product->stock - $reservation->quantity]);
            
            // Eliminar la reserva
            $reservation->delete();
            
            // Crear la orden
            Order::create([
                'product_id' => $product->id,
                'quantity' => $reservation->quantity,
                'total_price' => $product->price * $reservation->quantity,
                'status' => 'completed',
            ]);
            
            return true;
        });
    }
    
    private function releaseExpiredReservations(Product $product)
    {
        // Eliminar reservas expiradas
        $product->reservations()
            ->where('expires_at', '<', now())
            ->delete();
    }
}

Esta implementación más compleja demuestra cómo lockForUpdate() sigue siendo crucial incluso en sistemas sofisticados.

Rendimiento e Impacto en la Base de Datos

Es importante señalar que los bloqueos tienen un impacto en el rendimiento de la base de datos. Al usar lockForUpdate(), estás esencialmente forzando a las transacciones a ejecutarse en serie para las filas bloqueadas, lo que puede afectar el rendimiento si tienes un alto volumen de transacciones.

Algunas recomendaciones:

  1. Mantén tus transacciones lo más cortas posible.

  2. Bloquea solo las tablas y filas que realmente necesitas.

  3. Considera implementar una solución de cola para manejar picos de tráfico.

  4. Para sistemas de muy alto rendimiento, considera soluciones complementarias como Redis para el manejo de inventario en tiempo real.

Conclusión

Los bloqueos en Laravel, específicamente lockForUpdate() y sharedLock(), son herramientas poderosas para manejar la concurrencia en aplicaciones web. En sistemas críticos como la gestión de inventario, son esenciales para mantener la integridad de los datos y evitar problemas como la sobreventa.

Si bien los ejemplos en este artículo se centran en un sistema de inventario, los mismos principios se aplican a cualquier sistema donde la concurrencia pueda ser un problema: sistemas de reservas, plataformas de subastas en línea, aplicaciones de venta de entradas, y muchos más.

Espero que este artículo te haya ayudado a entender mejor cómo implementar correctamente los bloqueos en Laravel para garantizar la integridad de tus datos.

Nota: Para una implementación más robusta en producción, considera también manejar timeouts de bloqueo y estrategias de reintento, así como utilizar colas para operaciones que puedan resultar en bloqueos prolongados.

school Curso completo

Curso Laravel 12
Completo 2026

El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.

access_time 8 horas de contenido
layers 4 tecnologías en 1
update 100% actualizado
code Proyectos prácticos
Ver Curso Laravel 12 arrow_forward

star Incluido en cualquier suscripción

Rutas de aprendizaje