Javier Valencia Javier Valencia
Rails desde cero (III): ActiveRecord avanzado

Rails desde cero (III): ActiveRecord avanzado

Javier Valencia · · 6 min de lectura · 1842 visitas · Desarrollo
ruby rails tutorial rails-desde-cero activerecord

Tercera entrega de la serie Rails desde cero. Tiempo de lectura estimado: 15 minutos.

Si ya tienes claro cómo funcionan los modelos, las validaciones y las asociaciones básicas, es hora de hablar de lo que realmente separa una aplicación Rails mantenible de una que se convierte en un problema. En este artículo cubrimos los temas que más impactan en el rendimiento, la legibilidad y la escalabilidad: el problema N+1, eager loading, consultas avanzadas, el uso correcto de callbacks, y cómo estructurar la lógica de negocio cuando los modelos empiezan a crecer.

El problema N+1: el error más común en Rails

Rails desde cero (III): ActiveRecord avanzado

El problema N+1 es probablemente el bug de rendimiento más frecuente en aplicaciones Rails, y lo más traicionero es que no se ve en el código: se ve en los logs.

Imagina este controlador y esta vista:

# app/controllers/articles_controller.rb
def index
  @articles = Article.published.recent
end
<!-- app/views/articles/index.html.erb -->
<% @articles.each  do |article| %>
  <p><%= article.title %> — por <%= article.user.name %></p>
<% end  %>

A simple vista parece correcto. Pero en los logs verás algo así:

Article Load (1.2ms)  SELECT "articles".* FROM "articles" WHERE ...
User Load (0.4ms)     SELECT "users".* FROM "users" WHERE "users"."id" = 1
User Load (0.3ms)     SELECT "users".* FROM "users" WHERE "users"."id" = 2
User Load (0.4ms)     SELECT "users".* FROM "users" WHERE "users"."id" = 1
User Load (0.3ms)     SELECT "users".* FROM "users" WHERE "users"."id" = 3
...

Una consulta para cargar los artículos, y luego una consulta por cada artículo para cargar su usuario. Si tienes 100 artículos, son 101 consultas. Si tienes 1.000, son 1.001. Esto es el problema N+1.

La solución: includes

@articles = Article.published.recent.includes(:user)

Con includes, ActiveRecord carga todos los usuarios necesarios en una sola consulta adicional:

Article Load (1.2ms)  SELECT "articles".* FROM "articles" WHERE ...
User Load (0.8ms)     SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, ...)

De N+1 consultas a 2. La diferencia en rendimiento puede ser de órdenes de magnitud.

includes vs eager_load vs preload

Rails tiene tres métodos para cargar asociaciones de forma anticipada, y no son intercambiables:

preload siempre usa dos consultas separadas, independientemente de si necesitas filtrar por la asociación:

Article.preload(:user)
# SELECT * FROM articles
# SELECT * FROM users WHERE id IN (...)

eager_load hace un LEFT OUTER JOIN en una sola consulta. Es necesario cuando quieres filtrar u ordenar por campos de la asociación:

Article.eager_load(:user).where(users: { role: "admin" })
# SELECT articles.*, users.* FROM articles
# LEFT OUTER JOIN users ON users.id = articles.user_id
# WHERE users.role = 'admin'

includes decide automáticamente entre los dos anteriores según el contexto. En la mayoría de casos es la opción correcta.

# Usa preload (dos consultas)
Article.includes(:user)

# Detecta que necesita JOIN y usa eager_load automáticamente
Article.includes(:user).where(users: { role: "admin" })

Detectar N+1 en desarrollo

La gema Bullet detecta automáticamente problemas N+1 y te avisa en el log o con alertas en el navegador. Es casi obligatoria en cualquier proyecto Rails serio:

# Gemfile
gem "bullet", group: :development

# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.rails_logger = true
  Bullet.add_footer = true
end

Consultas avanzadas

Joins

Cuando necesitas consultar datos de tablas relacionadas sin cargar los objetos asociados en memoria:

# Artículos que tienen al menos un comentario
Article.joins(:comments).distinct

# Artículos de usuarios con rol admin
Article.joins(:user).where(users: { role: "admin" })

