Перейти к основному содержимому

5.11. Работа с БД в Ruby

Разработчику Архитектору

Работа с БД в Ruby

Ruby — язык, построенный на принципах человекоцентричности и выразительности, но при этом обладающий строгой архитектурой для работы с данными. Эффективное использование Ruby в задачах хранения, обработки и передачи информации требует чёткого понимания трёх уровней:

  • файловый уровень — работа с данными на уровне операционной системы;
  • уровень памяти и структур данных — манипуляции с объектами внутри процесса;
  • уровень внешнего хранения — взаимодействие с СУБД через драйверы, адаптеры и ORM.

Эти уровни не являются изолированными: они образуют непрерывную цепочку трансформации данных — от последовательности байтов в файловой системе до объекта в памяти, от объекта — к SQL-оператору, от SQL-ответа — обратно к объекту в приложении. Понимание этой цепочки необходимо для осознанного выбора инструментов, диагностики проблем и проектирования устойчивых архитектур.


Как Ruby работает с файлами

Работа с файлами в Ruby реализована посредством класса File, а также вспомогательных классов Dir, Pathname, Tempfile и модуля FileUtils. Все операции с файлами в Ruby происходят в рамках потоков ввода-вывода, представленных классом IO — родительским для File, Socket, StringIO и других.

Файловые дескрипторы и потоки

Каждый открытый файл в Ruby представлен экземпляром File, который инкапсулирует файловый дескриптор — целочисленный идентификатор, выданный ядром ОС. Дескрипторы управляются операционной системой, а Ruby предоставляет высокоуровневый API для их использования.

file = File.open('data.txt', 'r')
# ... работа с файлом
file.close

Рекомендуемый способ — использование блоковой формы File.open, при которой Ruby автоматически гарантирует закрытие файла по выходу из блока, даже при возникновении исключения:

File.open('data.txt', 'r') do |f|
content = f.read
# обработка
end
# файл гарантируется закрыт

Режимы доступа

Режимы открытия файлов ('r', 'w', 'a', 'r+', 'w+', 'a+') определяют, как будет использоваться файл: только для чтения, для записи с обнулением, для дописывания, совмещённый доступ и т.д. Каждый режим переводится в соответствующие флаги системного вызова open(2).

Важно: Ruby не накладывает собственных ограничений на размер обрабатываемых файлов, но при работе с большими объёмами данных необходимо учитывать потребление памяти. Методы вроде read загружают содержимое целиком, тогда как each_line, readpartial, gets позволяют читать потоково, построчно или блоками.

Кодировки и бинарные данные

Ruby с версии 1.9 обладает встроенной поддержкой многобайтовых кодировок. По умолчанию строки создаются с кодировкой Encoding.default_external, обычно UTF-8. При открытии файла можно явно указать кодировку:

File.open('data.txt', 'r:UTF-8') { |f| f.read }

Для бинарных данных используется модификатор 'b', отключающий автоматическое преобразование окончаний строк и интерпретацию байтов как символов:

File.open('image.png', 'rb') { |f| f.read }

Таким образом, работа с файлами в Ruby — это управляемая, кодировко-осознанная, потоково-ориентированная система ввода-вывода, строго привязанная к семантике операционной системы, но при этом адаптированная под удобство разработчика.


Как Ruby работает с данными в памяти

Прежде чем данные попадут в базу или будут прочитаны из неё, они проходят стадию жизни в оперативной памяти. В Ruby любые данные представлены объектами — экземплярами классов: String, Integer, Array, Hash, Time, BigDecimal, пользовательские классы и т.д.

Неизменяемость и мутабельность

Ruby следует стратегии «по умолчанию — мутабельность»: большинство встроенных структур (String, Array, Hash) позволяют изменять своё состояние in-place. Пример:

arr = [1, 2, 3]
arr << 4 # изменяет сам объект `arr`, а не создаёт новый

Однако существует и неизменяемый аналог — FrozenObject, а также методы-клонаторы (dup, clone). Для безопасной обработки данных, особенно в многопоточной среде или при передаче по цепочке трансформаций, часто применяют функциональный стиль с неизменяемыми структурами (map, select, reduce), создающими новые объекты, а не изменяющими исходные.

Сериализация и десериализация

Для сохранения состояния объекта в файл или передачи по сети применяется сериализация. Ruby поддерживает несколько форматов:

  • Marshal — встроенный механизм двоичной сериализации, быстрый и компактный, но не совместим между версиями Ruby, не безопасен для десериализации из ненадёжных источников (возможен RCE), и не поддерживает кросс-языковое чтение;
  • JSON — через JSON.generate / JSON.parse; требует, чтобы объекты были совместимы с JSON-моделью (только nil, true, false, числа, строки, массивы, хэши с символьными или строковыми ключами);
  • YAML — через YAML.dump / YAML.load; выразительный, человекочитаемый, но медленнее JSON и потенциально опасен при десериализации (CVE-2013-0156 и другие уязвимости);
  • CSV, XML, protobuf, MessagePack — через внешние библиотеки (csv, nokogiri, google-protobuf, msgpack соответственно).

Сериализация — это ключевой мост между оперативным представлением данных и их внешним хранением, будь то файл, сетевой пакет или строка SQL-запроса.

