Railsのトランザクションと原子性のバグ(翻訳)

注記: 本記事のコード例ではhas_and_belongs_to_many関連付け(通称HABTM)が使われていますが、Railsでこれを使ったリレーションは悪手とされています。現在のRailsでは代わりにhas_many :through関連付けを使うのが一般的です。

概要

原著者の許諾を得て翻訳・公開いたします。

Railsのトランザクションと原子性のバグ(翻訳)

Ruby on Railsのトランザクションは、熟練Rails開発者もつまづくことがあるほど扱いが微妙です。次のいくつかの例では、単純なトランザクションすら意図と違う動きをする可能性があることと、それによって気づかないうちに原子性(訳注: atomicity、不可分性とも)が損なわれることを示します。

設定

本記事では、SurveyとQuestionというモデルを持つシンプルなアプリを使って調べます。Surveyには名前が1つ必要で、Questionには何らかのテキストが必要だとします。例では、SurveyとQuestionの間には多対多(has_and_belongs_to_many)のリレーションが設定されています。

rails new sample
cd sample
rails generate model survey name:string
rails generate model question text:string
rails generate migration CreateJoinTableSurveyQuestion survey question
rake db:create
rake db:migrate
  • app/models/survey.rb
class Survey < ApplicationRecord
  validates :name, presence: true
  has_and_belongs_to_many :questions
  accepts_nested_attributes_for :questions
end
  • app/models/question.rb
class Question < ApplicationRecord
  validates :text, presence: true
  has_and_belongs_to_many :questions
end

コード例A

has_manyhas_and_belongs_to_many関連付けによって提供されるヘルパーメソッドは興味深い挙動を示します。次のスニペットをご覧ください。

survey = Survey.create(name: "Shapes")
question = Question.create(text: "九角形の辺はいくつあるか?")

survey.attributes = { name: "", question_ids: [question.id] }
survey.save
BEGIN
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
COMMIT
BEGIN
ROLLBACK

上のとおり、attributesquestion_ids=(またはquestions=)の代入が行われると、バリデーションの外部でCOMMITされるINSERT文がただちに実行され、その後ROLLBACKします。

以下のようにattributes=saveupdateに差し替えると、期待どおりに原子性が保たれます。updateは内部でwith_transaction_returning_statusのすべての変更をラップしています(with_transaction_returning_statusは、トランザクションにラップされたブロックを1つ取るメソッドで、ブロックが「真らしい」と評価された場合はCOMMITを実行し、ブロックが「偽らしい」と評価された場合はROLLBACKします)。

survey = Survey.create(name: "Shapes")
question = Question.create(text: "九角形の辺はいくつあるか?")

survey.update({ name: "", question_ids: [question.id] })
BEGIN
SELECT "questions".* FROM "questions" WHERE "questions"."id" IN (...)
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
ROLLBACK

次のように書くこともできます。

survey = Survey.create(name: "Shapes")
question = Question.create(text: "九角形の辺はいくつあるか?")

survey.with_transaction_returning_status do
  survey.attributes = { name: "", question_ids: [question.id] }
  survey.save
end
BEGIN
SELECT "questions".* FROM "questions" WHERE "questions"."id" IN (...)
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
ROLLBACK

コード例B

transactionメソッドは期待どおりに動作しないことがあります。次のスニペットをご覧ください。

survey = Survey.create(name: "Numbers")
question = Question.create(text: "プランク定数の値はいくつか?")

Survey.transaction do
  survey.update({ name: "", question_ids: [question.id] })
end
BEGIN
SELECT "questions".* FROM "questions" WHERE "questions"."id" = ...
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
COMMIT

興味深いことに、コード例Aの修正方法はここでは効きません。デフォルトでネストするトランザクションでは、親のトランザクションだけが使われます。

以下のようにActiveRecord::Rollback例外をraiseするか、親のトランザクションでjoinable: falseを指定することで、半端な変更が保存されないようになります。親のトランザクションでjoinable: falseを指定すると、多くのリレーショナルデータベースが備えるメカニズムとしての保存ポイントが内部で使われます。

survey = Survey.create(name: "Numbers")
question = Question.create(text: "プランク定数の値はいくつか?")

Survey.transaction do
  unless survey.update({ name: "", question_ids: [question.id] })
    raise ActiveRecord::Rollback
  end
end
BEGIN
SELECT "questions".* FROM "questions" WHERE "questions"."id" = ...
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
ROLLBACK

次のように書くこともできます。

survey = Survey.create(name: "Numbers")
question = Question.create(text: "プランク定数の値はいくつか?")

Survey.transaction(joinable: false) do
  survey.update({ name: "", question_ids: [question.id] })
end
BEGIN
SAVEPOINT active_record_...
SELECT "questions".* FROM "questions" WHERE "questions"."id" = ...
SELECT "questions".* FROM "questions" INNER JOIN "questions_surveys" ON "questions"."id" = "questions_surveys"."question_id" WHERE "questions_surveys"."survey_id" = ...
INSERT INTO "questions_surveys" ("survey_id", "question_id") VALUES (..., ...)
ROLLBACK TO SAVEPOINT active_record_...
COMMIT

まとめ

上の例から、いくつかの規則が得られます。

  1. 代入可能な関連付けを扱うときは、attributes APIをじかに使うことを避け、updatecreateを使うこと。
  2. (トランザクションの)ネストを扱う場合は、ROLLBACKを伝搬させる例外を使うか、親トランザクションをJOINできないようにすること。

関連記事

Ruby on Rails のhas_many 関連付けのフィルタテクニック4種(翻訳)

Railsのdefault_scopeは使うな、絶対(翻訳)

Rails: JOINすべきかどうか、それが問題だ — #includesの振舞いを理解する(翻訳)

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