Optimizando PHP: N+1 Queries

Uno de los mayores problemas que suelen tener las aplicaciones, y que a menudo pasa desapercibido, es la realización de la misma operación repetidamente dentro de un bucle.

Cómo cada operación dura muy poco y la base de datos no se “queja”, suele pasar desapercibido a la hora de monitorizar el rendimiento.

Si usas Sentry, lo tienes en el monitor de N+1 Queries.

Voy a poner un ejemplo sencillo.

Vamos a mostrar un listado de pedidos de un cliente, y tenemos un método que devuelve un Dto que usaremos en el API.

<aside> 💡 NOTA: Alguno de vosotros, vais a pensar que no tenéis este problema, por que montáis una mega Query o hacéis Eager Loading, etc.. Este tipo de soluciones no escalan bien en proyectos grandes. Si tu opinión es diferente, te aconsejo que no sigas leyendo.

</aside>

Por un lado tenemos el Dto que vamos a devolver, que tiene datos del pedido y del cliente.

final readonly class OrderListItemDto{   
    public function __construct(
        public OrderId $orderId,
        public string $orderNumber,
        public string $orderStatus,
        public string $orderDate,
        public string $customerName,
        public string $customerEmail
        ){
        }
 }

Ahora tenemos un servicio que devuelve el listado de pedidos de un cliente:

final readonly class OrderListService
{
    public function getOrderList(UserId $userId): array
    {
        /** @var array<OrderListItemDto> $result */
        $result = [];
        $orders = $this->orderRepository->findByUserId($userId);
        foreach ($orders as $order) {
            $result[] = $this->buildDto($order);
        }
        return $result;
    }

    private function buildDto(Order $order): OrderListItemDto
    {
        $customer = $this->customerRepository->findByUserId($order->userId);
        return new OrderListItemDto(
            orderId: $order->orderId,
            orderNumber: $order->orderNumber,
            orderStatus: $order->orderStatus,
            orderDate: $order->orderDate,
            customerName: $customer->customerName,
            customerEmail: $customer->customerEmail
        );
    }
}

El código aparentemente va bien, las operaciones son rápidas, pero tiene un problema. Por cada pedido se trae de nuevo el cliente…. Si me traigo 100 pedidos, 100 llamadas a la BBDD.

Un primera solución seria pasar por parámetro el cliente, o directamente el nombre y el email (depende del nivel de acoplamiento que queramos tener):


private function buildDto(Order $order, Customer $customer): OrderListItemDto
{
    return new OrderListItemDto(
        orderId: $order->orderId,
        orderNumber: $order->orderNumber,
        orderStatus: $order->orderStatus,
        orderDate: $order->orderDate,
        customerName: $customer->customerName,
        customerEmail: $customer->customerEmail
    );
}

Esto, con un listado de pedidos de 100, va prácticamente 100 veces más rápido.

Pero ahora se nos complica el asunto, queremos mostrar el país, del cual tenemos un código:


private function buildDto(Order $order, Customer $customer): OrderListItemDto
{
    $country = $this->countryRepository->findByCountryId($order->deliveryCountryId);
    return new OrderListItemDto(
        orderId: $order->orderId,
        orderNumber: $order->orderNumber,
        orderStatus: $order->orderStatus,
        orderDate: $order->orderDate,
        countryName: $country->name,
        customerName: $customer->customerName,
        customerEmail: $customer->customerEmail
    );
}

El país puede cambiar en cada pedido, no podemos pasarlo por parámetro.

Podemos jugar con esta opción:


  private function buildDto(Order $order, Customer $customer,array $countryNames): OrderListItemDto
    {
        return new OrderListItemDto(
            orderId: $order->orderId,
            orderNumber: $order->orderNumber,
            orderStatus: $order->orderStatus,
            orderDate: $order->orderDate,
            countryName: $countryNames[$order->deliveryCountryId],
            customerName: $customer->customerName,
            customerEmail: $customer->customerEmail
        );
    }

Donde le pasamos un array con todos los nombres de países (obtener todos los países es una sola query). De esta forma, reducimos el número de queries.

¿Que ocurre si necesitamos un dato que no podemos cargar en memoria, y que cambia en cada pedido?

Por ejemplo, nos hace falta el listado de marcas de productos que viene en el carrito. Y si la lista de marcas es por ejemplo 20.000. No podemos precargarla.

Aquí tenemos dos opciones, una de ellas agrega ruido y complejidad, la otra es más limpia, pero un poco menos optima:

   private function buildDto(Order $order, Customer $customer, array $countryNames): OrderListItemDto
    {
        $brandIds = [];
        foreach ($order->orderItems as $orderItem) {
            $brandIds[] = $orderItem->brandId;
        }
        $brands = $this->brandRepository->findByIds($brandIds);
        $brandNames = [];
        foreach ($brands as $brand) {
            $brandNames[$brand->id] = $brand->brandName;
        }

        return new OrderListItemDto(
            orderId: $order->orderId,
            orderNumber: $order->orderNumber,
            orderStatus: $order->orderStatus,
            orderDate: $order->orderDate,
            countryName: $countryNames[$order->deliveryCountryId],
            customerName: $customer->customerName,
            customerEmail: $customer->customerEmail,
            brandNames: array_map(fn($brandId) => $brandNames[$brandId], $brandIds)
        );
    }

En esta, recorremos los pedidos, y nos traemos todos los Id de marca que necesito. Se realiza una sola Query.

Y la otra opción es usando cache, de forma que:


 brandNames: array_map(fn($brandId) => 
     $this->brandRespository->getCachedBrandName($brandId)

<aside> 💡 Nota: Muchos programadores “teóricos” de DDD y Arquitectura Hexagonal, defienden que todo es un problema de arquitectura, y que el Negocio o Dominio no tiene que ensuciarse con esto, y solucionan este problema en la parte de infrastructura, que a menudo se traduce en “meter más servidores”.

</aside>

Resumen:

  • El proceso inicial, con 100 pedidos, cada uno con una media de 3 marcas. Tenía que hacer 500 Queries.
  • El proceso final, en las mismas condiciones, hace 4.

Aunque a veces lanzar 500 Queries es imperceptible, ya que pasamos de 400ms a 100ms de tiempo. El problema está cuando alguna de las Queries es lenta, o cuando tenemos muchos usuarios concurrentes, en ese caso, la optimización es super necesaria.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *