¿Cómo optimizar la aplicación web PHP Laravel para un alto rendimiento?

Laravel es muchas cosas. Pero rápido no es uno de ellos. ¡Aprendamos algunos trucos del oficio para hacerlo más rápido!

Ningún desarrollador de PHP es tocado por Laravel estos días. Son desarrolladores junior o de nivel medio que aman el desarrollo rápido que ofrece Laravel, o son desarrolladores senior que se ven obligados a aprender Laravel debido a las presiones del mercado.

De cualquier manera, no se puede negar que Laravel ha revitalizado el ecosistema de PHP (yo, seguro, habría dejado el mundo de PHP hace mucho tiempo si Laravel no estuviera allí).

Un fragmento de autoelogio (algo justificado) de Laravel

Sin embargo, dado que Laravel hace todo lo posible para facilitarle las cosas, significa que, en el fondo, está haciendo toneladas y toneladas de trabajo para asegurarse de que tenga una vida cómoda como desarrollador. Todas las funciones «mágicas» de Laravel que parecen funcionar tienen capas y capas de código que deben activarse cada vez que se ejecuta una función. Incluso una excepción simple rastrea qué tan profundo es el agujero del conejo (observe dónde comienza el error, hasta el núcleo principal):

Por lo que parece ser un error de compilación en una de las vistas, hay 18 llamadas de función para rastrear. Personalmente, me he encontrado con 40, y fácilmente podría haber más si está usando otras bibliotecas y complementos.

El punto es que, de forma predeterminada, estas capas sobre capas de código hacen que Laravel sea lento.

¿Qué tan lento es Laravel?

Honestamente, es simplemente imposible responder a esta pregunta por varias razones.

En primer lugar, no existe un estándar aceptable, objetivo y sensato para medir la velocidad de las aplicaciones web. ¿Más rápido o más lento en comparación con qué? ¿Bajo que condiciones?

En segundo lugar, una aplicación web depende de tantas cosas (base de datos, sistema de archivos, red, caché, etc.) que es una tontería hablar de velocidad. Una aplicación web muy rápida con una base de datos muy lenta es una aplicación web muy lenta. 🙂

Pero esta incertidumbre es precisamente la razón por la cual los puntos de referencia son populares. A pesar de que no significan nada (ver este y este), proporcionan un marco de referencia y nos ayudan a no volvernos locos. Por lo tanto, con varias pizcas de sal listas, tengamos una idea aproximada y errónea de la velocidad entre los marcos PHP.

Pasando por este bastante respetable GitHub fuenteasí es como se alinean los marcos PHP en comparación:

Es posible que ni siquiera notes a Laravel aquí (incluso si entrecierras los ojos) a menos que arrojes tu caso hasta el final de la cola. Sí, queridos amigos, ¡Laravel es el último! Ahora, por supuesto, la mayoría de estos «marcos» no son muy prácticos o incluso útiles, pero nos dicen qué tan lento es Laravel en comparación con otros más populares.

Normalmente, esta «lentitud» no aparece en las aplicaciones porque nuestras aplicaciones web cotidianas rara vez alcanzan números altos. Pero una vez que lo hacen (digamos, más de 200-500 de simultaneidad), los servidores comienzan a ahogarse y morir. Es el momento en el que incluso arrojar más hardware al problema no es suficiente, y las facturas de infraestructura aumentan tan rápido que sus altos ideales de computación en la nube se derrumban.

Pero bueno, ¡ánimo! Este artículo no trata sobre lo que no se puede hacer, sino sobre lo que se puede hacer. 🙂

La buena noticia es que puedes hacer mucho para que tu aplicación Laravel vaya más rápido. Varias veces rápido. Sí, no es broma. Puede hacer que el mismo código base se vuelva balístico y ahorrar varios cientos de dólares en facturas de infraestructura/alojamiento cada mes. ¿Cómo? Hagámoslo.

Cuatro tipos de optimizaciones

