5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Rails のトランザクションとコールバックについて

Posted at

はじめに

Rails のトランザクションとコールバック処理についてまとめました。
誤り等、ご指摘ございましたらコメントいただけますと幸いです :pray:

トランザクションとは

トランザクションとは複数の処理においてデータの整合性を保つための機能であり、DBに対するデータ更新の単位とも言えます。
例えば PostgreSQL の場合、BEGIN TRANSACTION でトランザクションの開始を宣言し、COMMIT(コミット) においてそのトランザクションに含まれていた処理をDBに反映させます。コミットを行うまではトランザクション内の変更を破棄して元の状態に戻すこと(ロールバック)ができます。(一度コミットをしてしまったら基本的に戻すことができないので、SQL的にコミットは緊張感を伴う処理になります。)
複数の処理を実行する際に、「Aの処理は成功したがBの処理は失敗した」みたいな状態がまずい場合( 例:銀行の出金処理と入金処理)、トランザクション内でこの一連の処理を行い一方の処理が失敗した時点でロールバックすることにより、データの整合性をとることができます。

Rails におけるトランザクション

Rails の場合、ActiveRecord の savedestroy メソッドにおいて自動的にトランザクションが張られます。そしてこのとき、付随するコールバックもこのトランザクションによってラップされます。

コールバック

ActiveRecordオブジェクトには、createsave などのメソッドをトリガとする、after_createafter_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

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?