Временные зоны, форматы дат и локализация

Ruby различает Time (основан на системном времени Unix, обычно UTC) и DateTime/Date (из стандартной библиотеки date). Класс Time поддерживает временную зону, но по умолчанию не хранит её метаданные — только смещение в момент конвертации. Для правильной работы с часовыми поясами рекомендуется:

  • использовать UTC внутри приложения;
  • конвертировать в локальное время только при вводе/выводе;
  • применять библиотеку tzinfo (входит в Rails) для работы с именованными зонами (например, 'Europe/Moscow'), а не только с UTC-смещениями.

Это особенно важно при сохранении временных меток в базе: хранение timestamp without time zone вместо timestamptz — частая ошибка, ведущая к рассинхронизации приложений в распределённых системах.


Как Ruby работает с базами данных и СУБД

Непосредственное взаимодействие Ruby с СУБД происходит через драйверы — библиотеки, реализующие протокол общения с конкретной системой (PostgreSQL, MySQL, SQLite и др.). Ruby не включает в стандартную поставку универсальный драйвер для СУБД, но предлагает Database Interface (DBI) — исторически первая попытка унификации, и, более современный и широко принятый стандарт — Ruby DB API, реализованный в виде интерфейса Database Connector.

Архитектура подключения: драйвер → адаптер → приложение

В типичной схеме:

  1. Драйвер (native driver) — низкоуровневая библиотека, написанная на C/Ruby, реализующая протокол СУБД (например, pg для PostgreSQL, mysql2 для MySQL, sqlite3 для SQLite). Она управляет соединением, отправкой байтовых пакетов, разбором ответов, обработкой ошибок на уровне протокола.
  2. Адаптер (adapter) — прослойка, нормализующая API разных драйверов под единый интерфейс. Часто реализуется в рамках ORM (например, ActiveRecord::ConnectionAdapters::PostgreSQLAdapter).
  3. Уровень приложения — код бизнес-логики, использующий единый API без привязки к конкретной СУБД.

Пример: прямая работа через драйвер pg

require 'pg'

conn = PG.connect(
host: 'localhost',
dbname: 'myapp_dev',
user: 'timur',
password: 'secret'
)

res = conn.exec('SELECT id, name FROM users WHERE active = $1', [true])
res.each do |row|
puts "ID: #{row['id']}, Name: #{row['name']}"
end
res.clear
conn.close

Здесь:

  • PG.connect устанавливает TCP-соединение с PostgreSQL, проходит аутентификацию (например, по md5 или scram-sha-256);
  • exec отправляет подготовленный запрос с параметризацией, предотвращая SQL-инъекции на уровне драйвера (значения $1, $2 передаются отдельно от текста запроса);
  • res — объект PG::Result, реализующий перебор строк без загрузки всего результата в память;
  • res.clear освобождает память, выделенную драйвером под ответ (важно при работе с большими выборками);
  • conn.close закрывает TCP-соединение.

Обратите внимание: даже на этом уровне Ruby-разработчик не работает с сырыми сокетами. Драйвер берёт на себя:

  • управление буферами;
  • повторные попытки при обрыве соединения (в некоторых реализациях);
  • конвертацию типов PostgreSQL (int4Integer, timestamptzTime, jsonbHash);
  • обработку уведомлений LISTEN/NOTIFY;
  • поддержку COPY для массовой загрузки.

Подключение к другим СУБД

Для MySQL используется драйвер mysql2, API которого схож, но не идентичен:

client = Mysql2::Client.new(host: 'localhost', username: 'root')
results = client.query('SELECT * FROM users')
results.each { |row| ... }

Здесь Mysql2::Result также поддерживает потоковый перебор, но параметризация производится по-другому — через интерполяцию с экранированием (.escape) или через prepare + execute.

SQLite3 работает через SQLite3::Database, полностью встраиваемую библиотеку — соединение устанавливается с файлом .db, а не с сетевым хостом.

Пул соединений

В веб-приложениях соединение с БД нельзя открывать/закрывать на каждый HTTP-запрос — это приведёт к исчерпанию лимитов СУБД и высокой задержке. Поэтому используется пул соединений — ограниченный набор открытых соединений, из которого приложение берёт свободное на время обработки запроса, а затем возвращает его в пул.

Драйверы вроде pg и mysql2 сами по себе не реализуют пул — он строится на уровне адаптера (например, в ActiveRecord) или с помощью отдельных библиотек (connection_pool). Пример минимального пула:

require 'connection_pool'
require 'pg'

pool = ConnectionPool.new(size: 5, timeout: 5) do
PG.connect(dbname: 'myapp')
end

pool.with_connection do |conn|
conn.exec('SELECT 1')
end

Это критически важный механизм масштабируемости: без пула даже десяток параллельных запросов могут привести к отказу СУБД.

Обработка транзакций

Транзакции в Ruby реализуются через явные вызовы BEGIN, COMMIT, ROLLBACK или через методы-обёртки драйверов/адаптеров:

conn.transaction do
conn.exec('INSERT INTO logs VALUES ($1)', ['start'])
# при исключении автоматически происходит ROLLBACK
end