# Varios joins encadenados
Article.joins(:user, :tags).where(tags: { name: "rails" })

La diferencia clave entre joins e includes: joins hace el JOIN para filtrar, pero no carga los objetos asociados en memoria. Si luego accedes a article.user dentro de un bucle, vuelves al problema N+1. includes sí carga los objetos.

En la práctica: usa joins para filtrar, usa includes para mostrar datos asociados, y combínalos cuando necesitas ambas cosas:

Article.joins(:user)
       .includes(:tags)
       .where(users: { role: "admin" })
       .order("users.name")
       ```

### Seleccionar columnas específicas

Cuando manejas tablas con muchas columnas o campos grandes, cargar solo lo que necesitas mejora el rendimiento:

```ruby
Article.select(:id, :title, :published_at)

Cuidado: los atributos que no hayas seleccionado lanzarán un ActiveModel::MissingAttributeError si intentas acceder a ellos.

SQL arbitrario con Arel y literales

Para consultas que la API de ActiveRecord no puede expresar directamente:

# Literal SQL donde sea necesario
Article.where("published_at > NOW() - INTERVAL '7 days'")

# Con Arel para algo más portable
Article.where(Article.arel_table[:published_at].gt(7.days.ago))

# Subconsultas
popular_tag_ids = Tag.where("articles_count > ?", 100).select(:id)
Article.joins(:tags).where(tags: { id: popular_tag_ids })

Agrupación y agregados

# Artículos publicados por mes
Article.published
       .group("DATE_TRUNC('month', published_at)")
       .count

# Media de comentarios por artículo
Article.joins(:comments)
       .group(:id)
       .average("comments.count")

# Los 5 autores con más artículos publicados
User.joins(:articles)
    .where(articles: { published: true })
    .group(:id)
    .order("COUNT(articles.id) DESC")
    .limit(5)
    .select("users.*, COUNT(articles.id) AS articles_count")

find_each y find_in_batches para grandes volúmenes

Article.all carga todos los registros en memoria de una vez. Con tablas grandes, esto puede consumir gigabytes de RAM. La solución es procesar en lotes:

# Procesa los registros de 1000 en 1000 (por defecto)
Article.find_each do |article|
  article.regenerate_slug!
end

# Con tamaño de lote personalizado
Article.published.find_each(batch_size: 500) do |article|
  ArticleIndexJob.perform_later(article.id)
end

# Si necesitas el lote completo como array
Article.find_in_batches(batch_size: 200) do |articles|
  ElasticsearchClient.bulk_index(articles)
end

Callbacks: cuándo usarlos y cuándo no

Rails desde cero (III): ActiveRecord avanzado

Los callbacks son tentadores porque hacen que el modelo sea «inteligente». Pero un modelo con demasiados callbacks se convierte en una caja negra: es difícil saber qué va a pasar cuando llamas a save, difícil de testear y difícil de razonar.

Cuándo los callbacks son apropiados

Los callbacks son adecuados para lógica que siempre debe ocurrir junto con el cambio de estado del registro, sin excepciones:

class Article < ApplicationRecord
  before_save :generate_slug
  before_save :set_word_count

  private

  def generate_slug
    self.slug = title.parameterize if title_changed?
  end

  def set_word_count
    self.word_count = body.to_s.split.length
  end
end

Calcular un slug o un contador de palabras a partir de atributos del propio modelo es un uso perfecto: es pura lógica interna que siempre debe mantenerse consistente.

Cuándo los callbacks son un problema

El problema aparece cuando los callbacks tienen efectos secundarios en el exterior: enviar emails, encolar jobs, modificar otros modelos, hacer llamadas HTTP…

# ❌ Problemático
class Article < ApplicationRecord
  after_create :send_notification_email
  after_create :notify_external_api
  after_destroy :update_author_stats

  private

  def send_notification_email
    ArticleMailer.published(self).deliver_later
  end
end

El problema es que ahora no puedes crear un artículo en tests sin que se intente enviar un email. No puedes crear artículos de prueba en seeds sin efectos secundarios. Y si el job falla, ¿sabes que fue porque se lanzó desde un callback?

