Saltar al contenido principal

Cómo manejé 15k req/min en campaña Laravel + Vue: Arquitectura real para picos de tráfico extremo

Autor
Ignacio AmatIgnacio Amat
Publicado
Lectura12 min
Sala de servidores con racks de hardware y luces azules, representando infraestructura de alto rendimiento

Las campañas de marca globales no avisan cuando van a explotar. Te dan una fecha de lanzamiento, un presupuesto de tráfico estimado y una expectativa tácita de que tu stack no se caiga. En este caso, la cifra real superó la estimación inicial por casi un orden de magnitud. Esto es exactamente lo que hicimos para que la aplicación siguiera respondiendo — con código real, decisiones documentadas y métricas post-evento.


El Contexto: Ventana de 48 Horas, Tráfico Impredecible

ParámetroEstimación InicialRealidad en Pico
Req/min~2.00015.000 (7.5×)
Duración48h continuas3 picos de 30 min cada 12h
Pagos/min~2001.200 (6×)
Dispositivos60% desktop65% móvil (3G/4G variable)

Stack original: Laravel 9 + Vue 2 (monolito frontend, bundle único 180 KB gzipped) + MySQL + pagos síncronos.

Síntomas en test de carga (5k req/min):

  • API: 502 Bad Gateway (PHP-FPM pool agotado)
  • Frontend: congelación en móviles gama media (bundle parse > 3s)
  • Pagos: timeouts proveedor → bloqueo workers → cascada de fallos

Lección clave: El problema nunca es un solo cuello de botella. Es una cascada: frontend pesado → más requests API → sin cache → pagos síncronos bloquean workers → todo colapsa.


1. Frontend: Vue 3 + Lazy Loading + Suspense = -47% Bundle

El Cambio

// main.ts — Entry point optimizado
import { createApp, Suspense } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'

// Shell crítico SOLO (header, footer, auth state)
const app = createApp({
  render: () => h(Suspense, {
    default: h(App),
    fallback: h('div', { class: 'app-shell-loading' }, 'Cargando...')
  })
})

app.use(createPinia())
app.use(router)
app.mount('#app')
// router/index.ts — Route-based code splitting
const routes = [
  {
    path: '/',
    name: 'campaign',
    component: () => import('@/views/CampaignView.vue'), // Chunk crítico
    meta: { preload: true }
  },
  {
    path: '/admin',
    name: 'admin',
    component: () => import('@/views/AdminView.vue'), // Lazy
    meta: { requiresAuth: true, roles: ['admin'] }
  },
  {
    path: '/analytics',
    name: 'analytics',
    component: defineAsyncComponent(() => import('@/views/AnalyticsView.vue')) // Lazy
  }
]

// Preload solo el chunk crítico en primera visita
router.beforeEach((to, from, next) => {
  if (to.meta.preload && !from.name) {
    import('@/views/CampaignView.vue') // Fuerza carga temprana
  }
  next()
})
<!-- CampaignView.vue — Shell + Suspense -->
<template>
  <div class="campaign-shell">
    <CampaignHeader /> <!-- Siempre en bundle inicial -->
    <main>
      <Suspense>
        <template #default>
          <router-view v-slot="{ Component }">
            <component :is="Component" />
          </router-view>
        </template>
        <template #fallback>
          <CampaignSkeleton /> <!-- Skeleton loading, no spinner -->
        </template>
      </Suspense>
    </main>
    <CampaignFooter />
  </div>
</template>

Resultados Medibles

MétricaAntesDespuésImpacto
Bundle inicial (gz)180 KB95 KB-47%
First Contentful Paint (3G)3.2s1.8s-44%
Time to Interactive (3G)5.1s2.9s-43%
Requests iniciales124-67%
Lighthouse Performance5892+34 pts

Por qué funciona: El usuario ve contenido útil en <2s. Los componentes admin/analytics (80 KB) nunca se descargan salvo que el usuario navegue allí. Suspense + skeleton evita layout shift.


2. Backend: Cacheo Selectivo con Redis + TTL Dinámico

El Problema

Cada request hacía 3 queries que cambiaban, pero no cada segundo:

  1. Config campaña (fase activa, fechas, reglas) → ~50ms
  2. Catálogo productos + stock → ~80ms
  3. Resultados tiempo real (leaderboard, contadores) → ~30ms

Solución: Cacheo por Capas con TTL Semántico

// app/Services/Cache/CampaignCacheService.php
class CampaignCacheService
{
    public function __construct(
        private Redis $redis,
        private CampaignRepository $campaigns,
        private ProductRepository $products
    ) {}

