El problema N+1 es probablemente el error de rendimiento más común en aplicaciones Rails. Aparece cuando cargas una lista de objetos y luego accedes a sus asociaciones dentro de un bucle, lo que provoca una query SQL por cada elemento en lugar de una sola query que traiga todo de golpe. En una lista de 100 pedidos con sus clientes, eso son 101 queries donde debería haber 2.
El problema N+1 en detalle
El ejemplo clásico:
# Vista ERB (o controlador)
@pedidos = Pedido.all
# En la vista
@pedidos.each do |pedido|
puts "#{pedido.numero} - #{pedido.cliente.nombre}" # <= Aquí está el problema
end
# SQL generado:
# SELECT * FROM pedidos;
# SELECT * FROM clientes WHERE id = 1;
# SELECT * FROM clientes WHERE id = 2;
# SELECT * FROM clientes WHERE id = 3;
# ... N queries más
ActiveRecord es lazy: no carga las asociaciones hasta que las necesitas. Cuando accedes a pedido.cliente, ejecuta una query en ese momento. Si lo haces dentro de un bucle de 100 pedidos, son 100 queries adicionales.
Eager loading con includes
La solución básica es includes, que le dice a ActiveRecord que precargue las asociaciones en la query inicial:
# Con includes
@pedidos = Pedido.includes(:cliente).all
# SQL generado:
# SELECT * FROM pedidos;
# SELECT * FROM clientes WHERE id IN (1, 2, 3, ...);
# Solo 2 queries, independientemente del número de pedidos
# Múltiples asociaciones
@pedidos = Pedido.includes(:cliente, :lineas_pedido).all
# Asociaciones anidadas
@pedidos = Pedido.includes(cliente: :direcciones, lineas_pedido: :producto).all
# Combinado con condiciones
@pedidos = Pedido.includes(:cliente)
.where(estado: 'pendiente')
.order(created_at: :desc)
includes vs preload vs eager_load
ActiveRecord tiene tres métodos para eager loading, con comportamientos distintos:
# includes: elige automáticamente entre preload y eager_load
# Usa preload si no hay condiciones en la asociación,
# eager_load si hay WHERE o ORDER sobre la asociación
# preload: siempre usa queries separadas (IN)
@pedidos = Pedido.preload(:cliente)
# SELECT * FROM pedidos;
# SELECT * FROM clientes WHERE id IN (...);
# eager_load: usa siempre LEFT JOIN
@pedidos = Pedido.eager_load(:cliente)
# SELECT pedidos.*, clientes.* FROM pedidos
# LEFT OUTER JOIN clientes ON clientes.id = pedidos.cliente_id;
# eager_load es necesario cuando filtras por la asociación
@pedidos = Pedido.eager_load(:cliente)
.where(clientes: { activo: true })
# Solo pedidos de clientes activos
La diferencia práctica: usa includes por defecto (deja que Rails decida), preload cuando quieres queries separadas explícitamente, y eager_load cuando necesitas filtrar u ordenar por campos de la asociación.
Detectar problemas N+1 con Bullet
Bullet es una gema que detecta automáticamente problemas N+1 y te avisa durante el desarrollo:
# Gemfile group :development do gem 'bullet' end # config/environments/development.rb config.after_initialize do Bullet.enable = true Bullet.alert = true # Popup en el navegador Bullet.rails_logger = true # Log en la consola Bullet.add_footer = true # Footer en la página end
Con Bullet activo, cada vez que tu aplicación ejecuta un N+1, aparece un aviso con el modelo y la asociación problemáticos. Es la forma más rápida de encontrar los hotspots sin analizar logs manualmente.
Queries complejas con joins y select
Para queries más avanzadas, ActiveRecord permite pasar SQL directamente cuando el DSL no es suficiente:
# Contar pedidos por cliente con un solo JOIN
clientes_con_pedidos = Cliente.joins(:pedidos)
.select('clientes.*, COUNT(pedidos.id) as total_pedidos')
.group('clientes.id')
clientes_con_pedidos.each do |cliente|
puts "#{cliente.nombre}: #{cliente.total_pedidos} pedidos"
end
# Una sola query
# Subconsultas
clientes_activos = Cliente.where(
"id IN (SELECT cliente_id FROM pedidos WHERE estado = 'completado' AND created_at > ?)",
30.days.ago
)
# Mismo resultado con ActiveRecord puro (más limpio)
pedidos_recientes = Pedido.where(estado: 'completado')
.where('created_at > ?', 30.days.ago)
.select(:cliente_id)
clientes_activos = Cliente.where(id: pedidos_recientes)
Paginación eficiente
La paginación con LIMIT y OFFSET es lenta en tablas grandes porque MySQL/PostgreSQL tiene que recorrer todas las filas anteriores. Para tablas con millones de registros, la alternativa es keyset pagination (paginación por cursor):
# Paginación por cursor (mucho más rápida en tablas grandes)
# En lugar de OFFSET, filtramos por el último id visto
def siguiente_pagina(ultimo_id, limite = 20)
Pedido.where('id > ?', ultimo_id)
.order(:id)
.limit(limite)
end
# Primera página
pedidos = Pedido.order(:id).limit(20)
# Siguiente página
ultimo_id = pedidos.last.id
pedidos = siguiente_pagina(ultimo_id, 20)
Kaminari y Pagy (las gemas de paginación más populares en Rails) usan OFFSET por defecto, que es suficiente para la mayoría de los casos. Solo necesitas keyset pagination cuando tienes tablas de varios millones de filas y la paginación a partir de la página 100+ se vuelve lenta.
El método explain para analizar queries
# Ver el plan de ejecución de una query puts Pedido.includes(:cliente).where(estado: 'pendiente').explain # Devuelve el EXPLAIN de MySQL/PostgreSQL # Para queries lentas, buscar full table scans (type: ALL en MySQL) # y añadir índices donde corresponda
Los índices son la otra mitad de la optimización de queries. Asegúrate de tener índices en las foreign keys (cliente_id, etc.) y en las columnas que aparecen con frecuencia en cláusulas WHERE u ORDER BY.
Si te interesa ver cómo se abordan consultas de base de datos en otros frameworks, el artículo sobre PHP 8.4 y sus mejoras incluye contexto sobre cómo PDO gestiona queries de forma segura en el ecosistema PHP.
Imagen: Pexels / Myburgh Roux