La alternativa es ser explícito en el lugar donde ocurre la acción de negocio:

# ✅ Explícito en el servicio o controlador
class ArticlesController < ApplicationController
  def create
    @article = Article.new(article_params)

    if @article.save
      ArticleMailer.published(@article).deliver_later
      ArticlePublishedJob.perform_later(@article.id)
      redirect_to @article
    else
      render :new, status: :unprocessable_entity
    end
  end
end

after_commit vs after_save

Si realmente necesitas un callback con efectos secundarios, usa after_commit en lugar de after_save. La razón es que after_save se ejecuta dentro de la transacción, y si algo falla después, la transacción se revierte pero el efecto secundario (el email, el job) ya se habrá lanzado.

# ❌ Se ejecuta dentro de la transacción
after_save :enqueue_index_job

# ✅ Se ejecuta solo cuando la transacción se confirma
after_commit :enqueue_index_job, on: :create

Mantener los modelos limpios

A medida que una aplicación crece, los modelos tienden a acumular lógica de negocio hasta convertirse en «fat models»: cientos de líneas, decenas de métodos, callbacks entrelazados. La comunidad Rails ha desarrollado varios patrones para combatir esto.

Concerns

Los concerns son módulos que encapsulan un conjunto de comportamientos reutilizables. Rails los carga automáticamente desde app/models/concerns/:

# app/models/concerns/publishable.rb
module Publishable
  extend ActiveSupport::Concern

  included do
    scope :published, -> { where(published: true) }
    scope :unpublished, -> { where(published: false) }

    validates :published_at, presence: true, if: :published?
  end

  def publish!
    update!(published: true, published_at: Time.current)
  end

  def unpublish!
    update!(published: false, published_at: nil)
  end

  def published?
    published? && published_at.present?
  end
end

# app/models/article.rb
class Article < ApplicationRecord
  include Publishable
end

Los concerns son útiles para comportamientos genuinamente reutilizables entre modelos (como Publishable, Searchable o Auditable). No son una solución para simplemente mover código a otro archivo: si el concern solo lo usa un modelo, probablemente ese código debería estar en el modelo directamente.

Objetos de servicio

Para lógica de negocio compleja que involucra múltiples modelos o efectos secundarios, los objetos de servicio son una opción muy popular:

# app/services/article_publisher.rb
class ArticlePublisher
  def initialize(article, publisher:)
    @article = article
    @publisher = publisher
  end

  def call
    return false unless @publisher.can_publish?(@article)

    ActiveRecord::Base.transaction do
      @article.publish!
      @publisher.increment!(:published_articles_count)
      AuditLog.create!(action: "article_published", actor: @publisher, target: @article)
    end

    ArticleMailer.published(@article).deliver_later
    true
  rescue ActiveRecord::RecordInvalid => e
    @article.errors.add(:base, e.message)
    false
  end
end

# En el controlador
def publish
  result = ArticlePublisher.new(@article, publisher: current_user).call

  if result
    redirect_to @article, notice: "Artículo publicado."
  else
    render :edit, status: :unprocessable_entity
  end
end

El servicio encapsula toda la operación: la validación de permisos, la transacción, el log de auditoría y el envío del email. El modelo y el controlador quedan limpios.

Query Objects

Para consultas complejas que no encajan bien en un scope, los Query Objects son una alternativa limpia:

# app/queries/popular_articles_query.rb
class PopularArticlesQuery
  def initialize(relation = Article.all)
    @relation = relation
  end

  def call(limit: 10, since: 1.month.ago)
    @relation
      .published
      .joins(:comments)
      .where("articles.published_at > ?", since)
      .group("articles.id")
      .order("COUNT(comments.id) DESC")
      .limit(limit)
      .select("articles.*, COUNT(comments.id) AS comments_count")
  end
end

# Uso
PopularArticlesQuery.new.call(limit: 5)
PopularArticlesQuery.new(Article.by_author(current_user)).call

Transacciones

Rails desde cero (III): ActiveRecord avanzado

Cuando necesitas que varias operaciones en base de datos ocurran juntas o no ocurran en absoluto, usa una transacción explícita:

