Ir al contenido principal
Laravel, shipping fast.
Capitulo 6 · Rendimiento y optimizacion

El N+1 con el que te toparas de verdad

Julian Beaujardin

Este es el que pilla a todo el mundo: una consulta que parece perfectamente razonable aislada se ejecuta una vez por fila de una colección en lugar de una vez para la colección entera. No tiene por qué ser una relación perezosa de Eloquent escondida tras un acceso a propiedad, aunque esa es la forma clásica. Cualquier consulta lanzada desde dentro de un bucle tiene la misma enfermedad.

ReconcileDomainsCommand recorre todos los dominios con renovación automática buscando aquellos cuya suscripción haya caducado. Comprueba esa suscripción de uno en uno:

// Bad: a query per iteration
// app/Console/Commands/ReconcileDomainsCommand.php
Domain::query()
    ->whereNotNull('registrar')
    ->where('auto_renew', true)
    ->cursor()
    ->each(function (Domain $domain) {
        if ($this->hasActiveMerchantSubscription($domain)) {
            return;
        }
        // ... flag the leak
    });

private function hasActiveMerchantSubscription(Domain $domain): bool
{
    return Subscription::query()
        ->where('stripe_id', $domain->stripe_subscription_id)
        ->whereNull('ends_at')
        ->exists();
}

cursor() evita que los dominios se carguen enteros en memoria, la decisión correcta en una tabla que puede tener miles de filas. Pero hasActiveMerchantSubscription() sigue lanzando una consulta nueva por cada dominio. Mil dominios con renovación automática significan mil búsquedas de suscripción: una consulta por fila, disfrazada de bucle eficiente en memoria.

// Good: one query per batch, not one per domain
Domain::query()
    ->whereNotNull('registrar')
    ->where('auto_renew', true)
    ->chunk(500, function ($domains) {
        $activeSubscriptionIds = Subscription::query()
            ->whereIn('stripe_id', $domains->pluck('stripe_subscription_id')->filter())
            ->whereNull('ends_at')
            ->pluck('stripe_id')
            ->flip();

        foreach ($domains as $domain) {
            if ($activeSubscriptionIds->has($domain->stripe_subscription_id)) {
                continue;
            }
            // ... flag the leak
        }
    });

chunk(500) cambia la memoria constante de cursor() por un lote acotado: 500 dominios a la vez en lugar de uno. A cambio, la comprobación de suscripciones colapsa a un único whereIn() por lote en lugar de una consulta por dominio. Menos consultas casi siempre te cuesta otra cosa; aquí es memoria en vez de un número ilimitado de conexiones. El arreglo no es gratis. Simplemente es mejor trato.

El mismo principio vale para una sola petición, no sólo para un barrido masivo. EditableAiController resuelve un token de edición y luego necesita el sitio de ese token, el servidor del sitio y el espacio de trabajo del servidor: tres saltos BelongsTo más. Cargados de forma perezosa son tres consultas más. El código real carga toda la cadena por adelantado:

// app/Http/Controllers/EditableAiController.php
$token = Token::query()
    ->with('site.server.workspace')
    ->where('id', $tokenString)
    ->first();

Una consulta en lugar de cuatro. No es un barrido masivo, es una sola petición, pero el principio es idéntico: conoce la cadena de relaciones que realmente necesitas y cárgala en una consulta en vez de dejar que cada salto dispare la suya.

El arreglo es obvio en cuanto alguien lo señala en una revisión. El problema es que nadie lo señala hasta que un job que terminaba en segundos tarda calladamente minutos. La regla: cualquier consulta —relación perezosa o llamada explícita— que corra una vez por fila de algo que estás recorriendo se agrupa o se carga por adelantado antes de hacer commit, no después de que alguien se dé cuenta.