laravelredisqueuesdevops

Redis Cluster en Laravel: Colas distribuidas sin dolor

Redis Cluster en Laravel: Colas distribuidas sin dolor

Si trabajas con aplicaciones Laravel en producción y tus colas comienzan a ser un cuello de botella, Redis Cluster es tu solución. Laravel 13.5 acaba de agregar soporte oficial para Redis Cluster en colas, eliminando las limitaciones de una instancia Redis simple.

En este artículo te mostraré cómo configurar, debuggear y optimizar colas distribuidas con Redis Cluster. Te ahorraré horas de investigación en la documentación oficial.

¿Por qué Redis Cluster y no una instancia simple?

Una instancia Redis simple tiene límites claros:

  • Throughput máximo: ~100k operaciones/segundo en hardware decente
  • Memoria limitada: Aunque tengas mucho RAM, todo está centralizado
  • Sin redundancia: Si cae Redis, tus colas se pierden
  • Escalabilidad horizontal: Imposible sin cambiar toda tu arquitectura

Redis Cluster resuelve todo esto:

┌─────────────────────────────────────────┐
│         Tu aplicación Laravel            │
├─────────────────────────────────────────┤
│   Queue, Cache, Sessions, etc.          │
├─────────────────────────────────────────┤
│  Redis Cluster (3-7 nodos mínimo)       │
├──────┬──────────────┬──────┬───────────┤
│Node1 │    Node2     │Node3 │   ...     │
│Shard │    Shard     │Shard │   Shard   │
└──────┴──────────────┴──────┴───────────┘

Con Cluster obtienes:

  • Particionamiento automático de datos
  • Failover automático si un nodo cae
  • Alta disponibilidad
  • Escalabilidad real

Configuración básica de Redis Cluster

Instalación de Redis Cluster

Primero, necesitas 3 nodos mínimo (recomendado 6: 3 maestros + 3 replicas).

Con Docker Compose (opción más rápida):

version: '3.8'

services:
  redis-node-1:
    image: redis:7-alpine
    command: redis-server --port 6379 --cluster-enabled yes --cluster-config-file nodes-1.conf
    ports:
      - "6379:6379"
    volumes:
      - redis-1-data:/data

  redis-node-2:
    image: redis:7-alpine
    command: redis-server --port 6380 --cluster-enabled yes --cluster-config-file nodes-2.conf
    ports:
      - "6380:6380"
    volumes:
      - redis-2-data:/data

  redis-node-3:
    image: redis:7-alpine
    command: redis-server --port 6381 --cluster-enabled yes --cluster-config-file nodes-3.conf
    ports:
      - "6381:6381"
    volumes:
      - redis-3-data:/data

volumes:
  redis-1-data:
  redis-2-data:
  redis-3-data:

Levanta los nodos:

docker-compose up -d

Crea el cluster:

docker-compose exec redis-node-1 redis-cli --cluster create \
  127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 \
  --cluster-replicas 0

Configuración en Laravel

Abre tu .env:

QUEUE_CONNECTION=redis
REDIS_CLUSTER=true

REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null

Luego configura config/database.php:

'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'),
    'options' => [
        'cluster' => env('REDIS_CLUSTER', false) ? 'cluster' : null,
    ],

    'cluster' => [
        [
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => env('REDIS_PORT', 6379),
        ],
        [
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => 6380,
        ],
        [
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => 6381,
        ],
    ],
],

También configura config/queue.php:

'redis' => [
    'driver' => 'redis',
    'connection' => 'default',
    'queue' => env('REDIS_QUEUE', 'default'),
    'retry_after' => 90,
    'block_for' => null,
    'migration' => 'migrations',
    'cluster' => env('REDIS_CLUSTER', false),
],

Creando colas para Cluster

Ahora que Cluster está configurado, crea un Job de ejemplo:

<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\Middleware\WithoutOverlapping;