ActiveRecord::Base.transaction do
  order = Order.create!(user: current_user, total: cart.total)
  cart.items.each do |item|
    order.line_items.create!(product: item.product, quantity: item.quantity)
    item.product.decrement!(:stock, item.quantity)
  end
  cart.destroy!
end

Si cualquier operación dentro del bloque lanza una excepción, toda la transacción se revierte. Importante: solo las excepciones hacen rollback, los false no. Por eso dentro de transacciones deberías usar siempre los métodos con !.

Si necesitas revertir manualmente desde dentro de la transacción:

ActiveRecord::Base.transaction do
  do_something
  raise ActiveRecord::Rollback if some_condition
  do_something_else
end

ActiveRecord::Rollback es especial: hace rollback pero no propaga la excepción fuera del bloque.

Locking: concurrencia y condiciones de carrera

Cuando múltiples procesos pueden modificar el mismo registro simultáneamente, necesitas alguna estrategia de locking.

Optimistic locking

Ideal cuando los conflictos son poco frecuentes. Rails lo soporta de forma nativa con una columna lock_version:

# En la migración
add_column :articles, :lock_version, :integer, default: 0

# ActiveRecord lo gestiona solo:
article_a = Article.find(1)
article_b = Article.find(1)  # misma versión

article_a.update!(title: "Versión A")  # ok, lock_version pasa a 1
article_b.update!(title: "Versión B")  # lanza ActiveRecord::StaleObjectError

Pessimistic locking

Para operaciones críticas donde no puedes permitir conflictos:

Article.transaction do
  article = Article.lock.find(1)  # SELECT ... FOR UPDATE
  article.increment!(:views_count)
end

lock bloquea la fila a nivel de base de datos hasta que la transacción termina. Úsalo con cuidado y en transacciones cortas para evitar deadlocks.

Algunos patrones útiles

Para cerrar el artículo, algunos métodos y patrones que aparecen constantemente en código Rails maduro:

# find_or_create_by: busca o crea atómicamente
tag = Tag.find_or_create_by(name: "ruby")

# upsert y upsert_all: insert or update eficiente sin pasar por Ruby
Article.upsert({ id: 1, title: "Actualizado", updated_at: Time.current })

# upsert_all para operaciones masivas
Article.upsert_all(articles_data, unique_by: :slug)

# update_all: actualiza sin cargar objetos en memoria
Article.where(status: "pending").update_all(status: "draft", updated_at: Time.current)

# delete_all: elimina sin cargar ni ejecutar callbacks
Article.where("created_at < ?", 1.year.ago).delete_all

# touch: actualiza solo el timestamp
article.touch  # updated_at = now
article.touch(:published_at)  # campo específico

# Dirty tracking: saber qué cambió antes de guardar
article.title = "Nuevo título"
article.title_changed?   # => true
article.title_was        # => "Título anterior"
article.changes          # => { "title" => ["Título anterior", "Nuevo título"] }

Resumen

ActiveRecord es una herramienta extremadamente poderosa, pero como cualquier herramienta poderosa, hay que saber dónde aprieta:

  • N+1: el problema más común y el más fácil de evitar con includes. Instala Bullet y no lo ignores.
  • Joins vs includes: distintos propósitos, a menudo complementarios.
  • Callbacks: úsalos para lógica interna del modelo, no para efectos secundarios.
  • after_commit en lugar de after_save cuando los efectos secundarios son inevitables.
  • Modelos limpios: concerns para comportamientos reutilizables, servicios para orquestar operaciones complejas, Query Objects para consultas sofisticadas.
  • Transacciones y locking cuando la consistencia de datos no es negociable.

Con estos patrones, la diferencia entre una aplicación que aguanta el crecimiento y una que se vuelve un problema empieza a ser visible muy pronto.

En el próximo artículo hablaremos de testing en Rails: cómo testear modelos, controladores y servicios, y cómo estructurar una suite de tests que sea útil y no un lastre.

¿Has visto alguno de estos patrones en tu código? ¿Tienes dudas sobre cuándo aplicar cada uno? Los comentarios son tuyos.