Такие блоки гарантируют атомарность: либо все изменения фиксируются, либо ни одно. Поддержка вложенных транзакций (savepoints) зависит от СУБД и драйвера.


ORM в Ruby: смысл, границы и компромиссы

Объектно-реляционное отображение (ORM) — архитектурный паттерн, решающий фундаментальную проблему: несоответствие между парадигмами объектно-ориентированного программирования и реляционной модели данных.

Это несоответствие проявляется в нескольких плоскостях:

ПлоскостьОбъектная модельРеляционная модель
ГранулярностьОбъекты могут содержать вложенные структуры, коллекции, ссылки на другие объектыТаблицы — плоские наборы строк; связи реализуются через внешние ключи
ИдентичностьОбъект идентифицируется по ссылке (object_id) и/или бизнес-значениюСтрока идентифицируется первичным ключом (обычно суррогатным id)
НаследованиеПоддерживается на уровне языка (классы, модули, super)Не поддерживается напрямую; требует стратегий: одна таблица на иерархию, одна таблица на класс, или класс-таблица
ПоведениеДанные + методы в одном объекте (инкапсуляция)Данные отделены от логики (логика в триггерах, процедурах, либо вынесена в приложение)
Жизненный циклОбъект создаётся, изменяется, уничтожается в памяти независимо от БДСтрока существует до явного DELETE; изменения фиксируются только внутри транзакции

ORM призван смягчить это несоответствие, но не устранить его полностью. Любая ORM — это компромисс между выразительностью, производительностью и контролем.

В Ruby этот компромисс проявляется особенно чётко: язык позволяет писать очень лаконичный, «магический» код, но цена этой магии — снижение прозрачности и предсказуемости. Поэтому выбор ORM (или отказ от неё) — это стратегическое решение, влияющее на:

  • скорость разработки прототипов;
  • сложность отладки производительности;
  • гибкость при изменении схемы БД;
  • возможность использования продвинутых SQL-фич (CTE, оконные функции, полнотекстовый поиск, гео-индексы).

Сравнение подходов: raw SQL → микрослои → ORM

1. Прямой SQL («bare metal»)

Что это: использование драйверов (pg, mysql2) напрямую, без промежуточных слоёв.

Преимущества:

  • Полный контроль над запросами, индексами, планами выполнения;
  • Возможность использовать 100 % возможностей СУБД (например, JSONB-операторы в PostgreSQL, ON CONFLICT DO UPDATE, LATERAL JOIN);
  • Минимальные накладные расходы на абстракции;
  • Прозрачная отладка: текст запроса виден явно.

Недостатки:

  • Дублирование кода при повторяющихся операциях (SELECT * FROM users WHERE id = ?);
  • Уязвимость к SQL-инъекциям при неправильной параметризации;
  • Сложность поддержки: изменение схемы требует ручного поиска всех запросов;
  • Отсутствие автоматической синхронизации объектов с БД (нет единого места, где описано: «что такое Пользователь»).

Когда оправдан:
в high-load сервисах, аналитических задачах, ETL-процессах, микросервисах, где требуется максимальная эффективность или нетиповые запросы.

2. Микрослои: Sequel, ROM

Что это: библиотеки, предоставляющие DSL для генерации SQL, но не навязывающие объектную модель. Пример — Sequel:

DB[:users].where(active: true).order(:created_at).limit(10)
# → SELECT * FROM users WHERE active = true ORDER BY created_at LIMIT 10

Особенности:

  • Запросы строятся программно, через цепочки методов;
  • Результат — хэши или простые структуры (Sequel::Model опционален);
  • Поддержка сложных конструкций: CTE, UNION, подзапросы;
  • Возможность «провалиться» в raw SQL в любой момент (Sequel.lit("..."));
  • Независимость от фреймворка — работает вне Rails.

Преимущества:

  • Баланс между выразительностью и контролем;
  • Лёгкость тестирования (запросы изолированы);
  • Хорошо подходит для read-heavy приложений и API;
  • Чёткое разделение: запросдоменная сущность.

Недостатки:

  • Нет встроенной поддержки миграций, ассоциаций, валидаций (требуются доп. расширения);
  • Меньше «магии» — разработчик сам управляет загрузкой связанных данных;
  • Меньше документации и сообщества по сравнению с ActiveRecord.

3. Полноценные ORM: ActiveRecord

Что это: полный цикл управления объектами, привязанных к строкам таблиц. ActiveRecord — часть Rails, но может использоваться отдельно.

Основной тезис ActiveRecord:

«Объект, соответствующий строке в таблице, несёт в себе и данные, и поведение, и знание о том, как сохранять/загружать себя».

Это реализует принцип Active Record (Martin Fowler, Patterns of Enterprise Application Architecture), в отличие от Data Mapper (например, Hibernate, SQLAlchemy), где объекты и механизм сохранения строго разделены.


ActiveRecord: устройство и принципы

Ядро: ActiveRecord::Base

Любой класс, наследующий от ActiveRecord::Base, автоматически:

  • связывается с таблицей по соглашению (например, Userusers);
  • получает методы доступа к атрибутам (столбцам таблицы);
  • наследует CRUD-операции: create, find, save, destroy и т.д.

Пример:

class User < ActiveRecord::Base
end