    /** Config: cambia solo al activar fase → TTL 5 min */
    public function getConfig(string $campaignId): CampaignConfig
    {
        return $this->redis->remember(
            "campaign:config:{$campaignId}",
            300, // 5 min = window de activación fase
            fn() => $this->campaigns->getConfig($campaignId)
        );
    }

    /** Catálogo: stock actualiza vía cola → TTL 2 min */
    public function getCatalog(string $campaignId): Collection
    {
        return $this->redis->remember(
            "campaign:catalog:{$campaignId}",
            120, // 2 min = sync stock desde cola
            fn() => $this->products->getActiveForCampaign($campaignId)
        );
    }

    /** Resultados: tiempo real = SIN cache */
    public function getLiveResults(string $campaignId): LiveResults
    {
        // Explícitamente NO cacheado — middleware skip_cache
        return $this->campaigns->getLiveResults($campaignId);
    }
}
// app/Http/Middleware/SkipCacheForLiveEndpoints.php
class SkipCacheForLiveEndpoints
{
    private const LIVE_ROUTES = [
        'campaign.live-results',
        'campaign.leaderboard',
        'campaign.stock-check'
    ];

    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        if ($request->routeIs(...self::LIVE_ROUTES)) {
            $response->headers->set('X-Cache', 'SKIP');
            $response->headers->set('Cache-Control', 'no-store, private');
        }

        return $response;
    }
}

Invalidación Inteligente (No “Cachear Todo”)

// app/Jobs/UpdateCampaignStock.php — Se ejecuta tras cada pago confirmado
class UpdateCampaignStock implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public string $campaignId,
        public int $productId,
        public int $quantitySold
    ) {}

    public function handle(CampaignCacheService $cache): void
    {
        // 1. Actualiza BD (stock real)
        Product::where('id', $this->productId)
            ->decrement('stock', $this->quantitySold);

        // 2. Invalida SOLO catálogo (config no cambia, live no cachea)
        $cache->invalidateCatalog($this->campaignId);

        // 3. Opcional: precalienta catálogo si TTL < 30s
        if ($cache->getCatalogTtl($this->campaignId) < 30) {
            $cache->warmCatalog($this->campaignId);
        }
    }
}

Resultados Medibles

EndpointAntes (p95)Después (p95)Cache Hit Rate
GET /api/campaign/config52 ms4 ms99.2%
GET /api/campaign/catalog84 ms6 ms94.8%
GET /api/campaign/live-results31 ms28 msN/A (skip)
API p95 global180 ms120 ms

Regla de oro: Cachear todo = inconsistencias. No cachear = carga innecesaria. Documenta cada decisión en el código (como skip_cache middleware) para que el siguiente dev entienda por qué un endpoint tiene 120s TTL y otro cero.


3. Pagos: Colas Laravel + Redis = Cero Bloqueo

El Riesgo

Proveedor pagos: latencia variable (300ms–3s), límite concurrencia 50 req/s.
Síncrono: 1.200 pagos/min en pico → 20 workers bloqueados 3s → pool agotado en 8s.

Arquitectura Async

// app/Http/Controllers/PaymentController.php
class PaymentController extends Controller
{
    public function initiate(InitiatePaymentRequest $request): JsonResponse
    {
        $user = $request->user();
        $campaign = $request->campaign();

        // 1. Crea registro "pendiente" en BD (idempotency key)
        $payment = Payment::create([
            'user_id' => $user->id,
            'campaign_id' => $campaign->id,
            'amount' => $campaign->price,
            'currency' => 'EUR',
            'status' => 'pending',
            'idempotency_key' => $request->header('Idempotency-Key') ?? Str::uuid(),
            'metadata' => $request->validated('metadata', []),
        ]);

        // 2. Dispara job ASYNC — request responde EN <200ms
        ProcessPaymentJob::dispatch($payment)
            ->onQueue('payments') // Worker dedicado
            ->delay(now()->addSeconds(2)); // Pequeño buffer

        // 3. Respuesta inmediata al frontend
        return response()->json([
            'payment_id' => $payment->id,
            'status' => 'processing',
            'poll_url' => route('payments.status', $payment),
            'estimated_seconds' => 5
        ], 202);
    }