En mi opinión, la optimización se puede hacer en cuatro niveles distintos (es decir, cuando se trata de aplicaciones PHP):

  • Nivel de idioma: esto significa que usa una versión más rápida del idioma y evita características/estilos específicos de codificación en el idioma que hacen que su código sea lento.
  • Nivel de marco: estas son las cosas que cubriremos en este artículo.
  • Nivel de infraestructura: ajuste de su administrador de procesos PHP, servidor web, base de datos, etc.
  • Nivel de hardware: pasar a un proveedor de alojamiento de hardware mejor, más rápido y más potente.

Todos estos tipos de optimizaciones tienen su lugar (por ejemplo, la optimización de PHP-fpm es bastante crítica y poderosa). Pero el enfoque de este artículo serán las optimizaciones puramente de tipo 2: aquellas relacionadas con el marco.

Por cierto, no hay ninguna razón detrás de la numeración y no es un estándar aceptado. Acabo de inventar esto. Por favor, nunca me citen y digan: «Necesitamos optimización de tipo 3 en nuestro servidor», o el líder de su equipo lo matará, me encontrará y luego me matará a mí también. 😀

Y ahora, por fin, llegamos a la tierra prometida.

Tenga en cuenta las consultas de base de datos n+1

El problema de consulta n+1 es común cuando se utilizan ORM. Laravel tiene su poderoso ORM llamado Eloquent, que es tan hermoso, tan conveniente que a menudo nos olvidamos de mirar lo que está pasando.

Considere un escenario muy común: mostrar la lista de todos los pedidos realizados por una lista determinada de clientes. Esto es bastante común en los sistemas de comercio electrónico y en cualquier interfaz de informes en general, donde necesitamos mostrar todas las entidades relacionadas con algunas entidades.

En Laravel, podríamos imaginar una función de controlador que hace el trabajo así:

class OrdersController extends Controller 
{
    // ... 

    public function getAllByCustomers(Request $request, array $ids) {
        $customers = Customer::findMany($ids);        
        $orders = collect(); // new collection
        
        foreach ($customers as $customer) {
            $orders = $orders->merge($customer->orders);
        }
        
        return view('admin.reports.orders', ['orders' => $orders]);
    }
}

¡Dulce! Y lo que es más importante, elegante, hermosa. 🤩🤩

Desafortunadamente, es una forma desastrosa de escribir código en Laravel.

Este es el por qué.

Cuando le pedimos al ORM que busque a los clientes dados, se genera una consulta SQL como esta:

SELECT * FROM customers WHERE id IN (22, 45, 34, . . .);

Que es exactamente como se esperaba. Como resultado, todas las filas devueltas se almacenan en la colección $clientes dentro de la función del controlador.

Ahora pasamos por encima de cada cliente uno por uno y obtenemos sus pedidos. Esto ejecuta la siguiente consulta. . .

SELECT * FROM orders WHERE customer_id = 22;

. . . tantas veces como clientes haya.

En otras palabras, si necesitamos obtener los datos del pedido de 1000 clientes, el número total de consultas de base de datos ejecutadas será 1 (para obtener todos los datos de los clientes) + 1000 (para obtener los datos del pedido de cada cliente) = 1001. Esto es de donde proviene el nombre n+1.

¿Podemos hacerlo mejor? ¡Seguramente! Al usar lo que se conoce como carga ansiosa, podemos obligar al ORM a realizar un JOIN y devolver todos los datos necesarios en una sola consulta. Como esto:

$orders = Customer::findMany($ids)->with('orders')->get();

La estructura de datos resultante es anidada, claro, pero los datos del pedido se pueden extraer fácilmente. La consulta única resultante, en este caso, es algo como esto:

SELECT * FROM customers INNER JOIN orders ON customers.id = orders.customer_id WHERE customers.id IN (22, 45, . . .);

Una sola consulta es, por supuesto, mejor que mil consultas adicionales. ¡Imagínese lo que sucedería si hubiera 10,000 clientes para procesar! ¡O Dios no lo quiera si también quisiéramos mostrar los artículos contenidos en cada pedido! Recuerde, el nombre de la técnica es carga ansiosa y casi siempre es una buena idea.

¡Guarde en caché la configuración!

Una de las razones de la flexibilidad de Laravel son las toneladas de archivos de configuración que forman parte del marco. ¿Quiere cambiar cómo/dónde se almacenan las imágenes?