user = User.find(42)
user.name = 'Тимур'
user.save

Внутри происходит:

  1. При первом обращении к User ActiveRecord читает метаданные таблицы (DESCRIBE users или information_schema);
  2. Создаётся кэш столбцов: имена, типы, NULL-допуск;
  3. Для каждого столбца динамически определяются геттеры/сеттеры (name, name=);
  4. При save генерируется UPDATE users SET name = 'Тимур' WHERE id = 42.

Важно: ActiveRecord не кэширует сами данные — каждый вызов find идёт в БД (если не используется ActiveRecord::QueryCache). Это ключевое отличие от Data Mapper, где часто применяется Identity Map.

Соглашения по умолчанию

ActiveRecord строго следует принципу «convention over configuration». Основные соглашения:

ПонятиеСоглашениеПример
Таблицамножественное, нижний регистр, подчёркиванияusers, order_items
Первичный ключid, тип bigintusers.id
Внешний ключ<имя_связи>_iduser_id в orders
Метка времениcreated_at, updated_at (автообновление при save)
Полиморфная связь<имя>_id, <имя>_typecommentable_id, commentable_type
Соединительная таблицалексикографический порядок имёнposts_tags (не tags_posts)

Эти соглашения снижают количество конфигурации, но требуют строгого следования. Отклонение возможно через явные настройки (self.table_name = 'my_users'), но нарушает предсказуемость.

Ассоциации: связи между сущностями

ActiveRecord предоставляет декларативный DSL для описания отношений:

class User < ActiveRecord::Base
has_many :posts
has_one :profile
belongs_to :department
end

class Post < ActiveRecord::Base
belongs_to :user
has_and_belongs_to_many :tags
end

Под капотом:

  • belongs_to :user → добавляет метод user, выполняющий SELECT * FROM users WHERE id = self.user_id;
  • has_many :posts → добавляет метод posts, выполняющий SELECT * FROM posts WHERE user_id = self.id;
  • При первом обращении к user.posts происходит ленивая загрузка (lazy loading) — запрос в БД выполняется только тогда, когда данные реально нужны.

Проблема N+1:
Если в цикле @users.each { |u| puts u.posts.count } — для каждого пользователя будет отдельный SELECT COUNT(*) FROM posts WHERE user_id = ?. Это классическая ошибка, решаемая через includes, eager_load, preload или joins.

Миграции: управление схемой как код

Миграции — это версионированный, воспроизводимый способ изменения структуры БД. Каждая миграция — Ruby-класс, наследующий от ActiveRecord::Migration:

class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :name, null: false
t.string :email, index: { unique: true }
t.timestamps
end
end
end

Внутри change — идемпотентные операции. При выполнении rails db:migrate:

  1. ActiveRecord читает таблицу schema_migrations;
  2. Находит неприменённые миграции (по версии времени);
  3. Выполняет change в транзакции (если СУБД это поддерживает);
  4. Записывает версию в schema_migrations.

Миграции позволяют:

  • Синхронизировать схему между разработчиками и средами;
  • Откатывать изменения (down);
  • Генерировать schema.rb — Ruby-представление текущей схемы (для быстрой загрузки в тестах).

Ограничения:

  • Миграции не должны зависеть от данных (например, User.first.update(...) — антипаттерн);
  • Изменения в продакшене требуют осторожности: add_column с NOT NULL без default блокирует таблицу в PostgreSQL.

Коллбэки и валидации: поведение объекта

ActiveRecord интегрирует логику в жизненный цикл объекта:

class User < ActiveRecord::Base
before_save :normalize_email
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }

private

def normalize_email
self.email = email.downcase.strip if email.present?
end
end

Коллбэки (before_save, after_create, around_destroy и др.) — это хуки, вызываемые в определённые моменты транзакции. Они удобны для:

  • нормализации данных;
  • логирования;
  • отправки уведомлений.

Но опасны при:

  • побочных эффектах вне транзакции (отправка email в after_commit — безопасно, в after_create — нет);
  • глубокой вложенности (save → коллбэк → update → другой коллбэк);
  • скрытой логике, усложняющей тестирование.

Валидации выполняются до отправки запроса в БД. Это позволяет:

  • давать человекочитаемые ошибки без round-trip к СУБД;
  • проверять сложные бизнес-правила.

Однако: валидации — не замена ограничениям БД. Уникальность email должна быть обеспечена и validates :uniqueness, и UNIQUE INDEX в БД — иначе возможны гонки при параллельных запросах.

Ленивые загрузки и выполнение запросов

ActiveRecord использует ленивые реляционные запросы (lazy relations). Выражение:

users = User.where(active: true).order(:name)

не выполняет SQL. Оно возвращает объект ActiveRecord::Relation — отложенный запрос. SQL будет сгенерирован и исполнен только при:

  • переборе (each, map);
  • вызове методов, требующих данных (first, last, count, to_a);
  • явном load.

Это позволяет строить запросы по частям:

scope = User.all
scope = scope.where(active: true) if params[:active]
scope = scope.limit(10) if params[:limit]
@users = scope # выполнится только при рендеринге

Но создаёт риск неожиданного запроса в «неподходящем месте» (например, внутри шаблона — это N+1 в чистом виде).