    public function status(Payment $payment): JsonResponse
    {
        $this->authorize('view', $payment);
        return response()->json([
            'status' => $payment->status,
            'transaction_id' => $payment->transaction_id,
            'receipt_url' => $payment->receipt_url,
        ]);
    }
}
// app/Jobs/ProcessPaymentJob.php
class ProcessPaymentJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 3;
    public $backoff = [10, 30, 60]; // Exponential backoff
    public $timeout = 60;

    public function __construct(public Payment $payment) {}

    public function handle(PaymentGateway $gateway, PaymentService $service): void
    {
        // Idempotencia: si ya procesado, no reintentar
        if ($this->payment->status !== 'pending') {
            return;
        }

        try {
            $this->payment->update(['status' => 'processing']);

            // Llamada proveedor (puede tardar 3s)
            $result = $gateway->charge([
                'amount' => $this->payment->amount,
                'currency' => $this->payment->currency,
                'idempotency_key' => $this->payment->idempotency_key,
                'customer_email' => $this->payment->user->email,
                'metadata' => $this->payment->metadata,
            ]);

            // Éxito: actualiza + dispara eventos
            $this->payment->update([
                'status' => 'completed',
                'transaction_id' => $result->id,
                'gateway_response' => $result->toArray(),
                'completed_at' => now(),
            ]);

            PaymentCompleted::dispatch($this->payment); // Evento dominio

        } catch (GatewayException $e) {
            // Reintentos automáticos via backoff
            if ($this->attempts() >= $this->tries) {
                $this->payment->update([
                    'status' => 'failed',
                    'failure_reason' => $e->getMessage(),
                    'failed_at' => now(),
                ]);
                PaymentFailed::dispatch($this->payment);
            }
            throw $e; // Re-lanza para reintento cola
        }
    }
}
// config/horizon.php — Worker dedicado pagos
'queues' => [
    'payments' => ['balance' => 3, 'timeout' => 120, 'tries' => 3],
    'notifications' => ['balance' => 1, 'timeout' => 60],
    'integrations' => ['balance' => 1, 'timeout' => 180],
    'default' => ['balance' => 1, 'timeout' => 60],
],

'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['payments', 'critical'],
            'balance' => 'simple',
            'processes' => 10, // 10 workers SOLO pagos
            'tries' => 3,
        ],
    ],
],

Frontend: Polling Inteligente (No WebSockets Innecesarios)

// composables/usePaymentStatus.ts
export function usePaymentStatus(paymentId: string) {
  const status = ref<'pending' | 'processing' | 'completed' | 'failed'>('pending')
  const error = ref<string | null>(null)

  const poll = async () => {
    try {
      const res = await $fetch(`/api/payments/${paymentId}/status`)
      status.value = res.status
      if (['completed', 'failed'].includes(res.status)) {
        stopPolling()
      }
    } catch (e) {
      error.value = 'Error consultando estado'
      stopPolling()
    }
  }

  // Polling con backoff: 1s → 2s → 3s → 5s (máx 30s)
  let interval = 1000
  const timer = setInterval(async () => {
    await poll()
    interval = Math.min(interval * 1.5, 5000)
  }, interval)

  const stopPolling = () => clearInterval(timer)

  onUnmounted(stopPolling)
  return { status, error, stopPolling }
}

Resultados Pagos

MétricaAntes (Sync)Después (Async)
API p95 en pico2.8s (timeouts)180 ms
Workers PHP-FPM100% saturados<15% uso
Pagos fallidos por timeout12%0.3%
Experiencia usuarioCongelación 3-10sFeedback inmediato + polling

4. Deploy Blue/Green: Cero Downtime en Ventana Crítica

Setup Laravel Forge + Script

# deploy.sh — Ejecutado en Forge post-deploy
#!/bin/bash
set -euo pipefail

SITE="campaign.example.com"
NEW_VERSION=$(git rev-parse --short HEAD)
BLUE_DIR="/home/forge/${SITE}-blue"
GREEN_DIR="/home/forge/${SITE}-green"
CURRENT=$(readlink -f "/home/forge/${SITE}")
TARGET=$([ "$CURRENT" = "$BLUE_DIR" ] && echo "$GREEN_DIR" || echo "$BLUE_DIR")

echo "🚀 Deploy $NEW_VERSION$TARGET (current: $CURRENT)"

# 1. Clona/actualiza entorno target
if [ ! -d "$TARGET" ]; then
  git clone git@github.com:org/repo.git "$TARGET"
fi
cd "$TARGET"
git fetch origin
git checkout main
git pull origin main

# 2. Instala deps + build (paralelo)
composer install --no-dev --prefer-dist --optimize-autoloader &
npm ci &
npm run build &
wait

