Шпаргалка по транзакциям в Rails
Недавно разбираясь с багой в коде after_commit
callback‘а в текущем проекте я внезапно осознал, что смутно представляю когда и как ActiveRecord создает неявные транзакции. after_commit
вызывается при завершении транзакции и важно понимать где и когда это происходит. Особенно меня интересовали вложенные неявные транзакции. Изучив тему, пришла мысль систематизировать и оформить все в виде такой себе шпаргалки по работе с транзакциями в Rails. Для наглядности везде приводятся генерируемые SQL-запросы на примере PostgreSQL, так как обычно это опускают и в статьях и документации по Rails.
Неявные транзакции
Как всем хорошо известно ActiveRecord неявно оборачивает все операции на которые можно навесить callback‘и (create
/save
/update
/destroy
, touch
, toggle
) в транзакции.
Account.create(name: 'KFC')
BEGIN
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "KFC"]]
COMMIT
Транзакция начинается командой BEGIN
и завершается командой COMMIT
. Для отката всех изменений транзакции используется команда ROLLBACK
(PostgreSQL documentation).
Если сохраняется целое дерево объектов (основная модель и ассоциированные с ней объекты), то эти операции также оборачиваются в одну общую транзакцию.
Account.create(
name: 'KFC',
payments: [Payment.new(amount: 10), Payment.new(amount: 13)]
)
BEGIN
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "KFC"]]
INSERT INTO "payments" ("amount", "account_id") VALUES ($1, $2) RETURNING "id" [["amount", "10.0"], ["account_id", 1]]
INSERT INTO "payments" ("amount", "account_id") VALUES ($1, $2) RETURNING "id" [["amount", "13.0"], ["account_id", 1]]
COMMIT
Явные транзакции
Чтобы создать транзакцию явно, нужно использовать метод ActiveRecord::Base.transaction
, вызывать метод transaction
на любом классе модели или на самом экземпляре. Все операции в транзакции оборачиваются в стандартный блок BEGIN
/COMMIT
.
ActiveRecord::Base.transaction do
Account.create(name: 'KFC')
end
BEGIN
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "KFC"]]
COMMIT
Хотя вызов Account.create
должен создавать свою транзакцию, внутри явно открытой транзакции неявная вложенная транзакция “поглощается”. Т.е. по умолчанию вложенная транзакция не создается.
Аналогично вызов transaction
внутри другой явной транзакции не приводит к созданию вложенной.
ActiveRecord::Base.transaction do
ActiveRecord::Base.transaction do
Account.create(name: 'KFC')
end
end
BEGIN
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "KFC"]]
COMMIT
Вложенные транзакции
Настоящие вложенные транзакции поддерживаются (согласно документации Rails) только в MS SQL, и в остальных поддерживаемых Rails РСУБД они имитируются save point‘ами (PostgreSQL documentation). Как следует из названия это просто точки восстановления, которые дают возможность откатить сделанные изменения до сохраненного ранее save point‘а. Как в компьютерных играх к сохраненному состоянию можно возвращаться множество раз из произвольного места в транзакции.
Доступные следующий операции:
SAVEPOINT
- создать save pointROLLBACK TO SAVEPOINT
- востановить состояние на момент создания save point‘аRELEASE SAVEPOINT
- удалить save point
Использовать save point‘ы можно только внутри транзакции (блока BEGIN
/COMMIT
). Очевидно, что это не полноценные транзакции, так как из свойств ACID этот механизм предоставляет только atomicity (“A”). Изоляция (“I”) не обеспечивается, так как если созданы несколько save point‘ов последовательно, то изменения в одной из них будут видны в остальных, в отличии от настоящих транзакций. Долговечность (“D”) тоже не гарантируется - завершение вложенной “транзакции” (RELEASE SAVEPOINT
) не означает сброс данных на диск, в отличии от завершения настоящей транзакции (командой COMMIT
).
Согласно документации save point должен автоматически удаляться если создается новый save point с таким же именем. В PostgreSQL предыдущий save point не удаляется и будет доступен снова после того как новый save point будет удален.
По умолчанию ActiveRecord не создает вложенную транзакцию (транзакцию в терминах Rails, не базы данных). Чтобы ее все таки создать, нужно использовать опцию requires_new: true
.
ActiveRecord::Base.transaction do
Account.create(name: 'KFC')
ActiveRecord::Base.transaction(requires_new: true) do
Account.create(name: "McDonald's")
end
end
BEGIN
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "KFC"]]
SAVEPOINT active_record_1
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "McDonald's"]]
RELEASE SAVEPOINT active_record_1
COMMIT
Как видно, второй INSERT
-запрос обернут в SAVEPOINT
/RELEASE SAVEPOINT
. Так имитируется вложенная транзакция.
Есть еще одна полезная опция joinable: false
. Она означает, что любая вложенная транзакция не будет “поглощаться” внешней (если ту создать с joinable: false
) .
ActiveRecord::Base.transaction(joinable: false) do
Account.create(name: 'KFC')
Account.create(name: "McDonald's")
end
BEGIN
SAVEPOINT active_record_1
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "KFC"]]
RELEASE SAVEPOINT active_record_1
SAVEPOINT active_record_1
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "McDonald's"]]
RELEASE SAVEPOINT active_record_1
COMMIT
То же самое можно наблюдать для вложенного вызова transaction
- он тоже теперь оборачивается в SAVEPOINT
/RELEASE SAVEPOINT
.
ActiveRecord::Base.transaction(joinable: false) do
ActiveRecord::Base.transaction do
Account.find_by(name: 'KFC')
end
end
BEGIN
SAVEPOINT active_record_1
SELECT "accounts".* FROM "accounts" WHERE "accounts"."name" = $1 LIMIT $2 [["name", "KFC"], ["LIMIT", 1]]
RELEASE SAVEPOINT active_record_1
COMMIT
Если есть несколько уровней вложенных транзакций, то не “поглощаются” только вложенные транзакции верхнего уровня. Остальные вложенные транзакции будут “поглощены”.
ActiveRecord::Base.transaction(joinable: false) do
ActiveRecord::Base.transaction do
ActiveRecord::Base.transaction do
Account.find_by(name: 'KFC')
end
end
end
BEGIN
SAVEPOINT active_record_1
SELECT "accounts".* FROM "accounts" WHERE "accounts"."name" = $1 LIMIT $2 [["name", "KFC"], ["LIMIT", 1]]
RELEASE SAVEPOINT active_record_1
COMMIT
Откат транзакций
Откат транзакции происходит автоматически, если был брошен exception.
ActiveRecord::Base.transaction do
Account.create(name: 'KFC')
raise
end
BEGIN
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "KFC"]]
ROLLBACK
Если exception брошен из вложенной транзакции, очевидно, откатывается как вложенная так и внешняя транзакция.
ActiveRecord::Base.transaction do
Account.create(name: 'KFC')
ActiveRecord::Base.transaction(requires_new: true) do
Account.create(name: "McDonald's")
raise
end
end
BEGIN
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "KFC"]]
SAVEPOINT active_record_1
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "McDonald's"]]
ROLLBACK TO SAVEPOINT active_record_1
ROLLBACK
Есть специальный exception ActiveRecord::Rollback
, который обрабатывается в транзакции особым образом. Обычный exception приведет к откату транзакции и будет брошен повторно. ActiveRecord::Rollback
приводит к откату транзакцию но повторно он не бросается. Так, например, можно откатить вложенную транзакцию но не прерывать внешнюю.
ActiveRecord::Base.transaction do
Account.create(name: 'KFC')
ActiveRecord::Base.transaction(requires_new: true) do
Account.create(name: "McDonald's")
raise ActiveRecord::Rollback
end
end
BEGIN
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "KFC"]]
SAVEPOINT active_record_1
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "McDonald's"]]
ROLLBACK TO SAVEPOINT active_record_1
COMMIT
Здесь есть одна тонкость в реализации этого механизма ActiveRecord. Как мы видели, если вложенная транзакция была объявлена без requires_new: true
, то она присоединяется к внешней транзакции, и фактически никакой вложенной транзакции нет. Соответственно, ее нельзя ни откатить ни закомитить. Но ActiveRecord::Rollback
exception все равно будет перехватываться на уровне этой “присоединенной” несуществующей вложенной транзакции и, таким образом, никакого отката изменений не будет.
ActiveRecord::Base.transaction do
Account.create(name: 'KFC')
ActiveRecord::Base.transaction do
Account.create(name: "McDonald's")
raise ActiveRecord::Rollback
end
end
BEGIN
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "KFC"]]
INSERT INTO "accounts" ("name") VALUES ($1) RETURNING "id" [["name", "McDonald's"]]
COMMIT
Заключение
Приведенные примеры проверялось как на Rails 4.2 так и на текущей версии Rails 5.2, поэтому можно рассчитывать на стабильность этого поведения.
Здесь не был рассмотрен вопрос пессимистичных блокировок, которые тесно связаны с транзакциями (PostgreSQL documentation). В Rails это реализуется с помощью методов:
ActiveRecord::Base#lock!
,ActiveRecord::Base#with_lock
- и
ActiveRecord::QueryMethods#lock
Детальнее это описано здесь - ActiveRecord::Locking::Pessimistic.