42
26

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 1 year has passed since last update.

ActiveRecordのトランザクションを理解する

Last updated at Posted at 2022-10-18

Railsのトランザクションについて理解が曖昧だったため調べてみました。トランザクションって何?という方から、トランザクションについて知ってるつもりな方まで読んでいただけると幸いです。

この記事の目的

  • トランザクションを理解する
  • Ruby on RailsのActive Recordでのトランザクションの使い方を理解する

トランザクションとは?

トランザクションとは、複数のSQL文によるデータの更新を「1つの処理」とし、全てのSQLの実行が成功した時にデータベースに更新分を反映させることです。データベースの整合性を保つ目的があります。

基本的に、複数のSQLを同時に実行する際はトランザクションを使う必要があります。

トランザクションの使用例

代表的なトランザクションの使用例は、銀行での取引です。

カエル🐸さんがクマ🐻さんに2万円送金するとします。🐸さんは日本におり、🐻さんはロンドンにいます。その際のデータベースの処理は下記のようになります。

  1. 🐸さんの銀行口座に2万円の残高があるか確認する
  2. 🐸さんの銀行口座から2万円を引き落とす
  3. 2万円を🐻さん側の通貨(今回はポンド)に変換する
  4. 🐻さんの銀行口座にお金を振り込む

この場合の処理を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クラス上で呼び出す必要があります。ただし、トランザクションモデルではなくデータベースとの連携のため、全ての場合でクラスインスタンスである必要はありません。

例えば、BalanceAccountという二つのモデルがあり、この二つを同時に更新する必要があるとします。

この場合、下記のようにAccountクラスに対しトランザクションメソッドを実行することができます。トランザクションが実行されたクラスに関係なく、save!メソッドを実行することができます。

Account.transaction do
    balance.save!
    account.save!
end

このように記述することでAccountクラスでメソッドを実行したとしても、balanceaccountの両方が更新されます。

また、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

savedestroyメソッドのトランザクション

Active Recordのsavedestroyメソッドには、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種類の場合のみのトランザクションの扱い方を説明しました。ここからは複数のデータベースを扱う場合のトランザクションの扱い方をご紹介します。

UserAccountという二つのモデルがあり、それぞれ別のデータベースで扱っているとします。Accountにあるサブスクリプション用の残高(subscription amount)から引き出しが完了したら、Usersubscription 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を使っても、MomokoSatoshiも作成されてしまいます。

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でのトランザクションの解説でした。もし理解が曖昧で内容に間違いなどある場合は、コメント欄でご指摘いただけると幸いです。

参考

42
26
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
42
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?