class ProcessPayment implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public int $orderId,
        public float $amount
    ) {
        $this->onQueue('payments');
        $this->delay(5); // Espera 5 segundos
    }

    /**
     * Middlewares para controlar concurrencia
     */
    public function middleware(): array
    {
        return [
            new RateLimited('payments'),
            new WithoutOverlapping("payment-{$this->orderId}"),
        ];
    }

    public function handle(): void
    {
        logger()->info("Processing payment: {$this->orderId}");
        
        try {
            // Simula procesamiento
            sleep(2);
            logger()->info("Payment processed: {$this->orderId}");
        } catch (\Exception $e) {
            logger()->error("Payment failed: {$e->getMessage()}");
            throw $e;
        }
    }

    public function failed(\Throwable $exception): void
    {
        logger()->critical("Payment job permanently failed", [
            'order_id' => $this->orderId,
            'error' => $exception->getMessage(),
        ]);
    }
}

Despáchalo desde tu controlador:

<?php

namespace App\Http\Controllers;

use App\Jobs\ProcessPayment;

class OrderController extends Controller
{
    public function store(Request $request)
    {
        $order = Order::create($request->validated());

        // Despacha el job a la cola "payments"
        ProcessPayment::dispatch($order->id, $order->total)
            ->onQueue('payments');

        return response()->json(['id' => $order->id]);
    }
}

Monitoreo de colas con Cluster

Inspeccionar estado de colas

Laravel 13.4+ agregó métodos de inspección mejorados:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Queue;

class QueueStatus extends Command
{
    protected $signature = 'queue:status';

    public function handle()
    {
        $queue = Queue::connection('redis');

        // Total de jobs en cola
        $pending = $this->getQueueSize('default');
        $this->info("Pending jobs: {$pending}");

        // Jobs fallidos
        if (method_exists($queue, 'failed')) {
            $failed = $queue->getConnection()->llen('queues:failed');
            $this->info("Failed jobs: {$failed}");
        }

        return 0;
    }

    private function getQueueSize(string $queue): int
    {
        return \DB::connection('redis')
            ->llen("queues:{$queue}");
    }
}

Ejecútalo:

php artisan queue:status

Dashboard en tiempo real

Crea un controlador para un dashboard:

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Redis;

class QueueDashboard extends Controller
{
    public function index()
    {
        $redis = Redis::connection();
        
        $queues = ['default', 'payments', 'emails'];
        $stats = [];

        foreach ($queues as $queue) {
            $pending = $redis->llen("queues:{$queue}");
            $reserved = $redis->llen("queues:{$queue}:reserved");
            
            $stats[$queue] = [
                'pending' => $pending,
                'reserved' => $reserved,
                'total' => $pending + $reserved,
            ];
        }

        return view('queue-dashboard', ['stats' => $stats]);
    }
}

Vista Blade:

<div class="grid grid-cols-3 gap-4">
    @foreach ($stats as $queue => $data)
        <div class="bg-white p-4 rounded">
            <h3 class="font-bold">{{ $queue }}</h3>
            <p>Pending: <span class="text-blue-600">{{ $data['pending'] }}</span></p>
            <p>Reserved: <span class="text-orange-600">{{ $data['reserved'] }}</span></p>
            <p>Total: <span class="text-green-600">{{ $data['total'] }}</span></p>
        </div>
    @endforeach
</div>

Configuración avanzada para Cluster

Balanceo de colas

Distribuye diferentes tipos de jobs entre colas especializadas:

// config/queue.php
'queues' => [
    'default' => env('REDIS_QUEUE', 'default'),
    'payments' => 'payments',        // Alta prioridad
    'emails' => 'emails',            // Baja prioridad
    'reports' => 'reports',          // Procesamiento pesado
],

Despácha según prioridad:

// Job crítico
ProcessPayment::dispatch($order->id, $amount)
    ->onQueue('payments');