Bueno, solo cambie el archivo config/filesystems.php (al menos al momento de escribir). ¿Quiere trabajar con varios controladores de cola? Siéntase libre de describirlos en config/queue.php. Acabo de contar y descubrí que hay 13 archivos de configuración para diferentes aspectos del marco, lo que garantiza que no se sentirá decepcionado sin importar lo que quiera cambiar.

Dada la naturaleza de PHP, cada vez que ingresa una nueva solicitud web, Laravel se activa, inicia todo y analiza todos estos archivos de configuración para descubrir cómo hacer las cosas de manera diferente esta vez. ¡Excepto que es estúpido si nada ha cambiado en los últimos días! Reconstruir la configuración en cada solicitud es un desperdicio que puede (de hecho, debe) evitarse, y la salida es un comando simple que ofrece Laravel:

php artisan config:cache

Lo que esto hace es combinar todos los archivos de configuración disponibles en uno solo y el caché está en algún lugar para una recuperación rápida. La próxima vez que haya una solicitud web, Laravel simplemente leerá este único archivo y se pondrá en marcha.

Dicho esto, el almacenamiento en caché de la configuración es una operación extremadamente delicada que puede estallarle en la cara. El problema más grande es que una vez que haya emitido este comando, la función env () llama desde todas partes, excepto que los archivos de configuración devolverán un valor nulo.

Tiene sentido cuando lo piensas. Si usa el almacenamiento en caché de configuración, le está diciendo al marco: «Sabes qué, creo que configuré las cosas muy bien y estoy 100% seguro de que no quiero que cambien». En otras palabras, espera que el entorno permanezca estático, que es para lo que son los archivos .env.

