Railsのトランザクションについて理解が曖昧だったため調べてみました。トランザクションって何?という方から、トランザクションについて知ってるつもりな方まで読んでいただけると幸いです。
この記事の目的
- トランザクションを理解する
- Ruby on RailsのActive Recordでのトランザクションの使い方を理解する
トランザクションとは?
トランザクションとは、複数のSQL文によるデータの更新を「1つの処理」とし、全てのSQLの実行が成功した時にデータベースに更新分を反映させることです。データベースの整合性を保つ目的があります。
基本的に、複数のSQLを同時に実行する際はトランザクションを使う必要があります。
トランザクションの使用例
代表的なトランザクションの使用例は、銀行での取引です。
カエル🐸さんがクマ🐻さんに2万円送金するとします。🐸さんは日本におり、🐻さんはロンドンにいます。その際のデータベースの処理は下記のようになります。
- 🐸さんの銀行口座に2万円の残高があるか確認する
- 🐸さんの銀行口座から2万円を引き落とす
- 2万円を🐻さん側の通貨(今回はポンド)に変換する
- 🐻さんの銀行口座にお金を振り込む
この場合の処理をRubyのコードに落とし込むと、下記のようになります。
amount = 20000
kaeru.debit_account(amount) if kaeru.sufficient_balance(amount)
credit_amount = convert_currency(amount, kuma)
perform_transfer(kuma, credit_amount, kaeru)
さて、もしこの処理の途中でプログラムに問題が起こってしまったらどうなるでしょうか? これが🐸さんの口座からお金が引き落とされた後、そして🐻さんの口座にお金が振り込まれる前だったら...?お金は一体どこに行ってしまうのでしょうか?
このような問題が起こった時に、誤ったデータをデータベースへ書き込む処理を止め、元の状態へ戻す処理が必要です。そこで、上記のコードをtransaction
で囲むことで、もしtransaction
ブロック内の処理中に何らかの問題が起こったら、例外エラーが発生するようになります。これでデータの整合性を保つことができます。
amount = 20000
ActiveRecord::Base.transaction do
kaeru.debit_account(amount) if kaeru.sufficient_balance(amount)
credit_amount = convert_currency(amount, kuma)
perform_transfer(kuma, credit_amount, kaeru)
end
トランザクションの使い方
トランザクションはクラスメソッドのため、ActiveRecordクラス上で呼び出す必要があります。ただし、トランザクションモデルではなくデータベースとの連携のため、全ての場合でクラスインスタンスである必要はありません。
例えば、Balance
とAccount
という二つのモデルがあり、この二つを同時に更新する必要があるとします。
この場合、下記のようにAccount
クラスに対しトランザクションメソッドを実行することができます。トランザクションが実行されたクラスに関係なく、save!
メソッドを実行することができます。
Account.transaction do
balance.save!
account.save!
end
このように記述することでAccount
クラスでメソッドを実行したとしても、balance
とaccount
の両方が更新されます。
また、transaction
メソッドは、下記のようにモデルインスタンスに対しても呼び出しが可能です。
account.transaction do
balance.save!
account.save!
end
ActiveRecord::Base・クラス・インスタンスのどれでトランザクションを使えばいいの?
先ほど述べたようにtransaction
メソッドはActiveRecord::Base
クラス、モデルクラス、モデルインスタンスのどこからでも実行が可能です。
それではどう使い分ければいいのでしょうか?
Ruby on RailsはActiveRecord::Base
クラスでtransaction
メソッドを提供しています。全てのモデルはActiveRecord::Base
を継承しているので、全てのモデルでtransaction
メソッドが使用できます。
モデルインスタンスでtransaction
メソッドを実行することで、シンプルでわかりやすいコードが書けるというメリットがあります。
account = Account.find(1)
account.transaction do
account.save!
balance.save!
end
下記では、Account
クラスでtransaction
メソッドを実行しています。上のコードと全く同じ処理です。
account = Account.find(1)
Account.transaction do
account.save!
balance.save!
end
save
とdestroy
メソッドのトランザクション
Active Recordのsave
とdestroy
メソッドには、transaction
が自動的にラップされているため、バリデーションやコールバック時もtransaction
が実行されます。
例外処理とロールバック
トランザクションで例外を実行するには、下記の2パターンがあります。
-
save!
やupdate!
などのActive Recordメソッドを使用する - 手動で例外を実行する
Account.transaction do
@new_account = Account.create!(account_params)
@referrer.update!(params[:reference_record])
end
もし問題が起こったときは、create!
やupdate!
メソッドが例外を発生させます。
!
をつけない場合は、問題が起こっても処理は実行され続けます。その場合は、明示的に例外処理を書く必要があります。
Account.transaction do
@new_account = Account.create(account_params)
raise ActiveRecords::RecordInvalid unless @new_user.persisted?
end
このコードを下記のように書き直すこともできます。
Account.transaction do
@new_account = Account.create(account_params)
raise ActiveRecords::RecordInvalid unless @new_user.persisted?
rescue ActiveRecord::RecordInvalid => exception
# エラー処理をここに書く
end
トランザクションの例外処理の注意点
トランザクションの例外処理の時にActiveRecord::StatementInvalid
をキャッチしないように注意しましょう。ActiveRecord::StatementInvalid
は、unique制約エラーなど、データベース側で起こるエラーだからです。
PostgreSQLなどのデータベースシステムでは、トランザクション内のデータベースエラーにより、全てのトランザクションがリスタートされるまで使用不可になってしまう可能性があります。
下記は、Userモデルでemail
のunique制約エラーが起こる場合のコードです。このような事態が起こった場合は、トランザクションをリスタートする必要があります。
ActiveRecord::Base.transaction do
User.create(email: "emiko@example.com")
begin
# ここでunique制約エラーが発生する
User.create(email: "emiko@example.com")
rescue ActiveRecord::StatementInvalid
# ActiveRecord::StatementInvalidをキャッチしたことにより、
# 全てのトランザクションが使用不可になってしまう
end
# PostgreSQLのトランザクションが使用不可の状態になっている
# 下記のコードはunique制約に違反していないが、エラーになってしまう
User.create(email: "satoshi@example.com")
# => "PG::Error: ERROR: current transaction is aborted, commands
# ignored until end of transaction block"
end
トランザクションのコールバック
トランザクションには大きく2つのコールバックがあります。
after_commit
after_rollback
after_commit
は、レコードがトランザクション内でsave
またはdestroy
され、トランザクションが適用(commit)される度に呼び出されます。
after_rollback
は、レコードがトランザクション内でsave
またはdestroy
され、トランザクションがロールバックされた時に呼び出されます。
トランザクションをネストする
ここまでは、データベースが1種類の場合のみのトランザクションの扱い方を説明しました。ここからは複数のデータベースを扱う場合のトランザクションの扱い方をご紹介します。
User
とAccount
という二つのモデルがあり、それぞれ別のデータベースで扱っているとします。Account
にあるサブスクリプション用の残高(subscription amount
)から引き出しが完了したら、User
のsubscription status
を更新するとします。このような場合、下記のようなtransaction
になります。
Account.transaction do
account = Account.find(user_id: params[:user_id])
account.withdraw(subscription_amount)
User.transaction do
user = User.find(id: params[:user_id])
user.update!(subscribed: true)
end
end
もしUser
側のトランザクションが失敗した場合、Account
側のトランザクションは中止されます。もしAccount
側のトランザクションが失敗してもUser
側のトランザクションは中止されます。このように複数のデータベースをまたがった場合でも、整合性を保つことができます。
ActiveRecord::Rollback
の注意点
ActiveRecord::Rollback
の例外エラーがraiseされた際は、注意が必要です。
下記のコードの場合、transaction
を使っても、Momoko
もSatoshi
も作成されてしまいます。
User.transaction do
User.create(username: 'Momoko')
User.transaction do
User.create(username: 'Satoshi')
raise ActiveRecord::Rollback
end
end
これには理由が2つあります。
ActiveRecord::Rollback
の例外エラーはRailsでraiseされます。上のコードだと、ActiveRecord::Rollback
は内側(3行目)のtransaction
によってraiseされます。この場合、外側(1行目)のトランザクションは、内側のトランザクションで例外エラーが発生したことを検知することができません。
2つ目は、ネストされたトランザクションはRails側でのみサポートされていることにあります。データベースはネストされたトランザクションをサポートしていません。(現時点ではMS-SQLのみサポートしているとのことです。詳しくはご自身でお調べください)このため、内側のトランザクションで例外エラーが発生しても、外側のトランザクションはこのことを検知することができず、コードに書かれた通りにデータを更新してしまうのです。
この問題の回避策としてrequires_new: true
を内側のトランザクションメソッドに渡しましょう。
User.transaction do
User.create(username: 'Momoko')
User.transaction(requires_new: true) do
User.create(username: 'Satoshi')
raise ActiveRecord::Rollback
end
end
こうすることで、Momoko
レコードのみ作成されます。もし内側のトランザクション内で問題が起こりロールバックが起こった場合、内側のトランザクションの最初の行(4行目)に戻ります。
まとめ
以上Railsでのトランザクションの解説でした。もし理解が曖昧で内容に間違いなどある場合は、コメント欄でご指摘いただけると幸いです。