// Job normal
SendOrderEmail::dispatch($order->id)
    ->onQueue('emails')
    ->delay(now()->addMinute());

// Job pesado
GenerateReport::dispatch($year)
    ->onQueue('reports')
    ->delay(now()->addHours(2));

Ejecuta workers separados para cada cola:

# Terminal 1: Pagos (prioridad máxima)
php artisan queue:work redis --queue=payments --tries=3

# Terminal 2: Emails (prioridad media)
php artisan queue:work redis --queue=emails --tries=1

# Terminal 3: Reportes (prioridad baja)
php artisan queue:work redis --queue=reports --tries=5 --timeout=600

Configuración SSL para Cluster en producción

Si usas Cluster en producción, protege las conexiones:

// config/database.php
'redis' => [
    'client' => 'phpredis',
    'options' => [
        'cluster' => 'cluster',
        'ssl' => [
            'verify_peer' => true,
            'verify_peer_name' => true,
            'allow_self_signed' => false,
        ],
    ],
    'cluster' => [
        [
            'scheme' => 'rediss', // Nota: rediss para SSL
            'host' => 'redis-1.example.com',
            'port' => 6379,
            'password' => env('REDIS_PASSWORD'),
        ],
        // ... más nodos
    ],
],

Manejo de excepciones en Cluster

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;

class ClusterAwareJob implements ShouldQueue
{
    use InteractsWithQueue, Queueable, SerializesModels;

    public function handle()
    {
        try {
            // Tu lógica aquí
        } catch (\RedisException $e) {
            // Reintentar después
            logger()->warning("Redis connection lost: {$e->getMessage()}");
            $this->release(10); // Reintenta en 10 segundos
        }
    }

    /**
     * Máximo de intentos
     */
    public $tries = 5;

    /**
     * Tiempo antes de asumir que falló
     */
    public $timeout = 120;
}

Testing con Redis Cluster

<?php

namespace Tests\Feature;

use App\Jobs\ProcessPayment;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;

class QueueTest extends TestCase
{
    public function test_payment_job_dispatched_to_cluster()
    {
        Queue::fake();

        ProcessPayment::dispatch(123, 99.99);

        Queue::assertPushed(ProcessPayment::class, function ($job) {
            return $job->orderId === 123;
        });
    }

    public function test_cluster_handles_payment_processing()
    {
        // Test integración real con Cluster
        Queue::connection('redis')->push(
            new ProcessPayment(456, 199.99)
        );

        $this->artisan('queue:work', [
            '--max-jobs' => 1,
            '--max-time' => 60,
        ]);

        // Verifica que se procesó
        $this->assertTrue(true);
    }
}

Troubleshooting común

”Connection refused” en Cluster

Verifica que todos los nodos están activos:

redis-cli -p 6379 ping
redis-cli -p 6380 ping
redis-cli -p 6381 ping

Deberían responder PONG.

Jobs se duplican entre nodos

Asegúrate de que cada worker proceso usa una conexión separada:

php artisan queue:work redis --sleep=3 --max-jobs=100

Rendimiento degradado

Monitorea la latencia de red entre nodos:

redis-cli -p 6379 --latency

Si ves >10ms, investiga tu infraestructura de red.

Puntos clave

  • Redis Cluster distribuye datos automáticamente entre nodos usando hash slots
  • Failover automático: Si un nodo cae, los replicas toman su lugar sin intervención
  • Configuración en Laravel 13.5+ es simple: solo activa REDIS_CLUSTER=true
  • Múltiples colas (payments, emails, reports) permite priorizar jobs críticos
  • Monitoreo continuo es esencial: revisa pending jobs, reserved, y failed
  • SSL en producción protege tu conexión Redis contra ataques man-in-the-middle
  • Testing con Cluster requiere conexión real, no solo Queue::fake()
  • Escalabilidad horizontal: Agrega nodos sin parar tu aplicación