Производительность и отладка: как видеть то, что делает ActiveRecord

Логирование SQL

ActiveRecord автоматически логирует все генерируемые SQL-запросы в Rails.logger (по умолчанию — log/development.log). Формат записи включает:

  • текст запроса с подставленными параметрами (в развёрнутом виде, не ?);
  • время выполнения в миллисекундах;
  • источник вызова (файл и строка, если включено config.active_record.verbose_query_logs = true).

Пример:

User Load (0.8ms)  SELECT "users".* FROM "users" WHERE "users"."id" = $1  [["id", 42]]

Важно: в production логирование SQL отключено по умолчанию (из соображений безопасности и производительности). При диагностике проблем его можно временно включить через:

ActiveRecord::Base.logger = Logger.new(STDOUT)

или на уровне запроса:

User.where(active: true).explain
# → выводит EXPLAIN ANALYZE для PostgreSQL

Инструмент explain

Метод .explain вызывает EXPLAINEXPLAIN ANALYZE, если поддерживается) в СУБД и возвращает план выполнения. Для PostgreSQL:

User.where(email: 'timur@example.com').explain
# → Seq Scan on users (cost=0.00..22.50 rows=1 width=202)
# Filter: ((email)::text = 'timur@example.com'::text)

Если индекс по email отсутствует — будет Seq Scan. С индексом — Index Scan.
Это прямой способ проверить, используется ли индекс в конкретном контексте запроса, а не только «есть ли он в таблице».

Отладка N+1 и ленивых загрузок

Проблема N+1 возникает, когда один запрос порождает N дополнительных. ActiveRecord не предотвращает её автоматически — но предоставляет инструменты диагностики.

  1. Статический анализ: гем bullet (не для продакшена!) перехватывает запросы и предупреждает о потенциальных N+1 при загрузке страницы.

  2. Ручной аудит через логи: при включённом логировании видно множественные повторяющиеся запросы к одной таблице с разными WHERE id = ?.

  3. Принудительная строгость: в тестах или staging можно включить режим, при котором любой запрос вне транзакции или контроллера вызывает исключение:

# config/environments/test.rb
config.active_record.raise_in_transactional_tests = true
  1. Эвристика includes vs joins:
    • includesLEFT OUTER JOIN + отдельный SELECT для ассоциаций (preload), если есть условия по связанным таблицам — переключается на eager_load (join);
    • joins → только INNER JOIN, без загрузки ассоциированных объектов (подходит для фильтрации, но не для использования полей связи);
    • preload → гарантированно два отдельных запроса: один для основной таблицы, один для связанных.

Выбор зависит от:

  • нужна ли фильтрация по полям связи (joins);
  • нужен ли доступ к полям связи (includes/preload);
  • допустима ли избыточность данных при JOIN (дублирование строк основной таблицы).

Измерение времени и профилирование

Для точного замера используется Benchmark или ActiveSupport::Notifications:

ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
puts "#{event.name} – #{event.duration} ms"
end

Это позволяет строить агрегированные отчёты: сколько запросов, суммарное время, медленные операции.


Транзакции и конкурентность

Уровни изоляции

ActiveRecord позволяет задать уровень изоляции при открытии транзакции:

User.transaction(isolation: :serializable) do
# ...
end

Поддерживаемые уровни зависят от СУБД:

  • PostgreSQL: read_uncommitted, read_committed (по умолчанию), repeatable_read, serializable;
  • MySQL (InnoDB): read_uncommitted, read_committed, repeatable_read (по умолчанию), serializable.

Важно: Ruby не эмулирует уровни изоляции — он просто передаёт директиву SET TRANSACTION ISOLATION LEVEL ... СУБД. Поведение определяется ядром базы данных.

Optimistic Locking

Защита от перезаписи при параллельном редактировании. Включается автоматически, если в таблице есть столбец lock_version (целое число, по умолчанию 0):

class Article < ActiveRecord::Base
# lock_version должен быть в таблице
end

# Поток 1:
a1 = Article.find(1)
a1.title = "v1"
a1.save # → UPDATE ... SET title = 'v1', lock_version = 1 WHERE id = 1 AND lock_version = 0

# Поток 2 (одновременно):
a2 = Article.find(1) # lock_version = 0
a2.title = "v2"
a2.save # пытается: UPDATE ... WHERE lock_version = 0 → 0 rows affected → выбрасывает ActiveRecord::StaleObjectError

Это проверяет, не изменилась ли строка с момента чтения. Подходит для сценариев с низкой конкуренцией и частыми конфликтами.

Pessimistic Locking

Явная блокировка строки через SELECT ... FOR UPDATE:

article = Article.lock.find(1)
# Выполняет: SELECT * FROM articles WHERE id = 1 FOR UPDATE
# Другие транзакции, пытающиеся взять FOR UPDATE или изменить строку, будут ждать
article.update!(title: 'Locked')

Методы:

  • .lockFOR UPDATE;
  • .lock('FOR SHARE') → разделяемая блокировка (только чтение);
  • .with_lock { ... } → оборачивает блок в транзакцию + FOR UPDATE.