Dicho esto, aquí hay algunas reglas estrictas, sagradas e inquebrantables de almacenamiento en caché de configuración:

  • Hágalo sólo en un sistema de producción.
  • Hágalo solo si está realmente seguro de que desea congelar la configuración.
  • En caso de que algo salga mal, deshaga la configuración con caché artesanal de php: borrar
  • ¡Oremos para que el daño hecho al negocio no sea significativo!
  • Reducir los servicios cargados automáticamente

    Para ser útil, Laravel carga una tonelada de servicios cuando se activa. Estos están disponibles en el archivo config/app.php como parte de la matriz de claves ‘proveedores’. Echemos un vistazo a lo que tengo en mi caso:

    /*
        |--------------------------------------------------------------------------
        | Autoloaded Service Providers
        |--------------------------------------------------------------------------
        |
        | The service providers listed here will be automatically loaded on the
        | request to your application. Feel free to add your own services to
        | this array to grant expanded functionality to your applications.
        |
        */
    
        'providers' => [
    
            /*
             * Laravel Framework Service Providers...
             */
            IlluminateAuthAuthServiceProvider::class,
            IlluminateBroadcastingBroadcastServiceProvider::class,
            IlluminateBusBusServiceProvider::class,
            IlluminateCacheCacheServiceProvider::class,
            IlluminateFoundationProvidersConsoleSupportServiceProvider::class,
            IlluminateCookieCookieServiceProvider::class,
            IlluminateDatabaseDatabaseServiceProvider::class,
            IlluminateEncryptionEncryptionServiceProvider::class,
            IlluminateFilesystemFilesystemServiceProvider::class,
            IlluminateFoundationProvidersFoundationServiceProvider::class,
            IlluminateHashingHashServiceProvider::class,
            IlluminateMailMailServiceProvider::class,
            IlluminateNotificationsNotificationServiceProvider::class,
            IlluminatePaginationPaginationServiceProvider::class,
            IlluminatePipelinePipelineServiceProvider::class,
            IlluminateQueueQueueServiceProvider::class,
            IlluminateRedisRedisServiceProvider::class,
            IlluminateAuthPasswordsPasswordResetServiceProvider::class,
            IlluminateSessionSessionServiceProvider::class,
            IlluminateTranslationTranslationServiceProvider::class,
            IlluminateValidationValidationServiceProvider::class,
            IlluminateViewViewServiceProvider::class,
    
            /*
             * Package Service Providers...
             */
    
            /*
             * Application Service Providers...
             */
            AppProvidersAppServiceProvider::class,
            AppProvidersAuthServiceProvider::class,
            // AppProvidersBroadcastServiceProvider::class,
            AppProvidersEventServiceProvider::class,
            AppProvidersRouteServiceProvider::class,
    
        ],

    Una vez más, conté, ¡y hay 27 servicios en la lista! Ahora, es posible que los necesite todos, pero es poco probable.

    Por ejemplo, estoy creando una API REST en este momento, lo que significa que no necesito el proveedor de servicios de sesión, el proveedor de servicios de visualización, etc. Y dado que estoy haciendo algunas cosas a mi manera y no sigo los valores predeterminados del marco , también puedo deshabilitar el proveedor de servicios de autenticación, el proveedor de servicios de paginación, el proveedor de servicios de traducción, etc. En general, casi la mitad de estos son innecesarios para mi caso de uso.

    Eche un vistazo detenidamente a su solicitud. ¿Necesita todos estos proveedores de servicios? Pero, por el amor de Dios, ¡no comente ciegamente estos servicios y empuje a la producción! Ejecute todas las pruebas, verifique las cosas manualmente en las máquinas de desarrollo y preparación, y sea muy, muy paranoico antes de apretar el gatillo. 🙂

    Sea prudente con las pilas de middleware

    Cuando necesite un procesamiento personalizado de la solicitud web entrante, la respuesta es crear un nuevo middleware. Ahora, es tentador abrir app/Http/Kernel.php y pegar el middleware en la pila web o api; de esa manera, estará disponible en toda la aplicación y si no está haciendo algo intrusivo (como iniciar sesión o notificar, por ejemplo).

    Sin embargo, a medida que la aplicación crece, esta colección de middleware global puede convertirse en una carga silenciosa para la aplicación si todos (o la mayoría) están presentes en cada solicitud, incluso si no hay una razón comercial para ello.

    En otras palabras, tenga cuidado de dónde agrega/aplica un nuevo middleware. Puede ser más conveniente agregar algo globalmente, pero la penalización del rendimiento es muy alta a largo plazo. Sé el dolor que tendría que soportar si tuviera que aplicar de forma selectiva el middleware cada vez que hay un nuevo cambio, ¡pero es un dolor que aceptaría y recomendaría de buena gana!

    Evite el ORM (a veces)

    Si bien Eloquent hace que muchos aspectos de la interacción de DB sean placenteros, tiene el costo de la velocidad. Al ser un mapeador, el ORM no solo tiene que obtener registros de la base de datos, sino también instanciar los objetos del modelo e hidratarlos (rellenarlos) con datos de columna.

    Entonces, si hace un simple $users = User::all() y hay, digamos, 10,000 usuarios, el marco obtendrá 10,000 filas de la base de datos e internamente hará 10,000 nuevos User() y completará sus propiedades con los datos relevantes. . Se trata de una enorme cantidad de trabajo que se realiza detrás de escena, y si la base de datos es donde su aplicación se está convirtiendo en un cuello de botella, a veces es una buena idea omitir el ORM.

    Esto es especialmente cierto para las consultas SQL complejas, en las que tendría que saltar muchos aros y escribir cierres sobre cierres y aún así terminar con una consulta eficiente. En tales casos, se prefiere hacer un DB::raw() y escribir la consulta a mano.

    Pasando por este El estudio de rendimiento, incluso para inserciones simples, Eloquent es mucho más lento a medida que aumenta la cantidad de registros:

    Utilice el almacenamiento en caché tanto como sea posible

    Uno de los secretos mejor guardados de la optimización de aplicaciones web es el almacenamiento en caché.

    Para los no iniciados, el almacenamiento en caché significa precomputar y almacenar resultados costosos (costosos en términos de uso de CPU y memoria), y simplemente devolverlos cuando se repite la misma consulta.

    Por ejemplo, en una tienda de comercio electrónico, puede encontrarse con que de los 2 millones de productos, la mayoría de las veces las personas están interesadas en los que están recién surtidos, dentro de un cierto rango de precios y para un grupo de edad en particular. Consultar la base de datos para obtener esta información es un desperdicio; dado que la consulta no cambia con frecuencia, es mejor almacenar estos resultados en algún lugar al que podamos acceder rápidamente.

    Laravel tiene soporte integrado para varios tipos de almacenamiento en caché. Además de usar un controlador de almacenamiento en caché y construir el sistema de almacenamiento en caché desde cero, es posible que desee usar algunos paquetes de Laravel que facilitan almacenamiento en caché del modelo, almacenamiento en caché de consultasetc.

    Pero tenga en cuenta que más allá de un cierto caso de uso simplificado, los paquetes de almacenamiento en caché preconstruidos pueden causar más problemas de los que resuelven.

    Preferir el almacenamiento en caché en memoria

    Cuando almacena algo en caché en Laravel, tiene varias opciones sobre dónde almacenar el cálculo resultante que debe almacenarse en caché. Estas opciones también se conocen como controladores de caché. Entonces, si bien es posible y perfectamente razonable usar el sistema de archivos para almacenar los resultados de la memoria caché, no es realmente lo que debe ser el almacenamiento en memoria caché.

    Idealmente, desea utilizar un caché en memoria (viviendo en la RAM por completo) como Redis, Memcached, MongoDB, etc., de modo que bajo cargas más altas, el almacenamiento en caché tenga un uso vital en lugar de convertirse en un cuello de botella en sí mismo.

    Ahora, podría pensar que tener un disco SSD es casi lo mismo que usar una memoria RAM, pero ni siquiera está cerca. Incluso informal puntos de referencia muestran que la RAM supera a la SSD entre 10 y 20 veces en lo que respecta a la velocidad.

    Mi sistema favorito cuando se trata de almacenamiento en caché es Redis. Es ridículamente rápido (100,000 operaciones de lectura por segundo son comunes), y para sistemas de caché muy grandes, se puede convertir en un grupo fácilmente.

    Cachear las rutas

    Al igual que la configuración de la aplicación, las rutas no cambian mucho con el tiempo y son candidatas ideales para el almacenamiento en caché. Esto es especialmente cierto si no puede soportar archivos grandes como yo y termina dividiendo su web.php y api.php en varios archivos. Un solo comando de Laravel empaqueta todas las rutas disponibles y las mantiene a mano para acceder a ellas en el futuro:

    php artisan route:cache

    Y cuando termine agregando o cambiando rutas, simplemente haga lo siguiente:

    php artisan route:clear

    Optimización de imágenes y CDN

    Las imágenes son el corazón y el alma de la mayoría de las aplicaciones web. Coincidentemente, también son los mayores consumidores de ancho de banda y una de las principales razones de la lentitud de las aplicaciones/sitios web. Si simplemente almacena ingenuamente las imágenes cargadas en el servidor y las envía de vuelta en respuestas HTTP, está dejando escapar una gran oportunidad de optimización.

    Mi primera recomendación es no almacenar imágenes localmente; hay que lidiar con el problema de la pérdida de datos y, dependiendo de la región geográfica en la que se encuentre su cliente, la transferencia de datos puede ser terriblemente lenta.

    En su lugar, busque una solución como nublado que cambia automáticamente el tamaño y optimiza las imágenes sobre la marcha.

    Si eso no es posible, use algo como Cloudflare para almacenar en caché y servir imágenes mientras están almacenadas en su servidor.

    Y si incluso eso no es posible, ajustar un poco el software de su servidor web para comprimir activos y dirigir el navegador del visitante para almacenar cosas en caché, hace una gran diferencia. Así es como se vería un fragmento de la configuración de Nginx:

    server {
    
       # file truncated
        
        # gzip compression settings
        gzip on;
        gzip_comp_level 5;
        gzip_min_length 256;
        gzip_proxied any;
        gzip_vary on;
    
       # browser cache control
       location ~* .(ico|css|js|gif|jpeg|jpg|png|woff|ttf|otf|svg|woff2|eot)$ {
             expires 1d;
             access_log off;
             add_header Pragma public;
             add_header Cache-Control "public, max-age=86400";
        }
    }

    Soy consciente de que la optimización de imágenes no tiene nada que ver con Laravel, pero es un truco tan simple y poderoso (y que a menudo se descuida) que no pude evitarlo.

    Optimización del cargador automático

    La carga automática es una característica ordenada y no tan antigua de PHP que podría decirse que salvó al lenguaje de la perdición. Dicho esto, el proceso de encontrar y cargar la clase relevante mediante el descifrado de una cadena de espacio de nombres dada lleva tiempo y puede evitarse en implementaciones de producción donde se desea un alto rendimiento. Una vez más, Laravel tiene una solución de un solo comando para esto:

    composer install --optimize-autoloader --no-dev

    Hazte amigo de las colas

    Colas son cómo procesas las cosas cuando hay muchas de ellas, y cada una de ellas tarda unos pocos milisegundos en completarse. Un buen ejemplo es el envío de correos electrónicos: un caso de uso generalizado en las aplicaciones web es enviar algunos correos electrónicos de notificación cuando un usuario realiza algunas acciones.

    Por ejemplo, en un producto recién lanzado, es posible que desee que el liderazgo de la empresa (unas 6-7 direcciones de correo electrónico) sea notificado cada vez que alguien realiza un pedido por encima de un valor determinado. Suponiendo que su puerta de enlace de correo electrónico pueda responder a su solicitud SMTP en 500 ms, estamos hablando de una buena espera de 3-4 segundos para el usuario antes de que se active la confirmación del pedido. aceptar.

    El remedio es almacenar los trabajos a medida que ingresan, decirle al usuario que todo salió bien y procesarlos (unos segundos) más tarde. Si hay un error, los trabajos en cola se pueden volver a intentar varias veces antes de que se declare que han fallado.

    Créditos: Microsoft.com

    Si bien un sistema de colas complica un poco la configuración (y agrega algunos gastos generales de monitoreo), es indispensable en una aplicación web moderna.

    Optimización de activos (Laravel Mix)

    Para cualquier activo de front-end en su aplicación Laravel, asegúrese de que haya una canalización que compile y minimice todos los archivos de activos. Aquellos que se sientan cómodos con un sistema de paquetes como Webpack, Gulp, Parcel, etc., no necesitan molestarse, pero si aún no lo están haciendo, Mezcla Laravel es una recomendación sólida.

    Mix es un envoltorio ligero (¡y delicioso, con toda honestidad!) alrededor de Webpack que se encarga de todos sus archivos CSS, SASS, JS, etc., para la producción. Un archivo .mix.js típico puede ser tan pequeño como este y aun así hacer maravillas:

    const mix = require('laravel-mix');
    
    mix.js('resources/js/app.js', 'public/js')
        .sass('resources/sass/app.scss', 'public/css');

    Esto se encarga automáticamente de las importaciones, la minimización, la optimización y todo el asunto cuando esté listo para la producción y ejecute la producción de ejecución de npm. Mix se ocupa no solo de los archivos JS y CSS tradicionales, sino también de los componentes Vue y React que podría tener en el flujo de trabajo de su aplicación.

    Más información aquí!

    Conclusión

    La optimización del rendimiento es más un arte que una ciencia: saber cómo y cuánto hacer es más importante que qué hacer. Dicho esto, no hay fin a cuánto y qué puedes optimizar en una aplicación de Laravel.

    Pero hagas lo que hagas, me gustaría dejarte un consejo de despedida: la optimización debe realizarse cuando haya una razón sólida, y no porque suene bien o porque estés paranoico sobre el rendimiento de la aplicación para más de 100 000 usuarios cuando en realidad solo hay 10

    Si no está seguro de si necesita optimizar su aplicación o no, no necesita patear el avispero proverbial. Una aplicación funcional que se siente aburrida pero que hace exactamente lo que tiene que hacer es diez veces más deseable que una aplicación que ha sido optimizada en una supermáquina híbrida mutante pero que falla de vez en cuando.

    Y, para que el novato se convierta en un maestro de Laravel, mira esto curso por Internet.

    ¡Que tus aplicaciones funcionen mucho, mucho más rápido! 🙂