【Rails】トランザクションの思わぬ落とし穴

2025/02/09に公開

概要

Rails のトランザクションについて、私の理解不足が原因で不具合を引き起こした事例があったので共有します。
クイズ形式で進めるので、ネタバレが気になる方は本編まで飛ばしてください!

TL;DR

  • トランザクションがロールバックされた場合、ActiveRecord オブジェクトの attributes の値はロールバックされないことに注意。
  • トランザクションロールバック後にオブジェクトの attributes をそのまま DB に保存すると、予期しない更新が発生する可能性がある。

本編

クイズ!

次のコードを実行すると、post の最終的な状態はどうなるでしょうか?
(こんなコードは書かない!というのはさておき...)

  def run
    post = Post.last
    begin
      ActiveRecord::Base.transaction do
        post.body = "updated!"
        post.save!
        raise
      end
    rescue
      post.title = "エラーが発生しました"
      post.save!
    end
  end

期待する仕様を RSpec で書くと、次のようになります。

  describe "#run" do
    subject(:run) { described_class.new.run }

    let!(:post) { create(:post, title: "title", body: "body") }

    it do
      run
      expect(post.reload).to have_attributes(
        title: "エラーが発生しました",
        body: "body"
      )
    end
  end

期待する流れ:

  1. 初期状態:title = "title"、body = "body"
  2. トランザクション内で body = "updated!" に変更 → 例外発生でロールバック → body は "body" に戻る。
  3. 例外処理で title = "エラーが発生しました" に変更。
  4. 最終的に、title: "エラーが発生しました"、body: "body" になる。

このテストは正しく通るでしょうか?

答えは...?

テストを実行して答え合わせしてみましょう。

rails@beb3e1f8be91:/rails$ rspec spec/lib/test_transaction_spec.rb -e "#run"
F
Failures:

  1) TestTransaction#run is expected to have attributes {:body => "body", :title => "エラーが発生しました"}
     Failure/Error:
       expect(post.reload).to have_attributes(
         title: "エラーが発生しました",
         body: "body"
       )
     
     expected #<Post id: 53, title: "エラーが発生しました", body: "updated!"> to have attributes {:body => "body"} but had attributes {:body => "updated!"}

テストが失敗しました!
期待と異なり、body は "updated!" のままになっています🤔

クイズは正解しましたか?
正解だった人は、正しい知識をお持ちなので、ここから先は読まなくても大丈夫です!

何が起こったか

プリントデバッグをして、各時点でのオブジェクトと DB の状態を確認してみます。

def run
    post = Post.last
    puts "① 初期値"
    p "DB: #{Post.last.title}, #{Post.last.body}"
    p "OBJ: #{post.title}, #{post.body}"
    begin
      ActiveRecord::Base.transaction do
        post.body = "updated!"
        post.save!
        puts "② 更新直後"
        p "DB: #{Post.last.title}, #{Post.last.body}"
        p "OBJ: #{post.title}, #{post.body}"
        raise
      end
    rescue
      puts "③ 例外発生直後"
      p "DB: #{Post.last.title}, #{Post.last.body}"
      p "OBJ: #{post.title}, #{post.body}"
      post.title = "エラーが発生しました"
      post.save!
      puts "④ save!後"
      p "DB: #{Post.last.title}, #{Post.last.body}"
      p "OBJ: #{post.title}, #{post.body}"
    end
end

実行結果

① 初期値
"DB: title, body"
"OBJ: title, body"
② 更新直後
"DB: title, updated!"
"OBJ: title, updated!"
③ 例外発生直後
"DB: title, body"
"OBJ: title, updated!"
④ save!"DB: エラーが発生しました, updated!"
"OBJ: エラーが発生しました, updated!"

結果を見ると、
ロールバック発生後の「③ 例外発生直後」で、オブジェクトのbodyが"updated!"のままになっている(ロールバックされていない)。
また、その状態でsave!しているため、DBの方にもbodyに"updated!"が保存されてしまっている。

これが原因で、期待と異なる挙動をしていたのですね。

ポイント

  • ロールバックで DB は元の状態に戻るが、オブジェクトの状態は変わらない
  • ロールバック後にオブジェクトの状態がそのまま保存されると、意図しない更新が発生する

回避策

トランザクション内の ActiveRecord オブジェクトは、トランザクションスコープ内で取得してスコープ内のみで使用するのがベストです。
今回のようにトランザクションブロック外でオブジェクトを使う場合は、reload して最新の状態を取得し直しましょう。

def run
    post = Post.last
    puts "① 初期値"
    p "DB: #{Post.last.title}, #{Post.last.body}"
    p "OBJ: #{post.title}, #{post.body}"
    begin
      ActiveRecord::Base.transaction do
        post.body = "updated!"
        post.save!
        puts "② 更新直後"
        p "DB: #{Post.last.title}, #{Post.last.body}"
        p "OBJ: #{post.title}, #{post.body}"
        raise
      end
    rescue
      post.reload
      puts "③ 例外発生直後"
      p "DB: #{Post.last.title}, #{Post.last.body}"
      p "OBJ: #{post.title}, #{post.body}"
      post.title = "エラーが発生しました"
      post.save!
      puts "④ save!後"
      p "DB: #{Post.last.title}, #{Post.last.body}"
      p "OBJ: #{post.title}, #{post.body}"
    end
end

テストを実行すると...

① 初期値
"DB: title, body"
"OBJ: title, body"
② 更新直後
"DB: title, updated!"
"OBJ: title, updated!"
③ 例外発生直後
"DB: title, body"
"OBJ: title, body"
④ save!"DB: エラーが発生しました, body"
"OBJ: エラーが発生しました, body"
.

Finished in 0.25916 seconds (files took 5.37 seconds to load)
1 example, 0 failures

テストが通りました!🎉

おわりに

いかがでしたか?
トランザクションを扱う際は、この挙動に注意しましょう

Discussion