Осторожно:
FOR UPDATE может вызвать взаимные блокировки (deadlock);
— в PostgreSQL блокировка снимается только по COMMIT/ROLLBACK;
— в MySQL (InnoDB) — то же самое, но поведение при SELECT без индекса может привести к блокировке всей таблицы.

touch: true и кэширование

Опция touch: true в ассоциациях автоматически обновляет updated_at у родительского объекта при изменении дочернего:

class Comment < ActiveRecord::Base
belongs_to :post, touch: true
end

При сохранении комментария:

UPDATE posts SET updated_at = NOW() WHERE id = ?;
UPDATE comments SET ... WHERE id = ?;

Это критически важно для:

  • инвалидации кэшей (например, Rails.cache.fetch("post/#{post.id}"));
  • сортировки по времени последней активности;
  • интеграций, отслеживающих изменения через updated_at.

Без touch изменение комментария не отразится на updated_at поста — и кэш не обновится.


Расширение ActiveRecord: типы, scope, enum, сериализация

Кастомные типы

ActiveRecord позволяет регистрировать собственные преобразователи значений «столбец ↔ объект». Например, для хранения Money как целого числа (копейки):

class MoneyType < ActiveRecord::Type::Value
def cast(value)
return value if value.is_a?(Money)
Money.new(value.to_i)
end

def serialize(value)
value.cents
end

def deserialize(value)
Money.new(value.to_i)
end
end

ActiveRecord::Type.register(:money, MoneyType)

class Product < ActiveRecord::Base
attribute :price, :money
end

Теперь product.price — объект Money, а в БД хранится INTEGER. Преимущества:

  • вынос логики преобразования из модели;
  • переиспользование типа в разных моделях;
  • поддержка валидаций и сериализации.

Scope

Scope — это именованные, цепляемые запросы, возвращающие Relation:

class User < ActiveRecord::Base
scope :active, -> { where(active: true) }
scope :by_name, ->(name) { where('name ILIKE ?', "%#{name}%") }
end

User.active.by_name('Тимур')

Scope всегда ленив: не выполняет запрос, возвращает Relation.
Правило: scope не должен вызывать all, to_a, first — это нарушает композируемость.

Enum

Символические перечисления, маппящиеся на целочисленные или строковые значения в БД:

class User < ActiveRecord::Base
enum status: { draft: 0, active: 1, archived: 2 }
end

user = User.new(status: :active)
user.active? # → true
user.status = 'archived'

Генерируемые методы:

  • status'active';
  • status= → присваивает символ или строку;
  • active! → сохраняет статус active;
  • active? → проверка.

Под капотом — преобразование между символом и значением. Хранение как INTEGER экономит место; как VARCHAR — повышает читаемость дампов.

Сериализация в JSON/Hash

Для хранения структурированных данных в одном столбце:

class User < ActiveRecord::Base
serialize :settings, JSON
# или в Rails 5+:
store :settings, accessors: [:theme, :language], coder: JSON
end

user = User.new
user.theme = 'dark'
user.language = 'ru'
user.save
# → INSERT ... (settings) VALUES ('{"theme":"dark","language":"ru"}')

store_accessor создаёт геттеры/сеттеры для вложенных ключей. Важно:

  • нет индексов по вложенным полям (в PostgreSQL можно использовать jsonb_path_ops);
  • валидация вложенных полей требует кастомных валидаторов;
  • миграция структуры settings — ручная задача (например, через update_all).

Границы ActiveRecord: когда и как выходить за рамки

ActiveRecord не универсален. Его слабые места:

СценарийПроблема ActiveRecordАльтернатива
Сложные отчёты с CTE, оконными функциямиНет встроенного DSLRaw SQL через find_by_sql, execute, или Sequel
Массовые операции (bulk insert/update)save — 1 запрос на записьinsert_all, upsert_all (Rails 6+), activerecord-import
Данные вне реляционной модели (графы, документы)Нет нативной поддержкиneo4j-ruby-driver, elasticsearch-ruby, прямой API
Высокая конкурентность, fine-grained lockinglock ограничен SELECT ... FOR UPDATERedis-мьютексы, pg_advisory_lock через raw SQL
Микросервисы с разными БДЖёсткая привязка к одной схемеRepository-паттерн, ROM, или отдельные классы данных

Интеграция raw SQL в ActiveRecord

Можно безопасно встраивать SQL без отказа от ActiveRecord:

class User < ActiveRecord::Base
def self.top_contributors(limit: 10)
find_by_sql(<<~SQL, limit: limit)
SELECT users.*, COUNT(posts.id) AS post_count
FROM users
LEFT JOIN posts ON posts.user_id = users.id
GROUP BY users.id
ORDER BY post_count DESC
LIMIT :limit
SQL
end
end

find_by_sql возвращает массив объектов User, инициализированных из результата — включая виртуальные атрибуты (post_count доступен как user.post_count).

Для запросов без маппинга на модель — connection.execute:

ActiveRecord::Base.connection.execute(
'UPDATE counters SET value = value + 1 WHERE name = $1 RETURNING value',
['hits']
).values.first

Использование Sequel внутри Rails

Можно инициализировать отдельный Sequel::Database для части приложения:

# config/initializers/sequel.rb
SequelDB = Sequel.connect(ENV['DATABASE_URL'])

