Настройка ActiveRecord для Ruby on Rails
ActiveRecord — реализация паттерна Active Record от DHH, встроенная в Rails. В Rails 7.x появились async queries, encrypts, строгие модели и инструмент компоновки запросов через with. Рассматриваем актуальную настройку для Rails 7.1+.
Конфигурация database.yml
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
timeout: 5000
connect_timeout: 5
checkout_timeout: 5
reaping_frequency: 10
variables:
statement_timeout: '10s' # убивает запросы длиннее 10 секунд
development:
<<: *default
database: myapp_development
test:
<<: *default
database: myapp_test
production:
primary:
<<: *default
url: <%= ENV['DATABASE_URL'] %>
replica:
<<: *default
url: <%= ENV['DATABASE_REPLICA_URL'] %>
replica: true
statement_timeout на уровне PostgreSQL-сессии — страховка от случайного full-scan на продакшне. Долгие операции (миграции, экспорт) нужно запускать с SET statement_timeout = 0 явно.
Подключение реплики
# config/application.rb (или config/environments/production.rb)
config.active_record.database_selector = { delay: 2.seconds }
config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :primary, reading: :replica }
end
Rails автоматически направляет SELECT на реплику с задержкой 2.seconds после последней записи (чтобы учесть репликационный лаг).
Модель
# app/models/product.rb
class Product < ApplicationRecord
belongs_to :category
has_many :product_tags, dependent: :destroy
has_many :tags, through: :product_tags
has_many :images, -> { order(:sort_order) }, class_name: 'ProductImage', dependent: :destroy
enum :status, { draft: 'draft', published: 'published', archived: 'archived' }, prefix: true
validates :title, presence: true, length: { maximum: 500 }
validates :slug, presence: true, uniqueness: true, format: { with: /\A[a-z0-9-]+\z/ }
validates :price, numericality: { greater_than: 0 }
before_validation :generate_slug, if: -> { slug.blank? && title.present? }
scope :published, -> { where(status: :published) }
scope :in_category, ->(id) { where(category_id: id) }
scope :recent, -> { order(created_at: :desc) }
scope :with_preview, -> { includes(:category, :tags, images: []) }
# Rails 7.1: encrypted attribute
# encrypts :internal_notes
private
def generate_slug
self.slug = title.parameterize
end
end
enum с prefix: true даёт методы status_published?, status_published! — это избегает конфликта имён, когда несколько enum используют одно значение.
Миграция
class CreateProducts < ActiveRecord::Migration[7.1]
def change
create_table :products do |t|
t.string :title, limit: 500, null: false
t.string :slug, limit: 520, null: false
t.decimal :price, precision: 12, scale: 2, null: false
t.string :status, limit: 20, null: false, default: 'draft'
t.references :category, null: false, foreign_key: { on_delete: :restrict }
t.boolean :is_featured, null: false, default: false
t.jsonb :meta
t.timestamps
end
add_index :products, :slug, unique: true
add_index :products, [:status, :created_at]
add_index :products, [:category_id, :status]
add_index :products, :meta, using: :gin # для поиска по jsonb
end
end
Запросы без N+1
# app/controllers/catalog_controller.rb
def index
@products = Product
.published
.in_category(params[:category_id])
.with_preview
.recent
.page(params[:page]).per(24)
end
with_preview подгружает category, tags и images через три дополнительных IN запроса — не JOIN. В Rails 7 для ассоциаций has_many :through это работает корректно.
Для определения N+1 в development используем Bullet:
# Gemfile (development)
gem 'bullet'
# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.rails_logger = true
Bullet.add_footer = true
end
Async queries (Rails 7.1)
# Параллельная загрузка данных без блокировки
products_promise = Product.published.recent.limit(10).load_async
stats_promise = Order.where(created_at: 1.week.ago..).count_async
products = products_promise.value # ждёт, если ещё не готово
stats = stats_promise.value
Запросы выполняются в фоновом потоке пула ActiveRecord. На PostgreSQL с несколькими коннекциями это даёт реальный выигрыш для dashboard-страниц.
Транзакции
ActiveRecord::Base.transaction do
order = Order.create!(user: current_user, status: :pending)
items.each do |item|
order.order_items.create!(
product_id: item[:product_id],
quantity: item[:quantity],
price: item[:price],
)
Product.find(item[:product_id]).decrement!(:stock, item[:quantity])
end
end
create! и decrement! с восклицательным знаком выбрасывают исключение при ошибке — транзакция откатится автоматически.
Сроки
Настройка ActiveRecord для нового Rails-проекта с нуля, включая реплику, миграции, seed-данные и Bullet: 1 день. Оптимизация существующего приложения (устранение N+1, добавление индексов, переход на async queries): 1–2 дня.







