はじめに
Rails のトランザクションとコールバック処理についてまとめました。
誤り等、ご指摘ございましたらコメントいただけますと幸いです
トランザクションとは
トランザクションとは複数の処理においてデータの整合性を保つための機能であり、DBに対するデータ更新の単位とも言えます。
例えば PostgreSQL の場合、BEGIN TRANSACTION
でトランザクションの開始を宣言し、COMMIT
(コミット) においてそのトランザクションに含まれていた処理をDBに反映させます。コミットを行うまではトランザクション内の変更を破棄して元の状態に戻すこと(ロールバック)ができます。(一度コミットをしてしまったら基本的に戻すことができないので、SQL的にコミットは緊張感を伴う処理になります。)
複数の処理を実行する際に、「Aの処理は成功したがBの処理は失敗した」みたいな状態がまずい場合( 例:銀行の出金処理と入金処理)、トランザクション内でこの一連の処理を行い一方の処理が失敗した時点でロールバックすることにより、データの整合性をとることができます。
Rails におけるトランザクション
Rails の場合、ActiveRecord の save
と destroy
メソッドにおいて自動的にトランザクションが張られます。そしてこのとき、付随するコールバックもこのトランザクションによってラップされます。
コールバック
ActiveRecordオブジェクトには、create
や save
などのメソッドをトリガとする、after_create
や after_save
などといった複数のコールバックが用意されております。(ちなみに after_save
はオブジェクトの作成と更新の両方で呼び出されます)
例えば Game モデルに、関連する tickets を作成する after_create
コールバックが設定されていた場合。
class Game < ApplicationRecord
has_many :tickets
after_create :create_tickets
private
def create_tickets
5.times { tickets.create! }
end
end
Game のオブジェクトを create
すると create_tickets
の処理もトランザクションの中で行われていることが分かります。
pry(main)> Game.create(name: 'game1')
TRANSACTION (0.2ms) BEGIN -- トランザクション開始
Game Create (0.3ms) INSERT INTO "games" ("name", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [...]
Ticket Create (0.2ms) INSERT INTO "tickets" ("game_id", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [...]
Ticket Create (0.2ms) INSERT INTO "tickets" ("game_id", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [...]
Ticket Create (0.2ms) INSERT INTO "tickets" ("game_id", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [...]
Ticket Create (0.2ms) INSERT INTO "tickets" ("game_id", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [...]
Ticket Create (0.2ms) INSERT INTO "tickets" ("game_id", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [...]
TRANSACTION (0.7ms) COMMIT -- コミット
次に、意図的にロールバックを実現するために throw :abort
をコールバック内に仕込んでみます。
class Game < ApplicationRecord
has_many :tickets
after_create :create_tickets
private
def create_tickets
5.times { tickets.create! }
throw :abort # ロールバックさせるために追加
end
end
すると、ロールバックが発生し結果として Game のオブジェクトも関連する tickets も作成されないことが分かります。
Game.create(name: 'game2')
TRANSACTION (0.1ms) BEGIN -- トランザクション開始
Game Create (1.0ms) INSERT INTO "games" ("name", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [...]
Ticket Create (0.3ms) INSERT INTO "tickets" ("game_id", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [...]
-- 省略
Ticket Create (0.2ms) INSERT INTO "tickets" ("game_id", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [...]
TRANSACTION (0.1ms) ROLLBACK -- ロールバック!
このように after_create
コールバックもトランザクション内の処理として実行されるため、処理の途中で失敗した場合もデータの整合性をとることができます。
一方注意が必要なのは、あくまでDB処理におけるロールバックなので、外部に依存する処理をトランザクション内で行なっていた場合はロールバックにより不整合が生じてしまう可能性があるということです。例えば、外部APIにPOSTしたりメール送信したりする処理などが挙げられます。このような場合はトランザクションコールバックの使用を検討します。
トランザクションコールバック
after_commit
及び after_rollback
はそれぞれトランザクションのコミット、ロールバックをトリガとします。つまり、トランザクションの外で実行される形となります。
class Game < ApplicationRecord
after_commit :say_hello
private
def say_hello
puts 'hello!!'
end
end
pry(main)> Game.create(name: 'game3')
TRANSACTION (0.1ms) BEGIN -- トランザクション開始
Game Create (0.3ms) INSERT INTO "games" ("name", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" [...]
TRANSACTION (4.4ms) COMMIT -- コミット
hello!! -- コミット後に say_hello が呼ばれている
コミットを待って実行されているのが分かりますね。確実にデータが永続化された状態で処理を行うことができます。
transaction メソッドで明示的にトランザクションを指定する
複数の更新処理などをひとつトランザクションの中で行いたい場合は、transaction メソッドで明示的にトランザクションを指定することができます。
例えば、tickets に price カラムを追加し Game オブジェクトに紐づく tickets を each で回して price を更新するようなジョブがあったとしましょう。(更新に失敗したときにちゃんと例外が発生するように update!
メソッド)
class DoubleTicketsPriceJob < ApplicationJob
queue_as :default
def perform(game)
game.tickets.each do |ticket|
new_price = ticket.price * 2
ticket.update!(price: new_price)
end
end
end
これを実行すると、tickets の数だけ都度トランザクションが張られる形になります。
pry(main)> DoubleTicketsPriceJob.perform_now(game)
Performing DoubleTicketsPriceJob (Job ID: ...) from Async(default) enqueued at with arguments: ...
TRANSACTION (0.2ms) BEGIN
Ticket Update (0.3ms) UPDATE "tickets" SET "price" = $1, "updated_at" = $2 WHERE "tickets"."id" = $3 [...]
TRANSACTION (2.4ms) COMMIT
TRANSACTION (0.1ms) BEGIN
Ticket Update (0.3ms) UPDATE "tickets" SET "price" = $1, "updated_at" = $2 WHERE "tickets"."id" = $3 [...]
TRANSACTION (4.4ms) COMMIT
TRANSACTION (0.2ms) BEGIN
Ticket Update (0.4ms) UPDATE "tickets" SET "price" = $1, "updated_at" = $2 WHERE "tickets"."id" = $3 [...]
TRANSACTION (0.5ms) COMMIT
TRANSACTION (0.1ms) BEGIN
Ticket Update (0.3ms) UPDATE "tickets" SET "price" = $1, "updated_at" = $2 WHERE "tickets"."id" = $3 [...]
TRANSACTION (0.3ms) COMMIT
TRANSACTION (0.1ms) BEGIN
Ticket Update (0.3ms) UPDATE "tickets" SET "price" = $1, "updated_at" = $2 WHERE "tickets"."id" = $3 [...]
TRANSACTION (0.3ms) COMMIT
each で回している処理の中でエラーが発生したときにどのようにハンドリングするかは、処理の質によって変わってくると思います。例えばレスポンスが不安定な外部APIを冪等に叩いているような場合は都度 rescue して上限回数分 retry したり、ログ出力なり通知を飛ばすなりして次のループに進めたりすることもありますかね。
今回の場合は一部の ticket のみが更新されていたら困ることを想定します。そこで、一連の処理をまとめてトランザクション化するために transaction メソッドのブロックで each のループを囲ってみます。
class DoubleTicketsPriceJob < ApplicationJob
queue_as :default
def perform(game)
ActiveRecord::Base.transaction do
game.tickets.each do |ticket|
new_price = ticket.price * 2
ticket.update!(price: new_price)
end
end
end
end
これで実行してみると、トランザクションが張られ全てのUPDATE文が走った後にコミットされているのが分かりますね。
pry(main)> DoubleTicketsPriceJob.perform_now(game)
Performing DoubleTicketsPriceJob (Job ID: ...) from Async(default) enqueued at with arguments: ...
TRANSACTION (0.2ms) BEGIN
Ticket Update (3.6ms) UPDATE "tickets" SET "price" = $1, "updated_at" = $2 WHERE "tickets"."id" = $3 [...]
Ticket Update (0.4ms) UPDATE "tickets" SET "price" = $1, "updated_at" = $2 WHERE "tickets"."id" = $3 [...]
Ticket Update (0.3ms) UPDATE "tickets" SET "price" = $1, "updated_at" = $2 WHERE "tickets"."id" = $3 [...]
Ticket Update (0.2ms) UPDATE "tickets" SET "price" = $1, "updated_at" = $2 WHERE "tickets"."id" = $3 [...]
Ticket Update (0.3ms) UPDATE "tickets" SET "price" = $1, "updated_at" = $2 WHERE "tickets"."id" = $3 [...]
TRANSACTION (3.7ms) COMMIT
まとめ
コールバックに頼りすぎることはよくないですが、コールバックのトリガや実行順番を正しく理解して、適切なトランザクションで稼働するコードを書けるようになることが大切だと思います!
参考
https://railsguides.jp/active_record_callbacks.html
https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html
https://techracho.bpsinc.jp/hachi8833/2020_11_30/101160
https://qiita.com/jnchito/items/3ef95ea144ed15df3637