# app/models/analytics_report.rb
class AnalyticsReport
def self.run
SequelDB[:events]
.where{ timestamp > (Date.today - 30) }
.group_and_count(:event_type)
end
end

Преимущества:

  • не влияет на пул соединений ActiveRecord;
  • использует тот же DATABASE_URL;
  • позволяет применять мощный DSL Sequel для аналитики, оставляя доменные модели в ActiveRecord.

Безопасность: защита от угроз на уровне данных

SQL-инъекции: как ActiveRecord предотвращает и где остаётся уязвимость

ActiveRecord защищает от инъекций только при корректном использовании параметризованных запросов. Основные безопасные паттерны:

  • where(active: true)WHERE active = $1;
  • where("name = ?", name) → параметр передаётся отдельно от строки;
  • where(name: name) — автоматическая параметризация;
  • find(id) — приведение id к целому числу и проверка типа.

Небезопасные паттерны (инъекция возможна):

  • where("name = #{name}") — интерполяция в строку;
  • order(params[:sort]) — динамическое имя столбца без вайтлиста;
  • group("CASE WHEN #{cond} THEN ... END").

Для безопасной работы с динамическими именами столбцов/таблиц используется Arel.sql только с валидацией по белому списку:

allowed_columns = %w[name email created_at]
column = params[:sort].in?(allowed_columns) ? params[:sort] : 'id'
User.order(Arel.sql("#{column} ASC"))

Arel.sql не экранирует — он лишь помечает строку как «доверенную». Ответственность за валидацию лежит на разработчике.

Массовое присваивание (mass assignment) и strong parameters

Уязвимость: злоумышленник отправляет PATCH /users/42 { "admin": true }, и если в контроллере используется User.update(params[:user]), — флаг admin присваивается.

Rails 4+ решает это через strong parameters:

def user_params
params.require(:user).permit(:name, :email)
# :admin не включён — будет вырезан из хэша
end

Механизм работает на уровне ActionController::Parameters:
require проверяет наличие ключа и выбрасывает ActionController::ParameterMissing при отсутствии;
permit возвращает новый хэш, содержащий только разрешённые ключи и их значения (вложенные структуры обрабатываются рекурсивно);
— значения не модифицируются — только фильтрация ключей.

Важно: permit не валидирует типы. permit(:age) пропустит "abc", но User.new(age: "abc") вызовет ошибку при save, если столбец ageinteger. Валидация типов — задача модели или отдельного слоя.

Утечки данных через логи

По умолчанию Rails фильтрует чувствительные параметры в логах:

# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [:password, :token]

При логировании запроса:

Parameters: {"email"=>"timur@example.com", "password"=>"[FILTERED]"}

Механизм работает на уровне ActionDispatch::Http::ParameterFilter:
— совпадение по имени параметра (точное или регулярное выражение);
— фильтрация происходит до записи в лог, включая Rails.logger, Sentry, Lograge;
— не защищает от утечек в binding.pry, puts params, или кастомных логгерах.

Рекомендация: не логировать params явно — использовать filtered_parameters.

Защита от XSS при рендеринге данных из БД

Данные из БД не считаются «безопасными» по умолчанию. В ERB:

<%= user.bio %>          → HTML-экранируется (например, `<script>` → `&lt;script&gt;`)
<%= raw user.bio %> → выводится как есть (опасно!)
<%= user.bio.html_safe %> → помечает строку как доверенную (опасно!)

html_safe не очищает — он лишь устанавливает флаг html_safe? = true. Если user.bio содержит <script>, браузер его выполнит.
Чистка требует отдельных инструментов: sanitize(user.bio), Loofah, Rails::Html::WhiteListSanitizer.


Тестирование: уровни и изоляция

Транзакционные тесты

По умолчанию Rails оборачивает каждый тест в transaction и откатывает её по завершении:

# Внутри теста:
ActiveRecord::Base.connection.begin_transaction
# ... тест
ActiveRecord::Base.connection.rollback_transaction

Преимущества:

  • изоляция между тестами;
  • высокая скорость (никаких INSERT/DELETE в БД);
  • идемпотентность.

Ограничения:

  • не работают с CREATE TABLE, DROP INDEX, TRUNCATE (DDL не поддерживает вложенность в большинстве СУБД);
  • не тестируют поведение вне транзакции (например, after_commit коллбэки).

Для DDL-тестов используется use_transactional_tests = false и ручная очистка (через database_cleaner или ActiveRecord::Base.connection.execute("DELETE FROM ...")).

Фикстуры vs Factories

Фикстуры (test/fixtures/users.yml) — это YAML-файлы, загружаемые до тестового набора:

# users.yml
timur:
name: Тимур
email: timur@example.com

Плюсы:

  • скорость загрузки (один INSERT на таблицу);
  • стабильные id, удобно для тестов ассоциаций;
  • не требуют Ruby-логики.

Минусы:

  • дублирование данных между фикстурами;
  • сложно поддерживать при изменении схемы;
  • не выражают бизнес-правила (например, «активный пользователь с профилем»).

Factories (например, factory_bot):

FactoryBot.define do
factory :user do
name { "User #{sequence(:user)}" }
email { "#{name.downcase}@example.com" }
association :profile
end
end