# 3. Migraciones + tests en entorno aislado
php artisan migrate --force --isolated
php artisan test --parallel --stop-on-failure

# 3b. Health check interno
curl -sf "http://localhost:8000/health" || { echo "❌ Health check failed"; exit 1; }

# 4. Switch atómico (nginx reload < 2s)
sudo sed -i "s|root $CURRENT|root $TARGET|" /etc/nginx/sites-available/$SITE
sudo nginx -t && sudo systemctl reload nginx

# 5. Verificación post-switch
sleep 3
curl -sf "https://$SITE/health" || { echo "❌ Post-switch failed, rolling back"; rollback; exit 1; }

echo "✅ Deploy $NEW_VERSION live en $TARGET"
// routes/web.php — Health check endpoint
Route::get('/health', function () {
    return response()->json([
        'status' => 'ok',
        'version' => config('app.version', 'unknown'),
        'git_sha' => trim(shell_exec('git rev-parse --short HEAD 2>/dev/null') ?? ''),
        'deployed_at' => now()->toISOString(),
        'checks' => [
            'database' => DB::connection()->getPdo() ? 'ok' : 'fail',
            'redis' => Redis::connection()->ping() === 'PONG' ? 'ok' : 'fail',
            'queue' => Horizon::isRunning() ? 'ok' : 'degraded',
        ],
    ])->header('Cache-Control', 'no-store');
})->middleware('throttle:60,1');

Durante la Campaña: 2 Hotfixes, 0 Incidentes

DeployMotivoTiempo SwitchRollback TiempoIncidencia Usuario
v2.1.3Fix typo copy hero1.8sN/A0
v2.1.4Ajuste TTL cache stock2.1sN/A0

Clave: Tests + migraciones + health check en entorno paralelo antes del switch. Si algo falla, el tráfico sigue en versión anterior.


Números Finales: Lo Que Importa al Negocio

KPIObjetivoResultadoImpacto Negocio
Uptime campaña99.5%99.97%0 ventas perdidas
API p95 (pico)<500ms120msUX fluida, 0 quejas
Bundle inicial<200KB95 KB+34% conversión móvil
Pagos fallidos<2%0.3%€42k recuperados
Incidencias críticas00Equipo producto tranquilo
Reutilización stack3 campañas másROI arquitectura ×4

Conclusión: La Dificultad No Es La Técnica, Es El Juicio

Manejar tráfico alto no es añadir servidores. Es entender dónde está el cuello de botella real:

Cuello de BotellaSíntomaSolución Aplicada
Frontend pesadoParse bundle >3s móvilVue 3 lazy loading + Suspense
Queries repetidas150ms/request en catálogoRedis TTL semántico (2-5 min)
Pagos síncronosWorkers bloqueados 3sColas Laravel + worker dedicado
Deploy riesgo30s downtime inaceptableBlue/Green + health checks

Ninguna técnica es exótica. Lo difícil es saber cuándo aplicar cada una, medir antes/después, y documentar para que el siguiente dev no rompa lo que funciona.


¿Tu Equipo Prepara Una Campaña Con Tráfico Inseguro?

Si usas Laravel y/o Vue y necesitas arquitectura que aguante picos sin despertarte a las 3 AM, puedo ayudarte:

  • Auditoría de arquitectura (2 días): Identifico cuellos de botella, propongo fixes priorizados
  • Implementación hands-on (1-2 semanas): Cacheo, colas, frontend, deploy zero-downtime
  • Load testing realista: Simulo tu patrón de tráfico, valido límites, documento runbooks

Modalidades:

  • EOR (Deel, Remote, Oyster) — rol core indefinido
  • Freelance B2B (Autónomo, factura intracomunitaria 0% IVA) — proyectos 3-12 meses
  • Indefinido directo — si tenéis entidad en España

Stack actual: Laravel 11, PHP 8.3+, Vue 3 + TS, Inertia.js, Livewire 3, Astro, Docker, GitHub Actions, Laravel Pulse, Sentry, Claude Code / Cursor / MCP servers.

Ver mi perfil, stack completo y condiciones →


Artículos Relacionados en Este Blog

Artículos relacionados

Revisa mi perfil como desarrollador

Si este artículo encaja con los retos técnicos de tu equipo, revisa mi stack o mi disponibilidad profesional.

Cuéntame qué necesitas

Puedes escribirme por un rol, contrato, colaboración técnica, una duda o una consulta general. Con 2-3 líneas de contexto suelo poder responder en menos de 24 horas hábiles.

0/500
Disponibilidad