create(:user, name: 'Тимур') # → создаёт пользователя и связанный профиль

Плюсы:

  • гибкость, композиция, наследование;
  • выражение бизнес-инвариантов в коде;
  • уникальные данные на каждый тест.

Минусы:

  • медленнее (каждый create — отдельный INSERT);
  • риск неявных зависимостей между фабриками.

Выбор зависит от типа теста:
unit-тесты моделей — factories (гибкость важнее скорости);
интеграционные/системные тесты — фикстуры (предсказуемость, быстродействие).

Мокирование БД

Для unit-тестов без обращения к БД используется мокирование:

allow(User).to receive(:find).with(42).and_return(double(id: 42, name: 'Тимур'))

Но:
— мокирование ActiveRecord-методов (where, save) часто приводит к хрупким тестам (тест зависит от реализации, а не поведения);
— лучше изолировать бизнес-логику в сервисные объекты, принимающие данные (не модели), и мокировать только внешние вызовы.

Антипаттерн:

# Плохо: тест зависит от внутреннего метода ActiveRecord
allow(user).to receive(:save).and_return(false)

Лучше:

# Хорошо: сервис принимает валидные данные и возвращает результат
result = UserService.create(name: 'Тимур', email: 't@example.com')
expect(result.success?).to be true

Миграции в продакшене: стратегии без простоев

Прямое применение rails db:migrate в production несёт риски:
— блокировка таблиц при ALTER TABLE;
— несовместимость кода и схемы в момент развёртывания.

Three-step migration — стандартная практика:

  1. Развернуть код, совместимый со старой и новой схемой.
    Пример: добавление столбца phone без NOT NULL:
    — код проверяет user.phone.present?, а не полагается на его наличие;
    — валидации не требуют phone.

  2. Применить миграцию (add_column :users, :phone, :string).
    — операция ADD COLUMN без NOT NULL и DEFAULT — мгновенная в PostgreSQL (12+), MySQL (8.0+);
    — данные не перезаписываются.

  3. Развернуть код, использующий новый столбец.
    — валидации, бизнес-логика, интерфейс.

Для несовместимых изменений (удаление столбца, изменение типа):

  • сначала убрать использование в коде (оставить столбец);
  • потом удалить столбец.

Для NOT NULL с DEFAULT:
— сначала add_column без NOT NULL;
— затем update_all порциями (чтобы не блокировать таблицу);
— затем change_column_null :users, :phone, false.

Инструменты: strong_migrations (гем, запрещающий опасные операции в development), pg_repack (перестроение таблиц без блокировки).


Мониторинг и наблюдаемость

Метрики ActiveRecord

Rails 7+ интегрируется с ActiveSupport::Notifications, что позволяет собирать:

  • количество запросов в секунду;
  • 95-й и 99-й перцентили времени выполнения;
  • количество slow queries (например, > 100 мс).

Пример отправки в Prometheus через prometheus-client:

ActiveSupport::Notifications.subscribe('sql.active_record') do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
DB_QUERY_DURATION.observe(event.duration / 1000.0) # в секундах
DB_QUERY_COUNT.increment
end

Алертинг по аномалиям

Типичные правила:

  • рост числа ROLLBACK в минуту (признак ошибок валидации или конфликтов optimistic locking);
  • увеличение времени выполнения SELECT к критическим таблицам (users, orders);
  • появление N+1: резкий скачок числа запросов на один HTTP-запрос.

Логирование slow queries

В PostgreSQL:
log_min_duration_statement = 200 — логировать запросы > 200 мс;
— в Rails: config.active_record.logger = ActiveSupport::Logger.new('log/slow_queries.log') с фильтром по duration.


Контекст: почему в Ruby доминирует ORM, а в других языках — нет

Это следствие архитектурных предпосылок языков и экосистем.

Язык/ЭкосистемаДоминирующий подходПричины
Ruby (Rails)ActiveRecord (full ORM)— Rails изначально строился как «full-stack framework» с соглашениями; — динамическая типизация позволяет легко внедрять методы в классы; — приоритет — скорость разработки MVP.
Java (Spring)JPA / Hibernate (Data Mapper ORM)— статическая типизация требует явного маппинга; — enterprise-традиции: разделение объектов и persistence; — поддержка сложных наследований, кэширования 2-го уровня.
GoRaw SQL / SQLBoiler / Ent (codegen)— отсутствие наследования и динамической диспетчеризации затрудняет ORM; — приоритет — производительность и прозрачность; — code generation компенсирует ручное написание.
RustDiesel (query builder) / sqlx (compile-time checks)— безопасность памяти запрещает «магические» маппинги времени выполнения; — compile-time проверка SQL (sqlx) — уникальное преимущество; — акцент на явном контроле.

В Ruby ORM стал стандартом, потому что:
— он органично вписался в философию Rails «convention over configuration»;
— динамическая природа языка позволяет реализовать «магию» без потери читаемости для целевой аудитории;
— сообщество сконцентрировалось на вебе, где CRUD-операции доминируют.

Однако в высоконагруженных, аналитических или embedded-сценариях Ruby-разработчики всё чаще используют микрослои (Sequel) или raw SQL — что подтверждает: инструмент выбирается под задачу, а не наоборот.